Inline-Anlage (Kategorien/Felder), Artikelfoto und CSV-Spalte "art"
- Web: Kategorien/Unterkategorien und eigene Felder direkt im Artikelformular
anlegen (ohne Umweg über die Kategorien-Seite).
- Artikelfoto per Kamera oder Galerie hochladen – Backend-Endpunkt
(PUT/DELETE /products/{id}/image), Web (Foto machen / Galerie) und iOS
(Kamera + PhotosPicker, inline in der Produktansicht angezeigt).
- CSV-Import: neue Spalte "art" (Gegenstand/Lebensmittel) steuert den Modus
automatisch angelegter Kategorien; Export schreibt sie mit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -240,6 +240,63 @@ def get_product_image(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
@@ -49,8 +49,26 @@ router = APIRouter(tags=["transfer"])
|
||||
|
||||
CSV_FIELDS = [
|
||||
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
|
||||
"gruppe", "kategorie", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
|
||||
"gruppe", "kategorie", "art", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
|
||||
]
|
||||
|
||||
|
||||
def _tracking_from_art(text) -> str:
|
||||
"""Spalte „art" → Verwaltungsart einer (neu angelegten) Kategorie.
|
||||
|
||||
Leer/unbekannt ⇒ Lebensmittel (bewahrt das Verhalten für zurückgespielte
|
||||
Lebensmittel-Exporte).
|
||||
"""
|
||||
t = (str(text) if text is not None else "").strip().lower()
|
||||
if t in ("gegenstand", "gegenstände", "object", "objekt", "non-food", "nonfood"):
|
||||
return CategoryTracking.object.value
|
||||
return CategoryTracking.food.value
|
||||
|
||||
|
||||
def _art_label(product: Product) -> str:
|
||||
if product.category and product.category.tracking == CategoryTracking.object.value:
|
||||
return "Gegenstand"
|
||||
return "Lebensmittel"
|
||||
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
|
||||
|
||||
|
||||
@@ -95,6 +113,7 @@ def export_stock_csv(
|
||||
product.package_label or "",
|
||||
product.group.name if product.group else "",
|
||||
_category_path(db, product.category),
|
||||
_art_label(product),
|
||||
product.min_stock if product.min_stock is not None else "",
|
||||
]
|
||||
lots = (
|
||||
@@ -437,7 +456,9 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
|
||||
if unit is None:
|
||||
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
|
||||
group = _get_or_create_group(db, row.get("gruppe"))
|
||||
category = _get_or_create_category(db, row.get("kategorie"))
|
||||
category = _get_or_create_category(
|
||||
db, row.get("kategorie"), tracking=_tracking_from_art(row.get("art"))
|
||||
)
|
||||
product = Product(
|
||||
barcode=barcode,
|
||||
name=name,
|
||||
|
||||
Reference in New Issue
Block a user