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:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
30
backend/app/routers/auth.py
Normal file
30
backend/app/routers/auth.py
Normal 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
|
||||
44
backend/app/routers/groups.py
Normal file
44
backend/app/routers/groups.py
Normal 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()
|
||||
42
backend/app/routers/locations.py
Normal file
42
backend/app/routers/locations.py
Normal 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()
|
||||
125
backend/app/routers/products.py
Normal file
125
backend/app/routers/products.py
Normal 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()
|
||||
49
backend/app/routers/settings.py
Normal file
49
backend/app/routers/settings.py
Normal 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)
|
||||
83
backend/app/routers/stock.py
Normal file
83
backend/app/routers/stock.py
Normal 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()
|
||||
68
backend/app/routers/users.py
Normal file
68
backend/app/routers/users.py
Normal 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()
|
||||
76
backend/app/routers/views.py
Normal file
76
backend/app/routers/views.py
Normal 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
|
||||
Reference in New Issue
Block a user