Files
fuel_track/app/api.py
T
2026-07-13 15:17:14 +02:00

471 lines
31 KiB
Python

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, socketio
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(); socketio.emit('app_changed',{'resource':'vehicle','id':v.id}); 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/<int:vehicle_id>/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();socketio.emit('app_changed',{'resource':'vehicle','id':v.id});return response(vehicle_dict(v),'Zapisano kartę pojazdu')
@api.post('/vehicles/<int:vehicle_id>/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()
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Udostępniono pojazd')
@api.delete('/vehicles/<int:vehicle_id>/shares/<int:user_id>')
@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()
socketio.emit('app_changed',{'resource':'vehicle_share','id':v.id});return response(vehicle_dict(v),'Odebrano dostęp')
@api.put('/vehicles/<int:vehicle_id>/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();socketio.emit('app_changed',{'resource':'card_policy','id':v.id});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()
if station_company and settings.allowed_stations and station_company not in settings.allowed_stations:
return fail('Ta stacja nie jest 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();socketio.emit('fuel_added',{'vehicle':vehicle.name,'gross':round(e.gross,2)});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();socketio.emit('app_changed',{'resource':'orlen'});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/<int:user_id>')
@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/<int:user_id>')
@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()
socketio.emit('app_changed', {'resource':'users','deleted_id':user_id})
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();socketio.emit('app_changed',{'resource':'settings'});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();socketio.emit('app_changed',{'resource':'app_settings'});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();socketio.emit('app_changed',{'resource':'fuel_cards'});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/<int:card_id>')
@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.put('/fuel-cards/<int:card_id>/stations/<int:station_id>')
@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();socketio.emit('app_changed',{'resource':'card_station_rule'});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/<int:station_id>/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();socketio.emit('app_changed',{'resource':'favorite_stations','user_id':current_user.id});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();socketio.emit('app_changed',{'resource':'allowed_stations'});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();socketio.emit('app_changed',{'resource':'stations'});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/<int:station_id>')
@role_required('boss','admin')
def update_station(station_id):
s=FuelStationCompany.query.get_or_404(station_id);d=payload();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)
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();socketio.emit('app_changed',{'resource':'stations','company_id':settings.id});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)
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/<int:company_id>')
@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()
socketio.emit('app_changed', {'resource':'company','deleted_id':company_id})
return response({'redirect':url_for('main.admin_companies')}, 'Usunięto firmę')
@api.put('/companies/<int:company_id>')
@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();socketio.emit('app_changed',{'resource':'company','id':c.id});return response(message='Zapisano firmę')
except Exception as exc:db.session.rollback();return fail(str(exc))
@api.put('/companies/<int:company_id>/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();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response(message='Zapisano ulubione stacje firmy')
@api.put('/companies/<int:company_id>/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();socketio.emit('app_changed',{'resource':'allowed_stations','company_id':company.id})
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/<int:company_id>/favorite-stations/<int:station_id>/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();socketio.emit('app_changed',{'resource':'company_favorites','company_id':c.id});return response({'favorite':state},'Zmieniono ulubione stacje')