Schritt 1: Fundament der Lebensmittel-Lagerverwaltung (Pantry)

Backend (FastAPI + PostgreSQL): Chargen mit MHD, FEFO-Auslagern,
Einheiten-Umrechnung (Stueck/g/ml + Packungen), Open-Food-Facts-Lookup
mit lokalem Fallback, JWT-Auth mit Rollen (Admin/Nutzer), erster Admin
beim Setup, Einkaufsliste, Ablaufwarnung, Lagerorte, pytest fuer FEFO.

Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte,
Lagerorte, Benutzerverwaltung, Einkaufsliste - rollenabhaengig.

Deploy: docker-compose + install.sh (Docker-Autoinstall, Secrets),
README und Roadmap fuer Schritt 2 (iOS) und Schritt 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 09:14:14 +02:00
commit 639126468f
57 changed files with 3460 additions and 0 deletions

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
.env
*.sqlite
*.db

21
backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# System-Abhängigkeiten für psycopg2 & bcrypt
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY tests ./tests
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

34
backend/app/config.py Normal file
View File

@@ -0,0 +1,34 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Database
database_url: str = "postgresql+psycopg2://pantry:pantry@localhost:5432/pantry"
# Auth / JWT
jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
# First admin, created on startup if no users exist
admin_username: str = "admin"
admin_password: str = "changeme"
# Open Food Facts
off_base_url: str = "https://world.openfoodfacts.org"
off_timeout_seconds: float = 8.0
# Behaviour
expiry_warning_days_default: int = 7
# CORS (comma separated); "*" allows all
cors_origins: str = "*"
@lru_cache
def get_settings() -> Settings:
return Settings()

34
backend/app/crud.py Normal file
View File

@@ -0,0 +1,34 @@
"""Kleine gemeinsame Helfer für Router."""
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .models import Product
from .schemas import ProductOut
from .services.stock import current_stock
def product_to_out(db: Session, product: Product) -> ProductOut:
out = ProductOut.model_validate(product)
out.stock = current_stock(db, product.id)
return out
def resolve_product(
db: Session, product_id: int | None, barcode: str | None
) -> Product:
"""Findet ein Produkt per ID oder Barcode; wirft 404, wenn keins passt."""
product: Product | None = None
if product_id is not None:
product = db.get(Product, product_id)
elif barcode:
product = db.query(Product).filter(Product.barcode == barcode).first()
else:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "product_id oder barcode erforderlich"
)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
return product

34
backend/app/database.py Normal file
View File

@@ -0,0 +1,34 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import get_settings
settings = get_settings()
# SQLite (used in tests) needs a special connect arg; Postgres does not.
connect_args = {}
if settings.database_url.startswith("sqlite"):
connect_args = {"check_same_thread": False}
engine = create_engine(
settings.database_url,
connect_args=connect_args,
pool_pre_ping=True,
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

39
backend/app/deps.py Normal file
View File

@@ -0,0 +1,39 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from .database import get_db
from .models import Role, User
from .security import decode_access_token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
_credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültige oder abgelaufene Anmeldung",
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> User:
payload = decode_access_token(token)
if payload is None:
raise _credentials_exc
username = payload.get("sub")
if not username:
raise _credentials_exc
user = db.query(User).filter(User.username == username).first()
if user is None:
raise _credentials_exc
return user
def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != Role.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nur Administratoren dürfen diese Aktion ausführen",
)
return current_user

61
backend/app/main.py Normal file
View File

@@ -0,0 +1,61 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .database import Base, SessionLocal, engine
from .routers import (
auth,
groups,
locations,
products,
settings as settings_router,
stock,
users,
views,
)
from .seed import ensure_first_admin
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Tabellen anlegen (MVP: create_all statt Alembic-Migrationen).
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
ensure_first_admin(db)
finally:
db.close()
yield
app = FastAPI(title="Pantry 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(views.router)
app.include_router(settings_router.router)

147
backend/app/models.py Normal file
View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import enum
from datetime import date, datetime, timezone
from sqlalchemy import (
Date,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class Role(str, enum.Enum):
admin = "admin"
user = "user"
class BaseUnit(str, enum.Enum):
piece = "piece"
gram = "gram"
milliliter = "milliliter"
class MovementType(str, enum.Enum):
in_ = "in"
out = "out"
adjust = "adjust"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Group(Base):
__tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
# Group-level minimum stock is a Schritt-3 feature; column kept for forward-compat.
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True)
products: Mapped[list[Product]] = relationship(back_populates="group")
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
class Product(Base):
__tablename__ = "products"
__table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
barcode: Mapped[str | None] = mapped_column(String(64), nullable=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
brand: Mapped[str | None] = mapped_column(String(255), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
base_unit: Mapped[BaseUnit] = mapped_column(
Enum(BaseUnit), nullable=False, default=BaseUnit.piece
)
# Number of base units contained in one package (e.g. 500 g per package).
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
)
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
group: Mapped[Group | None] = relationship(back_populates="products")
lots: Mapped[list[Lot]] = relationship(
back_populates="product", cascade="all, delete-orphan"
)
class Lot(Base):
__tablename__ = "lots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
best_before: Mapped[date | None] = mapped_column(Date, nullable=True)
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship(back_populates="lots")
class Movement(Base):
__tablename__ = "movements"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
lot_id: Mapped[int | None] = mapped_column(
ForeignKey("lots.id", ondelete="SET NULL"), nullable=True
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
type: Mapped[MovementType] = mapped_column(Enum(MovementType), nullable=False)
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(String(255), nullable=False)

72
backend/app/off.py Normal file
View File

@@ -0,0 +1,72 @@
"""Open Food Facts Lookup-Client."""
from __future__ import annotations
import json
import httpx
from .config import get_settings
settings = get_settings()
def _guess_base_unit(quantity: str | None) -> str:
"""Rät die Basiseinheit aus dem OFF-Feld 'quantity' (z.B. '500 g', '1 l')."""
if not quantity:
return "piece"
q = quantity.lower()
if "ml" in q or "cl" in q or "l" in q or "liter" in q:
return "milliliter"
if "kg" in q or " g" in q or "gramm" in q or q.strip().endswith("g"):
return "gram"
return "piece"
def lookup_barcode(barcode: str) -> dict | None:
"""Fragt Open Food Facts nach einem Barcode.
Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden.
"""
url = f"{settings.off_base_url}/api/v2/product/{barcode}.json"
try:
resp = httpx.get(
url,
timeout=settings.off_timeout_seconds,
headers={"User-Agent": "Pantry-Selfhosted/1.0 (github: pantry)"},
)
except httpx.HTTPError:
return None
if resp.status_code != 200:
return None
data = resp.json()
if data.get("status") != 1:
return None
product = data.get("product", {})
name = (
product.get("product_name_de")
or product.get("product_name")
or product.get("generic_name")
or ""
).strip()
if not name:
return None
categories = product.get("categories") or ""
category_tags = product.get("categories_tags") or []
return {
"barcode": barcode,
"name": name,
"brand": (product.get("brands") or "").strip() or None,
"image_url": product.get("image_front_url") or product.get("image_url") or None,
"base_unit": _guess_base_unit(product.get("quantity")),
"quantity_text": product.get("quantity"),
"category_suggestion": categories.split(",")[0].strip() if categories else None,
"category_tags": category_tags,
"source": "off",
"off_raw": json.dumps(product)[:20000],
}

View File

View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import User
from ..schemas import Token, UserOut
from ..security import create_access_token, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=Token)
def login(
form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
) -> Token:
user = db.query(User).filter(User.username == form.username).first()
if user is None or not verify_password(form.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Benutzername oder Passwort falsch",
)
token = create_access_token(subject=user.username, role=user.role.value)
return Token(access_token=token, role=user.role, username=user.username)
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)) -> User:
return current_user

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Group, User
from ..schemas import GroupCreate, GroupOut
router = APIRouter(prefix="/groups", tags=["groups"])
@router.get("", response_model=list[GroupOut])
def list_groups(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Group]:
return db.query(Group).order_by(Group.name).all()
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
def create_group(
payload: GroupCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Group:
if db.query(Group).filter(Group.name == payload.name).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
group = Group(name=payload.name, min_stock=payload.min_stock)
db.add(group)
db.commit()
db.refresh(group)
return group
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(
group_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
db.delete(group)
db.commit()

View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Location, User
from ..schemas import LocationCreate, LocationOut
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get("", response_model=list[LocationOut])
def list_locations(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Location]:
return db.query(Location).order_by(Location.name).all()
@router.post("", response_model=LocationOut, status_code=status.HTTP_201_CREATED)
def create_location(
payload: LocationCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Location:
loc = Location(name=payload.name, parent_id=payload.parent_id)
db.add(loc)
db.commit()
db.refresh(loc)
return loc
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location(
location_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
loc = db.get(Location, location_id)
if loc is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
db.delete(loc)
db.commit()

View File

@@ -0,0 +1,125 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..crud import product_to_out
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import BaseUnit, Product, User
from ..off import lookup_barcode
from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate
router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductOut])
def list_products(
q: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ProductOut]:
query = db.query(Product)
if q:
like = f"%{q}%"
query = query.filter(Product.name.ilike(like))
products = query.order_by(Product.name).all()
return [product_to_out(db, p) for p in products]
@router.get("/lookup", response_model=LookupResult)
def lookup(
barcode: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> LookupResult:
"""Barcode: erst lokal, dann Open Food Facts. Nur Vorschlag, legt nichts an."""
existing = db.query(Product).filter(Product.barcode == barcode).first()
if existing:
return LookupResult(found=True, existing_product=product_to_out(db, existing))
suggestion = lookup_barcode(barcode)
if suggestion:
return LookupResult(found=True, suggestion=suggestion)
return LookupResult(found=False)
@router.get("/{product_id}", response_model=ProductOut)
def get_product(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ProductOut:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
return product_to_out(db, product)
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(
payload: ProductCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
if payload.barcode:
exists = db.query(Product).filter(Product.barcode == payload.barcode).first()
if exists:
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits"
)
product = Product(
barcode=payload.barcode or None,
name=payload.name,
brand=payload.brand,
image_url=payload.image_url,
base_unit=payload.base_unit,
package_size=payload.package_size,
group_id=payload.group_id,
min_stock=payload.min_stock,
source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.patch("/{product_id}", response_model=ProductOut)
def update_product(
product_id: int,
payload: ProductUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "barcode" in data and data["barcode"]:
clash = (
db.query(Product)
.filter(Product.barcode == data["barcode"], Product.id != product_id)
.first()
)
if clash:
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
)
for field, value in data.items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
db.delete(product)
db.commit()

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..config import get_settings
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Setting, User
from ..schemas import SettingOut
router = APIRouter(prefix="/settings", tags=["settings"])
EXPIRY_WARNING_KEY = "expiry_warning_days"
def get_expiry_warning_days(db: Session) -> int:
row = db.get(Setting, EXPIRY_WARNING_KEY)
if row is None:
return get_settings().expiry_warning_days_default
try:
return int(row.value)
except ValueError:
return get_settings().expiry_warning_days_default
@router.get("", response_model=list[SettingOut])
def list_settings(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[SettingOut]:
rows = db.query(Setting).all()
known = {r.key: r.value for r in rows}
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
return [SettingOut(key=k, value=v) for k, v in known.items()]
@router.put("/{key}", response_model=SettingOut)
def set_setting(
key: str,
value: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> SettingOut:
row = db.get(Setting, key)
if row is None:
row = Setting(key=key, value=value)
db.add(row)
else:
row.value = value
db.commit()
return SettingOut(key=key, value=value)

View File

@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..crud import resolve_product
from ..database import get_db
from ..deps import get_current_user
from ..models import Lot, User
from ..schemas import (
CheckInRequest,
CheckInResponse,
CheckOutRequest,
CheckOutResponse,
LotOut,
)
from ..services.stock import StockError, check_in, check_out, current_stock
from ..services.units import UnitError
router = APIRouter(tags=["stock"])
@router.post("/stock/checkin", response_model=CheckInResponse)
def stock_checkin(
payload: CheckInRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> CheckInResponse:
product = resolve_product(db, payload.product_id, payload.barcode)
try:
lot = check_in(
db,
product=product,
quantity=payload.quantity,
unit=payload.unit,
best_before=payload.best_before,
location_id=payload.location_id,
user=user,
note=payload.note,
)
except UnitError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit()
db.refresh(lot)
return CheckInResponse(
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
)
@router.post("/stock/checkout", response_model=CheckOutResponse)
def stock_checkout(
payload: CheckOutRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> CheckOutResponse:
product = resolve_product(db, payload.product_id, payload.barcode)
try:
affected = check_out(
db,
product=product,
quantity=payload.quantity,
unit=payload.unit,
user=user,
note=payload.note,
)
except UnitError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return CheckOutResponse(
affected_lots=affected, product_stock=current_stock(db, product.id)
)
@router.get("/lots", response_model=list[LotOut])
def list_lots(
product_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Lot]:
query = db.query(Lot)
if product_id is not None:
query = query.filter(Lot.product_id == product_id)
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import require_admin
from ..models import Role, User
from ..schemas import UserCreate, UserOut, UserUpdate
from ..security import hash_password
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)])
@router.get("", response_model=list[UserOut])
def list_users(db: Session = Depends(get_db)) -> list[User]:
return db.query(User).order_by(User.username).all()
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User:
if db.query(User).filter(User.username == payload.username).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits")
user = User(
username=payload.username,
password_hash=hash_password(payload.password),
role=payload.role,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.patch("/{user_id}", response_model=UserOut)
def update_user(
user_id: int, payload: UserUpdate, db: Session = Depends(get_db)
) -> User:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
if payload.password is not None:
user.password_hash = hash_password(payload.password)
if payload.role is not None:
user.role = payload.role
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(require_admin),
) -> None:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
if user.id == current_admin.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Du kannst dich nicht selbst löschen")
# Verhindere das Löschen des letzten Admins.
if user.role == Role.admin:
admin_count = db.query(User).filter(User.role == Role.admin).count()
if admin_count <= 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Der letzte Administrator kann nicht gelöscht werden"
)
db.delete(user)
db.commit()

View File

@@ -0,0 +1,76 @@
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import Lot, Product, User
from ..schemas import ExpiringItem, ShoppingItem
from ..services.stock import current_stock
from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"])
@router.get("/shopping-list", response_model=list[ShoppingItem])
def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShoppingItem]:
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
items: list[ShoppingItem] = []
products = (
db.query(Product)
.filter(Product.min_stock.isnot(None), Product.min_stock > 0)
.all()
)
for product in products:
stock = current_stock(db, product.id)
if stock < product.min_stock:
items.append(
ShoppingItem(
product_id=product.id,
name=product.name,
base_unit=product.base_unit,
stock=stock,
min_stock=product.min_stock,
deficit=product.min_stock - stock,
)
)
items.sort(key=lambda i: i.deficit, reverse=True)
return items
@router.get("/expiring", response_model=list[ExpiringItem])
def expiring(
days: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ExpiringItem]:
"""Chargen, die innerhalb der Warnfrist ablaufen (oder schon abgelaufen sind)."""
if days is None:
days = get_expiry_warning_days(db)
today = date.today()
threshold = today + timedelta(days=days)
lots = (
db.query(Lot)
.filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0)
.order_by(Lot.best_before)
.all()
)
result: list[ExpiringItem] = []
for lot in lots:
product = lot.product
result.append(
ExpiringItem(
lot_id=lot.id,
product_id=product.id,
product_name=product.name,
quantity=lot.quantity,
base_unit=product.base_unit,
best_before=lot.best_before,
days_left=(lot.best_before - today).days,
)
)
return result

174
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,174 @@
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
from .models import BaseUnit, Role
# ---- Auth / Users ----
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
role: Role
username: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
role: Role
created_at: datetime
class UserCreate(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=4, max_length=255)
role: Role = Role.user
class UserUpdate(BaseModel):
password: str | None = Field(default=None, min_length=4, max_length=255)
role: Role | None = None
# ---- Groups ----
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
min_stock: float | None = None
class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
min_stock: float | None = None
# ---- Locations ----
class LocationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
parent_id: int | None = None
class LocationCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
parent_id: int | None = None
# ---- Products ----
class ProductBase(BaseModel):
barcode: str | None = None
name: str = Field(min_length=1, max_length=255)
brand: str | None = None
image_url: str | None = None
base_unit: BaseUnit = BaseUnit.piece
package_size: float | None = Field(default=None, gt=0)
group_id: int | None = None
min_stock: float | None = Field(default=None, ge=0)
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
barcode: str | None = None
name: str | None = Field(default=None, min_length=1, max_length=255)
brand: str | None = None
image_url: str | None = None
base_unit: BaseUnit | None = None
package_size: float | None = Field(default=None, gt=0)
group_id: int | None = None
min_stock: float | None = Field(default=None, ge=0)
class ProductOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
barcode: str | None
name: str
brand: str | None
image_url: str | None
base_unit: BaseUnit
package_size: float | None
group_id: int | None
min_stock: float | None
source: str
created_at: datetime
# angereichert:
stock: float = 0.0
class LookupResult(BaseModel):
found: bool
existing_product: ProductOut | None = None
suggestion: dict | None = None
# ---- Stock movements ----
class CheckInRequest(BaseModel):
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
unit: str
best_before: date | None = None
location_id: int | None = None
note: str | None = None
class CheckOutRequest(BaseModel):
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
unit: str
note: str | None = None
class LotOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
quantity: float
best_before: date | None
location_id: int | None
created_at: datetime
class CheckInResponse(BaseModel):
lot: LotOut
product_stock: float
class CheckOutResponse(BaseModel):
affected_lots: list[dict]
product_stock: float
# ---- Views ----
class ShoppingItem(BaseModel):
product_id: int
name: str
base_unit: BaseUnit
stock: float
min_stock: float
deficit: float
class ExpiringItem(BaseModel):
lot_id: int
product_id: int
product_name: str
quantity: float
base_unit: BaseUnit
best_before: date
days_left: int
class SettingOut(BaseModel):
key: str
value: str

31
backend/app/security.py Normal file
View File

@@ -0,0 +1,31 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_access_token(subject: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "role": role, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except JWTError:
return None

21
backend/app/seed.py Normal file
View File

@@ -0,0 +1,21 @@
"""Erstellt beim ersten Start einen Admin-Benutzer, falls noch keiner existiert."""
from sqlalchemy.orm import Session
from .config import get_settings
from .models import Role, User
from .security import hash_password
def ensure_first_admin(db: Session) -> None:
settings = get_settings()
has_users = db.query(User).first() is not None
if has_users:
return
admin = User(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
role=Role.admin,
)
db.add(admin)
db.commit()

View File

View File

@@ -0,0 +1,125 @@
"""Chargen-Logik: Einlagern erzeugt Lots, Auslagern bucht per FEFO ab."""
from __future__ import annotations
from datetime import date
from sqlalchemy import asc
from sqlalchemy.orm import Session
from ..models import Lot, Movement, MovementType, Product, User
from .units import to_base_quantity
class StockError(ValueError):
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
def current_stock(db: Session, product_id: int) -> float:
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
total = (
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
)
return float(sum(q for (q,) in total))
def check_in(
db: Session,
product: Product,
quantity: float,
unit: str,
best_before: date | None,
location_id: int | None,
user: User | None,
note: str | None = None,
) -> Lot:
"""Legt eine neue Charge an und protokolliert die Bewegung."""
quantity_base = to_base_quantity(product, quantity, unit)
lot = Lot(
product_id=product.id,
quantity=quantity_base,
best_before=best_before,
location_id=location_id,
)
db.add(lot)
db.flush() # lot.id verfügbar machen
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.in_,
quantity=quantity_base,
unit_used=unit,
note=note,
)
)
return lot
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
lots = (
db.query(Lot)
.filter(Lot.product_id == product_id, Lot.quantity > 0)
.order_by(asc(Lot.created_at))
.all()
)
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
# es über SQLite und Postgres identisch funktioniert.
return sorted(
lots,
key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id),
)
def check_out(
db: Session,
product: Product,
quantity: float,
unit: str,
user: User | None,
note: str | None = None,
) -> list[dict]:
"""Bucht ``quantity`` (in ``unit``) per FEFO von den Chargen ab.
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
"""
needed = to_base_quantity(product, quantity, unit)
available = current_stock(db, product.id)
if needed > available + 1e-9:
raise StockError(
f"Nicht genug Bestand: benötigt {needed:g}, verfügbar {available:g} "
f"{product.base_unit.value}"
)
affected: list[dict] = []
remaining = needed
for lot in _fefo_lots(db, product.id):
if remaining <= 1e-9:
break
take = min(lot.quantity, remaining)
lot.quantity -= take
remaining -= take
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.out,
quantity=take,
unit_used=unit,
note=note,
)
)
affected.append({"lot_id": lot.id, "quantity": take})
# Leere Charge entfernen, damit das "MHD-Array" sauber bleibt.
if lot.quantity <= 1e-9:
db.delete(lot)
return affected

View File

@@ -0,0 +1,64 @@
"""Einheiten-Umrechnung.
Der Bestand einer Charge (Lot) wird immer in der Basiseinheit des Produkts
gespeichert (Stück, Gramm oder Milliliter). Nutzer geben Mengen entweder in der
Basiseinheit oder in "Packungen" (package) an. Eine Packung entspricht
``product.package_size`` Basiseinheiten.
"""
from __future__ import annotations
from ..models import BaseUnit, Product
# Aliase, die als "Basiseinheit" akzeptiert werden.
_BASE_ALIASES: dict[str, BaseUnit] = {
"piece": BaseUnit.piece,
"pieces": BaseUnit.piece,
"stück": BaseUnit.piece,
"stueck": BaseUnit.piece,
"st": BaseUnit.piece,
"g": BaseUnit.gram,
"gram": BaseUnit.gram,
"gramm": BaseUnit.gram,
"ml": BaseUnit.milliliter,
"milliliter": BaseUnit.milliliter,
}
PACKAGE_UNITS = {"package", "packung", "pkg", "pack"}
class UnitError(ValueError):
"""Wird geworfen, wenn eine Einheit nicht zum Produkt passt."""
def to_base_quantity(product: Product, quantity: float, unit: str) -> float:
"""Rechnet eine vom Nutzer angegebene Menge in Basiseinheiten um."""
if quantity <= 0:
raise UnitError("Menge muss größer als 0 sein")
unit_norm = unit.strip().lower()
if unit_norm in PACKAGE_UNITS:
if not product.package_size or product.package_size <= 0:
raise UnitError(
"Für dieses Produkt ist keine Packungsgröße hinterlegt, "
"Packungen können nicht umgerechnet werden"
)
return quantity * product.package_size
mapped = _BASE_ALIASES.get(unit_norm)
if mapped is None:
raise UnitError(f"Unbekannte Einheit: {unit}")
if mapped != product.base_unit:
raise UnitError(
f"Einheit '{unit}' passt nicht zur Basiseinheit "
f"'{product.base_unit.value}' des Produkts"
)
return float(quantity)
def base_to_packages(product: Product, quantity_base: float) -> float | None:
"""Rechnet Basiseinheiten in (ggf. gebrochene) Packungen um, falls möglich."""
if not product.package_size or product.package_size <= 0:
return None
return quantity_base / product.package_size

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
SQLAlchemy==2.0.36
psycopg2-binary==2.9.10
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
httpx==0.28.1
python-dateutil==2.9.0.post0
pytest==8.3.4

36
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,36 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import BaseUnit, Product
@pytest.fixture()
def db():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
session = TestingSession()
try:
yield session
finally:
session.close()
engine.dispose()
@pytest.fixture()
def rice(db):
"""Produkt mit Basiseinheit Gramm und 500 g pro Packung."""
product = Product(
name="Basmati-Reis", base_unit=BaseUnit.gram, package_size=500, source="manual"
)
db.add(product)
db.commit()
db.refresh(product)
return product

View File

@@ -0,0 +1,81 @@
from datetime import date, timedelta
import pytest
from app.services.stock import StockError, check_in, check_out, current_stock
def _checkin(db, product, qty, unit, best_before=None):
lot = check_in(db, product, qty, unit, best_before, None, None)
db.commit()
return lot
def test_checkin_creates_lot_in_base_units(db, rice):
_checkin(db, rice, 3, "package") # 1500 g
assert current_stock(db, rice.id) == 1500
def test_checkout_partial_amount(db, rice):
_checkin(db, rice, 3, "package") # 1500 g
check_out(db, rice, 200, "g", None)
db.commit()
assert current_stock(db, rice.id) == 1300
def test_fefo_takes_earliest_expiry_first(db, rice):
soon = date.today() + timedelta(days=5)
later = date.today() + timedelta(days=60)
# Zuerst die später ablaufende Charge einlagern ...
_checkin(db, rice, 500, "g", best_before=later)
# ... dann die früher ablaufende.
_checkin(db, rice, 500, "g", best_before=soon)
check_out(db, rice, 400, "g", None)
db.commit()
# Die früh ablaufende Charge (soon) muss zuerst reduziert worden sein.
from app.models import Lot
lots = {l.best_before: l.quantity for l in db.query(Lot).all()}
assert lots[soon] == 100
assert lots[later] == 500
def test_fefo_spans_multiple_lots_and_removes_empty(db, rice):
soon = date.today() + timedelta(days=5)
later = date.today() + timedelta(days=60)
_checkin(db, rice, 500, "g", best_before=soon)
_checkin(db, rice, 500, "g", best_before=later)
check_out(db, rice, 700, "g", None) # 500 (soon) + 200 (later)
db.commit()
from app.models import Lot
lots = db.query(Lot).all()
assert len(lots) == 1 # leere soon-Charge entfernt
assert lots[0].best_before == later
assert lots[0].quantity == 300
def test_null_best_before_is_last(db, rice):
dated = date.today() + timedelta(days=30)
_checkin(db, rice, 300, "g", best_before=None)
_checkin(db, rice, 300, "g", best_before=dated)
check_out(db, rice, 300, "g", None)
db.commit()
from app.models import Lot
remaining = db.query(Lot).all()
# Die datierte Charge wird zuerst genommen, die NULL-Charge bleibt.
assert len(remaining) == 1
assert remaining[0].best_before is None
def test_checkout_more_than_available_raises(db, rice):
_checkin(db, rice, 100, "g")
with pytest.raises(StockError):
check_out(db, rice, 200, "g", None)

View File

@@ -0,0 +1,37 @@
import pytest
from app.models import BaseUnit, Product
from app.services.units import UnitError, base_to_packages, to_base_quantity
def test_base_unit_passthrough(rice):
assert to_base_quantity(rice, 200, "g") == 200
assert to_base_quantity(rice, 200, "gramm") == 200
def test_package_conversion(rice):
# 3 Packungen * 500 g = 1500 g
assert to_base_quantity(rice, 3, "package") == 1500
assert to_base_quantity(rice, 2, "packung") == 1000
def test_wrong_unit_rejected(rice):
with pytest.raises(UnitError):
to_base_quantity(rice, 1, "ml")
def test_package_without_size_rejected():
product = Product(name="Ei", base_unit=BaseUnit.piece, package_size=None)
with pytest.raises(UnitError):
to_base_quantity(product, 1, "package")
def test_non_positive_quantity_rejected(rice):
with pytest.raises(UnitError):
to_base_quantity(rice, 0, "g")
def test_base_to_packages(rice):
assert base_to_packages(rice, 1500) == 3
product = Product(name="Ei", base_unit=BaseUnit.piece, package_size=None)
assert base_to_packages(product, 10) is None