"""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 )