Open Food Facts liefert nur eine Bildadresse. Direkt dorthin zu verlinken hiesse:
Das Bild verschwindet, wenn OFF es austauscht, die Installation braucht Internet,
und jeder Seitenaufruf verraet OFF, welche Artikel jemand ansieht.
Das Bild wird daher einmal geholt und in einer eigenen Tabelle product_images
abgelegt - eigene Tabelle, damit Artikellisten die Blobs nicht mitziehen.
Geholt wird beim Anlegen, bei geaenderter Bildadresse und beim ersten Abruf
(so bekommen auch bestehende Artikel ihre Kopie, ohne Wanderung ueber alle
Datensaetze).
GET /products/{id}/image verlangt eine Anmeldung - aus den Bildern liesse sich
sonst ohne Konto ablesen, was im Vorrat liegt. Da ein <img src> keinen
Authorization-Header schickt, laedt die Oberflaeche das Bild ueber fetch und
zeigt es als Objekt-URL.
Angezeigt wird es rechts neben Barcode, Name und Marke. Fehlt ein Bild, rendert
die Komponente nichts und die Felder nehmen die volle Breite ein.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from fastapi.responses import Response
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..crud import product_to_out
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, require_admin
|
||
from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User
|
||
from ..off import lookup_barcode
|
||
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
|
||
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.group_codes import detach as detach_group_code, sync as sync_group_code
|
||
|
||
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)
|
||
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 [product_to_out(db, p) for p in 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.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.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
|
||
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),
|
||
source="manual",
|
||
)
|
||
db.add(product)
|
||
db.flush() # product.id fuer den Gruppen-Code
|
||
sync_group_code(db, product)
|
||
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)
|
||
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"
|
||
)
|
||
# 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
|
||
alte_bildadresse = product.image_url
|
||
for field, value in data.items():
|
||
setattr(product, field, value)
|
||
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
|
||
sync_group_code(db, product)
|
||
if "image_url" in data and data["image_url"] != alte_bildadresse:
|
||
# Neue Adresse heisst neues Bild – die alte Kopie waere sonst dauerhaft
|
||
# falsch, weil ``ensure`` nur nachlaedt, wenn gar keine Kopie da ist.
|
||
alt = db.get(ProductImage, product.id)
|
||
if alt is not None:
|
||
db.delete(alt)
|
||
db.flush()
|
||
if 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()
|