Files
Vorrania/backend/app/main.py
Scarriffle 5b952524d7 Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:33:56 +02:00

157 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
field_definitions,
groups,
locations,
maintenance,
package_types,
products,
settings as settings_router,
shops,
stock,
transfer,
units,
users,
views,
)
from .seed import (
ensure_builtin_categories,
ensure_builtin_package_types,
ensure_builtin_units,
ensure_example_object_categories,
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",
# Gegenstands-Verwaltung: Verwaltungsart je Kategorie. Bestehende
# (reine Lebensmittel-)Kategorien werden dabei auf "food" gesetzt.
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS tracking VARCHAR(16) "
"NOT NULL DEFAULT 'food'",
# Bezugsquelle und Onlineshop-Link am Artikel (nur für Gegenstände genutzt).
"ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_id INTEGER "
"REFERENCES shops(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
"REFERENCES locations(id) ON DELETE SET NULL",
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
]
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_example_object_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)
app.include_router(shops.router)
app.include_router(field_definitions.router)