Files
Vorrania/backend/app/routers/measures.py
T
ScarriffleandClaude Opus 5 f61c76034c Hausmasse und Oeffnungsfrist bekommen eine Oberflaeche
Die Endpunkte standen seit dem Backend-Commit, aber eintragen liess sich
nichts - die Mengen gingen nur per API rein. Damit war "2 EL Mehl" im
Verbrauchen-Dialog zwar vorgesehen, aber fuer niemanden erreichbar.

Zwei Stellen dafuer:

Stammdaten > Masse verwaltet nur die NAMEN ("Essloeffel", "Tasse"). Die
Seite ist absichtlich kurz, denn die Mengen gehoeren nicht an das Mass:
Ein Essloeffel Mehl sind etwa 10 g, einer Zucker etwa 12 g, und eine
Tasse Milch ist Milliliter. Ein globaler Wert waere bei jedem zweiten
Artikel falsch.

Kategorien > "Masse & Frist" ist deshalb der Ort, an dem die Arbeit
passiert. Beides steckt in einem Bedienfeld, weil es dieselbe Mechanik
ist: Der Wert gilt fuer die Kategorie und alles darunter, und der
naechste Vorfahr mit einem Eintrag gewinnt. Leere Felder erben - und
zeigen als Platzhalter, WAS sie erben ("erbt 5 Tage (Kategorie: Mehl &
Zucker)", "10 g" mit Abzeichen "geerbt"). Ohne das muesste man den
Kategorienbaum im Kopf verfolgen, um eine leere Eingabe zu verstehen.

Dafuer zwei kleine Ergaenzungen am Backend: CategoryOut nennt die
geerbte Frist samt Herkunft, und /categories/{id}/measures nimmt
?vererbt=true, um neben den eigenen auch die von oben geerbten Mengen zu
liefern. Die Bearbeitungsansicht ohne Flagge bleibt wie sie war.

Von Hand durchgeklickt - und dabei einen echten Fehler gefunden: Der
API-Aufruf im Browser hat das vererbt-Flag verschluckt, weil die
Funktion nur ein Argument annahm. Die Unterkategorie zeigte dadurch
leere Felder, obwohl der Server die geerbten Werte lieferte. Jetzt
stehen dort "10 g" und "3 g" mit der Quelle im Tooltip.

4 neue Tests, Suite bei 516.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:37:53 +02:00

320 lines
11 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.
"""Hausmaße: die Namen (Stammdaten) und ihre Mengen je Kategorie und Artikel.
Der Name ist global („Esslöffel"), die Menge nicht: 1 EL Mehl sind 10 g, 1 EL
Zucker 12 g. Deshalb zwei Ebenen – ein Maß anlegen ist Stammdatenpflege, ihm
eine Menge geben hängt an Kategorie oder Artikel.
"""
# ACHTUNG: Hier steht bewusst KEIN ``from __future__ import annotations`` –
# aus demselben Grund wie in package_types.py (``-> None`` wuerde als
# Antwortmodell gewertet und bricht mit 204 den Start des Dienstes ab).
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import (
Category,
CategoryMeasure,
Measure,
Product,
ProductMeasure,
User,
)
from ..schemas import (
MeasureAmountIn,
MeasureAmountOut,
MeasureIn,
MeasureOut,
)
from ..services.conversion import PACKAGE_TOKENS, find_unit
from ..services.vererbung import geerbtes_mass_kategorie, verfuegbare_masse
router = APIRouter(tags=["measures"])
def _measure_or_404(db: Session, measure_id: int) -> Measure:
measure = db.get(Measure, measure_id)
if measure is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Maß nicht gefunden")
return measure
def _pruefe_namen(db: Session, name: str, short: str | None, ausser_id: int | None = None) -> None:
"""Name und Kurzform müssen eindeutig und frei von Kollisionen sein.
Ein Maß, das genauso heißt wie eine verwaltete Einheit oder wie „Packung",
wäre dauerhaft unerreichbar: ``to_base`` prüft beide vorher. Lieber hier
ablehnen als still etwas anlegen, das nie greift.
"""
for wert, feld in ((name, "Name"), (short, "Kurzform")):
if not wert:
continue
t = wert.strip().lower()
if t in PACKAGE_TOKENS:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"'{wert}' ist als Packungs-Angabe vergeben und würde nie greifen.",
)
if find_unit(db, t) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"'{wert}' ist bereits eine verwaltete Einheit. Als Maß würde es "
f"nie greifen, weil Einheiten zuerst geprüft werden.",
)
doppelt = db.query(Measure).filter(
(func.lower(Measure.name) == t) | (func.lower(Measure.short) == t)
)
if ausser_id is not None:
doppelt = doppelt.filter(Measure.id != ausser_id)
if doppelt.first() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, f"{feld} '{wert}' gibt es schon.")
# --------------------------------------------------------------------------
# Stammdaten
# --------------------------------------------------------------------------
@router.get("/measures", response_model=list[MeasureOut])
def list_measures(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Measure]:
return db.query(Measure).order_by(Measure.position, Measure.id).all()
@router.post("/measures", response_model=MeasureOut, status_code=status.HTTP_201_CREATED)
def create_measure(
payload: MeasureIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Measure:
_pruefe_namen(db, payload.name, payload.short)
measure = Measure(
name=payload.name.strip(),
short=(payload.short or "").strip() or None,
position=payload.position,
)
db.add(measure)
db.commit()
db.refresh(measure)
return measure
@router.patch("/measures/{measure_id}", response_model=MeasureOut)
def update_measure(
measure_id: int,
payload: MeasureIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Measure:
"""Umbenennen – auch bei eingebauten Maßen, wie bei Einheiten und Gebinden.
Die Mengen hängen an der ID, nicht am Namen, und wandern deshalb mit.
"""
measure = _measure_or_404(db, measure_id)
_pruefe_namen(db, payload.name, payload.short, ausser_id=measure.id)
measure.name = payload.name.strip()
measure.short = (payload.short or "").strip() or None
measure.position = payload.position
db.commit()
db.refresh(measure)
return measure
@router.delete("/measures/{measure_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_measure(
measure_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Maß löschen – samt aller Mengen, die daran hängen.
Die Mengen werden ausdrücklich abgeräumt: SQLite setzt Fremdschlüssel nicht
ohne Weiteres um, sonst blieben Waisen stehen (gleiche Begründung wie in
``delete_category``).
"""
measure = _measure_or_404(db, measure_id)
if measure.is_builtin:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Eingebaute Maße lassen sich nicht löschen – umbenennen geht aber.",
)
db.query(CategoryMeasure).filter(CategoryMeasure.measure_id == measure.id).delete()
db.query(ProductMeasure).filter(ProductMeasure.measure_id == measure.id).delete()
db.delete(measure)
db.commit()
# --------------------------------------------------------------------------
# Mengen je Kategorie und Artikel
# --------------------------------------------------------------------------
@router.get("/products/{product_id}/measures", response_model=list[MeasureAmountOut])
def product_measures(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MeasureAmountOut]:
"""Die für diesen Artikel WIRKSAMEN Maße, mit Herkunft.
Grundlage für die Einheitenauswahl beim Verbrauchen – dort soll „EL" nur
auftauchen, wenn dafür auch eine Menge bekannt ist.
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
return [
MeasureAmountOut(
measure_id=a.measure.id, name=a.measure.name, short=a.measure.short,
amount=a.amount, base=a.base, herkunft=a.herkunft,
)
for a in verfuegbare_masse(db, product)
]
@router.put("/products/{product_id}/measures/{measure_id}", response_model=MeasureAmountOut)
def set_product_measure(
product_id: int,
measure_id: int,
payload: MeasureAmountIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MeasureAmountOut:
"""Menge nur für diesen Artikel – schlägt die der Kategorie."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
measure = _measure_or_404(db, measure_id)
row = (
db.query(ProductMeasure)
.filter(ProductMeasure.product_id == product.id,
ProductMeasure.measure_id == measure.id)
.first()
)
if row is None:
row = ProductMeasure(product_id=product.id, measure_id=measure.id,
amount=payload.amount, base=payload.base.value)
db.add(row)
else:
row.amount = payload.amount
row.base = payload.base.value
db.commit()
return MeasureAmountOut(
measure_id=measure.id, name=measure.name, short=measure.short,
amount=row.amount, base=row.base, herkunft="Artikel",
)
@router.delete("/products/{product_id}/measures/{measure_id}",
status_code=status.HTTP_204_NO_CONTENT)
def delete_product_measure(
product_id: int,
measure_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Eigene Menge entfernen – danach gilt wieder die der Kategorie."""
db.query(ProductMeasure).filter(
ProductMeasure.product_id == product_id,
ProductMeasure.measure_id == measure_id,
).delete()
db.commit()
@router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut])
def category_measures(
category_id: int,
vererbt: bool = False,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MeasureAmountOut]:
"""Die an DIESER Kategorie hinterlegten Mengen.
Ohne ``vererbt`` nur die eigenen – das ist die Bearbeitungsansicht. Mit
``vererbt=true`` kommen die von oben geerbten dazu, erkennbar an ihrer
Herkunft („Kategorie: Mehl & Zucker"); die Maske zeigt damit auch, was
gelten wuerde, wenn man den eigenen Eintrag loescht. Was ein ARTIKEL am Ende
bekommt, zeigt ``/products/{id}/measures``.
"""
if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
rows = (
db.query(CategoryMeasure, Measure)
.join(Measure, CategoryMeasure.measure_id == Measure.id)
.filter(CategoryMeasure.category_id == category_id)
.order_by(Measure.position, Measure.id)
.all()
)
eigene = [
MeasureAmountOut(
measure_id=m.id, name=m.name, short=m.short,
amount=cm.amount, base=cm.base, herkunft="Kategorie",
)
for cm, m in rows
]
if not vererbt:
return eigene
# Zusaetzlich, was diese Kategorie von oben ERBT. Die Maske zeigt damit
# beides: was hier steht und was gelten wuerde, wenn man es loescht.
category = db.get(Category, category_id)
gesetzt = {e.measure_id for e in eigene}
for measure in db.query(Measure).order_by(Measure.position, Measure.id).all():
if measure.id in gesetzt:
continue
angabe = geerbtes_mass_kategorie(db, category, measure.id)
if angabe is None:
continue
eigene.append(MeasureAmountOut(
measure_id=measure.id, name=measure.name, short=measure.short,
amount=angabe.amount, base=angabe.base, herkunft=angabe.herkunft,
))
return eigene
@router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut)
def set_category_measure(
category_id: int,
measure_id: int,
payload: MeasureAmountIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MeasureAmountOut:
"""Menge für eine Kategorie – gilt auch für ihre Unterkategorien."""
if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
measure = _measure_or_404(db, measure_id)
row = (
db.query(CategoryMeasure)
.filter(CategoryMeasure.category_id == category_id,
CategoryMeasure.measure_id == measure.id)
.first()
)
if row is None:
row = CategoryMeasure(category_id=category_id, measure_id=measure.id,
amount=payload.amount, base=payload.base.value)
db.add(row)
else:
row.amount = payload.amount
row.base = payload.base.value
db.commit()
return MeasureAmountOut(
measure_id=measure.id, name=measure.name, short=measure.short,
amount=row.amount, base=row.base, herkunft="Kategorie",
)
@router.delete("/categories/{category_id}/measures/{measure_id}",
status_code=status.HTTP_204_NO_CONTENT)
def delete_category_measure(
category_id: int,
measure_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
db.query(CategoryMeasure).filter(
CategoryMeasure.category_id == category_id,
CategoryMeasure.measure_id == measure_id,
).delete()
db.commit()