diff --git a/app/__init__.py b/app/__init__.py index 093a92d..1fb27aa 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -53,11 +53,11 @@ def create_app(test_config=None): if request.endpoint == "static": filename = request.view_args.get("filename", "") if request.view_args else "" if filename.endswith(".css"): - response.headers["Cache-Control"] = "public, max-age=1800, immutable" + response.headers["Cache-Control"] = "public, max-age=3600, immutable" else: - response.headers["Cache-Control"] = "public, max-age=1800, immutable" + response.headers["Cache-Control"] = "public, max-age=3600, immutable" elif response.mimetype == "text/html": - response.headers["Cache-Control"] = "private, no-store" + response.headers["Cache-Control"] = "private, no-store," return response @login_manager.user_loader @@ -77,7 +77,7 @@ def create_app(test_config=None): if request.path.startswith("/api/"): return {"ok": False, "message": "Wymagane uwierzytelnienie Bearer lub aktywna sesja"}, 401 from flask import redirect - return redirect(url_for("auth.login", next=request.url)) + return redirect(url_for("auth.login", next=request.full_path.rstrip("?"))) @app.context_processor def inject_theme(): diff --git a/app/api.py b/app/api.py deleted file mode 100644 index d569ac1..0000000 --- a/app/api.py +++ /dev/null @@ -1,518 +0,0 @@ -from collections import defaultdict -from datetime import datetime, date -from decimal import Decimal -from functools import wraps -from flask import Blueprint, jsonify, request, url_for, current_app -from flask_login import current_user, login_required, login_user, logout_user -from sqlalchemy import extract -from sqlalchemy.exc import IntegrityError -from .extensions import db -from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule -from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS -from .station_catalog import sync_station_catalog -from . import THEMES -from .api_tokens import create_access_token - -api = Blueprint('api', __name__, url_prefix='/api') -FUEL_TYPES = ('PB95','PB98','DIESEL','LPG') - -def response(data=None, message=None, status=200): - payload={'ok': status < 400} - if message: payload['message']=message - if data is not None: payload['data']=data - return jsonify(payload), status - -def fail(message, status=400): return response(message=message,status=status) - -def role_required(*allowed): - def deco(fn): - @wraps(fn) - @login_required - def wrapped(*a,**kw): - if current_user.role not in allowed: return fail('Brak uprawnień',403) - return fn(*a,**kw) - return wrapped - return deco - -def payload(): - return request.get_json(silent=True) or request.form - -def boolv(data,key): - v=data.get(key) - return v in (True,1,'1','true','on','yes') - -def accessible_vehicles(): - if current_user.role=='admin': return Vehicle.query.order_by(Vehicle.name).all() - ids={v.id for v in current_user.owned_vehicles+current_user.shared_vehicles} - return Vehicle.query.filter(Vehicle.id.in_(ids)).order_by(Vehicle.name).all() if ids else [] - -def user_dict(u): return {'id':u.id,'name':u.name,'email':u.email,'role':u.role,'active':u.active,'fuel_card_id':u.fuel_card_id,'fuel_card':u.fuel_card.name if u.fuel_card else None,'company_id':u.company_id,'company':u.company.name if u.company else None} -def vehicle_dict(v): return {'id':v.id,'name':v.name,'make':v.make,'model':v.model,'registration':v.registration,'fuel_type':v.fuel_type,'owner':user_dict(v.owner),'drivers':[user_dict(x) for x in v.drivers],'has_fuel_card':bool(v.fuel_card_id),'fuel_card_id':v.fuel_card_id,'fuel_card':v.fuel_card.name if v.fuel_card else None,'current_odometer':v.current_odometer} -def station_dict(s): - settings=CompanySettings.query.first() - return {'id':s.id,'brand_name':s.display_name,'company_name':s.company_name,'nip':s.nip,'regon':s.regon,'brands':s.station_name_list,'regions':s.region_list,'selected_region':s.selected_region,'station_count':s.station_count,'has_petrol':s.has_petrol,'has_diesel':s.has_diesel,'has_lpg':s.has_lpg,'use_orlen_last_price':s.use_orlen_last_price,'active':s.active,'favorite':s in current_user.favorite_stations if current_user.is_authenticated else False,'allowed':s in settings.allowed_stations if settings else True} - -@api.post('/auth/login') -def login(): - d=payload(); user=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first() - if not user or not user.check_password(d.get('password') or '') or not user.active: return fail('Nieprawidłowy e-mail lub hasło',401) - login_user(user); return response({'user':user_dict(user),'redirect':url_for('main.dashboard')},'Zalogowano') - -@api.post('/auth/token') -def token_login(): - d = payload() - email = (d.get('email') or d.get('username') or '').strip().lower() - user = User.query.filter_by(email=email).first() - if not user or not user.check_password(d.get('password') or '') or not user.active: - return jsonify({'error':'invalid_grant','error_description':'Nieprawidłowy e-mail lub hasło'}), 401 - expires_in = int(current_app.config['API_TOKEN_MAX_AGE_SECONDS']) - return jsonify({ - 'access_token': create_access_token(user), - 'token_type': 'Bearer', - 'expires_in': expires_in, - 'user': user_dict(user), - }) - -@api.post('/auth/logout') -@login_required -def logout(): logout_user(); return response({'redirect':url_for('auth.login')},'Wylogowano') - -@api.get('/me') -@login_required -def me(): return response(user_dict(current_user)) - -@api.get('/vehicles') -@login_required -def get_vehicles(): return response([vehicle_dict(v) for v in accessible_vehicles()]) - -@api.post('/vehicles') -@login_required -def create_vehicle(): - d=payload() - try: - card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None - owner_id=int(d.get('owner_id') or current_user.id);owner=db.session.get(User,owner_id) - if not owner: return fail('Nie znaleziono właściciela',404) - if current_user.role not in ('boss','admin') and owner.id!=current_user.id:return fail('Brak uprawnień',403) - if current_user.role=='boss' and owner.company_id!=current_user.company_id:return fail('Użytkownik jest w innej firmie',403) - v=Vehicle(name=d['name'].strip(),make=d['make'].strip(),model=d['model'].strip(),registration=d['registration'].strip().upper(),fuel_type=d['fuel_type'],owner_id=owner.id,company_id=owner.company_id,has_fuel_card=bool(card_id),fuel_card_id=card_id,current_odometer=int(d.get('current_odometer') or 0)) - db.session.add(v); db.session.commit(); return response(vehicle_dict(v),'Dodano pojazd',201) - except (KeyError,ValueError,IntegrityError) as exc: db.session.rollback(); return fail(str(getattr(exc,'orig',exc)),400) - -@api.put('/vehicles//fuel-card') -@login_required -def assign_vehicle_fuel_card(vehicle_id): - v=Vehicle.query.get_or_404(vehicle_id) - if current_user.role not in ('boss','admin') and v.owner_id!=current_user.id:return fail('Brak uprawnień',403) - d=payload();card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None - card=db.session.get(FuelCard,card_id) if card_id else None - if card_id and not card:return fail('Nie znaleziono karty paliwowej',404) - if card and v.company_id and card.company_id!=v.company_id:return fail('Karta należy do innej firmy',400) - v.fuel_card_id=card_id;v.has_fuel_card=bool(card_id);db.session.commit();return response(vehicle_dict(v),'Zapisano kartę pojazdu') - -@api.post('/vehicles//shares') -@login_required -def share_vehicle(vehicle_id): - v=Vehicle.query.get_or_404(vehicle_id) - if current_user.role!='admin' and v.owner_id!=current_user.id: return fail('Brak uprawnień',403) - d=payload(); u=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first() - if not u:return fail('Nie znaleziono użytkownika',404) - if u not in v.drivers:v.drivers.append(u);db.session.commit();return response(vehicle_dict(v),'Udostępniono pojazd') - -@api.delete('/vehicles//shares/') -@login_required -def unshare_vehicle(vehicle_id,user_id): - v=Vehicle.query.get_or_404(vehicle_id) - if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403) - u=User.query.get_or_404(user_id) - if u in v.drivers:v.drivers.remove(u);db.session.commit();return response(vehicle_dict(v),'Odebrano dostęp') - -@api.put('/vehicles//card-policy') -@login_required -def card_policy(vehicle_id): - v=Vehicle.query.get_or_404(vehicle_id) - if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403) - d=payload();p=v.fuel_card_policy or FuelCardPolicy(vehicle=v) - p.use_orlen_last_price=False;p.discount_percent=Decimal(d.get('discount_percent') or 0);p.surcharge_per_liter=Decimal(d.get('surcharge_per_liter') or 0);p.description=(d.get('description') or '').strip() or None - db.session.add(p);db.session.commit();return response(message='Zapisano zasady rozliczeń') - -@api.post('/fuel-entries') -@login_required -def create_fuel_entry(): - d=payload(); vehicles=accessible_vehicles() - try: vehicle=next((v for v in vehicles if v.id==int(d['vehicle_id'])),None) - except Exception: vehicle=None - if not vehicle:return fail('Brak dostępu do pojazdu',403) - try: - odo=int(d['odometer']); fueled_at=datetime.fromisoformat(d['fueled_at']); station_company=db.session.get(FuelStationCompany,int(d['station_company_id'])) if d.get('station_company_id') else None - settings=vehicle.company or current_user.company or CompanySettings.query.first() - access_mode=settings.station_access_mode or 'all_prefer_favorites' - if station_company and access_mode=='allowed_only' and station_company not in settings.allowed_stations: - return fail('Ta stacja nie jest dozwolona przez politykę firmy',403) - if station_company and access_mode=='favorites_allowed_only' and station_company not in settings.allowed_stations and station_company not in settings.favorite_stations: - return fail('Ta stacja nie jest ulubiona ani dozwolona przez politykę firmy',403) - wholesale=source=None; region=(station_company.selected_region if station_company else None) or settings.region - try: - p=fetch_orlen_price(d['fuel_type'],fueled_at.date(),region);wholesale=p['price_per_liter'];source=p['source'] - except Exception: pass - card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else (vehicle.fuel_card_id or current_user.fuel_card_id) - card=db.session.get(FuelCard,card_id) if card_id else None - used_card=boolv(d,'used_fuel_card') and bool(card) - if used_card and station_company: - rule=FuelCardStationRule.query.filter_by(fuel_card_id=card.id,station_company_id=station_company.id).first() - if rule and not rule.allowed:return fail('Ta stacja jest zablokowana dla wybranej karty paliwowej',403) - e=FuelEntry(vehicle_id=vehicle.id,user_id=current_user.id,fueled_at=fueled_at,liters=Decimal(d['liters']),price_per_liter=Decimal(d['price_per_liter']),fuel_type=d['fuel_type'],odometer=odo,station=station_company.company_name if station_company else d.get('station'),station_company_id=station_company.id if station_company else None,fuel_card_id=card.id if used_card else None,invoice_number=d.get('invoice_number'),used_fuel_card=used_card,wholesale_price=wholesale,wholesale_source=source) - vehicle.current_odometer=odo;db.session.add(e);db.session.commit();return response({'id':e.id,'redirect':url_for('main.dashboard')},'Zapisano tankowanie',201) - except Exception as exc:db.session.rollback();return fail(str(exc),400) - -@api.get('/orlen/prices') -@login_required -def orlen_prices(): - year=request.args.get('year',type=int) or date.today().year; fuels=[x.upper() for x in request.args.getlist('fuel') if x.upper() in FUEL_TYPES] or ['PB95']; regions=[x.lower() for x in request.args.getlist('region') if x.lower() in POLISH_REGIONS] - rows=OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(fuels),extract('year',OrlenPrice.effective_date)==year).order_by(OrlenPrice.effective_date).all() - return response([{'fuel_type':r.fuel_type,'date':r.effective_date.isoformat(),'price_per_liter':float(r.price_per_liter),'region':r.region,'source':r.source} for r in rows if r.fuel_type!='LPG' or not regions or r.region in regions]) - -@api.post('/orlen/sync') -@role_required('boss','admin') -def orlen_sync(): - d=payload(); fuels=d.get('fuels',[]); fuels=[fuels] if isinstance(fuels,str) else fuels; fuels=[f.upper() for f in fuels if f.upper() in FUEL_TYPES]; year=int(d.get('year',date.today().year)) - regions=d.get('regions',[]); regions=[regions] if isinstance(regions,str) else regions; regions=[str(r).strip().lower() for r in regions if str(r).strip().lower() in POLISH_REGIONS] - if not fuels:return fail('Wybierz co najmniej jedno paliwo') - settings=CompanySettings.query.first(); result={}; total=0 - try: - for fuel in fuels: - added=updated=0 - for row in fetch_orlen_range(fuel,date(year,1,1),min(date(year,12,31),date.today()),'all' if fuel=='LPG' else settings.region): - if fuel=='LPG' and regions and row.get('region') not in regions: continue - x=OrlenPrice.query.filter_by(fuel_type=row['fuel_type'],effective_date=row['effective_date'],region=row['region']).first() - if x: - for k,v in row.items():setattr(x,k,v) - x.fetched_at=datetime.utcnow();updated+=1 - else:db.session.add(OrlenPrice(**row));added+=1 - result[fuel]={'added':added,'updated':updated};total+=added+updated - db.session.commit();return response(result,f'Przetworzono {total} rekordów') - except Exception as exc:db.session.rollback();return fail(str(exc),502) - -@api.get('/orlen/preview') -@login_required -def orlen_preview(): - try:return response(fetch_orlen_price(request.args['fuel_type'],datetime.fromisoformat(request.args['date']).date(),request.args.get('region') or CompanySettings.query.first().region)) - except Exception as exc:return fail(str(exc),502) - -@api.get('/users') -@role_required('admin') -def users(): - page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',25,type=int),100);q=request.args.get('q','').strip();query=User.query - if q: query=query.filter(db.or_(User.name.ilike(f'%{q}%'),User.email.ilike(f'%{q}%'))) - p=query.order_by(User.name).paginate(page=page,per_page=per,error_out=False);return response({'items':[user_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total}) - -@api.post('/users') -@role_required('boss','admin') -def create_user(): - d=payload() - try: - if len(d.get('password',''))<8:raise ValueError('Hasło musi mieć co najmniej 8 znaków') - company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id) - if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403) - u=User(name=d['user_name'].strip(),email=d['email'].strip().lower(),role=d.get('role','user'),company_id=company_id);u.set_password(d['password']);db.session.add(u);db.session.commit();return response(user_dict(u),'Dodano użytkownika',201) - except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc))) - -@api.put('/users/') -@role_required('admin') -def update_user(user_id): - u=User.query.get_or_404(user_id);d=payload() - try: - u.name=d['name'].strip();u.email=d['email'].strip().lower();u.role=d['role'];u.active=boolv(d,'active');u.company_id=int(d.get('company_id') or u.company_id or CompanySettings.query.first().id);u.fuel_card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None;pw=d.get('password','') - if pw: - if len(pw)<8:raise ValueError('Nowe hasło musi mieć co najmniej 8 znaków') - u.set_password(pw) - db.session.commit();return response(user_dict(u),'Zaktualizowano użytkownika') - except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc))) - -@api.delete('/users/') -@role_required('admin') -def delete_user(user_id): - user = User.query.get_or_404(user_id) - if user.id == current_user.id: - return fail('Nie możesz usunąć własnego konta administratora', 400) - if user.role == 'admin': - remaining_active_admins = User.query.filter( - User.role == 'admin', User.active.is_(True), User.id != user.id - ).count() - if remaining_active_admins < 1: - return fail('Nie można usunąć ostatniego aktywnego administratora', 409) - if user.owned_vehicles: - return fail('Najpierw przypisz pojazdy tego użytkownika innemu właścicielowi', 409) - if FuelEntry.query.filter_by(user_id=user.id).first(): - return fail('Użytkownik ma historię tankowań i nie może zostać usunięty. Możesz go dezaktywować.', 409) - user.shared_vehicles.clear() - user.favorite_stations.clear() - db.session.delete(user) - db.session.commit() - return response(message='Usunięto użytkownika') - -@api.get('/settings') -@role_required('boss','admin') -def get_settings(): - s=CompanySettings.query.first();return response({'name':s.name,'entity_type':s.entity_type,'vat_rate':float(s.vat_rate),'vat_deduction_percent':float(s.vat_deduction_percent),'region':s.region,'invoice_split_day':s.invoice_split_day,'invoice_split_enabled':(db.session.get(AppSetting,'invoice_split_enabled').value=='1')}) - -@api.put('/settings') -@role_required('boss','admin') -def update_settings(): - d=payload();s=CompanySettings.query.first() - try: - s.name=d['name'];s.entity_type=d['entity_type'];s.vat_rate=Decimal(d['vat_rate']);s.vat_deduction_percent=Decimal(d['vat_deduction_percent']);s.region=d['region'].strip().lower();s.invoice_split_day=int(d.get('invoice_split_day') or 15) - db.session.merge(AppSetting(key='invoice_split_enabled',value='1' if boolv(d,'invoice_split_enabled') else '0'));db.session.commit();return response(message='Zapisano ustawienia') - except Exception as exc:db.session.rollback();return fail(str(exc)) - - -@api.get('/app-settings') -@role_required('boss','admin') -def get_app_settings(): - return response({'theme':(db.session.get(AppSetting,'theme') or AppSetting(value='bootstrap')).value}) - -@api.put('/app-settings') -@role_required('boss','admin') -def update_app_settings(): - d=payload();theme=d.get('theme','bootstrap') - if theme not in THEMES:return fail('Nieprawidłowy motyw') - db.session.merge(AppSetting(key='theme',value=theme));db.session.commit();return response(message='Zapisano ustawienia aplikacji') - -@api.get('/fuel-cards') -@login_required -def get_fuel_cards(): - rows=FuelCard.query.order_by(FuelCard.name).all() - return response([{'id':x.id,'name':x.name,'provider':x.provider,'active':x.active,'description':x.description} for x in rows]) - -@api.post('/fuel-cards') -@role_required('boss','admin') -def create_fuel_card(): - d=payload() - try: - company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id) - if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403) - card=FuelCard(name=d['name'].strip(),provider=d['provider'].strip(),description=(d.get('description') or '').strip() or None,active=True,company_id=company_id) - db.session.add(card);db.session.commit();return response({'id':card.id},'Dodano kartę paliwową',201) - except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc))) - -@api.put('/fuel-cards/') -@role_required('boss','admin') -def update_fuel_card(card_id): - card=FuelCard.query.get_or_404(card_id);d=payload();card.name=d.get('name',card.name).strip();card.provider=d.get('provider',card.provider).strip();card.description=(d.get('description') or '').strip() or None;card.active=boolv(d,'active');db.session.commit();return response(message='Zapisano kartę') - -@api.delete('/fuel-cards/') -@role_required('boss','admin') -def delete_fuel_card(card_id): - card = FuelCard.query.get_or_404(card_id) - if current_user.role == 'boss' and card.company_id != current_user.company_id: - return fail('Brak uprawnień', 403) - - users_count = User.query.filter_by(fuel_card_id=card.id).count() - vehicles_count = Vehicle.query.filter_by(fuel_card_id=card.id).count() - entries_count = FuelEntry.query.filter_by(fuel_card_id=card.id).count() - blockers = [] - if users_count: - blockers.append(f'{users_count} użytkowników') - if vehicles_count: - blockers.append(f'{vehicles_count} pojazdów') - if entries_count: - blockers.append(f'{entries_count} tankowań') - if blockers: - return fail( - 'Nie można usunąć karty, ponieważ jest przypisana do: ' + ', '.join(blockers) + - '. Najpierw odłącz kartę od użytkowników i pojazdów. Karty użytej w tankowaniach nie można usunąć; możesz ją wyłączyć.', - 409, - ) - - company_id = card.company_id - db.session.delete(card) - db.session.commit() - return response( - {'redirect':url_for('main.admin_companies', company_id=company_id)}, - 'Usunięto kartę flotową', - ) - -@api.put('/fuel-cards//stations/') -@role_required('boss','admin') -def update_card_station_rule(card_id,station_id): - FuelCard.query.get_or_404(card_id);FuelStationCompany.query.get_or_404(station_id);d=payload() - rule=FuelCardStationRule.query.filter_by(fuel_card_id=card_id,station_company_id=station_id).first() or FuelCardStationRule(fuel_card_id=card_id,station_company_id=station_id) - rule.allowed=boolv(d,'allowed');rule.use_orlen_last_price=boolv(d,'use_orlen_last_price');rule.discount_net_percent=Decimal(d.get('discount_net_percent') or 0);rule.surcharge_net_per_liter=Decimal(d.get('surcharge_net_per_liter') or 0) - db.session.add(rule);db.session.commit();return response(message='Zapisano warunki karty dla stacji') - -@api.get('/stations') -@login_required -def stations(): - q=request.args.get('q','').strip();page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',30,type=int),100);query=FuelStationCompany.query.filter_by(active=True) - if q: - for term in [x for x in q.split() if x]: - needle=f'%{term}%' - query=query.filter(db.or_(FuelStationCompany.brand_name.ilike(needle),FuelStationCompany.company_name.ilike(needle),FuelStationCompany.nip.ilike(needle),FuelStationCompany.regon.ilike(needle),FuelStationCompany.station_names.ilike(needle))) - sort=request.args.get('sort','company_name');direction=request.args.get('direction','asc') - allowed={'company_name':FuelStationCompany.brand_name,'station_count':FuelStationCompany.station_count,'nip':FuelStationCompany.nip,'regon':FuelStationCompany.regon,'selected_region':FuelStationCompany.selected_region} - col=allowed.get(sort,FuelStationCompany.company_name);query=query.order_by(col.desc() if direction=='desc' else col.asc(),FuelStationCompany.brand_name.asc()) - p=query.paginate(page=page,per_page=per,error_out=False);return response({'items':[station_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total,'sort':sort,'direction':direction}) - -@api.get('/stations//points') -@login_required -def station_points(station_id): - station=FuelStationCompany.query.get_or_404(station_id) - page=max(request.args.get('page',1,type=int),1) - per_page=min(max(request.args.get('per_page',50,type=int),1),100) - q=(request.args.get('q') or '').strip() - query=FuelStationPoint.query.filter_by(station_company_id=station.id) - if q: - needle=f'%{q}%' - query=query.filter(db.or_(FuelStationPoint.station_name.ilike(needle),FuelStationPoint.street.ilike(needle),FuelStationPoint.city.ilike(needle),FuelStationPoint.postal_code.ilike(needle))) - query=query.order_by(FuelStationPoint.city.asc(),FuelStationPoint.street.asc(),FuelStationPoint.street_number.asc()) - result=query.paginate(page=page,per_page=per_page,error_out=False) - items=[{'id':x.id,'dkn':x.ure_dkn.split(':',1)[0] if x.ure_dkn else '','name':x.station_name,'address':x.address,'city':x.city,'region':x.region,'coordinates':x.coordinates,'has_petrol':x.has_petrol,'has_diesel':x.has_diesel,'has_lpg':x.has_lpg} for x in result.items] - return response({'station':station_dict(station),'items':items,'page':result.page,'pages':result.pages,'total':result.total}) - -@api.put('/me/favorite-stations') -@login_required -def favorite_stations(): - d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids - try: ids=[int(x) for x in ids] - except Exception: return fail('Nieprawidłowa lista stacji') - if len(ids)>10:return fail('Użytkownik może mieć maksymalnie 10 ulubionych stacji') - current_user.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] - db.session.commit();return response([station_dict(x) for x in current_user.favorite_stations],'Zapisano ulubione stacje') - -@api.put('/settings/allowed-stations') -@role_required('boss','admin') -def allowed_stations(): - d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids - try: ids=[int(x) for x in ids] - except Exception: return fail('Nieprawidłowa lista stacji') - settings=CompanySettings.query.first();settings.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] - db.session.commit();return response([station_dict(x) for x in settings.allowed_stations],'Zapisano dozwolone stacje') - -@api.post('/stations/sync') -@role_required('boss','admin') -def stations_sync(): - try: - companies=aggregate_ure_companies(fetch_ure_stations()) - added,updated=sync_station_catalog(companies) - db.session.commit();return response({'added':added,'updated':updated},f'Katalog URE: dodano {added}, zaktualizowano {updated}') - except Exception as exc:db.session.rollback();return fail(f'Nie udało się pobrać danych URE: {exc}',502) - -@api.put('/stations/') -@role_required('boss','admin') -def update_station(station_id): - s=FuelStationCompany.query.get_or_404(station_id);d=payload() - region=None - if 'selected_region' in d: - region=(d.get('selected_region') or '').strip().lower() - if region and region not in s.region_list:return fail('Wybrane województwo nie występuje w danych tej firmy') - try: - company_id=int(d.get('company_id') or current_user.company_id or 0) - except (TypeError,ValueError): - return fail('Nieprawidłowa firma') - settings=CompanySettings.query.get_or_404(company_id) - if current_user.role=='boss' and current_user.company_id!=settings.id:return fail('Brak uprawnień',403) - if region is not None: s.selected_region=region - s.active=boolv(d,'active');allowed=boolv(d,'allowed') - if allowed and s not in settings.allowed_stations: settings.allowed_stations.append(s) - if not allowed and s in settings.allowed_stations: settings.allowed_stations.remove(s) - db.session.commit();return response(station_dict(s),f'Zapisano ustawienia stacji dla firmy {settings.name}') - -@api.get('/companies') -@role_required('boss','admin') -def companies_list(): - return response([{'id':c.id,'name':c.name,'entity_type':c.entity_type,'region':c.region,'active':c.active,'users_count':len(c.users),'cards_count':FuelCard.query.filter_by(company_id=c.id).count()} for c in CompanySettings.query.order_by(CompanySettings.name).all()]) - -@api.post('/companies') -@role_required('admin') -def companies_create(): - d=payload() - try: - c=CompanySettings(name=(d.get('name') or '').strip(),entity_type=d.get('entity_type','JDG'),vat_rate=Decimal(d.get('vat_rate') or 23),vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or 50),region=(d.get('region') or 'mazowieckie').lower(),invoice_split_day=int(d.get('invoice_split_day') or 15),active=True,station_access_mode='all_prefer_favorites') - if not c.name: raise ValueError('Nazwa firmy jest wymagana') - db.session.add(c);db.session.commit();return response({'id':c.id,'redirect':url_for('main.admin_companies',company_id=c.id)},'Dodano firmę',201) - except Exception as exc:db.session.rollback();return fail(str(exc)) - -@api.delete('/companies/') -@role_required('admin') -def companies_delete(company_id): - company = CompanySettings.query.get_or_404(company_id) - blockers = [] - users_count = User.query.filter_by(company_id=company.id).count() - vehicles_count = Vehicle.query.filter_by(company_id=company.id).count() - cards_count = FuelCard.query.filter_by(company_id=company.id).count() - if users_count: - blockers.append(f'{users_count} użytkowników') - if vehicles_count: - blockers.append(f'{vehicles_count} pojazdów') - if cards_count: - blockers.append(f'{cards_count} kart paliwowych') - if blockers: - return fail( - 'Nie można usunąć firmy, ponieważ ma przypisane: ' + ', '.join(blockers) + - '. Najpierw przenieś lub usuń te dane albo dezaktywuj firmę.', - 409, - ) - company.favorite_stations.clear() - company.allowed_stations.clear() - db.session.delete(company) - db.session.commit() - return response({'redirect':url_for('main.admin_companies')}, 'Usunięto firmę') - -@api.put('/companies/') -@role_required('boss','admin') -def companies_update(company_id): - c=CompanySettings.query.get_or_404(company_id);d=payload() - if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403) - try: - c.name=(d.get('name') or c.name).strip();c.entity_type=d.get('entity_type',c.entity_type);c.vat_rate=Decimal(d.get('vat_rate') or c.vat_rate);c.vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or c.vat_deduction_percent);c.region=(d.get('region') or c.region).lower();c.invoice_split_day=int(d.get('invoice_split_day') or c.invoice_split_day);c.active=boolv(d,'active') - db.session.commit();return response(message='Zapisano firmę') - except Exception as exc:db.session.rollback();return fail(str(exc)) - -@api.put('/companies//favorite-stations') -@role_required('boss','admin') -def company_favorites(company_id): - c=CompanySettings.query.get_or_404(company_id) - if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403) - d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids - try: ids=[int(x) for x in ids] - except Exception:return fail('Nieprawidłowa lista stacji') - if len(ids)>25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji') - c.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] - db.session.commit();return response(message='Zapisano ulubione stacje firmy') - - -@api.put('/companies//station-access') -@role_required('boss','admin') -def company_station_access(company_id): - company=CompanySettings.query.get_or_404(company_id) - if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403) - d=payload();mode=(d.get('station_access_mode') or '').strip() - modes={'all_prefer_favorites','all','allowed_only','favorites_allowed_only'} - if mode not in modes:return fail('Nieprawidłowy tryb dostępu do stacji') - company.station_access_mode=mode - db.session.commit() - return response({'station_access_mode':mode},'Zapisano politykę dostępu do stacji') - -@api.put('/companies//allowed-stations') -@role_required('boss','admin') -def company_allowed_stations(company_id): - company=CompanySettings.query.get_or_404(company_id) - if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403) - d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids - try: ids=[int(x) for x in ids] - except Exception:return fail('Nieprawidłowa lista stacji') - company.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] - db.session.commit() - message='Wszystkie aktywne stacje są dozwolone' if not ids else 'Zapisano zamkniętą listę dozwolonych stacji' - return response([station_dict(x) for x in company.allowed_stations],message) - -@api.post('/companies//favorite-stations//toggle') -@role_required('boss','admin') -def company_favorite_toggle(company_id,station_id): - c=CompanySettings.query.get_or_404(company_id);s=FuelStationCompany.query.get_or_404(station_id) - if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403) - if s in c.favorite_stations:c.favorite_stations.remove(s);state=False - else: - if len(c.favorite_stations)>=25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji') - c.favorite_stations.append(s);state=True - db.session.commit();return response({'favorite':state},'Zmieniono ulubione stacje') diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..015e2c9 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +api = Blueprint("api", __name__, url_prefix="/api") + +from . import auth, companies, fuel_cards, fuel_entries, orlen, settings, stations, users, vehicles # noqa: E402,F401 diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..d97a756 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,44 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.post('/auth/login') +def login(): + d=payload(); user=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first() + if not user or not user.check_password(d.get('password') or '') or not user.active: return fail('Nieprawidłowy e-mail lub hasło',401) + login_user(user); return response({'user':user_dict(user),'redirect':url_for('main.dashboard')},'Zalogowano') + +@api.post('/auth/token') +def token_login(): + d = payload() + email = (d.get('email') or d.get('username') or '').strip().lower() + user = User.query.filter_by(email=email).first() + if not user or not user.check_password(d.get('password') or '') or not user.active: + return jsonify({'error':'invalid_grant','error_description':'Nieprawidłowy e-mail lub hasło'}), 401 + expires_in = int(current_app.config['API_TOKEN_MAX_AGE_SECONDS']) + return jsonify({ + 'access_token': create_access_token(user), + 'token_type': 'Bearer', + 'expires_in': expires_in, + 'user': user_dict(user), + }) + +@api.get('/me') +@login_required +def me(): return response(user_dict(current_user)) diff --git a/app/api/common.py b/app/api/common.py new file mode 100644 index 0000000..7849e75 --- /dev/null +++ b/app/api/common.py @@ -0,0 +1,82 @@ +from functools import wraps + +from flask import jsonify, request +from flask_login import current_user, login_required + +from ..extensions import db +from ..models import CompanySettings, FuelStationCompany, User, Vehicle + + +def response(data=None, message=None, status=200): + payload = {"ok": status < 400} + if message: + payload["message"] = message + if data is not None: + payload["data"] = data + return jsonify(payload), status + + +def fail(message, status=400): + return response(message=message, status=status) + + +def role_required(*allowed): + def decorator(function): + @wraps(function) + @login_required + def wrapped(*args, **kwargs): + if current_user.role not in allowed: + return fail("Brak uprawnień", 403) + return function(*args, **kwargs) + return wrapped + return decorator + + +def payload(): + return request.get_json(silent=True) or request.form + + +def boolv(data, key): + value = data.get(key) + return value in (True, 1, "1", "true", "on", "yes") + + +def accessible_vehicles(): + if current_user.role == "admin": + return Vehicle.query.order_by(Vehicle.name).all() + ids = {vehicle.id for vehicle in current_user.owned_vehicles + current_user.shared_vehicles} + return Vehicle.query.filter(Vehicle.id.in_(ids)).order_by(Vehicle.name).all() if ids else [] + + +def user_dict(user): + return { + "id": user.id, "name": user.name, "email": user.email, "role": user.role, + "active": user.active, "fuel_card_id": user.fuel_card_id, + "fuel_card": user.fuel_card.name if user.fuel_card else None, + "company_id": user.company_id, "company": user.company.name if user.company else None, + } + + +def vehicle_dict(vehicle): + return { + "id": vehicle.id, "name": vehicle.name, "make": vehicle.make, "model": vehicle.model, + "registration": vehicle.registration, "fuel_type": vehicle.fuel_type, + "owner": user_dict(vehicle.owner), "drivers": [user_dict(x) for x in vehicle.drivers], + "has_fuel_card": bool(vehicle.fuel_card_id), "fuel_card_id": vehicle.fuel_card_id, + "fuel_card": vehicle.fuel_card.name if vehicle.fuel_card else None, + "current_odometer": vehicle.current_odometer, + } + + +def station_dict(station): + settings = CompanySettings.query.first() + return { + "id": station.id, "brand_name": station.display_name, "company_name": station.company_name, + "nip": station.nip, "regon": station.regon, "brands": station.station_name_list, + "regions": station.region_list, "selected_region": station.selected_region, + "station_count": station.station_count, "has_petrol": station.has_petrol, + "has_diesel": station.has_diesel, "has_lpg": station.has_lpg, + "use_orlen_last_price": station.use_orlen_last_price, "active": station.active, + "favorite": station in current_user.favorite_stations if current_user.is_authenticated else False, + "allowed": station in settings.allowed_stations if settings else True, + } diff --git a/app/api/companies.py b/app/api/companies.py new file mode 100644 index 0000000..8c4e169 --- /dev/null +++ b/app/api/companies.py @@ -0,0 +1,119 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.get('/companies') +@role_required('boss','admin') +def companies_list(): + return response([{'id':c.id,'name':c.name,'entity_type':c.entity_type,'region':c.region,'active':c.active,'users_count':len(c.users),'cards_count':FuelCard.query.filter_by(company_id=c.id).count()} for c in CompanySettings.query.order_by(CompanySettings.name).all()]) + +@api.post('/companies') +@role_required('admin') +def companies_create(): + d=payload() + try: + c=CompanySettings(name=(d.get('name') or '').strip(),entity_type=d.get('entity_type','JDG'),vat_rate=Decimal(d.get('vat_rate') or 23),vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or 50),region=(d.get('region') or 'mazowieckie').lower(),invoice_split_day=int(d.get('invoice_split_day') or 15),active=True,station_access_mode='all_prefer_favorites') + if not c.name: raise ValueError('Nazwa firmy jest wymagana') + db.session.add(c);db.session.commit();return response({'id':c.id,'redirect':url_for('main.admin_companies',company_id=c.id)},'Dodano firmę',201) + except Exception as exc:db.session.rollback();return fail(str(exc)) + +@api.delete('/companies/') +@role_required('admin') +def companies_delete(company_id): + company = CompanySettings.query.get_or_404(company_id) + blockers = [] + users_count = User.query.filter_by(company_id=company.id).count() + vehicles_count = Vehicle.query.filter_by(company_id=company.id).count() + cards_count = FuelCard.query.filter_by(company_id=company.id).count() + if users_count: + blockers.append(f'{users_count} użytkowników') + if vehicles_count: + blockers.append(f'{vehicles_count} pojazdów') + if cards_count: + blockers.append(f'{cards_count} kart paliwowych') + if blockers: + return fail( + 'Nie można usunąć firmy, ponieważ ma przypisane: ' + ', '.join(blockers) + + '. Najpierw przenieś lub usuń te dane albo dezaktywuj firmę.', + 409, + ) + company.favorite_stations.clear() + company.allowed_stations.clear() + db.session.delete(company) + db.session.commit() + return response({'redirect':url_for('main.admin_companies')}, 'Usunięto firmę') + +@api.put('/companies/') +@role_required('boss','admin') +def companies_update(company_id): + c=CompanySettings.query.get_or_404(company_id);d=payload() + if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403) + try: + c.name=(d.get('name') or c.name).strip();c.entity_type=d.get('entity_type',c.entity_type);c.vat_rate=Decimal(d.get('vat_rate') or c.vat_rate);c.vat_deduction_percent=Decimal(d.get('vat_deduction_percent') or c.vat_deduction_percent);c.region=(d.get('region') or c.region).lower();c.invoice_split_day=int(d.get('invoice_split_day') or c.invoice_split_day);c.active=boolv(d,'active') + db.session.commit();return response(message='Zapisano firmę') + except Exception as exc:db.session.rollback();return fail(str(exc)) + +@api.put('/companies//favorite-stations') +@role_required('boss','admin') +def company_favorites(company_id): + c=CompanySettings.query.get_or_404(company_id) + if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403) + d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids + try: ids=[int(x) for x in ids] + except Exception:return fail('Nieprawidłowa lista stacji') + if len(ids)>25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji') + c.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] + db.session.commit();return response(message='Zapisano ulubione stacje firmy') + + +@api.put('/companies//station-access') +@role_required('boss','admin') +def company_station_access(company_id): + company=CompanySettings.query.get_or_404(company_id) + if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403) + d=payload();mode=(d.get('station_access_mode') or '').strip() + modes={'all_prefer_favorites','all','allowed_only','favorites_allowed_only'} + if mode not in modes:return fail('Nieprawidłowy tryb dostępu do stacji') + company.station_access_mode=mode + db.session.commit() + return response({'station_access_mode':mode},'Zapisano politykę dostępu do stacji') + +@api.put('/companies//allowed-stations') +@role_required('boss','admin') +def company_allowed_stations(company_id): + company=CompanySettings.query.get_or_404(company_id) + if current_user.role=='boss' and current_user.company_id!=company.id:return fail('Brak uprawnień',403) + d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids + try: ids=[int(x) for x in ids] + except Exception:return fail('Nieprawidłowa lista stacji') + company.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] + db.session.commit() + message='Wszystkie aktywne stacje są dozwolone' if not ids else 'Zapisano zamkniętą listę dozwolonych stacji' + return response([station_dict(x) for x in company.allowed_stations],message) + +@api.post('/companies//favorite-stations//toggle') +@role_required('boss','admin') +def company_favorite_toggle(company_id,station_id): + c=CompanySettings.query.get_or_404(company_id);s=FuelStationCompany.query.get_or_404(station_id) + if current_user.role=='boss' and current_user.company_id!=c.id:return fail('Brak uprawnień',403) + if s in c.favorite_stations:c.favorite_stations.remove(s);state=False + else: + if len(c.favorite_stations)>=25:return fail('Firma może mieć maksymalnie 25 ulubionych stacji') + c.favorite_stations.append(s);state=True + db.session.commit();return response({'favorite':state},'Zmieniono ulubione stacje') diff --git a/app/api/fuel_cards.py b/app/api/fuel_cards.py new file mode 100644 index 0000000..fac4668 --- /dev/null +++ b/app/api/fuel_cards.py @@ -0,0 +1,81 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.get('/fuel-cards') +@login_required +def get_fuel_cards(): + rows=FuelCard.query.order_by(FuelCard.name).all() + return response([{'id':x.id,'name':x.name,'provider':x.provider,'active':x.active,'description':x.description} for x in rows]) + +@api.post('/fuel-cards') +@role_required('boss','admin') +def create_fuel_card(): + d=payload() + try: + company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id) + if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403) + card=FuelCard(name=d['name'].strip(),provider=d['provider'].strip(),description=(d.get('description') or '').strip() or None,active=True,company_id=company_id) + db.session.add(card);db.session.commit();return response({'id':card.id},'Dodano kartę paliwową',201) + except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc))) + +@api.put('/fuel-cards/') +@role_required('boss','admin') +def update_fuel_card(card_id): + card=FuelCard.query.get_or_404(card_id);d=payload();card.name=d.get('name',card.name).strip();card.provider=d.get('provider',card.provider).strip();card.description=(d.get('description') or '').strip() or None;card.active=boolv(d,'active');db.session.commit();return response(message='Zapisano kartę') + +@api.delete('/fuel-cards/') +@role_required('boss','admin') +def delete_fuel_card(card_id): + card = FuelCard.query.get_or_404(card_id) + if current_user.role == 'boss' and card.company_id != current_user.company_id: + return fail('Brak uprawnień', 403) + + users_count = User.query.filter_by(fuel_card_id=card.id).count() + vehicles_count = Vehicle.query.filter_by(fuel_card_id=card.id).count() + entries_count = FuelEntry.query.filter_by(fuel_card_id=card.id).count() + blockers = [] + if users_count: + blockers.append(f'{users_count} użytkowników') + if vehicles_count: + blockers.append(f'{vehicles_count} pojazdów') + if entries_count: + blockers.append(f'{entries_count} tankowań') + if blockers: + return fail( + 'Nie można usunąć karty, ponieważ jest przypisana do: ' + ', '.join(blockers) + + '. Najpierw odłącz kartę od użytkowników i pojazdów. Karty użytej w tankowaniach nie można usunąć; możesz ją wyłączyć.', + 409, + ) + + company_id = card.company_id + db.session.delete(card) + db.session.commit() + return response( + {'redirect':url_for('main.admin_companies', company_id=company_id)}, + 'Usunięto kartę flotową', + ) + +@api.put('/fuel-cards//stations/') +@role_required('boss','admin') +def update_card_station_rule(card_id,station_id): + FuelCard.query.get_or_404(card_id);FuelStationCompany.query.get_or_404(station_id);d=payload() + rule=FuelCardStationRule.query.filter_by(fuel_card_id=card_id,station_company_id=station_id).first() or FuelCardStationRule(fuel_card_id=card_id,station_company_id=station_id) + rule.allowed=boolv(d,'allowed');rule.use_orlen_last_price=boolv(d,'use_orlen_last_price');rule.discount_net_percent=Decimal(d.get('discount_net_percent') or 0);rule.surcharge_net_per_liter=Decimal(d.get('surcharge_net_per_liter') or 0) + db.session.add(rule);db.session.commit();return response(message='Zapisano warunki karty dla stacji') diff --git a/app/api/fuel_entries.py b/app/api/fuel_entries.py new file mode 100644 index 0000000..0044bc1 --- /dev/null +++ b/app/api/fuel_entries.py @@ -0,0 +1,48 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.post('/fuel-entries') +@login_required +def create_fuel_entry(): + d=payload(); vehicles=accessible_vehicles() + try: vehicle=next((v for v in vehicles if v.id==int(d['vehicle_id'])),None) + except Exception: vehicle=None + if not vehicle:return fail('Brak dostępu do pojazdu',403) + try: + odo=int(d['odometer']); fueled_at=datetime.fromisoformat(d['fueled_at']); station_company=db.session.get(FuelStationCompany,int(d['station_company_id'])) if d.get('station_company_id') else None + settings=vehicle.company or current_user.company or CompanySettings.query.first() + access_mode=settings.station_access_mode or 'all_prefer_favorites' + if station_company and access_mode=='allowed_only' and station_company not in settings.allowed_stations: + return fail('Ta stacja nie jest dozwolona przez politykę firmy',403) + if station_company and access_mode=='favorites_allowed_only' and station_company not in settings.allowed_stations and station_company not in settings.favorite_stations: + return fail('Ta stacja nie jest ulubiona ani dozwolona przez politykę firmy',403) + wholesale=source=None; region=(station_company.selected_region if station_company else None) or settings.region + try: + p=fetch_orlen_price(d['fuel_type'],fueled_at.date(),region);wholesale=p['price_per_liter'];source=p['source'] + except Exception: pass + card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else (vehicle.fuel_card_id or current_user.fuel_card_id) + card=db.session.get(FuelCard,card_id) if card_id else None + used_card=boolv(d,'used_fuel_card') and bool(card) + if used_card and station_company: + rule=FuelCardStationRule.query.filter_by(fuel_card_id=card.id,station_company_id=station_company.id).first() + if rule and not rule.allowed:return fail('Ta stacja jest zablokowana dla wybranej karty paliwowej',403) + e=FuelEntry(vehicle_id=vehicle.id,user_id=current_user.id,fueled_at=fueled_at,liters=Decimal(d['liters']),price_per_liter=Decimal(d['price_per_liter']),fuel_type=d['fuel_type'],odometer=odo,station=station_company.company_name if station_company else d.get('station'),station_company_id=station_company.id if station_company else None,fuel_card_id=card.id if used_card else None,invoice_number=d.get('invoice_number'),used_fuel_card=used_card,wholesale_price=wholesale,wholesale_source=source) + vehicle.current_odometer=odo;db.session.add(e);db.session.commit();return response({'id':e.id,'redirect':url_for('main.dashboard')},'Zapisano tankowanie',201) + except Exception as exc:db.session.rollback();return fail(str(exc),400) diff --git a/app/api/orlen.py b/app/api/orlen.py new file mode 100644 index 0000000..5e03b3c --- /dev/null +++ b/app/api/orlen.py @@ -0,0 +1,53 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.get('/orlen/prices') +@login_required +def orlen_prices(): + year=request.args.get('year',type=int) or date.today().year; fuels=[x.upper() for x in request.args.getlist('fuel') if x.upper() in FUEL_TYPES] or ['PB95']; regions=[x.lower() for x in request.args.getlist('region') if x.lower() in POLISH_REGIONS] + rows=OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(fuels),extract('year',OrlenPrice.effective_date)==year).order_by(OrlenPrice.effective_date).all() + return response([{'fuel_type':r.fuel_type,'date':r.effective_date.isoformat(),'price_per_liter':float(r.price_per_liter),'region':r.region,'source':r.source} for r in rows if r.fuel_type!='LPG' or not regions or r.region in regions]) + +@api.post('/orlen/sync') +@role_required('boss','admin') +def orlen_sync(): + d=payload(); fuels=d.get('fuels',[]); fuels=[fuels] if isinstance(fuels,str) else fuels; fuels=[f.upper() for f in fuels if f.upper() in FUEL_TYPES]; year=int(d.get('year',date.today().year)) + regions=d.get('regions',[]); regions=[regions] if isinstance(regions,str) else regions; regions=[str(r).strip().lower() for r in regions if str(r).strip().lower() in POLISH_REGIONS] + if not fuels:return fail('Wybierz co najmniej jedno paliwo') + settings=CompanySettings.query.first(); result={}; total=0 + try: + for fuel in fuels: + added=updated=0 + for row in fetch_orlen_range(fuel,date(year,1,1),min(date(year,12,31),date.today()),'all' if fuel=='LPG' else settings.region): + if fuel=='LPG' and regions and row.get('region') not in regions: continue + x=OrlenPrice.query.filter_by(fuel_type=row['fuel_type'],effective_date=row['effective_date'],region=row['region']).first() + if x: + for k,v in row.items():setattr(x,k,v) + x.fetched_at=datetime.utcnow();updated+=1 + else:db.session.add(OrlenPrice(**row));added+=1 + result[fuel]={'added':added,'updated':updated};total+=added+updated + db.session.commit();return response(result,f'Przetworzono {total} rekordów') + except Exception as exc:db.session.rollback();return fail(str(exc),502) + +@api.get('/orlen/preview') +@login_required +def orlen_preview(): + try:return response(fetch_orlen_price(request.args['fuel_type'],datetime.fromisoformat(request.args['date']).date(),request.args.get('region') or CompanySettings.query.first().region)) + except Exception as exc:return fail(str(exc),502) diff --git a/app/api/settings.py b/app/api/settings.py new file mode 100644 index 0000000..cd99307 --- /dev/null +++ b/app/api/settings.py @@ -0,0 +1,46 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.get('/settings') +@role_required('boss','admin') +def get_settings(): + s=CompanySettings.query.first();return response({'name':s.name,'entity_type':s.entity_type,'vat_rate':float(s.vat_rate),'vat_deduction_percent':float(s.vat_deduction_percent),'region':s.region,'invoice_split_day':s.invoice_split_day,'invoice_split_enabled':(db.session.get(AppSetting,'invoice_split_enabled').value=='1')}) + +@api.put('/settings') +@role_required('boss','admin') +def update_settings(): + d=payload();s=CompanySettings.query.first() + try: + s.name=d['name'];s.entity_type=d['entity_type'];s.vat_rate=Decimal(d['vat_rate']);s.vat_deduction_percent=Decimal(d['vat_deduction_percent']);s.region=d['region'].strip().lower();s.invoice_split_day=int(d.get('invoice_split_day') or 15) + db.session.merge(AppSetting(key='invoice_split_enabled',value='1' if boolv(d,'invoice_split_enabled') else '0'));db.session.commit();return response(message='Zapisano ustawienia') + except Exception as exc:db.session.rollback();return fail(str(exc)) + + +@api.get('/app-settings') +@role_required('boss','admin') +def get_app_settings(): + return response({'theme':(db.session.get(AppSetting,'theme') or AppSetting(value='bootstrap')).value}) + +@api.put('/app-settings') +@role_required('boss','admin') +def update_app_settings(): + d=payload();theme=d.get('theme','bootstrap') + if theme not in THEMES:return fail('Nieprawidłowy motyw') + db.session.merge(AppSetting(key='theme',value=theme));db.session.commit();return response(message='Zapisano ustawienia aplikacji') diff --git a/app/api/stations.py b/app/api/stations.py new file mode 100644 index 0000000..3615494 --- /dev/null +++ b/app/api/stations.py @@ -0,0 +1,96 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.get('/stations') +@login_required +def stations(): + q=request.args.get('q','').strip();page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',30,type=int),100);query=FuelStationCompany.query.filter_by(active=True) + if q: + for term in [x for x in q.split() if x]: + needle=f'%{term}%' + query=query.filter(db.or_(FuelStationCompany.brand_name.ilike(needle),FuelStationCompany.company_name.ilike(needle),FuelStationCompany.nip.ilike(needle),FuelStationCompany.regon.ilike(needle),FuelStationCompany.station_names.ilike(needle))) + sort=request.args.get('sort','company_name');direction=request.args.get('direction','asc') + allowed={'company_name':FuelStationCompany.brand_name,'station_count':FuelStationCompany.station_count,'nip':FuelStationCompany.nip,'regon':FuelStationCompany.regon,'selected_region':FuelStationCompany.selected_region} + col=allowed.get(sort,FuelStationCompany.company_name);query=query.order_by(col.desc() if direction=='desc' else col.asc(),FuelStationCompany.brand_name.asc()) + p=query.paginate(page=page,per_page=per,error_out=False);return response({'items':[station_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total,'sort':sort,'direction':direction}) + +@api.get('/stations//points') +@login_required +def station_points(station_id): + station=FuelStationCompany.query.get_or_404(station_id) + page=max(request.args.get('page',1,type=int),1) + per_page=min(max(request.args.get('per_page',50,type=int),1),100) + q=(request.args.get('q') or '').strip() + query=FuelStationPoint.query.filter_by(station_company_id=station.id) + if q: + needle=f'%{q}%' + query=query.filter(db.or_(FuelStationPoint.station_name.ilike(needle),FuelStationPoint.street.ilike(needle),FuelStationPoint.city.ilike(needle),FuelStationPoint.postal_code.ilike(needle))) + query=query.order_by(FuelStationPoint.city.asc(),FuelStationPoint.street.asc(),FuelStationPoint.street_number.asc()) + result=query.paginate(page=page,per_page=per_page,error_out=False) + items=[{'id':x.id,'dkn':x.ure_dkn.split(':',1)[0] if x.ure_dkn else '','name':x.station_name,'address':x.address,'city':x.city,'region':x.region,'coordinates':x.coordinates,'has_petrol':x.has_petrol,'has_diesel':x.has_diesel,'has_lpg':x.has_lpg} for x in result.items] + return response({'station':station_dict(station),'items':items,'page':result.page,'pages':result.pages,'total':result.total}) + +@api.put('/me/favorite-stations') +@login_required +def favorite_stations(): + d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids + try: ids=[int(x) for x in ids] + except Exception: return fail('Nieprawidłowa lista stacji') + if len(ids)>10:return fail('Użytkownik może mieć maksymalnie 10 ulubionych stacji') + current_user.favorite_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] + db.session.commit();return response([station_dict(x) for x in current_user.favorite_stations],'Zapisano ulubione stacje') + +@api.put('/settings/allowed-stations') +@role_required('boss','admin') +def allowed_stations(): + d=payload();ids=request.form.getlist('station_ids') if request.form else d.get('station_ids',[]);ids=[ids] if isinstance(ids,(str,int)) else ids + try: ids=[int(x) for x in ids] + except Exception: return fail('Nieprawidłowa lista stacji') + settings=CompanySettings.query.first();settings.allowed_stations=FuelStationCompany.query.filter(FuelStationCompany.id.in_(ids),FuelStationCompany.active.is_(True)).all() if ids else [] + db.session.commit();return response([station_dict(x) for x in settings.allowed_stations],'Zapisano dozwolone stacje') + +@api.post('/stations/sync') +@role_required('boss','admin') +def stations_sync(): + try: + companies=aggregate_ure_companies(fetch_ure_stations()) + added,updated=sync_station_catalog(companies) + db.session.commit();return response({'added':added,'updated':updated},f'Katalog URE: dodano {added}, zaktualizowano {updated}') + except Exception as exc:db.session.rollback();return fail(f'Nie udało się pobrać danych URE: {exc}',502) + +@api.put('/stations/') +@role_required('boss','admin') +def update_station(station_id): + s=FuelStationCompany.query.get_or_404(station_id);d=payload() + region=None + if 'selected_region' in d: + region=(d.get('selected_region') or '').strip().lower() + if region and region not in s.region_list:return fail('Wybrane województwo nie występuje w danych tej firmy') + try: + company_id=int(d.get('company_id') or current_user.company_id or 0) + except (TypeError,ValueError): + return fail('Nieprawidłowa firma') + settings=CompanySettings.query.get_or_404(company_id) + if current_user.role=='boss' and current_user.company_id!=settings.id:return fail('Brak uprawnień',403) + if region is not None: s.selected_region=region + s.active=boolv(d,'active');allowed=boolv(d,'allowed') + if allowed and s not in settings.allowed_stations: settings.allowed_stations.append(s) + if not allowed and s in settings.allowed_stations: settings.allowed_stations.remove(s) + db.session.commit();return response(station_dict(s),f'Zapisano ustawienia stacji dla firmy {settings.name}') diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..3c20049 --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,71 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.get('/users') +@role_required('admin') +def users(): + page=request.args.get('page',1,type=int);per=min(request.args.get('per_page',25,type=int),100);q=request.args.get('q','').strip();query=User.query + if q: query=query.filter(db.or_(User.name.ilike(f'%{q}%'),User.email.ilike(f'%{q}%'))) + p=query.order_by(User.name).paginate(page=page,per_page=per,error_out=False);return response({'items':[user_dict(x) for x in p.items],'page':p.page,'pages':p.pages,'total':p.total}) + +@api.post('/users') +@role_required('boss','admin') +def create_user(): + d=payload() + try: + if len(d.get('password',''))<8:raise ValueError('Hasło musi mieć co najmniej 8 znaków') + company_id=int(d.get('company_id') or current_user.company_id or CompanySettings.query.first().id) + if current_user.role=='boss' and company_id!=current_user.company_id:return fail('Brak uprawnień',403) + u=User(name=d['user_name'].strip(),email=d['email'].strip().lower(),role=d.get('role','user'),company_id=company_id);u.set_password(d['password']);db.session.add(u);db.session.commit();return response(user_dict(u),'Dodano użytkownika',201) + except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc))) + +@api.put('/users/') +@role_required('admin') +def update_user(user_id): + u=User.query.get_or_404(user_id);d=payload() + try: + u.name=d['name'].strip();u.email=d['email'].strip().lower();u.role=d['role'];u.active=boolv(d,'active');u.company_id=int(d.get('company_id') or u.company_id or CompanySettings.query.first().id);u.fuel_card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None;pw=d.get('password','') + if pw: + if len(pw)<8:raise ValueError('Nowe hasło musi mieć co najmniej 8 znaków') + u.set_password(pw) + db.session.commit();return response(user_dict(u),'Zaktualizowano użytkownika') + except Exception as exc:db.session.rollback();return fail(str(getattr(exc,'orig',exc))) + +@api.delete('/users/') +@role_required('admin') +def delete_user(user_id): + user = User.query.get_or_404(user_id) + if user.id == current_user.id: + return fail('Nie możesz usunąć własnego konta administratora', 400) + if user.role == 'admin': + remaining_active_admins = User.query.filter( + User.role == 'admin', User.active.is_(True), User.id != user.id + ).count() + if remaining_active_admins < 1: + return fail('Nie można usunąć ostatniego aktywnego administratora', 409) + if user.owned_vehicles: + return fail('Najpierw przypisz pojazdy tego użytkownika innemu właścicielowi', 409) + if FuelEntry.query.filter_by(user_id=user.id).first(): + return fail('Użytkownik ma historię tankowań i nie może zostać usunięty. Możesz go dezaktywować.', 409) + user.shared_vehicles.clear() + user.favorite_stations.clear() + db.session.delete(user) + db.session.commit() + return response(message='Usunięto użytkownika') diff --git a/app/api/vehicles.py b/app/api/vehicles.py new file mode 100644 index 0000000..a719a6c --- /dev/null +++ b/app/api/vehicles.py @@ -0,0 +1,74 @@ +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal + +from flask import current_app, jsonify, request, url_for +from flask_login import current_user, login_required, login_user +from sqlalchemy import extract +from sqlalchemy.exc import IntegrityError + +from . import api +from .common import accessible_vehicles, boolv, fail, payload, response, role_required, station_dict, user_dict, vehicle_dict +from .. import THEMES +from ..api_tokens import create_access_token +from ..extensions import db +from ..models import AppSetting, CompanySettings, FuelCard, FuelCardPolicy, FuelCardStationRule, FuelEntry, FuelStationCompany, FuelStationPoint, OrlenPrice, User, Vehicle +from ..services import POLISH_REGIONS, aggregate_ure_companies, calculate_costs, fetch_orlen_price, fetch_orlen_range, fetch_ure_stations, invoice_period +from ..station_catalog import sync_station_catalog + +FUEL_TYPES = ("PB95", "PB98", "DIESEL", "LPG") + +@api.get('/vehicles') +@login_required +def get_vehicles(): return response([vehicle_dict(v) for v in accessible_vehicles()]) + +@api.post('/vehicles') +@login_required +def create_vehicle(): + d=payload() + try: + card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None + owner_id=int(d.get('owner_id') or current_user.id);owner=db.session.get(User,owner_id) + if not owner: return fail('Nie znaleziono właściciela',404) + if current_user.role not in ('boss','admin') and owner.id!=current_user.id:return fail('Brak uprawnień',403) + if current_user.role=='boss' and owner.company_id!=current_user.company_id:return fail('Użytkownik jest w innej firmie',403) + v=Vehicle(name=d['name'].strip(),make=d['make'].strip(),model=d['model'].strip(),registration=d['registration'].strip().upper(),fuel_type=d['fuel_type'],owner_id=owner.id,company_id=owner.company_id,has_fuel_card=bool(card_id),fuel_card_id=card_id,current_odometer=int(d.get('current_odometer') or 0)) + db.session.add(v); db.session.commit(); return response(vehicle_dict(v),'Dodano pojazd',201) + except (KeyError,ValueError,IntegrityError) as exc: db.session.rollback(); return fail(str(getattr(exc,'orig',exc)),400) + +@api.put('/vehicles//fuel-card') +@login_required +def assign_vehicle_fuel_card(vehicle_id): + v=Vehicle.query.get_or_404(vehicle_id) + if current_user.role not in ('boss','admin') and v.owner_id!=current_user.id:return fail('Brak uprawnień',403) + d=payload();card_id=int(d['fuel_card_id']) if d.get('fuel_card_id') else None + card=db.session.get(FuelCard,card_id) if card_id else None + if card_id and not card:return fail('Nie znaleziono karty paliwowej',404) + if card and v.company_id and card.company_id!=v.company_id:return fail('Karta należy do innej firmy',400) + v.fuel_card_id=card_id;v.has_fuel_card=bool(card_id);db.session.commit();return response(vehicle_dict(v),'Zapisano kartę pojazdu') + +@api.post('/vehicles//shares') +@login_required +def share_vehicle(vehicle_id): + v=Vehicle.query.get_or_404(vehicle_id) + if current_user.role!='admin' and v.owner_id!=current_user.id: return fail('Brak uprawnień',403) + d=payload(); u=User.query.filter_by(email=(d.get('email') or '').strip().lower()).first() + if not u:return fail('Nie znaleziono użytkownika',404) + if u not in v.drivers:v.drivers.append(u);db.session.commit();return response(vehicle_dict(v),'Udostępniono pojazd') + +@api.delete('/vehicles//shares/') +@login_required +def unshare_vehicle(vehicle_id,user_id): + v=Vehicle.query.get_or_404(vehicle_id) + if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403) + u=User.query.get_or_404(user_id) + if u in v.drivers:v.drivers.remove(u);db.session.commit();return response(vehicle_dict(v),'Odebrano dostęp') + +@api.put('/vehicles//card-policy') +@login_required +def card_policy(vehicle_id): + v=Vehicle.query.get_or_404(vehicle_id) + if current_user.role!='admin' and v.owner_id!=current_user.id:return fail('Brak uprawnień',403) + d=payload();p=v.fuel_card_policy or FuelCardPolicy(vehicle=v) + p.use_orlen_last_price=False;p.discount_percent=Decimal(d.get('discount_percent') or 0);p.surcharge_per_liter=Decimal(d.get('surcharge_per_liter') or 0);p.description=(d.get('description') or '').strip() or None + db.session.add(p);db.session.commit();return response(message='Zapisano zasady rozliczeń') diff --git a/app/auth.py b/app/auth.py index 84496b4..599945a 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,19 +1,58 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash -from flask_login import login_user, logout_user, login_required +from urllib.parse import urljoin, urlparse +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required, login_user, logout_user from .models import User auth = Blueprint("auth", __name__) +def is_safe_redirect_url(target: str | None) -> bool: + if not target: + return False + + host_url = urlparse(request.host_url) + redirect_url = urlparse(urljoin(request.host_url, target)) + + return ( + redirect_url.scheme in {"http", "https"} + and redirect_url.netloc == host_url.netloc + ) + + @auth.route("/login", methods=["GET", "POST"]) def login(): - if request.method == "POST": - user = User.query.filter_by(email=request.form["email"].strip().lower()).first() - if user and user.check_password(request.form["password"]): - login_user(user); return redirect(url_for("main.dashboard")) - flash("Nieprawidłowy e-mail lub hasło", "danger") - return render_template("login.html") + if current_user.is_authenticated: + return redirect(url_for("main.dashboard")) -@auth.route("/logout") + if request.method == "GET": + return render_template("login.html") + + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + remember = request.form.get("remember") == "on" + + if not email or not password: + flash("Podaj adres e-mail i hasło.", "warning") + return render_template("login.html"), 400 + + user = User.query.filter_by(email=email).first() + + if user is None or not user.check_password(password): + flash("Nieprawidłowy e-mail lub hasło.", "danger") + return render_template("login.html"), 401 + + login_user(user, remember=remember) + + next_url = request.args.get("next") + + if is_safe_redirect_url(next_url): + return redirect(next_url) + + return redirect(url_for("main.dashboard")) + + +@auth.route("/logout", methods=["POST"]) @login_required def logout(): - logout_user(); return redirect(url_for("auth.login")) + logout_user() + flash("Zostałeś wylogowany.", "success") + return redirect(url_for("auth.login")) \ No newline at end of file diff --git a/app/openapi.py b/app/openapi.py index a29cc1c..99c9c40 100644 --- a/app/openapi.py +++ b/app/openapi.py @@ -19,7 +19,6 @@ def spec(): 'paths':{ '/api/auth/login':{'post':{'tags':['Auth'],'security':[],'summary':'Logowanie','requestBody':{'required':True,'content':{'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string'},'password':{'type':'string'}}}}}},'responses':{'200':{'description':'Zalogowano'},'401':{'description':'Błędne dane'}}}}, '/api/auth/token':{'post':{'tags':['Auth'],'security':[],'summary':'Pobranie tokenu Bearer','description':'Endpoint zgodny z OAuth2 Password używany przez przycisk Authorize w Swagger UI. W polu username wpisz adres e-mail.','requestBody':{'required':True,'content':{'application/x-www-form-urlencoded':{'schema':{'type':'object','required':['username','password'],'properties':{'username':{'type':'string','format':'email','description':'Adres e-mail'},'password':{'type':'string','format':'password'}}}},'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string','format':'email'},'password':{'type':'string','format':'password'}}}}}},'responses':{'200':{'description':'Token wydany','content':{'application/json':{'schema':{'type':'object','properties':{'access_token':{'type':'string'},'token_type':{'type':'string','example':'Bearer'},'expires_in':{'type':'integer'}}}}}},'401':{'description':'Błędne dane logowania'}}}}, - '/api/auth/logout':{'post':{'tags':['Auth'],'summary':'Wylogowanie','responses':{'200':{'description':'Wylogowano'}}}}, '/api/me':{'get':{'tags':['Auth'],'summary':'Bieżący użytkownik','responses':{'200':{'description':'Użytkownik'}}}}, '/api/vehicles':{'get':{'tags':['Vehicles'],'summary':'Lista dostępnych pojazdów','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Vehicles'],'summary':'Dodanie pojazdu','responses':{'201':{'description':'Utworzono'}}}}, '/api/vehicles/{vehicle_id}/shares':{'post':{'tags':['Vehicles'],'summary':'Udostępnienie pojazdu','parameters':[{'in':'path','name':'vehicle_id','required':True,'schema':{'type':'integer'}}],'responses':{'200':{'description':'Udostępniono'}}}}, diff --git a/app/static/js/auth.js b/app/static/js/auth.js index 1a1b3b8..286dd7d 100644 --- a/app/static/js/auth.js +++ b/app/static/js/auth.js @@ -1 +1 @@ -(()=>{const F=FuelTrack;F.bindAuth=()=>{const login=F.qs('form.api-login-form');if(login&&!login.dataset.bound){login.dataset.bound='1';login.addEventListener('submit',async e=>{e.preventDefault();try{const r=await fetch('/api/auth/login',{method:'POST',body:new FormData(login),headers:{Accept:'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.message||'Błąd logowania');location.href=(d.data||{}).redirect||'/'}catch(x){F.notify(x.message,'danger')}})}F.qsa('[data-api-logout]').forEach(a=>{if(a.dataset.bound)return;a.dataset.bound='1';a.addEventListener('click',async e=>{e.preventDefault();const d=await (await fetch('/api/auth/logout',{method:'POST'})).json();location.href=(d.data||{}).redirect||'/login'})})}})(); +(()=>{const F=FuelTrack;F.bindAuth=()=>{const login=F.qs('form.api-login-form');if(login&&!login.dataset.bound){login.dataset.bound='1';login.addEventListener('submit',async e=>{e.preventDefault();try{const r=await fetch('/api/auth/login',{method:'POST',body:new FormData(login),headers:{Accept:'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.message||'Błąd logowania');location.href=(d.data||{}).redirect||'/'}catch(x){F.notify(x.message,'danger')}})}}})(); diff --git a/app/templates/base.html b/app/templates/base.html index 3544932..698cd63 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -31,7 +31,9 @@ Administracja {% endif %} - Wyloguj +
+ +