Files
einkaufsapp/backend/app/routers/items.py
2026-08-08 20:31:58 +02:00

246 lines
8.0 KiB
Python

"""Listeneinträge: anlegen, ändern, verschieben, zurückstellen, löschen.
Dazu die gruppierte Ansicht Markt -> Warengruppe -> Artikel, die sowohl
die App als auch der Druck in Phase 9 verwendet. Die Sortierlogik liegt
bewusst nur hier, damit Bildschirm und Papier nicht auseinanderlaufen.
"""
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.deps import DbSession, VerifiedUser
from app.models import Article, Category, ListItem, Market
from app.list_view import build_view, item_out
from app.print_view import render_print
from app.permissions import EditableList, ReadableList, bump_rev
from app.prices import record_price
from app.push import notify_list_changed
from app.routers.catalog import _check_belongs
from app.schemas import MessageOut
from app.schemas_shopping import (
ClearBoughtOut,
ItemCreateIn,
ItemOut,
ItemStatus,
ItemUpdateIn,
ListView,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists/{list_id}", tags=["items"])
@router.get("/items", response_model=list[ItemOut])
def get_items(
lst: ReadableList,
db: DbSession,
status_filter: ItemStatus | None = Query(default=None, alias="status"),
):
stmt = select(ListItem).where(
ListItem.list_id == lst.id, ListItem.deleted_at.is_(None)
)
if status_filter:
stmt = stmt.where(ListItem.status == status_filter)
rows = db.scalars(stmt.order_by(ListItem.created_at)).all()
return [item_out(i) for i in rows]
@router.post("/items", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
def create_item(
payload: ItemCreateIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
if not payload.article_id and not payload.article_name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Entweder article_id oder article_name angeben.",
)
rev = bump_rev(db, lst.id)
if payload.article_id:
article = _check_belongs(db, Article, payload.article_id, lst.id, "Artikel")
else:
name = payload.article_name.strip()
# Vorhandenen Artikel wiederverwenden. Der Vergleich ist dank
# utf8mb4_unicode_ci von Haus aus unabhaengig von Gross- und
# Kleinschreibung.
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.name == name,
Article.deleted_at.is_(None),
)
)
if article is None:
article = Article(list_id=lst.id, name=name, row_rev=rev)
db.add(article)
db.flush()
_check_belongs(db, Market, payload.market_id, lst.id, "Markt")
_check_belongs(db, Category, payload.category_id, lst.id, "Warengruppe")
item = ListItem(
list_id=lst.id,
article_id=article.id,
# Ohne Angabe die Vorgaben aus dem Artikel übernehmen.
market_id=payload.market_id or article.default_market_id,
category_id=payload.category_id or article.default_category_id,
count=payload.count,
pack_size=payload.pack_size,
pack_unit=(payload.pack_unit or None),
variant=(payload.variant or None),
note=(payload.note or None),
status="open",
created_by=user.id,
row_rev=rev,
)
db.add(item)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Eintrag konnte nicht angelegt werden."
) from None
db.refresh(item)
background.add_task(notify_list_changed, lst.id, user.id)
return item_out(item)
@router.patch("/items/{item_id}", response_model=ItemOut)
def update_item(
item_id: str,
payload: ItemUpdateIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Deckt alles ab, was während des Einkaufs passiert: abhaken,
zurückstellen, Menge ändern, Preis erfassen, in einen anderen Markt
verschieben."""
item = db.get(ListItem, item_id)
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if payload.clear_market:
item.market_id = None
elif "market_id" in data:
_check_belongs(db, Market, data["market_id"], lst.id, "Markt")
item.market_id = data["market_id"]
if payload.clear_category:
item.category_id = None
elif "category_id" in data:
_check_belongs(db, Category, data["category_id"], lst.id, "Warengruppe")
item.category_id = data["category_id"]
for field in ("count", "pack_size", "pack_unit", "variant", "note",
"status", "price_cents"):
if field in data:
setattr(item, field, data[field])
item.row_rev = bump_rev(db, lst.id)
# Nach dem Setzen der Felder: Der Preis wird nur festgehalten, wenn
# auch ein Markt zugeordnet ist - ohne den ergibt er keinen
# Vergleichswert.
record_price(db, item)
db.commit()
db.refresh(item)
background.add_task(notify_list_changed, lst.id, user.id)
return item_out(item)
@router.delete("/items/{item_id}", response_model=MessageOut)
def delete_item(
item_id: str, lst: EditableList, db: DbSession, user: VerifiedUser,
background: BackgroundTasks,
):
item = db.get(ListItem, item_id)
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
item.deleted_at = utcnow()
item.row_rev = bump_rev(db, lst.id)
db.commit()
background.add_task(notify_list_changed, lst.id, user.id)
return MessageOut(detail="Eintrag gelöscht.")
@router.post("/items/clear-bought", response_model=ClearBoughtOut)
def clear_bought(
lst: EditableList, db: DbSession, user: VerifiedUser,
background: BackgroundTasks,
):
"""Entfernt alle als gekauft markierten Einträge in einem Zug.
Ein einziger Revisionsschritt für den gesamten Vorgang - so sieht ein
synchronisierender Client eine geschlossene Änderung statt dutzender
einzelner.
"""
rev = bump_rev(db, lst.id)
now = utcnow()
rows = db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.status == "bought",
ListItem.deleted_at.is_(None),
)
).all()
for item in rows:
item.deleted_at = now
item.row_rev = rev
db.commit()
if rows:
background.add_task(notify_list_changed, lst.id, user.id)
return ClearBoughtOut(removed=len(rows), rev=rev)
# ==========================================================================
# Gruppierte Ansicht
# ==========================================================================
@router.get("/print", response_class=HTMLResponse, include_in_schema=True)
def print_view(
lst: ReadableList,
db: DbSession,
include_bought: bool = Query(default=True),
include_deferred: bool = Query(default=False),
):
"""Druckfertige Seite auf A4.
Standardmäßig ohne zurückgestellte Artikel: Wer ausdruckt, will die
Einkaufsliste, nicht die Merkliste.
"""
view = build_view(
db, lst, include_bought=include_bought, include_deferred=include_deferred
)
return HTMLResponse(render_print(view, include_bought=include_bought))
@router.get("/view", response_model=ListView)
def grouped_view(
lst: ReadableList,
db: DbSession,
include_bought: bool = Query(default=True),
include_deferred: bool = Query(default=True),
):
"""Markt -> Warengruppe -> Artikel, alphabetisch innerhalb der Gruppe.
Die Aufbereitung liegt in app/list_view.py, weil der oeffentliche Link
und der Ausdruck dieselbe Gliederung brauchen.
"""
return build_view(
db, lst, include_bought=include_bought, include_deferred=include_deferred
)