Files
Vorrania/backend/app/main.py
Scarriffle 5ce4411d1e Backend: Einzelstücke (Items) mit UID je Gegenstand
Gegenstands-Produkte koennen als Einzelstuecke gefuehrt werden (Product.individual):
jedes physische Stueck ist ein Item mit eigener kurzer UID (fuer QR), eigenem
Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz. So laesst sich dasselbe
Modell mehrfach getrennt fuehren (Powerbank 2024 + 2025).

Neu: models.Item + Product.individual (+Migration), Schemas, services/items.py
(UID-Erzeugung, Anreicherung), routers/items.py (CRUD, /items/by-uid/{uid} zur
QR-Aufloesung, /items/{id}/remove mit Grund als Bewegung). current_stock zaehlt
bei Einzelstueck-Produkten die Items. Tests + HTTP-Smoke gruen.

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

162 lines
5.8 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,
items,
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)",
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
"NOT NULL DEFAULT FALSE",
]
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)
app.include_router(items.router)