from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import Response from sqlalchemy.orm import Session, joinedload, selectinload from ..crud import product_to_out, product_tracking, products_to_out_bulk from ..database import get_db from ..deps import get_current_user, require_admin from ..models import ( Barcode, BaseUnit, Category, Group, Location, Movement, MovementType, Product, ProductImage, ProductLocationMinStock, RemovalReason, Shop, User, ) from ..off import lookup_barcode from ..schemas import ( BarcodeCreate, LocationMinStockIn, LookupResult, ProductCreate, ProductOut, ProductUpdate, RemovalHistoryItem, RemovalStat, RemovalSummary, ) from ..services import images from ..services.categories import suggest_category from .categories import descendant_ids from ..services.conversion import ConversionError, resolve_product_unit from ..services.fields import FieldError, apply_field_values from ..services.group_codes import detach as detach_group_code, sync as sync_group_code from ..services.stock import removal_stats router = APIRouter(prefix="/products", tags=["products"]) @router.get("", response_model=list[ProductOut]) def list_products( q: str | None = None, category_id: int | None = None, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[ProductOut]: """Artikel auflisten, optional nach Name und Kategorie eingegrenzt. ``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will auch "Schokolade" sehen. """ query = db.query(Product).options( # Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst. joinedload(Product.category), joinedload(Product.shop), joinedload(Product.display_unit), joinedload(Product.min_stock_unit), selectinload(Product.field_values), selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location), ) if q: like = f"%{q}%" query = query.filter(Product.name.ilike(like)) if category_id == 0: query = query.filter(Product.category_id.is_(None)) elif category_id is not None: query = query.filter(Product.category_id.in_(descendant_ids(db, category_id))) products = query.order_by(Product.name).all() return products_to_out_bulk(db, products) @router.get("/lookup", response_model=LookupResult) def lookup( barcode: str, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> LookupResult: """Barcode auflösen: Produkt (Haupt- oder Alias-Code), Gruppen-Code, sonst OFF.""" code = barcode.strip() existing = db.query(Product).filter(Product.barcode == code).first() if existing is None: alias = db.query(Barcode).filter(Barcode.code == code).first() if alias is not None and alias.product_id: existing = db.get(Product, alias.product_id) if existing: return LookupResult(found=True, existing_product=product_to_out(db, existing)) # Code kann einer Gruppe zugeordnet sein (z.B. alle Mehl-Marken in "Mehl"). group_id = group_name = None alias = db.query(Barcode).filter(Barcode.code == code).first() if alias is not None and alias.group_id: group = db.get(Group, alias.group_id) if group is not None: group_id, group_name = group.id, group.name suggestion = lookup_barcode(code) # Die OFF-Einordnung steuert die KATEGORIE (Ueberblick), nicht die Gruppe. category = suggest_category(db, suggestion) category_id = category.id if category else None category_name = category.name if category else None if suggestion: return LookupResult( found=True, suggestion=suggestion, group_id=group_id, group_name=group_name, category_id=category_id, category_name=category_name, ) return LookupResult(found=False, group_id=group_id, group_name=group_name) @router.get("/{product_id}", response_model=ProductOut) def get_product( product_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> ProductOut: product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") return product_to_out(db, product) @router.put("/{product_id}/location-min-stock", response_model=ProductOut) def set_product_location_min_stock( product_id: int, payload: list[LocationMinStockIn], db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: """Mindestbestände je Lagerort komplett ersetzen (Menge 0 = Eintrag entfällt).""" product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") db.query(ProductLocationMinStock).filter( ProductLocationMinStock.product_id == product_id ).delete() gesehen: set[int] = set() for eintrag in payload: if eintrag.min_stock <= 0 or eintrag.location_id in gesehen: continue if db.get(Location, eintrag.location_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") gesehen.add(eintrag.location_id) db.add(ProductLocationMinStock( product_id=product_id, location_id=eintrag.location_id, min_stock=eintrag.min_stock, )) db.commit() db.refresh(product) return product_to_out(db, product) @router.get("/{product_id}/removals", response_model=RemovalSummary) def product_removals( product_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> RemovalSummary: """Entnahmen mit Grund: Summe je Grund plus die jüngsten Einträge.""" product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") stats = [ RemovalStat(reason=RemovalReason(grund), quantity=v["quantity"], count=v["count"]) for grund, v in removal_stats(db, product_id).items() ] stats.sort(key=lambda s: s.quantity, reverse=True) rows = ( db.query(Movement) .filter( Movement.product_id == product_id, Movement.type == MovementType.out, Movement.reason.isnot(None), ) .order_by(Movement.created_at.desc()) .limit(50) .all() ) loc_names = {loc.id: loc.name for loc in db.query(Location).all()} user_ids = {m.user_id for m in rows if m.user_id} users = ( {u.id: u.username for u in db.query(User).filter(User.id.in_(user_ids)).all()} if user_ids else {} ) history = [ RemovalHistoryItem( reason=RemovalReason(m.reason), quantity=m.quantity, location_id=m.location_id, location_name=loc_names.get(m.location_id), note=m.note, username=users.get(m.user_id), created_at=m.created_at, ) for m in rows ] return RemovalSummary(stats=stats, history=history) @router.get("/{product_id}/off", response_model=LookupResult) def off_vergleich( product_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> LookupResult: """Open Food Facts erneut befragen – auch für einen bereits angelegten Artikel. ``/lookup`` kann das nicht: Kennt es den Barcode schon, meldet es den eigenen Artikel und fragt OFF gar nicht erst. Zum Vergleichen braucht es aber genau die fremden Daten. Neben dem Haupt-Barcode werden die zusätzlichen EAN-Codes durchprobiert – oft ist nur einer davon bei OFF hinterlegt. """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") codes = [product.barcode] if product.barcode else [] codes += [ b.code for b in db.query(Barcode).filter(Barcode.product_id == product_id).all() if b.code not in codes ] if not codes: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Dieser Artikel hat keinen Barcode – ohne den kann Open Food Facts nichts finden.", ) # Gegenstände zuerst in der allgemeinen Produkt-DB nachschlagen, Lebensmittel in OFF. prefer = "object" if product_tracking(db, product) == "object" else None for code in codes: suggestion = lookup_barcode(code, prefer=prefer) if suggestion: category = suggest_category(db, suggestion) return LookupResult( found=True, suggestion=suggestion, category_id=category.id if category else None, category_name=category.name if category else None, ) return LookupResult(found=False) @router.get("/{product_id}/image") def get_product_image( product_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> Response: """Artikelbild aus der eigenen Datenbank – nie von Open Food Facts. Anders als das Logo verlangt diese Route eine Anmeldung: Aus den Bildern liesse sich sonst ohne Konto ablesen, was im Vorrat liegt. """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") bild = images.ensure(db, product) if bild is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Bild vorhanden") return Response( content=bild.data, media_type=bild.content_type, headers={ "Cache-Control": "private, max-age=300", "ETag": f'"bild-{product_id}-{int(bild.updated_at.timestamp())}"', }, ) @router.put("/{product_id}/image", response_model=ProductOut) async def upload_product_image( product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: """Eigenes Foto hochladen (Kamera oder Galerie) und beim Artikel ablegen. ``source_url = None`` markiert das Bild als selbst hochgeladen – so ersetzt es der Bildabgleich beim Speichern nicht durch ein Bild aus einer Adresse (solange keine Bildadresse gesetzt ist). """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") if file.content_type not in images.ALLOWED_TYPES: erlaubt = ", ".join(sorted(t.split("/")[-1] for t in images.ALLOWED_TYPES)) raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Dieses Bildformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.", ) data = await file.read() if not data: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.") if len(data) > images.MAX_BYTES: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Das Bild ist zu groß ({len(data) // 1024} KB). " f"Erlaubt sind höchstens {images.MAX_BYTES // 1024} KB.", ) bild = db.get(ProductImage, product.id) if bild is None: bild = ProductImage( product_id=product.id, content_type=file.content_type, data=data, source_url=None ) db.add(bild) else: bild.content_type, bild.data, bild.source_url = file.content_type, data, None db.commit() db.refresh(product) return product_to_out(db, product) @router.delete("/{product_id}/image", status_code=status.HTTP_204_NO_CONTENT) def delete_product_image( product_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: """Foto entfernen. Eine gesetzte Bildadresse bleibt bestehen.""" bild = db.get(ProductImage, product_id) if bild is not None: db.delete(bild) db.commit() @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def create_product( payload: ProductCreate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: if payload.barcode: exists = db.query(Product).filter(Product.barcode == payload.barcode).first() if exists: raise HTTPException( status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits" ) base_unit = payload.base_unit display_unit_id = None if payload.unit_id is not None: try: base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden") product = Product( barcode=payload.barcode or None, name=payload.name, brand=payload.brand, image_url=payload.image_url, base_unit=base_unit, display_unit_id=display_unit_id, package_size=payload.package_size, package_label=payload.package_label or None, date_precision=payload.date_precision.value, group_id=payload.group_id, category_id=payload.category_id, min_stock=payload.min_stock, min_stock_unit_id=payload.min_stock_unit_id, min_stock_in_packages=bool(payload.min_stock_in_packages), shop_id=payload.shop_id, product_url=payload.product_url or None, individual=bool(payload.individual), bulk=bool(payload.bulk), source="manual", ) db.add(product) db.flush() # product.id fuer den Gruppen-Code sync_group_code(db, product) try: apply_field_values(db, product, payload.field_values) except FieldError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc if product.image_url: # Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler: # Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern. images.store(db, product, product.image_url) db.commit() db.refresh(product) return product_to_out(db, product) @router.patch("/{product_id}", response_model=ProductOut) def update_product( product_id: int, payload: ProductUpdate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") data = payload.model_dump(exclude_unset=True) # Feldwerte sind keine Spalte, sondern eigene Zeilen – getrennt behandeln. field_values = data.pop("field_values", None) if "barcode" in data and data["barcode"]: clash = ( db.query(Product) .filter(Product.barcode == data["barcode"], Product.id != product_id) .first() ) if clash: raise HTTPException( status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits" ) if data.get("shop_id") is not None and db.get(Shop, data["shop_id"]) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden") # Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit. if "unit_id" in data: unit_id = data.pop("unit_id") data.pop("base_unit", None) # unit_id hat Vorrang if unit_id is None: product.display_unit_id = None else: try: product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc if data.get("min_stock_in_packages") is None: data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL if data.get("date_precision") is None: data.pop("date_precision", None) # Spalte ist NOT NULL else: data["date_precision"] = data["date_precision"].value for field, value in data.items(): setattr(product, field, value) if field_values is not None: try: apply_field_values(db, product, field_values) except FieldError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc # Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen. sync_group_code(db, product) if "image_url" in data: # Massgeblich ist, woher die vorhandene Kopie stammt - nicht, ob sich # die Adresse am Artikel geaendert hat. Sonst passierte genau dann # nichts, wenn die Adresse schon stimmte, der Abruf damals aber # fehlschlug: Der Artikel bliebe dauerhaft ohne Bild. alt = db.get(ProductImage, product.id) if alt is not None and alt.source_url != product.image_url: db.delete(alt) db.flush() alt = None if alt is None and product.image_url: images.store(db, product, product.image_url) db.commit() db.refresh(product) return product_to_out(db, product) @router.post("/{product_id}/barcodes", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def add_product_barcode( product_id: int, payload: BarcodeCreate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: """Weiteren EAN-Code zu einem Produkt hinzufügen.""" product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") code = payload.code.strip() if db.query(Barcode).filter(Barcode.code == code).first() or ( db.query(Product).filter(Product.barcode == code).first() ): raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben") db.add(Barcode(code=code, note=(payload.note or None), product_id=product.id)) db.commit() db.refresh(product) return product_to_out(db, product) @router.delete("/{product_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT) def delete_product_barcode( product_id: int, code: str, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: entry = ( db.query(Barcode) .filter(Barcode.product_id == product_id, Barcode.code == code) .first() ) if entry is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden") db.delete(entry) db.commit() @router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_product( product_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") detach_group_code(db, product) db.delete(product) db.commit()