Files
Vorrania/backend/app/routers/products.py
Scarriffle 0ba968985f Bild uebernehmen: sichtbare Rueckmeldung und Nachholen bei fehlender Kopie
Zwei Ursachen dafuer, dass der Knopf folgenlos wirkte.

Sichtbar: Uebernehmen schreibt nur ins Formular, das Artikelbild wechselt erst
beim Speichern - es gab aber nichts, woran man das erkannt haette. Die Spalte
"Bisher" zeigt jetzt sofort das uebernommene Bild mit Kennzeichnung, der Knopf
heisst danach "Uebernommen", und eine Meldung erklaert den naechsten Schritt.

Tatsaechlich folgenlos war der haeufigste Fall: Stimmte die Bildadresse
laengst und nur der Abruf war damals fehlgeschlagen, aenderte sich beim
Speichern nichts - und die Bedingung fragte genau nach einer Aenderung. Sie
richtet sich jetzt danach, woher die vorhandene Kopie stammt. Fehlt sie, wird
geholt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:03:28 +02:00

327 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}/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.",
)
for code in codes:
suggestion = lookup_barcode(code)
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.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
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:
# 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()