Files
Vorrania/backend/app/routers/products.py
T
ScarriffleandClaude Opus 5 2247c8cf69 Rezepte aus Mealie gegen den Bestand pruefen
Neue Seite "Rezepte" (nur sichtbar, wenn Mealie eingerichtet ist): links
die Rezeptliste mit Suche, rechts das gewaehlte Rezept mit allen Zutaten
und der einen Frage, die zaehlt - kann ich das heute kochen, oder muss ich
vorher einkaufen? Je Zutat steht der Zustand: vorhanden, zu wenig, fehlt,
Menge unklar oder nicht zugeordnet; Fehlendes ist rot.

Die Portionenzahl laesst sich hochdrehen. Vorrania rechnet die Mengen dann
hoch, bevor es sie mit dem Bestand vergleicht - ein Rezept fuer 4, das man
fuer 8 kocht, braucht die doppelte Menge, und genau danach soll der
Abgleich gehen. Nennt das Rezept keine Portionszahl, wird nicht skaliert
und die Seite sagt das auch.

Drei Dinge entscheidet der Abgleich nacheinander (services/rezepte.py,
bewusst ohne HTTP, damit sich alles mit erfundenen Zutaten pruefen laesst):

1. Welcher Artikel ist gemeint? Unscharf ueber den Namen, gegen Artikel
   UND Gruppen - "Milch" ist markenuebergreifend gemeint, und genau dafuer
   gibt es Gruppen. Die Schwelle liegt hoeher als beim Kassenzettel: Dort
   sieht ein Mensch die Vorschlaege durch, hier darf nicht stillschweigend
   das Falsche als vorhanden gelten. Unter der Schwelle bleibt es bei
   "nicht zugeordnet" samt Kandidatenliste.
2. Wie viel ist das in Gramm? Ueber die Einheiten und die Hausmasse der
   Kategorie - "2 EL Zucker" wird zu 24 g. Fehlt das Mass fuer diese
   Kategorie, meldet die Zutat genau das zurueck (Massname und
   Kategorie-ID), damit daraus ein "Mass jetzt anlegen" werden kann.
3. Reicht der Bestand? Bei Gruppen ueber dieselbe gewichtete Summe wie in
   der Einkaufsliste, damit Untergruppen und Zweiteinheiten gleich zaehlen.

Die Fuzzy-Heuristik lag in routers/products.py und ist nach
services/matching.py gezogen: Der Rezeptabgleich stellt dieselbe Frage, und
zwei Kopien waeren mit der Zeit auseinandergelaufen. Der Kassenzettel-Scan
nutzt sie unveraendert weiter, test_receipt_match.py bleibt gruen.

Dazu zwei Dinge aus deiner Rueckmeldung zur Einstellungs-Karte:

- Der verbundene Zustand bleibt jetzt stehen, statt nur als Meldung
  aufzublitzen: Adresse, Version und "Dauertoken hinterlegt" samt den
  Knoepfen Pruefen / Zugang aendern / Trennen. Das Formular erscheint nur,
  solange nichts eingerichtet ist - ein Dauerzustand gehoert nicht in ein
  Eingabefeld, sonst sieht man nie, ob es geklappt hat.
- Leere Felder melden sich auf Deutsch ("Bitte das Passwort eingeben.")
  statt mit der rohen Schema-Meldung "String should have at least 1
  character", die niemandem sagt, welches Feld gemeint ist.

Der Knopf "Fehlende zur Einkaufsliste" fehlt noch: Vorranias Einkaufsliste
wird heute vollstaendig aus Mindestbestaenden gerechnet und hat keinen Ort
fuer einen eigenen Eintrag. Das ist der naechste Schritt; die Seite sagt es
auch so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 13:10:04 +02:00

661 lines
25 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.
import difflib
import re
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
HTTPException,
Request,
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,
CategoryTracking,
Group,
Location,
Movement,
MovementType,
Product,
ProductImage,
ProductLocationMinStock,
RemovalReason,
Shop,
User,
)
from ..off import lookup_barcode
from ..schemas import (
BarcodeCreate,
LocationMinStockIn,
LookupResult,
MatchCandidate,
MatchLine,
MatchRequest,
ProductCreate,
ProductOut,
ProductUpdate,
RemovalHistoryItem,
RemovalStat,
RemovalSummary,
)
from ..services import images
from ..services.categories import suggest_category
from .categories import descendant_ids
from .settings import get_receipt_match_threshold
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.matching import match_score, normalisieren
from ..services.min_stock import schreibe_ueberall
from ..services.stock import removal_stats
router = APIRouter(prefix="/products", tags=["products"])
# Kassenzettel-Abgleich: eine OCR-Zeile den aehnlichsten Lebensmitteln zuordnen.
# Die Heuristik liegt in services/matching.py, weil der Rezeptabgleich sie
# genauso braucht.
_normalisieren = normalisieren
_match_score = match_score
@router.post("/match", response_model=list[MatchLine])
def match_receipt(
payload: MatchRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MatchLine]:
"""Kassenzettel-Zeilen den ähnlichsten LEBENSMITTELN zuordnen (Score 0–100).
Gegenstände (auch Verbrauchsgegenstände) bleiben außen vor; je Zeile die besten
Treffer über dem Schwellwert (aus Request oder Einstellung)."""
schwelle = payload.threshold if payload.threshold is not None else get_receipt_match_threshold(db)
schwelle = max(0, min(100, schwelle))
lebensmittel = [
p
for p in db.query(Product).options(joinedload(Product.category)).all()
if product_tracking(db, p) != CategoryTracking.object.value
]
ergebnis: list[MatchLine] = []
for zeile in payload.lines:
text = (zeile or "").strip()
treffer = [(p, _match_score(text, p.name)) for p in lebensmittel]
treffer = sorted(
(ps for ps in treffer if ps[1] >= schwelle),
key=lambda ps: ps[1],
reverse=True,
)
ergebnis.append(MatchLine(
text=text,
candidates=[
MatchCandidate(
product_id=p.id, name=p.name, brand=p.brand, score=s,
package_size=p.package_size, base_unit=p.base_unit.value,
)
for p, s in treffer[:5]
],
))
return ergebnis
@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.group),
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,
request: Request,
background_tasks: BackgroundTasks,
thumb: bool = False,
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.
Diese Route wird je Tabellenzeile einmal aufgerufen. Sie muss deshalb
schnell sein und darf unter keinen Umstaenden warten – siehe Modulkopf von
``services/images.py``. Drei Zusagen:
* ``thumb=true`` liefert das Vorschaubild (~3 KB) statt des Originals.
* Eine unveraenderte Kopie beantwortet sie mit 304, ohne Daten zu senden.
* Fehlt die lokale Kopie, wird sie NACH der Antwort im Hintergrund geholt.
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
bild = db.get(ProductImage, product_id)
if bild is None:
if product.image_url and images.nachholen_faellig(product_id):
background_tasks.add_task(images.nachholen, product_id, product.image_url)
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Bild vorhanden")
# Vorschau und Original brauchen VERSCHIEDENE Marken – sonst liefert der
# Zwischenspeicher des Browsers die Briefmarke fuer das grosse Bild aus.
marke = f'"bild-{product_id}-{int(bild.updated_at.timestamp())}{"-v" if thumb else ""}"'
kopf = {"Cache-Control": "private, max-age=300", "ETag": marke}
if request.headers.get("if-none-match") == marke:
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=kopf)
daten, typ = bild.data, bild.content_type
if thumb:
klein = bild.thumb if bild.thumb is not None else images.vorschau_nachziehen(db, bild)
if klein is not None:
daten, typ = klein, images.THUMB_TYPE
return Response(content=daten, media_type=typ, headers=kopf)
@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.",
)
klein = images.vorschau(data, file.content_type)
bild = db.get(ProductImage, product.id)
if bild is None:
bild = ProductImage(
product_id=product.id, content_type=file.content_type, data=data,
thumb=klein, source_url=None,
)
db.add(bild)
else:
bild.content_type, bild.data, bild.source_url = file.content_type, data, None
bild.thumb = klein
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()
def _pruefe_zweiteinheit(base_unit, basis, anzahl, menge) -> None:
"""Die Zweiteinheit muss vollstaendig und auf eine ANDERE Art zeigen.
Eine halbe Bruecke waere schlimmer als keine: sie saehe im Formular richtig
aus, wuerde aber nirgends greifen (``zweit_faktor`` liefert dann None).
"""
gesetzt = [x for x in (basis, anzahl, menge) if x is not None]
if not gesetzt:
return
if len(gesetzt) != 3:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Zweiteinheit unvollständig: Art, Anzahl und Menge gehören zusammen.",
)
if basis == base_unit:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Die Zweiteinheit muss eine ANDERE Art sein als die Einheit des "
"Artikels – sonst gibt es nichts umzurechnen.",
)
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(
payload: ProductCreate,
background_tasks: BackgroundTasks,
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")
_pruefe_zweiteinheit(
base_unit, payload.secondary_base, payload.secondary_count, payload.secondary_amount
)
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,
secondary_base=payload.secondary_base.value if payload.secondary_base else None,
secondary_count=payload.secondary_count,
secondary_amount=payload.secondary_amount,
date_precision=payload.date_precision.value,
group_id=payload.group_id,
category_id=payload.category_id,
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
# Der Mindestbestand ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
schreibe_ueberall(db, product, payload.min_stock)
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
db.commit()
db.refresh(product)
if product.image_url:
# Bild erst NACH dem Anlegen im Hintergrund holen – der Netzwerk-Abruf
# soll das Anlegen nicht mehrere Sekunden blockieren. Schlägt er fehl,
# bleibt der Artikel trotzdem angelegt (nur ohne Bild).
background_tasks.add_task(images.nachholen, product.id, product.image_url)
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
# ``min_stock`` ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
# Der Vertrag nach aussen bleibt aber gleich (Basiseinheiten), damit
# aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen.
ueberall_gesetzt = "min_stock" in data
ueberall_wert = data.pop("min_stock", None)
# Zweiteinheit: Enum -> String, und nur vollstaendig oder gar nicht.
if "secondary_base" in data:
_pruefe_zweiteinheit(
data.get("base_unit") or product.base_unit,
data["secondary_base"],
data.get("secondary_count", product.secondary_count),
data.get("secondary_amount", product.secondary_amount),
)
data["secondary_base"] = (
data["secondary_base"].value if data["secondary_base"] else None
)
# Bruecke entfernt: die beiden Zahlen muessen mit, sonst bliebe eine
# halbe Angabe stehen, die nirgends greift.
if data["secondary_base"] is None:
data["secondary_count"] = None
data["secondary_amount"] = None
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 ueberall_gesetzt:
schreibe_ueberall(db, product, ueberall_wert)
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()