Files
einkaufsapp/backend/app/routers/catalog.py

496 lines
16 KiB
Python

"""Katalogdaten einer Liste: Märkte, Warengruppen, Artikel.
Alle Schreibzugriffe erhöhen den Revisionszähler der Liste und schreiben
den neuen Wert in `row_rev` der geänderten Zeile.
"""
from datetime import timedelta
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.deps import DbSession
from app.models import (
Article,
ArticleAttribute,
ArticleMarket,
Category,
ListItem,
Market,
)
from app.permissions import EditableList, ReadableList, bump_rev
from app.schemas import MessageOut
from app.product_lookup import lookup
from app.schemas_shopping import (
ArticleIn,
ArticleSuggestion,
ArticleOut,
ArticleUpdateIn,
AttributeOut,
CategoryIn,
CategoryOut,
MarketIn,
MarketOut,
ProductLookupOut,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists/{list_id}", tags=["catalog"])
def _check_belongs(db: Session, model, obj_id: str | None, list_id: str, label: str):
"""Verhindert, dass eine Liste auf Objekte einer fremden Liste zeigt.
Ohne diese Prüfung könnte ein Mitglied durch Angabe einer fremden ID
Rückschlüsse auf andere Listen ziehen."""
if obj_id is None:
return None
obj = db.get(model, obj_id)
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{label} nicht gefunden")
return obj
# ==========================================================================
# Märkte
# ==========================================================================
@router.get("/markets", response_model=list[MarketOut])
def get_markets(lst: ReadableList, db: DbSession):
rows = db.scalars(
select(Market)
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
.order_by(Market.sort_order, Market.name)
).all()
return [MarketOut.model_validate(m) for m in rows]
@router.post("/markets", response_model=MarketOut, status_code=status.HTTP_201_CREATED)
def create_market(payload: MarketIn, lst: EditableList, db: DbSession):
rev = bump_rev(db, lst.id)
market = Market(
list_id=lst.id, name=payload.name.strip(),
sort_order=payload.sort_order, row_rev=rev,
)
db.add(market)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
) from None
return MarketOut.model_validate(market)
@router.put("/markets/{market_id}", response_model=MarketOut)
def update_market(market_id: str, payload: MarketIn, lst: EditableList, db: DbSession):
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
market.name = payload.name.strip()
market.sort_order = payload.sort_order
market.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
) from None
return MarketOut.model_validate(market)
@router.delete("/markets/{market_id}", response_model=MessageOut)
def delete_market(market_id: str, lst: EditableList, db: DbSession):
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
rev = bump_rev(db, lst.id)
market.deleted_at = utcnow()
market.row_rev = rev
# Einträge nicht mitlöschen, nur den Marktbezug lösen - sie landen
# dann in der Gruppe "Ohne Markt" und gehen nicht verloren.
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.market_id == market_id,
ListItem.deleted_at.is_(None),
)
).all():
item.market_id = None
item.row_rev = rev
db.commit()
return MessageOut(detail="Markt gelöscht. Betroffene Einträge sind ohne Markt.")
# ==========================================================================
# Warengruppen
# ==========================================================================
@router.get("/categories", response_model=list[CategoryOut])
def get_categories(lst: ReadableList, db: DbSession):
rows = db.scalars(
select(Category)
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
.order_by(Category.sort_order, Category.name)
).all()
return [CategoryOut.model_validate(c) for c in rows]
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(payload: CategoryIn, lst: EditableList, db: DbSession):
rev = bump_rev(db, lst.id)
cat = Category(
list_id=lst.id, name=payload.name.strip(),
sort_order=payload.sort_order, row_rev=rev,
)
db.add(cat)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
) from None
return CategoryOut.model_validate(cat)
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(
category_id: str, payload: CategoryIn, lst: EditableList, db: DbSession
):
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
cat.name = payload.name.strip()
cat.sort_order = payload.sort_order
cat.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
) from None
return CategoryOut.model_validate(cat)
@router.delete("/categories/{category_id}", response_model=MessageOut)
def delete_category(category_id: str, lst: EditableList, db: DbSession):
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
rev = bump_rev(db, lst.id)
cat.deleted_at = utcnow()
cat.row_rev = rev
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.category_id == category_id,
ListItem.deleted_at.is_(None),
)
).all():
item.category_id = None
item.row_rev = rev
db.commit()
return MessageOut(detail="Warengruppe gelöscht.")
# ==========================================================================
# Artikel
# ==========================================================================
def _article_out(a: Article) -> ArticleOut:
return ArticleOut(
id=a.id, name=a.name, barcode=a.barcode, note=a.note,
default_market_id=a.default_market_id,
default_category_id=a.default_category_id,
attributes=[
AttributeOut(name=x.attr_name, value=x.attr_value) for x in a.attributes
],
available_market_ids=[x.market_id for x in a.availability],
row_rev=a.row_rev,
)
def _apply_attributes(db: Session, article: Article, attributes) -> None:
"""Ersetzt die Attributmenge vollständig."""
for old in list(article.attributes):
db.delete(old)
article.attributes = []
db.flush()
seen = set()
for attr in attributes:
name = attr.name.strip()
if not name or name in seen:
continue
seen.add(name)
db.add(
ArticleAttribute(
article_id=article.id, attr_name=name, attr_value=attr.value.strip()
)
)
def _apply_availability(db: Session, article: Article, market_ids, list_id: str) -> None:
for old in db.scalars(
select(ArticleMarket).where(ArticleMarket.article_id == article.id)
).all():
db.delete(old)
db.flush()
for mid in dict.fromkeys(market_ids):
_check_belongs(db, Market, mid, list_id, "Markt")
db.add(ArticleMarket(article_id=article.id, market_id=mid))
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(
lst: ReadableList,
db: DbSession,
q: str | None = None,
with_barcode: bool | None = None,
):
stmt = select(Article).where(
Article.list_id == lst.id, Article.deleted_at.is_(None)
)
if q:
# Nur der LIKE-Operator, kein zusammengebautes SQL - der Suchtext
# geht als gebundener Parameter in die Abfrage. Sonderzeichen von
# LIKE werden maskiert, damit "50%" nicht alles findet.
needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\"))
if with_barcode is True:
stmt = stmt.where(Article.barcode.is_not(None))
elif with_barcode is False:
stmt = stmt.where(Article.barcode.is_(None))
rows = db.scalars(stmt.order_by(Article.name).limit(500)).all()
return [_article_out(a) for a in rows]
SUGGEST_MONTHS = 24
SUGGEST_LIMIT = 8
SUGGEST_MIN_CHARS = 3
@router.get("/articles/suggest", response_model=list[ArticleSuggestion])
def suggest_articles(
lst: ReadableList,
db: DbSession,
q: str = Query(min_length=SUGGEST_MIN_CHARS, max_length=200),
):
"""Vorschlaege aus dem, was auf dieser Liste schon eingetragen wurde.
Quelle sind die Listeneintraege der letzten 24 Monate - auch die
bereits geloeschten, denn gerade die abgehakten und aufgeraeumten
sind die interessanten. Was jemand vor drei Jahren einmal gekauft
hat, hilft dagegen nicht mehr und wuerde die Liste nur verlaengern.
Zurueckgegeben werden die zuletzt verwendeten Angaben: Menge,
Gebinde, Eigenschaft, Markt und Warengruppe. Wer den Vorschlag
antippt, hat die Felder gefuellt und kann sie noch aendern.
"""
needle = q.strip()
if len(needle) < SUGGEST_MIN_CHARS:
return []
# LIKE-Sonderzeichen maskieren, sonst faende die Eingabe "50%" alles.
escaped = (
needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
cutoff = utcnow() - timedelta(days=SUGGEST_MONTHS * 30)
rows = db.execute(
select(ListItem, Article)
.join(Article, Article.id == ListItem.article_id)
.where(
ListItem.list_id == lst.id,
ListItem.created_at >= cutoff,
Article.deleted_at.is_(None),
Article.name.like(f"%{escaped}%", escape="\\"),
)
.order_by(ListItem.created_at.desc())
.limit(600)
).all()
# Je Artikel den juengsten Eintrag behalten und mitzaehlen, wie oft
# er vorkam - haeufig Verwendetes soll oben stehen.
best: dict[str, dict] = {}
for item, article in rows:
entry = best.get(article.id)
if entry is None:
best[article.id] = {"item": item, "article": article, "count": 1}
else:
entry["count"] += 1
def rank(entry: dict) -> tuple[int, float]:
# Erst Haeufigkeit, dann Aktualitaet.
return (-entry["count"], -entry["item"].created_at.timestamp())
out = []
for entry in sorted(best.values(), key=rank)[:SUGGEST_LIMIT]:
item, article = entry["item"], entry["article"]
out.append(
ArticleSuggestion(
article_id=article.id,
name=article.name,
count=item.count,
pack_size=item.pack_size,
pack_unit=item.pack_unit,
variant=item.variant,
market_id=item.market_id or article.default_market_id,
category_id=item.category_id or article.default_category_id,
times_used=entry["count"],
last_used_at=item.created_at,
)
)
return out
@router.get("/barcode/{barcode}", response_model=ProductLookupOut)
def resolve_barcode(barcode: str, lst: ReadableList, db: DbSession):
"""Strichcode auflösen - erst im eigenen Bestand, dann außerhalb.
Die Reihenfolge ist wichtig: Was hier schon gepflegt wurde, ist
verlässlicher als eine Fremdquelle, und ein eigener Name soll nicht
von einer Datenbank überschrieben werden.
"""
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == barcode,
Article.deleted_at.is_(None),
)
)
if article is not None:
return ProductLookupOut(
barcode=barcode, found=True, source="catalog",
article_id=article.id, name=article.name,
)
hit = lookup(db, barcode)
if hit is None or not hit.found:
return ProductLookupOut(barcode=barcode, found=False, source="none")
return ProductLookupOut(
barcode=barcode,
found=True,
source="openfoodfacts",
name=hit.name,
brand=hit.brand,
package=hit.package,
count=hit.count,
pack_size=hit.pack_size,
pack_unit=hit.pack_unit,
)
@router.get("/articles/by-barcode/{barcode}", response_model=ArticleOut)
def article_by_barcode(barcode: str, lst: ReadableList, db: DbSession):
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == barcode,
Article.deleted_at.is_(None),
)
)
if article is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Kein Artikel mit diesem Barcode"
)
return _article_out(article)
@router.post("/articles", response_model=ArticleOut, status_code=status.HTTP_201_CREATED)
def create_article(payload: ArticleIn, lst: EditableList, db: DbSession):
_check_belongs(db, Market, payload.default_market_id, lst.id, "Markt")
_check_belongs(db, Category, payload.default_category_id, lst.id, "Warengruppe")
rev = bump_rev(db, lst.id)
article = Article(
list_id=lst.id, name=payload.name.strip(),
barcode=(payload.barcode or None), note=(payload.note or None),
default_market_id=payload.default_market_id,
default_category_id=payload.default_category_id,
row_rev=rev,
)
db.add(article)
db.flush()
_apply_attributes(db, article, payload.attributes)
_apply_availability(db, article, payload.available_market_ids, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
) from None
db.refresh(article)
return _article_out(article)
@router.patch("/articles/{article_id}", response_model=ArticleOut)
def update_article(
article_id: str, payload: ArticleUpdateIn, lst: EditableList, db: DbSession
):
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
data = payload.model_dump(exclude_unset=True)
if "default_market_id" in data:
_check_belongs(db, Market, data["default_market_id"], lst.id, "Markt")
article.default_market_id = data["default_market_id"]
if "default_category_id" in data:
_check_belongs(db, Category, data["default_category_id"], lst.id, "Warengruppe")
article.default_category_id = data["default_category_id"]
if "name" in data and data["name"]:
article.name = data["name"].strip()
if "barcode" in data:
article.barcode = data["barcode"] or None
if "note" in data:
article.note = data["note"] or None
if payload.attributes is not None:
_apply_attributes(db, article, payload.attributes)
if payload.available_market_ids is not None:
_apply_availability(db, article, payload.available_market_ids, lst.id)
article.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
) from None
db.refresh(article)
return _article_out(article)
@router.delete("/articles/{article_id}", response_model=MessageOut)
def delete_article(article_id: str, lst: EditableList, db: DbSession):
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
rev = bump_rev(db, lst.id)
article.deleted_at = utcnow()
article.row_rev = rev
# Offene Einträge dieses Artikels verschwinden mit. Alles andere
# hinterließe Einträge, die auf einen gelöschten Artikel zeigen.
removed = 0
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.article_id == article_id,
ListItem.deleted_at.is_(None),
)
).all():
item.deleted_at = utcnow()
item.row_rev = rev
removed += 1
db.commit()
return MessageOut(
detail=f"Artikel gelöscht, dazu {removed} Eintrag/Einträge auf der Liste."
)