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

157 lines
5.2 KiB
Python

"""Aufbereitung der gruppierten Listenansicht: Markt → Warengruppe → Artikel.
Liegt bewusst außerhalb der Router, weil drei Stellen dieselbe Gliederung
brauchen: die App, der öffentliche Link und der Ausdruck. Läge die Logik
im Router, würden die drei mit der Zeit auseinanderlaufen.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Category, ListItem, Market, ShoppingList
from app.schemas_shopping import ItemOut, ListView, ViewCategory, ViewMarket
NO_MARKET = "Ohne Markt"
NO_CATEGORY = "Ohne Warengruppe"
def creator_label(item: ListItem) -> str | None:
"""Anzeigename, sonst der lokale Teil der Adresse. Die vollständige
E-Mail-Adresse anderer Mitglieder wird nie ausgeliefert."""
if item.creator is None:
return None
return item.creator.display_name or item.creator.email.split("@")[0]
def item_out(item: ListItem, *, anonymous: bool = False) -> ItemOut:
"""`anonymous=True` für öffentliche Links: Dort ist der Empfängerkreis
unbestimmt, deshalb darf nicht erkennbar sein, wer was eingetragen hat."""
return ItemOut(
id=item.id,
article_id=item.article_id,
article_name=item.article.name,
created_by=None if anonymous else item.created_by,
created_by_name=None if anonymous else creator_label(item),
market_id=item.market_id,
category_id=item.category_id,
count=item.count,
pack_size=item.pack_size,
pack_unit=item.pack_unit,
variant=item.variant,
note=item.note,
status=item.status,
price_cents=item.price_cents,
total_cents=(item.price_cents * item.count) if item.price_cents else None,
row_rev=item.row_rev,
updated_at=item.updated_at,
)
def build_view(
db: Session,
lst: ShoppingList,
*,
include_bought: bool = True,
include_deferred: bool = True,
anonymous: bool = False,
) -> ListView:
"""Märkte und Warengruppen ordnen sich nach `sort_order`, dann nach
Namen - so lässt sich die Reihenfolge an den tatsächlichen Weg durch
den Laden anpassen. Artikel innerhalb einer Gruppe alphabetisch.
Einträge ohne Zuordnung landen in Sammelgruppen am Ende, damit nichts
unsichtbar wird.
"""
wanted = {"open"}
if include_bought:
wanted.add("bought")
if include_deferred:
wanted.add("deferred")
items = db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.deleted_at.is_(None),
ListItem.status.in_(wanted),
)
).all()
markets = {
m.id: m
for m in db.scalars(
select(Market).where(Market.list_id == lst.id, Market.deleted_at.is_(None))
).all()
}
categories = {
c.id: c
for c in db.scalars(
select(Category).where(
Category.list_id == lst.id, Category.deleted_at.is_(None)
)
).all()
}
buckets: dict[str | None, dict[str | None, list[ListItem]]] = {}
for item in items:
mid = item.market_id if item.market_id in markets else None
cid = item.category_id if item.category_id in categories else None
buckets.setdefault(mid, {}).setdefault(cid, []).append(item)
def market_key(mid: str | None) -> tuple[int, int, str]:
if mid is None:
return (1, 0, "") # "Ohne Markt" ganz nach hinten
m = markets[mid]
return (0, m.sort_order, m.name.casefold())
def category_key(cid: str | None) -> tuple[int, int, str]:
if cid is None:
return (1, 0, "")
c = categories[cid]
return (0, c.sort_order, c.name.casefold())
out_markets: list[ViewMarket] = []
grand_total = 0
for mid in sorted(buckets, key=market_key):
out_categories: list[ViewCategory] = []
market_total = 0
open_count = 0
for cid in sorted(buckets[mid], key=category_key):
group = sorted(buckets[mid][cid], key=lambda i: i.article.name.casefold())
for item in group:
if item.status == "open":
open_count += 1
if item.price_cents:
# Preis gilt je Gebinde, multipliziert wird mit der
# Stückzahl - nicht mit der Packungsgröße. Vorher
# ergaben 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
market_total += item.price_cents * item.count
out_categories.append(
ViewCategory(
category_id=cid,
category_name=categories[cid].name if cid else NO_CATEGORY,
items=[item_out(i, anonymous=anonymous) for i in group],
)
)
grand_total += market_total
out_markets.append(
ViewMarket(
market_id=mid,
market_name=markets[mid].name if mid else NO_MARKET,
categories=out_categories,
open_count=open_count,
total_cents=market_total,
)
)
return ListView(
list_id=lst.id,
list_name=lst.name,
rev=lst.rev,
markets=out_markets,
grand_total_cents=grand_total,
)