496 lines
16 KiB
Python
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."
|
|
)
|