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>
327 lines
12 KiB
Python
327 lines
12 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}/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()
|