254 lines
8.3 KiB
Python
254 lines
8.3 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.article_ops import change_item_article
|
|
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)
|
|
rev = bump_rev(db, lst.id)
|
|
|
|
if data.get("article_name"):
|
|
try:
|
|
change_item_article(db, lst, item, data["article_name"], rev)
|
|
except ValueError as exc:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from None
|
|
|
|
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 = rev
|
|
# 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
|
|
)
|