rewrite styles

This commit is contained in:
Mateusz Gruszczyński
2026-07-14 11:52:36 +02:00
parent 780681dfd5
commit e200c91881
19 changed files with 220 additions and 65 deletions
-1
View File
@@ -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
+1 -1
View File
@@ -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"]
-9
View File
@@ -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
+31
View File
@@ -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`.
+5 -5
View File
@@ -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"}
+21 -26
View File
@@ -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/<int:vehicle_id>/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/<int:vehicle_id>/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/<int:vehicle_id>/shares/<int:user_id>')
@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/<int:vehicle_id>/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/<int:card_id>')
@@ -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/<int:station_id>')
@@ -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/<int:company_id>')
@@ -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/<int:company_id>/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/<int:company_id>/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/<int:company_id>/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')
-1
View File
@@ -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")
-2
View File
@@ -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)
+2 -3
View File
@@ -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"
+2 -1
View File
@@ -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}}
+7 -1
View File
@@ -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}
+21 -1
View File
@@ -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}
+27 -1
View File
@@ -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}}
+1 -1
View File
@@ -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());
+33 -6
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#0d6efd">
<title>{% block title %}FuelTrack{% endblock %}</title>
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
<link rel="stylesheet" href="{{ asset_urls.choices_css }}">
@@ -11,14 +12,41 @@
<link rel="stylesheet" href="{{ static_url('css/charts.css') }}">
<link rel="stylesheet" href="{{ static_url('css/choices.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom sticky-top"><div class="container"><a class="navbar-brand fw-bold text-body-emphasis" href="{{ url_for('main.dashboard') }}">FuelTrack</a>{% if current_user.is_authenticated %}<div class="d-flex gap-2 align-items-center flex-wrap"><a class="btn btn-sm btn-secondary" href="{{ url_for('main.dashboard') }}">Podsumowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.fuel') }}">Tankowanie</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.vehicles') }}">Pojazdy</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.orlen_data') }}">Ceny Orlen</a>{% if current_user.role in ['boss','admin'] %}<a class="btn btn-sm btn-secondary" href="{{ url_for('main.stations') }}">Stacje</a><a class="btn btn-sm btn-secondary" href="{{ url_for('main.admin_companies') }}">Administracja</a>{% endif %}<a class="btn btn-sm btn-danger" href="/api/auth/logout" data-api-logout>Wyloguj</a></div>{% endif %}</div></nav>
<main class="container py-4">{% with msgs=get_flashed_messages(with_categories=true) %}{% for cat,msg in msgs %}<div class="alert alert-{{cat}}">{{msg}}</div>{% endfor %}{% endwith %}{% block content %}{% endblock %}</main>
<footer class="border-top mt-auto py-3"><div class="container d-flex justify-content-between align-items-center small text-body-secondary"><span>FuelTrack</span><a href="/docs" target="_blank" rel="noopener">API Docs</a></div></footer>
<body class="{% if not current_user.is_authenticated %}auth-page{% endif %}">
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg app-navbar sticky-top" aria-label="Główna nawigacja">
<div class="container">
<a class="navbar-brand app-brand" href="{{ url_for('main.dashboard') }}" aria-label="FuelTrack — strona główna">
<span class="brand-fuel">Fuel</span><span class="brand-track">Track</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavigation" aria-controls="mainNavigation" aria-expanded="false" aria-label="Pokaż nawigację"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="mainNavigation">
<div class="navbar-nav ms-auto align-items-lg-center gap-lg-1 py-3 py-lg-0">
<a class="nav-link {% if request.endpoint=='main.dashboard' %}active{% endif %}" href="{{ url_for('main.dashboard') }}">Podsumowanie</a>
<a class="nav-link {% if request.endpoint=='main.fuel' %}active{% endif %}" href="{{ url_for('main.fuel') }}">Tankowanie</a>
<a class="nav-link {% if request.endpoint=='main.vehicles' %}active{% endif %}" href="{{ url_for('main.vehicles') }}">Pojazdy</a>
<a class="nav-link {% if request.endpoint=='main.orlen_data' %}active{% endif %}" href="{{ url_for('main.orlen_data') }}">Ceny Orlen</a>
{% if current_user.role in ['boss','admin'] %}
<a class="nav-link {% if request.endpoint=='main.stations' %}active{% endif %}" href="{{ url_for('main.stations') }}">Stacje</a>
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('main.admin') %}active{% endif %}" href="{{ url_for('main.admin_companies') }}">Administracja</a>
{% endif %}
<span class="navbar-divider d-none d-lg-block"></span>
<a class="btn btn-outline-danger btn-sm ms-lg-2" href="/api/auth/logout" data-api-logout>Wyloguj</a>
</div>
</div>
</div>
</nav>
{% endif %}
<main class="container app-main {% if not current_user.is_authenticated %}auth-main{% endif %}">
{% with msgs=get_flashed_messages(with_categories=true) %}
{% for cat,msg in msgs %}<div class="alert alert-{{cat}} alert-dismissible fade show app-alert" role="alert">{{msg}}<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Zamknij"></button></div>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% if current_user.is_authenticated %}<footer class="app-footer"><div class="container d-flex justify-content-between align-items-center small"><span>@linuxiarz.pl</span><a href="/docs" target="_blank" rel="noopener">Dokumentacja API</a></div></footer>{% endif %}
<script src="{{ asset_urls.bootstrap_js }}"></script>
<script src="{{ asset_urls.choices_js }}"></script>
<script src="{{ asset_urls.chart_js }}"></script>
<script src="{{ asset_urls.socket_io_js }}"></script>
<script src="{{ static_url('js/core.js') }}"></script>
<script src="{{ static_url('js/charts.js') }}"></script>
<script src="{{ static_url('js/ajax.js') }}"></script>
@@ -27,7 +55,6 @@
<script src="{{ static_url('js/fuel.js') }}"></script>
<script src="{{ static_url('js/auth.js') }}"></script>
<script src="{{ static_url('js/users.js') }}"></script>
<script src="{{ static_url('js/socket.js') }}"></script>
<script src="{{ static_url('js/init.js') }}"></script>
{% block scripts %}{% endblock %}
</body></html>
+29 -1
View File
@@ -1 +1,29 @@
{% extends 'base.html' %}{% block title %}Logowanie FuelTrack{% endblock %}{% block content %}<div class="row justify-content-center"><div class="col-md-5 col-lg-4"><div class="card shadow-sm"><div class="card-body p-4"><h1 class="h3 mb-3">Logowanie</h1><form method="post" action="/api/auth/login" class="api-login-form" autocomplete="on"><label class="form-label">E-mail</label><input class="form-control mb-3" type="email" name="email" autocomplete="username" required><label class="form-label">Hasło</label><input class="form-control mb-3" type="password" name="password" autocomplete="current-password" required><button class="btn btn-primary w-100">Zaloguj</button></form></div></div></div></div>{% endblock %}
{% extends 'base.html' %}
{% block title %}Logowanie FuelTrack{% endblock %}
{% block content %}
<div class="auth-shell">
<div class="auth-brand" aria-label="FuelTrack">
<span class="brand-fuel">Fuel</span><span class="brand-track">Track</span>
</div>
<div class="card auth-card">
<div class="card-body p-4 p-sm-5">
<div class="mb-4">
<div class="page-kicker">Panel użytkownika</div>
<h1 class="h3 mb-2">Zaloguj się</h1>
<p class="text-body-secondary mb-0">Wprowadź dane dostępowe do aplikacji.</p>
</div>
<form method="post" action="/api/auth/login" class="api-login-form" autocomplete="on">
<div class="mb-3">
<label class="form-label" for="login-email">E-mail</label>
<input class="form-control" id="login-email" type="email" name="email" autocomplete="username" required autofocus>
</div>
<div class="mb-4">
<label class="form-label" for="login-password">Hasło</label>
<input class="form-control" id="login-password" type="password" name="password" autocomplete="current-password" required>
</div>
<button class="btn btn-primary w-100">Zaloguj</button>
</form>
</div>
</div>
</div>
{% endblock %}
Executable
+37
View File
@@ -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
-2
View File
@@ -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
+3 -3
View File
@@ -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