diff --git a/.env.example b/.env.example index 7bafe69..6c114b9 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,6 @@ BOOTSTRAP_JS_URL=https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap. CHOICES_CSS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css CHOICES_JS_URL=https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js CHART_JS_URL=https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js -SOCKET_IO_JS_URL=https://cdn.socket.io/4.8.1/socket.io.min.js THEME_BOOTSTRAP_URL=https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css THEME_FLATLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css THEME_DARKLY_URL=https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/darkly/bootstrap.min.css diff --git a/Dockerfile b/Dockerfile index 27dc367..ed16929 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,5 +15,5 @@ RUN mkdir -p /data HEALTHCHECK --interval=30s --timeout=6s --retries=3 \ CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.getenv('APP_PORT', '8000') + '/health', timeout=5)" -CMD ["sh", "-c", "gunicorn --worker-class gthread --workers ${GUNICORN_WORKERS:-1} --threads ${GUNICORN_THREADS:-8} --timeout ${GUNICORN_TIMEOUT:-0} --bind 0.0.0.0:${APP_PORT:-8000} wsgi:app"] +CMD ["sh", "-c", "gunicorn --worker-class gthread --workers ${GUNICORN_WORKERS:-1} --threads ${GUNICORN_THREADS:-8} --timeout ${GUNICORN_TIMEOUT:-60} --bind 0.0.0.0:${APP_PORT:-8000} wsgi:app"] diff --git a/README.md b/README.md index b556b7b..e9b792c 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,6 @@ Pierwsze konto: Zmień hasło i `SECRET_KEY` przed wdrożeniem. -## WebSocket i Gunicorn - -Projekt uruchamia jeden worker `gthread`, 8 wątków i wyłączony timeout dla długich połączeń Socket.IO: - -```bash -gunicorn --worker-class gthread --workers 1 --threads 8 --timeout 0 --bind 0.0.0.0:8000 wsgi:app -``` - -Nie uruchamiaj aplikacji przez samo `gunicorn wsgi:app`, ponieważ domyślny worker `sync` i timeout 30 sekund mogą przerywać WebSocket błędem `SystemExit: 1`. ## Ceny Orlen diff --git a/UX_CHANGES.md b/UX_CHANGES.md new file mode 100644 index 0000000..661d083 --- /dev/null +++ b/UX_CHANGES.md @@ -0,0 +1,31 @@ +# Zmiany UX i usunięcie Socket.IO + +## Socket.IO + +Socket.IO nie było potrzebne do podstawowego działania aplikacji. Formularze i nawigacja korzystają z REST API oraz fetch/AJAX. Jedynym widocznym zastosowaniem był globalny toast po dodaniu tankowania, a zdarzenia `app_changed` nie miały klienta, który je obsługiwał. + +Usunięto: +- Flask-SocketIO i simple-websocket z zależności, +- inicjalizację i emisje po stronie Flask, +- klienta Socket.IO z CDN i `static/js/socket.js`, +- zmienną środowiskową Socket.IO, +- konfigurację Gunicorn przeznaczoną dla długich połączeń. + +## UX/UI + +- responsywna nawigacja Bootstrap z menu mobilnym i aktywną sekcją, +- spójne tło, karty, cienie, promienie, odstępy i typografia, +- czytelniejsze formularze, tabele, modale, badge i przyciski, +- poprawione zachowanie wykresów na małych ekranach, +- zamykane komunikaty flash, +- zachowane motywy Bootstrap/Bootswatch oraz wszystkie istniejące widoki i operacje. + +## Weryfikacja + +- kompilacja modułów Python: OK, +- testy: 25 passed. + +## Ujednolicenie motywów +- Ciemny motyw korzysta z tego samego bazowego arkusza Bootstrap co jasny, dzięki czemu navbar, logo i komponenty zachowują identyczne proporcje. +- Tryb ciemny zmienia wyłącznie paletę kolorów przez `data-bs-theme="dark"` i lokalne reguły kolorystyczne. +- Stopka wyświetla teraz `@linuxiarz.pl`. diff --git a/app/__init__.py b/app/__init__.py index d76db81..e9c6652 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,7 +2,7 @@ from flask import Flask, request, url_for import click import hashlib from pathlib import Path -from .extensions import db, login_manager, socketio +from .extensions import db, login_manager from .models import User, AppSetting from .config import Config @@ -22,7 +22,7 @@ def _themes(app): return { "bootstrap": {"label":"Bootstrap", "url":app.config["THEME_BOOTSTRAP_URL"], "mode":"light"}, "flatly": {"label":"Flatly", "url":app.config["THEME_FLATLY_URL"], "mode":"light"}, - "darkly": {"label":"Darkly", "url":app.config["THEME_DARKLY_URL"], "mode":"dark"}, + "darkly": {"label":"Ciemny", "url":app.config["THEME_BOOTSTRAP_URL"], "mode":"dark"}, "pulse": {"label":"Pulse", "url":app.config["THEME_PULSE_URL"], "mode":"light"}, } @@ -31,7 +31,7 @@ def create_app(test_config=None): app = Flask(__name__) app.config.from_object(Config) if test_config: app.config.update(test_config) - db.init_app(app); login_manager.init_app(app); socketio.init_app(app) + db.init_app(app); login_manager.init_app(app) from .database import prepare_database prepare_database(app) @@ -57,7 +57,7 @@ def create_app(test_config=None): else: response.headers["Cache-Control"] = "public, max-age=2592000, immutable" elif response.mimetype == "text/html": - response.headers["Cache-Control"] = "no-store, no-cache, private, must-revalidate" + response.headers["Cache-Control"] = "no-store, private, must-revalidate" return response @login_manager.user_loader @@ -86,7 +86,7 @@ def create_app(test_config=None): theme_name = record.value if record and record.value in themes else "bootstrap" return {"app_theme": themes[theme_name], "app_theme_name": theme_name, "themes": themes, "asset_urls": {"bootstrap_js": app.config["BOOTSTRAP_JS_URL"], "choices_css": app.config["CHOICES_CSS_URL"], - "choices_js": app.config["CHOICES_JS_URL"], "chart_js": app.config["CHART_JS_URL"], "socket_io_js": app.config["SOCKET_IO_JS_URL"]}} + "choices_js": app.config["CHOICES_JS_URL"], "chart_js": app.config["CHART_JS_URL"]}} @app.get("/health") def health(): return {"status":"ok"} diff --git a/app/api.py b/app/api.py index 0c2ec48..d569ac1 100644 --- a/app/api.py +++ b/app/api.py @@ -6,7 +6,7 @@ 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 .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 @@ -96,7 +96,7 @@ def create_vehicle(): 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) + 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') @@ -108,7 +108,7 @@ def assign_vehicle_fuel_card(vehicle_id): 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') + 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 @@ -117,8 +117,7 @@ def share_vehicle(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') + 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 @@ -126,8 +125,7 @@ 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') + 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 @@ -136,7 +134,7 @@ def card_policy(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ń') + db.session.add(p);db.session.commit();return response(message='Zapisano zasady rozliczeń') @api.post('/fuel-entries') @login_required @@ -164,7 +162,7 @@ def create_fuel_entry(): 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) + 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') @@ -192,7 +190,7 @@ def orlen_sync(): 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') + 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') @@ -251,7 +249,6 @@ def delete_user(user_id): 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') @@ -265,7 +262,7 @@ 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') + 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)) @@ -279,7 +276,7 @@ def get_app_settings(): 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') + db.session.merge(AppSetting(key='theme',value=theme));db.session.commit();return response(message='Zapisano ustawienia aplikacji') @api.get('/fuel-cards') @login_required @@ -295,7 +292,7 @@ def create_fuel_card(): 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) + 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/') @@ -330,7 +327,6 @@ def delete_fuel_card(card_id): company_id = card.company_id db.session.delete(card) db.session.commit() - socketio.emit('app_changed', {'resource':'fuel_cards', 'deleted_id':card_id}) return response( {'redirect':url_for('main.admin_companies', company_id=company_id)}, 'Usunięto kartę flotową', @@ -342,7 +338,7 @@ 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') + db.session.add(rule);db.session.commit();return response(message='Zapisano warunki karty dla stacji') @api.get('/stations') @login_required @@ -381,7 +377,7 @@ def favorite_stations(): 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') + 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') @@ -390,7 +386,7 @@ def allowed_stations(): 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') + 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') @@ -398,7 +394,7 @@ 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}') + 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/') @@ -419,7 +415,7 @@ def update_station(station_id): 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}') + db.session.commit();return response(station_dict(s),f'Zapisano ustawienia stacji dla firmy {settings.name}') @api.get('/companies') @role_required('boss','admin') @@ -460,7 +456,6 @@ def companies_delete(company_id): 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/') @@ -470,7 +465,7 @@ def companies_update(company_id): 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ę') + db.session.commit();return response(message='Zapisano firmę') except Exception as exc:db.session.rollback();return fail(str(exc)) @api.put('/companies//favorite-stations') @@ -483,7 +478,7 @@ def company_favorites(company_id): 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') + db.session.commit();return response(message='Zapisano ulubione stacje firmy') @api.put('/companies//station-access') @@ -495,7 +490,7 @@ def company_station_access(company_id): 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();socketio.emit('app_changed',{'resource':'station_access','company_id':company.id}) + db.session.commit() return response({'station_access_mode':mode},'Zapisano politykę dostępu do stacji') @api.put('/companies//allowed-stations') @@ -507,7 +502,7 @@ def company_allowed_stations(company_id): 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}) + 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) @@ -520,4 +515,4 @@ def company_favorite_toggle(company_id,station_id): 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') + db.session.commit();return response({'favorite':state},'Zmieniono ulubione stacje') diff --git a/app/config.py b/app/config.py index 9f28684..40ea272 100644 --- a/app/config.py +++ b/app/config.py @@ -50,7 +50,6 @@ class Config: CHOICES_CSS_URL = env("CHOICES_CSS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css") CHOICES_JS_URL = env("CHOICES_JS_URL", "https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js") CHART_JS_URL = env("CHART_JS_URL", "https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js") - SOCKET_IO_JS_URL = env("SOCKET_IO_JS_URL", "https://cdn.socket.io/4.8.1/socket.io.min.js") THEME_BOOTSTRAP_URL = env("THEME_BOOTSTRAP_URL", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css") THEME_FLATLY_URL = env("THEME_FLATLY_URL", "https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/flatly/bootstrap.min.css") diff --git a/app/extensions.py b/app/extensions.py index 0117e8e..4562081 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -1,8 +1,6 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager -from flask_socketio import SocketIO db = SQLAlchemy() login_manager = LoginManager() login_manager.login_view = "auth.login" -socketio = SocketIO(async_mode="threading", cors_allowed_origins="*", ping_timeout=60, ping_interval=25) diff --git a/app/main.py b/app/main.py index f93270f..bfe2bf3 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_login import login_required, current_user from sqlalchemy import extract from sqlalchemy.exc import IntegrityError -from .extensions import db, socketio +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 @@ -21,7 +21,6 @@ def wants_json(): return request.headers.get("X-Requested-With") == "XMLHttpRequest" or request.is_json def ok_response(message, **extra): - socketio.emit("app_changed", {"message": message}) if wants_json(): return jsonify({"ok": True, "message": message, **extra}) flash(message, "success") @@ -174,7 +173,7 @@ def fuel(): except Exception: pass station_label = station_company.company_name if station_company else request.form.get("station") e = FuelEntry(vehicle_id=vehicle.id, user_id=current_user.id, fueled_at=fueled_at, liters=Decimal(request.form["liters"]), price_per_liter=Decimal(request.form["price_per_liter"]), fuel_type=request.form["fuel_type"], odometer=odometer, station=station_label, station_company_id=station_company.id if station_company else None, invoice_number=request.form.get("invoice_number"), used_fuel_card="used_fuel_card" in request.form, wholesale_price=wholesale, wholesale_source=source) - vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); socketio.emit("fuel_added", {"vehicle": vehicle.name, "gross": round(e.gross,2)}); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard")) + vehicle.current_odometer = odometer; db.session.add(e); db.session.commit(); response = ok_response("Zapisano tankowanie", redirect=url_for("main.dashboard")); return response or redirect(url_for("main.dashboard")) allowed=list(settings.allowed_stations) company_favorites=list(settings.favorite_stations) access_mode=settings.station_access_mode or "all_prefer_favorites" diff --git a/app/static/css/charts.css b/app/static/css/charts.css index 74ca09b..b753193 100644 --- a/app/static/css/charts.css +++ b/app/static/css/charts.css @@ -1 +1,2 @@ -.chart-wrap{height:420px;position:relative;overflow:hidden}.chart-box{height:320px;position:relative;overflow:hidden}.chart-empty{position:absolute;inset:0;display:grid;place-items:center;border:1px dashed var(--bs-border-color);border-radius:var(--bs-border-radius);color:var(--bs-secondary-color)} +.chart-wrap{height:420px;position:relative;overflow:hidden}.chart-box{height:320px;position:relative;overflow:hidden}.chart-empty{position:absolute;inset:0;display:grid;place-items:center;border:1px dashed var(--bs-border-color);border-radius:.85rem;color:var(--bs-secondary-color);background:var(--bs-tertiary-bg)} +@media(max-width:575.98px){.chart-wrap{height:330px}.chart-box{height:270px}} diff --git a/app/static/css/choices.css b/app/static/css/choices.css index 1fb00d2..9048896 100644 --- a/app/static/css/choices.css +++ b/app/static/css/choices.css @@ -1,2 +1,8 @@ -.choices{margin-bottom:0}.choices__inner,.choices__input,.choices__list--dropdown,.choices__list[aria-expanded]{background-color:var(--bs-body-bg);color:var(--bs-body-color);border-color:var(--bs-border-color)}.choices__input{border-bottom-color:var(--bs-border-color)}.choices__list--dropdown,.choices__list[aria-expanded]{z-index:1080;max-height:260px;overflow:auto}.choices__list--dropdown .choices__item--selectable.is-highlighted,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background-color:var(--bs-tertiary-bg)}.lpg-region-modal .modal-body{min-height:420px}.lpg-region-modal .choices__list--dropdown,.lpg-region-modal .choices__list[aria-expanded]{position:absolute;width:100%} +.choices{margin-bottom:0}.choices__inner,.choices__input,.choices__list--dropdown,.choices__list[aria-expanded]{background-color:var(--bs-body-bg);color:var(--bs-body-color);border-color:var(--bs-border-color)}.choices__inner{border-radius:.7rem;min-height:2.75rem;padding:.45rem .75rem}.choices__input{border-bottom-color:var(--bs-border-color)}.choices__list--dropdown,.choices__list[aria-expanded]{z-index:1080;max-height:260px;overflow:auto}.choices__list--dropdown .choices__item--selectable.is-highlighted,.choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background-color:var(--bs-tertiary-bg)}.lpg-region-modal .modal-body{min-height:420px}.lpg-region-modal .choices__list--dropdown,.lpg-region-modal .choices__list[aria-expanded]{position:absolute;width:100%} [data-station-proposal-select]+.choices .choices__list--dropdown,[data-station-proposal-select]+.choices .choices__list[aria-expanded]{min-width:100%;width:max-content;max-width:min(42rem,90vw)} +[data-bs-theme="dark"] .choices__inner,[data-bs-theme="dark"] .choices__input,[data-bs-theme="dark"] .choices__list--dropdown,[data-bs-theme="dark"] .choices__list[aria-expanded]{background:#171a1e!important;border-color:#4a5057!important;color:#f8f9fa!important} +[data-bs-theme="dark"] .choices__input::placeholder{color:#8f969e} +[data-bs-theme="dark"] .choices__list--single .choices__item,[data-bs-theme="dark"] .choices__item--choice{color:#f1f3f5} +[data-bs-theme="dark"] .choices__list--dropdown .choices__item--selectable.is-highlighted,[data-bs-theme="dark"] .choices__list[aria-expanded] .choices__item--selectable.is-highlighted{background:#2c3238!important;color:#fff} +[data-bs-theme="dark"] .choices[data-type*=select-one]::after{border-color:#aeb4bb transparent transparent} +[data-bs-theme="dark"] .choices[data-type*=select-one].is-open::after{border-color:transparent transparent #aeb4bb} diff --git a/app/static/css/components.css b/app/static/css/components.css index 9a14abe..815902a 100644 --- a/app/static/css/components.css +++ b/app/static/css/components.css @@ -1 +1,21 @@ -.metric strong{display:block;font-size:1.45rem;margin-top:.25rem}.station-brand{font-weight:700}.station-company{font-size:.875rem;color:var(--bs-secondary-color)} +.card{border:1px solid var(--bs-border-color-translucent);border-radius:var(--app-radius);box-shadow:var(--app-shadow);overflow:hidden}.card-body{padding:1.35rem}.card-title,.card h2{letter-spacing:-.02em}.card-header{background:transparent;border-bottom-color:var(--bs-border-color-translucent);padding:1rem 1.35rem} +.metric{height:100%;position:relative;transition:transform .18s ease,box-shadow .18s ease}.metric:hover{transform:translateY(-2px);box-shadow:var(--app-shadow-hover)}.metric small{color:var(--bs-secondary-color);font-weight:600}.metric strong{display:block;font-size:clamp(1.25rem,2vw,1.65rem);line-height:1.2;margin-top:.45rem;letter-spacing:-.035em}.metric::before{content:"";position:absolute;inset:0 auto 0 0;width:.25rem;background:var(--bs-primary)} +.btn{border-radius:.65rem;font-weight:600}.btn-primary{box-shadow:0 .2rem .65rem color-mix(in srgb,var(--bs-primary) 22%,transparent)}.form-control,.form-select{border-radius:.7rem;min-height:2.75rem}.form-control:focus,.form-select:focus{box-shadow:0 0 0 .22rem color-mix(in srgb,var(--bs-primary) 18%,transparent)}.form-label{font-size:.9rem;font-weight:650;margin-bottom:.4rem} +.table{--bs-table-bg:transparent;margin-bottom:0}.table>:not(caption)>*>*{padding:.8rem .75rem;border-bottom-color:var(--bs-border-color-translucent)}.table thead th{font-size:.75rem;text-transform:uppercase;letter-spacing:.045em;color:var(--bs-secondary-color);font-weight:700;white-space:nowrap}.table tbody tr:last-child td{border-bottom:0}.table-responsive{border-radius:.7rem}.badge{border-radius:50rem;font-weight:650;padding:.4em .65em}.nav-tabs{gap:.25rem;border-bottom-color:var(--bs-border-color)}.nav-tabs .nav-link{border-radius:.65rem .65rem 0 0;font-weight:600}.list-group-item{padding:.9rem 1rem}.modal-content{border:0;border-radius:var(--app-radius);box-shadow:0 1rem 3rem rgba(0,0,0,.18)}.modal-header,.modal-footer{border-color:var(--bs-border-color-translucent)} +.section-heading{display:flex;justify-content:space-between;align-items:center;gap:1rem;margin-bottom:1rem}.empty-state{padding:2.5rem 1rem;text-align:center;color:var(--bs-secondary-color)}.station-brand{font-weight:700}.station-company{font-size:.875rem;color:var(--bs-secondary-color)} +@media(max-width:575.98px){.card-body{padding:1.05rem}.metric .card-body{padding:1rem}.table>:not(caption)>*>*{padding:.7rem .6rem}.btn:not(.btn-sm):not(.btn-link){min-height:2.65rem}} + +[data-bs-theme="dark"] .card,[data-bs-theme="dark"] .modal-content,[data-bs-theme="dark"] .list-group-item{background-color:#202327;border-color:#3a3f45;color:#f1f3f5} +[data-bs-theme="dark"] .card-header,[data-bs-theme="dark"] .modal-header,[data-bs-theme="dark"] .modal-footer{border-color:#3a3f45} +[data-bs-theme="dark"] .form-control,[data-bs-theme="dark"] .form-select{background-color:#171a1e;border-color:#4a5057;color:#f8f9fa} +[data-bs-theme="dark"] .form-control::placeholder{color:#8f969e;opacity:1} +[data-bs-theme="dark"] .form-control:focus,[data-bs-theme="dark"] .form-select:focus{background-color:#171a1e;border-color:var(--bs-primary);color:#fff} +[data-bs-theme="dark"] .form-select option{background:#171a1e;color:#f8f9fa} +[data-bs-theme="dark"] .input-group-text{background:#292d32;border-color:#4a5057;color:#d9dde1} +[data-bs-theme="dark"] .table{--bs-table-color:#e9ecef;--bs-table-hover-color:#fff;--bs-table-hover-bg:rgba(255,255,255,.045);--bs-table-striped-color:#e9ecef;--bs-table-striped-bg:rgba(255,255,255,.025)} +[data-bs-theme="dark"] .table thead th{color:#adb5bd} +[data-bs-theme="dark"] .text-body-secondary,[data-bs-theme="dark"] .text-muted{color:#aeb4bb!important} +[data-bs-theme="dark"] .bg-body-tertiary{background-color:#181b1f!important} +[data-bs-theme="dark"] .dropdown-menu{background:#202327;border-color:#3a3f45;box-shadow:0 .75rem 2rem rgba(0,0,0,.35)} +[data-bs-theme="dark"] .dropdown-item{color:#e9ecef} +[data-bs-theme="dark"] .dropdown-item:hover,[data-bs-theme="dark"] .dropdown-item:focus{background:#2b3035;color:#fff} diff --git a/app/static/css/layout.css b/app/static/css/layout.css index 265d625..0b6b68f 100644 --- a/app/static/css/layout.css +++ b/app/static/css/layout.css @@ -1 +1,27 @@ -html,body{min-height:100%}body{min-height:100vh}main{min-height:calc(100vh - 57px)}.navbar-brand{white-space:nowrap}#live-alerts{max-width:420px} +:root{--app-radius:1rem;--app-shadow:0 .4rem 1.5rem rgba(15,23,42,.07);--app-shadow-hover:0 .75rem 2rem rgba(15,23,42,.11)} +html,body{min-height:100%}body{min-height:100vh;display:flex;flex-direction:column;background:var(--bs-tertiary-bg)} +.app-navbar{background:color-mix(in srgb,var(--bs-body-bg) 92%,transparent);border-bottom:1px solid var(--bs-border-color-translucent);backdrop-filter:blur(14px);box-shadow:0 .15rem .75rem rgba(15,23,42,.04)} +.navbar-brand{font-weight:800;letter-spacing:-.04em;white-space:nowrap}.app-brand,.auth-brand{font-size:1.4rem;line-height:1;text-decoration:none}.brand-fuel{color:var(--bs-emphasis-color)}.brand-track{color:var(--bs-primary)} +.app-brand::after,.auth-brand::after{content:"";display:inline-block;width:.38rem;height:.38rem;margin-left:.18rem;border-radius:50%;background:var(--bs-warning);box-shadow:0 0 0 .2rem color-mix(in srgb,var(--bs-warning) 18%,transparent);vertical-align:.15em} +.app-navbar .nav-link{border-radius:.6rem;padding:.55rem .75rem;font-weight:550;color:var(--bs-secondary-color)}.app-navbar .nav-link:hover{color:var(--bs-emphasis-color);background:var(--bs-tertiary-bg)}.app-navbar .nav-link.active{color:var(--bs-primary);background:color-mix(in srgb,var(--bs-primary) 10%,transparent)}.navbar-divider{height:1.5rem;border-left:1px solid var(--bs-border-color);margin-left:.35rem} +.app-main{flex:1;padding-top:2rem;padding-bottom:3rem}.app-footer{padding:1.25rem 0;border-top:1px solid var(--bs-border-color-translucent);background:var(--bs-body-bg);color:var(--bs-secondary-color)}.app-footer a{color:inherit} +.page-header{display:flex;justify-content:space-between;align-items:flex-end;gap:1rem;margin-bottom:1.5rem}.page-header h1{letter-spacing:-.035em}.page-kicker{font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;font-weight:700;color:var(--bs-primary);margin-bottom:.3rem} +#live-alerts{max-width:min(420px,calc(100vw - 2rem))}.app-alert{box-shadow:var(--app-shadow);border:0} +.auth-page{background:radial-gradient(circle at 20% 10%,color-mix(in srgb,var(--bs-primary) 14%,transparent),transparent 34%),radial-gradient(circle at 85% 85%,color-mix(in srgb,var(--bs-warning) 10%,transparent),transparent 30%),var(--bs-tertiary-bg)}.auth-main{display:grid;place-items:center;width:100%;padding-top:2rem;padding-bottom:2rem}.auth-shell{width:min(100%,28rem)}.auth-brand{display:block;width:max-content;margin:0 auto 1.25rem;font-size:1.8rem;font-weight:850;letter-spacing:-.055em}.auth-card{border-color:color-mix(in srgb,var(--bs-primary) 18%,var(--bs-border-color));box-shadow:0 1.25rem 3rem rgba(15,23,42,.13)} + +/* Geometria interfejsu jest wspólna dla wszystkich motywów. Motyw zmienia wyłącznie kolory. */ +.app-navbar{min-height:4.25rem} +.app-navbar .container{min-height:4.25rem} +.app-navbar .navbar-brand{margin-right:1rem;padding-top:.3125rem;padding-bottom:.3125rem} +.app-navbar .navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;border-width:1px;border-radius:.375rem} +.app-brand{font-family:inherit;font-size:1.4rem!important;font-weight:800!important;line-height:1!important;letter-spacing:-.04em!important} +.app-navbar .nav-link{font-size:1rem;line-height:1.5} + +[data-bs-theme="dark"]{--app-shadow:0 .45rem 1.6rem rgba(0,0,0,.28);--app-shadow-hover:0 .8rem 2.2rem rgba(0,0,0,.38)} +[data-bs-theme="dark"] body{background:#17191c;color:#f1f3f5} +[data-bs-theme="dark"] .app-navbar{background:rgba(24,26,29,.94);border-bottom-color:#34383d;box-shadow:0 .2rem 1rem rgba(0,0,0,.32)} +[data-bs-theme="dark"] .app-footer{background:#1b1d20;border-top-color:#34383d} +[data-bs-theme="dark"] .brand-fuel{color:#f8f9fa} +[data-bs-theme="dark"] .auth-page{background:radial-gradient(circle at 20% 10%,rgba(55,139,230,.16),transparent 36%),radial-gradient(circle at 85% 85%,rgba(255,193,7,.08),transparent 32%),#141619} +@media(max-width:991.98px){.app-main{padding-top:1.35rem}.app-navbar .navbar-collapse{border-top:1px solid var(--bs-border-color-translucent);margin-top:.75rem}.app-navbar .nav-link{padding:.7rem .8rem}.page-header{align-items:stretch;flex-direction:column}} +@media(max-width:575.98px){.app-main{padding-left:1rem;padding-right:1rem}.page-header h1{font-size:1.65rem}.auth-main{padding-top:1.25rem;padding-bottom:1.25rem}} diff --git a/app/static/js/init.js b/app/static/js/init.js index da0d151..0ae8ad5 100644 --- a/app/static/js/init.js +++ b/app/static/js/init.js @@ -1 +1 @@ -FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>{FuelTrack.initSocket();FuelTrack.initPage()}); +FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>FuelTrack.initPage()); diff --git a/app/templates/base.html b/app/templates/base.html index fd9c802..6b6b54c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,6 +3,7 @@ + {% block title %}FuelTrack{% endblock %} @@ -11,14 +12,41 @@ - - -
{% with msgs=get_flashed_messages(with_categories=true) %}{% for cat,msg in msgs %}
{{msg}}
{% endfor %}{% endwith %}{% block content %}{% endblock %}
- + +{% if current_user.is_authenticated %} + +{% endif %} +
+ {% with msgs=get_flashed_messages(with_categories=true) %} + {% for cat,msg in msgs %}{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+{% if current_user.is_authenticated %}{% endif %} - @@ -27,7 +55,6 @@ - {% block scripts %}{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 424a87d..cc86c49 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1 +1,29 @@ -{% extends 'base.html' %}{% block title %}Logowanie – FuelTrack{% endblock %}{% block content %}

Logowanie

{% endblock %} +{% extends 'base.html' %} +{% block title %}Logowanie – FuelTrack{% endblock %} +{% block content %} +
+
+ FuelTrack +
+
+
+
+
Panel użytkownika
+

Zaloguj się

+

Wprowadź dane dostępowe do aplikacji.

+
+ +
+
+
+{% endblock %} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..2569120 --- /dev/null +++ b/dev.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT_DIR" + +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + +mkdir -p instance + +export FLASK_ENV="${FLASK_ENV:-development}" +export DATABASE_URL="${DATABASE_URL:-sqlite:///$ROOT_DIR/instance/fueltrack.db}" +export APP_HOST="${APP_HOST:-0.0.0.0}" +export APP_PORT="${APP_PORT:-8000}" +export GUNICORN_WORKERS="${GUNICORN_WORKERS:-1}" +export GUNICORN_THREADS="${GUNICORN_THREADS:-8}" +export GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-0}" + +echo "FuelTrack DEV" +echo "Adres: http://localhost:${APP_PORT}" +echo "Baza: ${DATABASE_URL}" + +exec gunicorn \ + --reload \ + --access-logfile - \ + --error-logfile - \ + --worker-class gthread \ + --workers "$GUNICORN_WORKERS" \ + --threads "$GUNICORN_THREADS" \ + --timeout "$GUNICORN_TIMEOUT" \ + --bind "$APP_HOST:$APP_PORT" \ + wsgi:app diff --git a/requirements.txt b/requirements.txt index d6e2f0f..eeabbbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ Flask>=3.1.1 Flask-SQLAlchemy>=3.1.1 Flask-Login>=0.6.3 -Flask-SocketIO>=5.5.1 -simple-websocket>=1.1.0 requests>=2.32.4 gunicorn>=23.0.0 pytest>=8.4.1 diff --git a/tests/test_app.py b/tests/test_app.py index 83747a5..a999a01 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -93,9 +93,9 @@ def test_html_cache_headers_and_static_versioning(tmp_path): app=make_app(tmp_path, "cache.db") client=app.test_client() response=client.get('/login') - assert response.headers['Cache-Control'] == 'no-store, no-cache, private, must-revalidate' - assert response.headers['Pragma'] == 'no-cache' - assert response.headers['Expires'] == '0' + assert response.headers['Cache-Control'] == 'no-store, private, must-revalidate' + assert 'Pragma' not in response.headers + assert 'Expires' not in response.headers html=response.get_data(as_text=True) assert '/static/css/layout.css?v=' in html assert '/static/js/core.js?v=' in html