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>
320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""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()
|