Erste Produktivversion
This commit is contained in:
156
backend/app/list_view.py
Normal file
156
backend/app/list_view.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user