docekr
This commit is contained in:
+2
-4
@@ -12,10 +12,8 @@ COPY . .
|
|||||||
|
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
||||||
EXPOSE 8000
|
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)"
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
|
||||||
CMD sh -c 'curl -f http://localhost:${APP_PORT:-8000}/health || exit 1'
|
|
||||||
|
|
||||||
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:-0} --bind 0.0.0.0:${APP_PORT:-8000} wsgi:app"]
|
||||||
|
|
||||||
|
|||||||
@@ -38,25 +38,12 @@ Zakładka **Ceny Orlen** pozwala:
|
|||||||
|
|
||||||
Dla Pb95, Pb98 i diesla wartość API za m³ jest dzielona przez 1000. Endpoint LPG zwraca regionalny zestaw dostępny w chwili pobrania.
|
Dla Pb95, Pb98 i diesla wartość API za m³ jest dzielona przez 1000. Endpoint LPG zwraca regionalny zestaw dostępny w chwili pobrania.
|
||||||
|
|
||||||
## Motyw
|
|
||||||
|
|
||||||
Motyw jest ustawiany globalnie przez szefa lub administratora w zakładce **Administracja**. Darkly ustawia także `data-bs-theme="dark"`, dzięki czemu tło, formularze, tabele, listy i wykresy używają ciemnej palety.
|
|
||||||
|
|
||||||
## Testy
|
## Testy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pytest -q
|
python -m pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Wersja v5
|
|
||||||
|
|
||||||
- jawny endpoint zapisu ustawień administracyjnych,
|
|
||||||
- opcjonalny podział faktur kartowych po dniu miesiąca,
|
|
||||||
- lista udostępnień pojazdu i odbieranie dostępu,
|
|
||||||
- reguły cennika karty: Orlen last price, rabat procentowy i dopłata za litr,
|
|
||||||
- porównanie ceny detalicznej z kwotą do zapłaty na wykresie i w tabeli.
|
|
||||||
|
|
||||||
## REST API i Swagger
|
## REST API i Swagger
|
||||||
|
|
||||||
Interfejs zapisuje i modyfikuje dane wyłącznie przez endpointy `/api/...`. Widoki HTML odpowiadają za prezentację, natomiast operacje biznesowe przechodzą przez REST API i zwracają JSON.
|
Interfejs zapisuje i modyfikuje dane wyłącznie przez endpointy `/api/...`. Widoki HTML odpowiadają za prezentację, natomiast operacje biznesowe przechodzą przez REST API i zwracają JSON.
|
||||||
@@ -79,20 +66,6 @@ Autoryzacja API korzysta z tej samej bezpiecznej sesji cookie co interfejs. Najw
|
|||||||
Stare endpointy formularzy pozostają chwilowo jako warstwa zgodności dla zakładek HTML, ale interfejs nie wysyła już do nich operacji zapisu.
|
Stare endpointy formularzy pozostają chwilowo jako warstwa zgodności dla zakładek HTML, ale interfejs nie wysyła już do nich operacji zapisu.
|
||||||
|
|
||||||
|
|
||||||
## Wersja v8
|
|
||||||
|
|
||||||
- odnośnik do Swagger UI znajduje się w stopce,
|
|
||||||
- specyfikacja OpenAPI nie deklaruje zbędnej sekcji `servers`,
|
|
||||||
- województwo firmy wybierane jest z listy 16 województw,
|
|
||||||
- wybór wielu województw LPG jest domyślnie ukryty; każda wybrana pozycja tworzy osobną serię,
|
|
||||||
- wyszukiwanie stacji działa na żywo przez AJAX i obejmuje firmę, marki, NIP oraz REGON,
|
|
||||||
- sortowanie katalogu po nazwie, liczbie punktów, NIP, REGON i województwie,
|
|
||||||
- last price działa wyłącznie według wybranej stacji i użycia karty paliwowej,
|
|
||||||
- maksymalnie 10 ulubionych sieci użytkownika; domyślnie wybierane są największe sieci,
|
|
||||||
- lista sieci dozwolonych przez firmę jest egzekwowana również przez API,
|
|
||||||
- konfiguracja portów, bazy, timeoutów i Gunicorna znajduje się w `.env`,
|
|
||||||
- dodane `.gitignore` oraz `.dockerignore`.
|
|
||||||
|
|
||||||
Skopiuj i dostosuj konfigurację:
|
Skopiuj i dostosuj konfigurację:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -137,7 +110,6 @@ Kod integracji został rozdzielony na:
|
|||||||
|
|
||||||
Frontend jest podzielony funkcjonalnie w `app/static/js/` i `app/static/css/`.
|
Frontend jest podzielony funkcjonalnie w `app/static/js/` i `app/static/css/`.
|
||||||
|
|
||||||
|
|
||||||
## Punkty stacji URE
|
## Punkty stacji URE
|
||||||
Po imporcie katalogu URE kliknięcie nazwy firmy mającej więcej niż jeden punkt otwiera modal z listą placówek, adresami, województwem i dostępnymi paliwami.
|
Po imporcie katalogu URE kliknięcie nazwy firmy mającej więcej niż jeden punkt otwiera modal z listą placówek, adresami, województwem i dostępnymi paliwami.
|
||||||
|
|
||||||
|
|||||||
+37
-39
@@ -1,12 +1,23 @@
|
|||||||
from flask import Flask
|
from flask import Flask, request, url_for
|
||||||
import click
|
import click
|
||||||
from sqlalchemy import text
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
from .extensions import db, login_manager, socketio
|
from .extensions import db, login_manager, socketio
|
||||||
from .models import User, CompanySettings, AppSetting
|
from .models import User, AppSetting
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
||||||
THEMES = {name: {} for name in ("bootstrap", "flatly", "darkly", "pulse")}
|
THEMES = {name: {} for name in ("bootstrap", "flatly", "darkly", "pulse")}
|
||||||
|
|
||||||
|
|
||||||
|
def _static_hashes(app):
|
||||||
|
static_root = Path(app.static_folder)
|
||||||
|
hashes = {}
|
||||||
|
for path in static_root.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
relative = path.relative_to(static_root).as_posix()
|
||||||
|
hashes[relative] = hashlib.md5(path.read_bytes()).hexdigest()
|
||||||
|
return hashes
|
||||||
|
|
||||||
def _themes(app):
|
def _themes(app):
|
||||||
return {
|
return {
|
||||||
"bootstrap": {"label":"Bootstrap", "url":app.config["THEME_BOOTSTRAP_URL"], "mode":"light"},
|
"bootstrap": {"label":"Bootstrap", "url":app.config["THEME_BOOTSTRAP_URL"], "mode":"light"},
|
||||||
@@ -21,11 +32,34 @@ def create_app(test_config=None):
|
|||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
if test_config: app.config.update(test_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); socketio.init_app(app)
|
||||||
|
|
||||||
|
from .database import prepare_database
|
||||||
|
prepare_database(app)
|
||||||
from .auth import auth; from .main import main
|
from .auth import auth; from .main import main
|
||||||
from .api import api
|
from .api import api
|
||||||
from .openapi import docs
|
from .openapi import docs
|
||||||
app.register_blueprint(auth); app.register_blueprint(main); app.register_blueprint(api); app.register_blueprint(docs)
|
app.register_blueprint(auth); app.register_blueprint(main); app.register_blueprint(api); app.register_blueprint(docs)
|
||||||
|
|
||||||
|
static_hashes = _static_hashes(app)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_static_url():
|
||||||
|
def static_url(filename):
|
||||||
|
return url_for("static", filename=filename, v=static_hashes.get(filename, ""))
|
||||||
|
return {"static_url": static_url}
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def set_cache_headers(response):
|
||||||
|
if request.endpoint == "static":
|
||||||
|
filename = request.view_args.get("filename", "") if request.view_args else ""
|
||||||
|
if filename.endswith(".css"):
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
|
||||||
|
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"
|
||||||
|
return response
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id): return db.session.get(User, int(user_id))
|
def load_user(user_id): return db.session.get(User, int(user_id))
|
||||||
|
|
||||||
@@ -60,40 +94,4 @@ def create_app(test_config=None):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(f"Zresetowano hasło administratora: {normalized_email}")
|
click.echo(f"Zresetowano hasło administratora: {normalized_email}")
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
if db.engine.dialect.name == "sqlite":
|
|
||||||
def add_column(table, column, ddl, index_sql=None):
|
|
||||||
columns = {row[1] for row in db.session.execute(text(f"PRAGMA table_info({table})")).fetchall()}
|
|
||||||
if column not in columns:
|
|
||||||
db.session.execute(text(f"ALTER TABLE {table} ADD COLUMN {ddl}"))
|
|
||||||
if index_sql:
|
|
||||||
db.session.execute(text(index_sql))
|
|
||||||
add_column("fuel_entry", "station_company_id", "station_company_id INTEGER REFERENCES fuel_station_company(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_entry_station_company_id ON fuel_entry (station_company_id)")
|
|
||||||
add_column("fuel_entry", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_entry_fuel_card_id ON fuel_entry (fuel_card_id)")
|
|
||||||
add_column("vehicle", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_vehicle_fuel_card_id ON vehicle (fuel_card_id)")
|
|
||||||
add_column("user", "fuel_card_id", "fuel_card_id INTEGER REFERENCES fuel_card(id)", "CREATE INDEX IF NOT EXISTS ix_user_fuel_card_id ON user (fuel_card_id)")
|
|
||||||
add_column("user", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_user_company_id ON user (company_id)")
|
|
||||||
add_column("vehicle", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_vehicle_company_id ON vehicle (company_id)")
|
|
||||||
add_column("fuel_card", "company_id", "company_id INTEGER REFERENCES company_settings(id)", "CREATE INDEX IF NOT EXISTS ix_fuel_card_company_id ON fuel_card (company_id)")
|
|
||||||
add_column("company_settings", "active", "active BOOLEAN NOT NULL DEFAULT 1")
|
|
||||||
add_column("fuel_station_company", "brand_name", "brand_name VARCHAR(120) NOT NULL DEFAULT ''", "CREATE INDEX IF NOT EXISTS ix_fuel_station_company_brand_name ON fuel_station_company (brand_name)")
|
|
||||||
db.session.commit()
|
|
||||||
if not CompanySettings.query.first(): db.session.add(CompanySettings())
|
|
||||||
if not db.session.get(AppSetting, "theme"): db.session.add(AppSetting(key="theme", value="bootstrap"))
|
|
||||||
if not db.session.get(AppSetting, "invoice_split_enabled"): db.session.add(AppSetting(key="invoice_split_enabled", value="1"))
|
|
||||||
if not User.query.first():
|
|
||||||
u=User(email="admin@example.com", name="Administrator", role="admin"); u.set_password("admin123!"); db.session.add(u)
|
|
||||||
db.session.commit()
|
|
||||||
from .models import FuelStationCompany
|
|
||||||
from .integrations.ure import infer_brand
|
|
||||||
for station in FuelStationCompany.query.filter(db.or_(FuelStationCompany.brand_name.is_(None), FuelStationCompany.brand_name == "")).all():
|
|
||||||
station.brand_name = infer_brand(station.company_name)
|
|
||||||
db.session.commit()
|
|
||||||
default_company=CompanySettings.query.order_by(CompanySettings.id).first()
|
|
||||||
if default_company:
|
|
||||||
User.query.filter(User.company_id.is_(None)).update({User.company_id:default_company.id})
|
|
||||||
db.session.execute(text("UPDATE vehicle SET company_id = (SELECT company_id FROM user WHERE user.id = vehicle.owner_id) WHERE company_id IS NULL"))
|
|
||||||
db.session.execute(text("UPDATE fuel_card SET company_id = :cid WHERE company_id IS NULL"), {"cid":default_company.id})
|
|
||||||
db.session.commit()
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from .database_init import initialize_database
|
||||||
|
from .database_migrations import migrate_database
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _startup_lock(app: Flask):
|
||||||
|
lock_path = Path(app.instance_path) / ".database-startup.lock"
|
||||||
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with lock_path.open("w") as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_database(app: Flask) -> list[str]:
|
||||||
|
with app.app_context(), _startup_lock(app):
|
||||||
|
initialize_database()
|
||||||
|
return migrate_database()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["initialize_database", "migrate_database", "prepare_database"]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
from .models import AppSetting, CompanySettings, User
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"theme": "bootstrap",
|
||||||
|
"invoice_split_enabled": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_schema() -> None:
|
||||||
|
"""Tworzy wszystkie tabele, ograniczenia i indeksy zdefiniowane w modelach."""
|
||||||
|
db.metadata.create_all(bind=db.engine)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_initial_data() -> None:
|
||||||
|
"""Dodaje minimalne dane startowe do świeżej bazy."""
|
||||||
|
if not CompanySettings.query.first():
|
||||||
|
db.session.add(CompanySettings())
|
||||||
|
|
||||||
|
for key, value in DEFAULT_SETTINGS.items():
|
||||||
|
if db.session.get(AppSetting, key) is None:
|
||||||
|
db.session.add(AppSetting(key=key, value=value))
|
||||||
|
|
||||||
|
if not User.query.first():
|
||||||
|
admin = User(
|
||||||
|
email="admin@example.com",
|
||||||
|
name="Administrator",
|
||||||
|
role="admin",
|
||||||
|
)
|
||||||
|
admin.set_password("admin123!")
|
||||||
|
db.session.add(admin)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_database() -> None:
|
||||||
|
"""Zakłada świeżą bazę: schemat, indeksy, constraints i dane startowe."""
|
||||||
|
create_schema()
|
||||||
|
seed_initial_data()
|
||||||
|
|
||||||
|
|
||||||
|
def schema_summary() -> dict[str, list[str]]:
|
||||||
|
"""Zwraca utworzone tabele i indeksy; przydatne do logów i testów."""
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
tables = sorted(inspector.get_table_names())
|
||||||
|
indexes = sorted(
|
||||||
|
f"{table}.{index['name']}"
|
||||||
|
for table in tables
|
||||||
|
for index in inspector.get_indexes(table)
|
||||||
|
if index.get("name")
|
||||||
|
)
|
||||||
|
return {"tables": tables, "indexes": indexes}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from . import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
initialize_database()
|
||||||
|
summary = schema_summary()
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Baza danych została zainicjalizowana "
|
||||||
|
f"({len(summary['tables'])} tabel, {len(summary['indexes'])} indeksów)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Migration:
|
||||||
|
version: str
|
||||||
|
upgrade: Callable[[], None]
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATIONS: tuple[Migration, ...] = ()
|
||||||
|
|
||||||
|
def ensure_migration_table() -> None:
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
if inspector.has_table("schema_migration"):
|
||||||
|
return
|
||||||
|
|
||||||
|
db.session.execute(text("""
|
||||||
|
CREATE TABLE schema_migration (
|
||||||
|
version VARCHAR(100) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database() -> list[str]:
|
||||||
|
"""Uruchamia wyłącznie niewykonane migracje."""
|
||||||
|
ensure_migration_table()
|
||||||
|
|
||||||
|
applied = {
|
||||||
|
row[0]
|
||||||
|
for row in db.session.execute(text("SELECT version FROM schema_migration"))
|
||||||
|
}
|
||||||
|
completed: list[str] = []
|
||||||
|
|
||||||
|
for migration in MIGRATIONS:
|
||||||
|
if migration.version in applied:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
migration.upgrade()
|
||||||
|
db.session.execute(
|
||||||
|
text("INSERT INTO schema_migration (version) VALUES (:version)"),
|
||||||
|
{"version": migration.version},
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
completed.append(migration.version)
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from . import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
applied = migrate_database()
|
||||||
|
|
||||||
|
if applied:
|
||||||
|
print("Wykonane migracje: " + ", ".join(applied))
|
||||||
|
else:
|
||||||
|
print("Brak nowych migracji.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+14
-14
@@ -6,10 +6,10 @@
|
|||||||
<title>{% block title %}FuelTrack{% endblock %}</title>
|
<title>{% block title %}FuelTrack{% endblock %}</title>
|
||||||
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
|
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
|
||||||
<link rel="stylesheet" href="{{ asset_urls.choices_css }}">
|
<link rel="stylesheet" href="{{ asset_urls.choices_css }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
|
<link rel="stylesheet" href="{{ static_url('css/layout.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
|
<link rel="stylesheet" href="{{ static_url('css/components.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/charts.css') }}">
|
<link rel="stylesheet" href="{{ static_url('css/charts.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/choices.css') }}">
|
<link rel="stylesheet" href="{{ static_url('css/choices.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<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>
|
||||||
@@ -19,15 +19,15 @@
|
|||||||
<script src="{{ asset_urls.choices_js }}"></script>
|
<script src="{{ asset_urls.choices_js }}"></script>
|
||||||
<script src="{{ asset_urls.chart_js }}"></script>
|
<script src="{{ asset_urls.chart_js }}"></script>
|
||||||
<script src="{{ asset_urls.socket_io_js }}"></script>
|
<script src="{{ asset_urls.socket_io_js }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core.js') }}"></script>
|
<script src="{{ static_url('js/core.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
<script src="{{ static_url('js/charts.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/ajax.js') }}"></script>
|
<script src="{{ static_url('js/ajax.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/stations.js') }}"></script>
|
<script src="{{ static_url('js/stations.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/orlen.js') }}"></script>
|
<script src="{{ static_url('js/orlen.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/fuel.js') }}"></script>
|
<script src="{{ static_url('js/fuel.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
<script src="{{ static_url('js/auth.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/users.js') }}"></script>
|
<script src="{{ static_url('js/users.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/socket.js') }}"></script>
|
<script src="{{ static_url('js/socket.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/init.js') }}"></script>
|
<script src="{{ static_url('js/init.js') }}"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
@@ -23,4 +23,3 @@ services:
|
|||||||
timeout: 6s
|
timeout: 6s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
|
|||||||
+53
-7
@@ -1,9 +1,14 @@
|
|||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.database import initialize_database, migrate_database
|
||||||
from app.services import calculate_costs, invoice_period
|
from app.services import calculate_costs, invoice_period
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(tmp_path, name):
|
||||||
|
return create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": f"sqlite:///{tmp_path / name}"})
|
||||||
|
|
||||||
def test_health(tmp_path):
|
def test_health(tmp_path):
|
||||||
app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'test.db'}"})
|
app=make_app(tmp_path, "test.db")
|
||||||
assert app.test_client().get('/health').json=={"status":"ok"}
|
assert app.test_client().get('/health').json=={"status":"ok"}
|
||||||
|
|
||||||
def test_costs_50_percent_vat():
|
def test_costs_50_percent_vat():
|
||||||
@@ -17,7 +22,7 @@ def test_invoice_period():
|
|||||||
assert invoice_period(datetime(2026,7,16),15).endswith('16–koniec')
|
assert invoice_period(datetime(2026,7,16),15).endswith('16–koniec')
|
||||||
|
|
||||||
def test_admin_settings_ajax_has_working_route(tmp_path):
|
def test_admin_settings_ajax_has_working_route(tmp_path):
|
||||||
app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'admin.db'}"})
|
app=make_app(tmp_path, "admin.db")
|
||||||
client=app.test_client()
|
client=app.test_client()
|
||||||
client.post('/login',data={'email':'admin@example.com','password':'admin123!'})
|
client.post('/login',data={'email':'admin@example.com','password':'admin123!'})
|
||||||
response=client.post('/admin',data={'action':'settings','name':'Firma','entity_type':'JDG','vat_rate':'23','vat_deduction_percent':'50','invoice_split_day':'15','invoice_split_enabled':'on','region':'mazowieckie','theme':'darkly'},headers={'X-Requested-With':'XMLHttpRequest'})
|
response=client.post('/admin',data={'action':'settings','name':'Firma','entity_type':'JDG','vat_rate':'23','vat_deduction_percent':'50','invoice_split_day':'15','invoice_split_enabled':'on','region':'mazowieckie','theme':'darkly'},headers={'X-Requested-With':'XMLHttpRequest'})
|
||||||
@@ -26,7 +31,7 @@ def test_admin_settings_ajax_has_working_route(tmp_path):
|
|||||||
|
|
||||||
def test_ure_brand_aggregation(tmp_path):
|
def test_ure_brand_aggregation(tmp_path):
|
||||||
from app.integrations.ure import aggregate_ure_companies
|
from app.integrations.ure import aggregate_ure_companies
|
||||||
app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'ure.db'}"})
|
app=make_app(tmp_path, "ure.db")
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
rows=[{"nazwa":"ORLEN S.A.","nip":"7740001454","regon":"610188201","nazwaStacji":"100","wojewodztwo":"mazowieckie","benzynySilnikowe":True},
|
rows=[{"nazwa":"ORLEN S.A.","nip":"7740001454","regon":"610188201","nazwaStacji":"100","wojewodztwo":"mazowieckie","benzynySilnikowe":True},
|
||||||
{"nazwa":"ORLEN S.A.","nip":"7740001454","regon":"610188201","nazwaStacji":"101","wojewodztwo":"śląskie","olejeNapedowe":True}]
|
{"nazwa":"ORLEN S.A.","nip":"7740001454","regon":"610188201","nazwaStacji":"101","wojewodztwo":"śląskie","olejeNapedowe":True}]
|
||||||
@@ -37,7 +42,7 @@ def test_ure_brand_aggregation(tmp_path):
|
|||||||
|
|
||||||
def test_ure_duplicate_dkn_is_merged(tmp_path):
|
def test_ure_duplicate_dkn_is_merged(tmp_path):
|
||||||
from app.integrations.ure import aggregate_ure_companies
|
from app.integrations.ure import aggregate_ure_companies
|
||||||
app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'dup.db'}"})
|
app=make_app(tmp_path, "dup.db")
|
||||||
rows=[
|
rows=[
|
||||||
{"dkn":977,"nazwa":"Test Sp. z o.o.","nip":"123","nazwaStacji":"Punkt paliw","infraUlica":"A","infraNrLokalu":"1","infraPoczta":"Miasto","benzynySilnikowe":True},
|
{"dkn":977,"nazwa":"Test Sp. z o.o.","nip":"123","nazwaStacji":"Punkt paliw","infraUlica":"A","infraNrLokalu":"1","infraPoczta":"Miasto","benzynySilnikowe":True},
|
||||||
{"dkn":977,"nazwa":"Test Sp. z o.o.","nip":"123","nazwaStacji":"Punkt paliw i LPG","infraUlica":"A","infraNrLokalu":"1","infraPoczta":"Miasto","gazPlynnyLPG":True},
|
{"dkn":977,"nazwa":"Test Sp. z o.o.","nip":"123","nazwaStacji":"Punkt paliw i LPG","infraUlica":"A","infraNrLokalu":"1","infraPoczta":"Miasto","gazPlynnyLPG":True},
|
||||||
@@ -51,7 +56,7 @@ def test_ure_duplicate_dkn_is_merged(tmp_path):
|
|||||||
assert result[0]["points"][0]["has_lpg"] is True
|
assert result[0]["points"][0]["has_lpg"] is True
|
||||||
|
|
||||||
def test_company_and_card_forms_use_post(tmp_path):
|
def test_company_and_card_forms_use_post(tmp_path):
|
||||||
app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'forms.db'}"})
|
app=make_app(tmp_path, "forms.db")
|
||||||
client=app.test_client()
|
client=app.test_client()
|
||||||
client.post('/login',data={'email':'admin@example.com','password':'admin123!'})
|
client.post('/login',data={'email':'admin@example.com','password':'admin123!'})
|
||||||
html=client.get('/admin/companies').get_data(as_text=True)
|
html=client.get('/admin/companies').get_data(as_text=True)
|
||||||
@@ -62,7 +67,7 @@ def test_company_and_card_forms_use_post(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_cannot_delete_last_admin(tmp_path):
|
def test_cannot_delete_last_admin(tmp_path):
|
||||||
app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'delete.db'}"})
|
app=make_app(tmp_path, "delete.db")
|
||||||
client=app.test_client()
|
client=app.test_client()
|
||||||
client.post('/api/auth/login',data={'email':'admin@example.com','password':'admin123!'})
|
client.post('/api/auth/login',data={'email':'admin@example.com','password':'admin123!'})
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -75,10 +80,51 @@ def test_cannot_delete_last_admin(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_reset_admin_password(tmp_path):
|
def test_cli_reset_admin_password(tmp_path):
|
||||||
app=create_app({"TESTING":True,"SQLALCHEMY_DATABASE_URI":f"sqlite:///{tmp_path/'cli.db'}"})
|
app=make_app(tmp_path, "cli.db")
|
||||||
runner=app.test_cli_runner()
|
runner=app.test_cli_runner()
|
||||||
result=runner.invoke(args=['reset-admin-password','--email','admin@example.com','--password','NoweHaslo123!'])
|
result=runner.invoke(args=['reset-admin-password','--email','admin@example.com','--password','NoweHaslo123!'])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
client=app.test_client()
|
client=app.test_client()
|
||||||
response=client.post('/api/auth/login',data={'email':'admin@example.com','password':'NoweHaslo123!'})
|
response=client.post('/api/auth/login',data={'email':'admin@example.com','password':'NoweHaslo123!'})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
||||||
|
html=response.get_data(as_text=True)
|
||||||
|
assert '/static/css/layout.css?v=' in html
|
||||||
|
assert '/static/js/core.js?v=' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_cache_for_30_days(tmp_path):
|
||||||
|
app=make_app(tmp_path, "static.db")
|
||||||
|
response=app.test_client().get('/static/css/layout.css')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers['Cache-Control'] == 'public, max-age=2592000, immutable'
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_initializes_database(tmp_path):
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": f"sqlite:///{tmp_path / 'empty.db'}"})
|
||||||
|
with app.app_context():
|
||||||
|
tables = inspect(db.engine).get_table_names()
|
||||||
|
assert "user" in tables
|
||||||
|
assert "schema_migration" in tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_runner_creates_registry_without_legacy_migrations(tmp_path):
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
app = make_app(tmp_path, "migrations.db")
|
||||||
|
with app.app_context():
|
||||||
|
assert migrate_database() == []
|
||||||
|
assert "schema_migration" in inspect(db.engine).get_table_names()
|
||||||
|
assert migrate_database() == []
|
||||||
|
|||||||
Reference in New Issue
Block a user