"""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 fastapi import APIRouter, HTTPException, 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, 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] @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." )