From cb37390a29115e6b81f8fe8eedeb52b182993d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 13 Jul 2026 13:48:37 +0200 Subject: [PATCH] docekr --- Dockerfile | 6 +-- README.md | 28 -------------- app/__init__.py | 76 +++++++++++++++++++------------------- app/database.py | 32 ++++++++++++++++ app/database_init.py | 75 +++++++++++++++++++++++++++++++++++++ app/database_migrations.py | 76 ++++++++++++++++++++++++++++++++++++++ app/templates/base.html | 28 +++++++------- docker-compose.yml | 1 - tests/test_app.py | 60 ++++++++++++++++++++++++++---- 9 files changed, 289 insertions(+), 93 deletions(-) create mode 100644 app/database.py create mode 100644 app/database_init.py create mode 100644 app/database_migrations.py diff --git a/Dockerfile b/Dockerfile index 791361b..27dc367 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,8 @@ COPY . . RUN mkdir -p /data -EXPOSE 8000 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD sh -c 'curl -f http://localhost:${APP_PORT:-8000}/health || exit 1' +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"] diff --git a/README.md b/README.md index c767110..6eed52c 100644 --- a/README.md +++ b/README.md @@ -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. -## 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 ```bash 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 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. -## 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ę: ```bash @@ -137,7 +110,6 @@ Kod integracji został rozdzielony na: Frontend jest podzielony funkcjonalnie w `app/static/js/` i `app/static/css/`. - ## 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. diff --git a/app/__init__.py b/app/__init__.py index feb0362..a57f9fe 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,12 +1,23 @@ -from flask import Flask +from flask import Flask, request, url_for import click -from sqlalchemy import text +import hashlib +from pathlib import Path from .extensions import db, login_manager, socketio -from .models import User, CompanySettings, AppSetting +from .models import User, AppSetting from .config import Config 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): return { "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) if test_config: app.config.update(test_config) 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 .api import api from .openapi import 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 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() 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 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..b2b5301 --- /dev/null +++ b/app/database.py @@ -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"] diff --git a/app/database_init.py b/app/database_init.py new file mode 100644 index 0000000..c507dfa --- /dev/null +++ b/app/database_init.py @@ -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() diff --git a/app/database_migrations.py b/app/database_migrations.py new file mode 100644 index 0000000..7d035c5 --- /dev/null +++ b/app/database_migrations.py @@ -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() diff --git a/app/templates/base.html b/app/templates/base.html index b7f27de..fd9c802 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,10 +6,10 @@ {% block title %}FuelTrack{% endblock %} - - - - + + + + @@ -19,15 +19,15 @@ - - - - - - - - - - + + + + + + + + + + {% block scripts %}{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index ac11ca9..e6254a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,4 +23,3 @@ services: timeout: 6s retries: 3 start_period: 20s - diff --git a/tests/test_app.py b/tests/test_app.py index b780706..e56d77b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,9 +1,14 @@ from app import create_app +from app.database import initialize_database, migrate_database from app.services import calculate_costs, invoice_period 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): - 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"} 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') 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.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'}) @@ -26,7 +31,7 @@ def test_admin_settings_ajax_has_working_route(tmp_path): def test_ure_brand_aggregation(tmp_path): 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(): 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}] @@ -37,7 +42,7 @@ def test_ure_brand_aggregation(tmp_path): def test_ure_duplicate_dkn_is_merged(tmp_path): 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=[ {"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}, @@ -51,7 +56,7 @@ def test_ure_duplicate_dkn_is_merged(tmp_path): assert result[0]["points"][0]["has_lpg"] is True 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.post('/login',data={'email':'admin@example.com','password':'admin123!'}) 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): - 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.post('/api/auth/login',data={'email':'admin@example.com','password':'admin123!'}) with app.app_context(): @@ -75,10 +80,51 @@ def test_cannot_delete_last_admin(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() result=runner.invoke(args=['reset-admin-password','--email','admin@example.com','--password','NoweHaslo123!']) assert result.exit_code == 0 client=app.test_client() response=client.post('/api/auth/login',data={'email':'admin@example.com','password':'NoweHaslo123!'}) 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() == []