split api pythons

This commit is contained in:
Mateusz Gruszczyński
2026-07-14 14:55:46 +02:00
parent 72757cad55
commit cf4398a2a0
17 changed files with 776 additions and 535 deletions
+5
View File
@@ -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
+44
View File
@@ -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))
+82
View File
@@ -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,
}
+119
View File
@@ -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/<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()
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();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();return response(message='Zapisano ulubione stacje firmy')
@api.put('/companies/<int:company_id>/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/<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()
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();return response({'favorite':state},'Zmieniono ulubione stacje')
+81
View File
@@ -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/<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.delete('/fuel-cards/<int:card_id>')
@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/<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();return response(message='Zapisano warunki karty dla stacji')
+48
View File
@@ -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)
+53
View File
@@ -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)
+46
View File
@@ -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')
+96
View File
@@ -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/<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();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/<int:station_id>')
@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}')
+71
View File
@@ -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/<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()
return response(message='Usunięto użytkownika')
+74
View File
@@ -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/<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();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();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();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();return response(message='Zapisano zasady rozliczeń')