Zwei Grenzen sassen tief im Datenmodell. Erstens war "i" im gespeicherten Layout zugleich die Kartenart; da react-grid-layout eindeutige Kennungen verlangt, ging jede Art genau einmal - zwei Artikel gleichzeitig beobachten war damit unmoeglich. Zweitens war dashboard_layouts.user_id unique, also genau ein Dashboard je Benutzer. Jetzt ist "i" die Kennung dieser einen Karte und "type" ihre Art, dazu kommt "props" fuer das, was nur diese Karte angeht (etwa welcher Artikel). Aeltere Anordnungen haben kein "type"; fuer sie gilt die alte Kennung als Art, sodass gespeicherte Startseiten unveraendert weiterlaufen. Die Tabelle bekommt Name und Reihenfolge, das unique faellt. Sein Name haengt davon ab, wie die Tabelle entstanden ist, deshalb wird er in pg_constraint nachgeschlagen statt geraten. Eine Feinheit beim Anlegen: Wer noch auf der Admin-Vorgabe sitzt und sein erstes eigenes Dashboard anlegt, wuerde die Vorgabe schlagartig verlieren - sobald eigene Zeilen da sind, zaehlen nur noch die. Deshalb wird sie vorher uebernommen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.6 KiB
Python
139 lines
4.6 KiB
Python
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from sqlalchemy import text
|
||
|
||
from .config import get_settings
|
||
from .database import Base, SessionLocal, engine
|
||
from .routers import (
|
||
api_tokens,
|
||
auth,
|
||
branding,
|
||
categories,
|
||
dashboard,
|
||
groups,
|
||
locations,
|
||
maintenance,
|
||
package_types,
|
||
products,
|
||
settings as settings_router,
|
||
stock,
|
||
transfer,
|
||
units,
|
||
users,
|
||
views,
|
||
)
|
||
from .seed import (
|
||
ensure_builtin_categories,
|
||
ensure_builtin_package_types,
|
||
ensure_builtin_units,
|
||
ensure_first_admin,
|
||
)
|
||
from .services.group_codes import backfill as backfill_group_codes
|
||
|
||
settings = get_settings()
|
||
|
||
|
||
def _ensure_schema() -> None:
|
||
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
|
||
|
||
create_all() legt nur fehlende Tabellen an, ändert aber keine bestehenden.
|
||
Auf Postgres holen wir neue nullable-Spalten per ADD COLUMN IF NOT EXISTS nach,
|
||
damit vorhandene Installationen ihre Daten behalten. Auf SQLite (Tests) sind
|
||
die Spalten bereits durch create_all vorhanden.
|
||
"""
|
||
if engine.dialect.name != "postgresql":
|
||
return
|
||
stmts = [
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS display_unit_id INTEGER "
|
||
"REFERENCES units(id) ON DELETE SET NULL",
|
||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||
"REFERENCES units(id) ON DELETE SET NULL",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||
"REFERENCES units(id) ON DELETE SET NULL",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||
"NOT NULL DEFAULT FALSE",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS date_precision VARCHAR(8) "
|
||
"NOT NULL DEFAULT 'day'",
|
||
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS best_before_precision VARCHAR(8) "
|
||
"NOT NULL DEFAULT 'day'",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
|
||
"REFERENCES categories(id) ON DELETE SET NULL",
|
||
# Mehrere Dashboards je Benutzer: Name und Reihenfolge kommen dazu.
|
||
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS name VARCHAR(80) "
|
||
"NOT NULL DEFAULT 'Übersicht'",
|
||
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
|
||
"NOT NULL DEFAULT 0",
|
||
]
|
||
with engine.begin() as conn:
|
||
for stmt in stmts:
|
||
conn.execute(text(stmt))
|
||
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
|
||
# Der Name der Beschraenkung haengt davon ab, wie die Tabelle entstanden
|
||
# ist, deshalb wird er nachgeschlagen statt geraten.
|
||
namen = conn.execute(
|
||
text(
|
||
"SELECT conname FROM pg_constraint "
|
||
"WHERE conrelid = 'dashboard_layouts'::regclass AND contype = 'u'"
|
||
)
|
||
).scalars().all()
|
||
for name in namen:
|
||
conn.execute(text(f'ALTER TABLE dashboard_layouts DROP CONSTRAINT "{name}"'))
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Tabellen anlegen (MVP: create_all statt Alembic-Migrationen).
|
||
Base.metadata.create_all(bind=engine)
|
||
_ensure_schema()
|
||
db = SessionLocal()
|
||
try:
|
||
ensure_builtin_units(db)
|
||
ensure_builtin_package_types(db)
|
||
ensure_builtin_categories(db)
|
||
ensure_first_admin(db)
|
||
# Codes bestehender Gruppen-Zuordnungen nachziehen.
|
||
backfill_group_codes(db)
|
||
finally:
|
||
db.close()
|
||
yield
|
||
|
||
|
||
app = FastAPI(title="Vorrania – Lebensmittel-Lagerverwaltung", version="1.0.0", lifespan=lifespan)
|
||
|
||
origins = ["*"] if settings.cors_origins.strip() == "*" else [
|
||
o.strip() for o in settings.cors_origins.split(",") if o.strip()
|
||
]
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/health", tags=["meta"])
|
||
def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
|
||
app.include_router(auth.router)
|
||
app.include_router(users.router)
|
||
app.include_router(products.router)
|
||
app.include_router(stock.router)
|
||
app.include_router(locations.router)
|
||
app.include_router(groups.router)
|
||
app.include_router(units.router)
|
||
app.include_router(package_types.router)
|
||
app.include_router(views.router)
|
||
app.include_router(transfer.router)
|
||
app.include_router(api_tokens.router)
|
||
app.include_router(settings_router.router)
|
||
app.include_router(branding.router)
|
||
app.include_router(categories.router)
|
||
app.include_router(maintenance.router)
|
||
app.include_router(dashboard.router)
|