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
+37 -39
View File
@@ -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
+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>
<link id="bootstrap-theme" rel="stylesheet" href="{{ app_theme.url }}">
<link rel="stylesheet" href="{{ asset_urls.choices_css }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/charts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/choices.css') }}">
<link rel="stylesheet" href="{{ static_url('css/layout.css') }}">
<link rel="stylesheet" href="{{ static_url('css/components.css') }}">
<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>
@@ -19,15 +19,15 @@
<script src="{{ asset_urls.choices_js }}"></script>
<script src="{{ asset_urls.chart_js }}"></script>
<script src="{{ asset_urls.socket_io_js }}"></script>
<script src="{{ url_for('static', filename='js/core.js') }}"></script>
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script src="{{ url_for('static', filename='js/ajax.js') }}"></script>
<script src="{{ url_for('static', filename='js/stations.js') }}"></script>
<script src="{{ url_for('static', filename='js/orlen.js') }}"></script>
<script src="{{ url_for('static', filename='js/fuel.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script src="{{ url_for('static', filename='js/users.js') }}"></script>
<script src="{{ url_for('static', filename='js/socket.js') }}"></script>
<script src="{{ url_for('static', filename='js/init.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>
<script src="{{ static_url('js/stations.js') }}"></script>
<script src="{{ static_url('js/orlen.js') }}"></script>
<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>