157 lines
5.2 KiB
Python
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,
|
|
)
|