This commit is contained in:
Mateusz Gruszczyński
2026-07-13 13:48:37 +02:00
parent bfbc29effe
commit cb37390a29
9 changed files with 289 additions and 93 deletions
+2 -4
View File
@@ -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"]
-28
View File
@@ -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
View File
@@ -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
+32
View File
@@ -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"]
+75
View File
@@ -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()
+76
View File
@@ -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
View File
@@ -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>
-1
View File
@@ -23,4 +23,3 @@ services:
timeout: 6s timeout: 6s
retries: 3 retries: 3
start_period: 20s start_period: 20s
+53 -7
View File
@@ -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('16koniec') assert invoice_period(datetime(2026,7,16),15).endswith('16koniec')
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() == []