From 72256f4fe9c6c6f3204737953cd9110c969766e0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 14:37:17 +0200 Subject: [PATCH] Backend: globale Einzelstueck-Liste (GET /items) + Bild-Version an Produkten Neuer Endpoint GET /items liefert alle Einzelstuecke ueber alle Produkte, angereichert mit Produkt-, Kategorie-, Lagerort- und Shop-Angaben (Beziehungen vorgeladen). ItemOut bekommt category_id/category_name. ProductOut bekommt image_version (Epoch der letzten Bildaenderung, None ohne Bild) - identisch zum ETag der Bild-Route, damit Clients Bilder cachen und nur bei Aenderung neu laden. Grundlage fuer die neue Einzelstueck-Liste (Web+iOS) und den iOS-Bild-Cache. Co-Authored-By: Claude Opus 4.8 --- backend/app/crud.py | 10 +++++++- backend/app/routers/items.py | 26 ++++++++++++++++++++- backend/app/schemas.py | 5 ++++ backend/app/services/items.py | 3 +++ backend/tests/test_items.py | 35 ++++++++++++++++++++++++++-- backend/tests/test_product_images.py | 12 ++++++++++ 6 files changed, 87 insertions(+), 4 deletions(-) diff --git a/backend/app/crud.py b/backend/app/crud.py index f73eb3c..f6f4ec0 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -7,7 +7,7 @@ from datetime import date from fastapi import HTTPException, status from sqlalchemy.orm import Session -from .models import Barcode, Category, CategoryTracking, Lot, Product +from .models import Barcode, Category, CategoryTracking, Lot, Product, ProductImage from .schemas import BarcodeOut, LocationMinStockOut, ProductOut from .services.conversion import KIND_OF_BASE, display_unit_info from .services.stock import current_stock @@ -44,6 +44,14 @@ def product_to_out(db: Session, product: Product) -> ProductOut: for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all() ] out.category_name = product.category.name if product.category else None + # Bild-Version = Epoch der letzten Bildänderung (identisch zum ETag der + # Bild-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild. + bild_ts = ( + db.query(ProductImage.updated_at) + .filter(ProductImage.product_id == product.id) + .scalar() + ) + out.image_version = int(bild_ts.timestamp()) if bild_ts is not None else None out.tracking = CategoryTracking(product_tracking(db, product)) out.shop_name = product.shop.name if product.shop else None out.kind = KIND_OF_BASE[product.base_unit].value diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index 080fb35..0de9282 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -9,7 +9,7 @@ import re from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import Response -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from ..database import get_db from ..deps import get_current_user, require_admin @@ -115,6 +115,30 @@ def create_items( return [item_to_out(i) for i in created] +@router.get("/items", response_model=list[ItemOut]) +def list_all_items( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[ItemOut]: + """Alle Einzelstücke über alle Produkte – Grundlage der Einzelstück-Liste. + + Angereichert mit Produkt-, Kategorie-, Lagerort- und Shop-Angaben; die + Beziehungen werden vorgeladen, damit die Liste nicht in N+1-Abfragen zerfällt. + """ + rows = ( + db.query(Item) + .options( + joinedload(Item.product).joinedload(Product.category), + joinedload(Item.location), + joinedload(Item.shop), + joinedload(Item.documents), + ) + .order_by(Item.id) + .all() + ) + return [item_to_out(i) for i in rows] + + @router.get("/items/by-uid/{uid}", response_model=ItemOut) def item_by_uid( uid: str, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9db520c..e38079a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -354,6 +354,9 @@ class ProductOut(BaseModel): # angereichert: stock: float = 0.0 expired_count: int = 0 + # Epoch (Sekunden) der letzten Bildänderung, sonst None (= kein Bild). Der + # Client cacht Bilder danach und lädt nur bei geänderter Version neu. + image_version: int | None = None kind: str = "" unit_name: str = "" unit_factor: float = 1.0 @@ -591,6 +594,8 @@ class ItemOut(BaseModel): # Für QR-Auflösung/Anzeige mitgeliefert: product_name: str | None = None product_brand: str | None = None + category_id: int | None = None + category_name: str | None = None documents: list[ItemDocumentOut] = [] diff --git a/backend/app/services/items.py b/backend/app/services/items.py index 45e2f28..f80c6e3 100644 --- a/backend/app/services/items.py +++ b/backend/app/services/items.py @@ -28,4 +28,7 @@ def item_to_out(item: Item) -> ItemOut: out.shop_name = item.shop.name if item.shop else None out.product_name = item.product.name if item.product else None out.product_brand = item.product.brand if item.product else None + kategorie = item.product.category if item.product else None + out.category_id = kategorie.id if kategorie else None + out.category_name = kategorie.name if kategorie else None return out diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py index 9ff0e36..698c420 100644 --- a/backend/tests/test_items.py +++ b/backend/tests/test_items.py @@ -2,8 +2,17 @@ import pytest -from app.models import Category, CategoryTracking, Item, ItemDocument, Product, Role, User -from app.routers.items import _safe_filename, create_items, get_item +from app.models import ( + Category, + CategoryTracking, + Item, + ItemDocument, + Location, + Product, + Role, + User, +) +from app.routers.items import _safe_filename, create_items, get_item, list_all_items from app.schemas import ItemCreate @@ -49,6 +58,28 @@ def test_item_out_listet_belege(db, admin): assert fresh.documents[0].content_type == "application/pdf" +def test_list_all_items_angereichert(db, admin): + cat = Category(name="Elektronik", tracking=CategoryTracking.object.value) + db.add(cat) + db.flush() + product = Product(name="Kamera", brand="Sony", category_id=cat.id, individual=True) + db.add(product) + db.flush() + ort = Location(name="Keller") + db.add(ort) + db.commit() + create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, _=admin) + + alle = list_all_items(db=db, _=admin) + assert len(alle) == 2 + e = alle[0] + assert e.product_name == "Kamera" + assert e.product_brand == "Sony" + assert e.category_name == "Elektronik" + assert e.location_id == ort.id + assert e.location_name == "Keller" + + def test_safe_filename_entfernt_header_zeichen(): assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf" assert _safe_filename(" ") == "beleg" diff --git a/backend/tests/test_product_images.py b/backend/tests/test_product_images.py index 4f3b155..623c7c6 100644 --- a/backend/tests/test_product_images.py +++ b/backend/tests/test_product_images.py @@ -17,6 +17,18 @@ def _artikel(db, url=None): return p +def test_image_version_spiegelt_bildzeitstempel(db): + from app.crud import product_to_out + + p = _artikel(db) + assert product_to_out(db, p).image_version is None # kein Bild + img = ProductImage(product_id=p.id, content_type="image/png", data=EIN_PIXEL) + db.add(img) + db.commit() + db.refresh(img) + assert product_to_out(db, p).image_version == int(img.updated_at.timestamp()) + + def test_ohne_bildadresse_gibt_es_nichts_zu_holen(db, monkeypatch): monkeypatch.setattr(images, "fetch", lambda url: (_ for _ in ()).throw(AssertionError)) assert images.ensure(db, _artikel(db)) is None