Backend: Kaufpreis + Belege an Einzelstücken, PDF-Garantie-Vorschlag
- Item: price_cents + currency (Kaufpreis rappen-/centgenau). Migration ergaenzt. - Neue Tabelle item_documents (mehrere Belege je Stueck, PDF oder Bild); Blob wird verzoegert geladen, damit Item-Listen leicht bleiben. - Endpunkte: Upload (POST), Ansehen/Download (GET), Loeschen (DELETE) je Stueck. Dateiname beim Download gehaertet (keine Header-Injektion). - services/warranty.py: schaetzt aus PDF-Text lokal ein Garantieende (Zeitraum + Kaufdatum, oder Datum nahe Garantie-Stichwort); pypdf ergaenzt. Der Upload liefert den Vorschlag zurueck. 10 neue Tests, Suite 136 gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,17 +5,40 @@ Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
|
||||
Gegenstands-Produkte mit ``Product.individual``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Item, Location, Movement, MovementType, Product, Shop, User
|
||||
from ..schemas import ItemCreate, ItemOut, ItemRemove, ItemUpdate
|
||||
from ..models import (
|
||||
Item,
|
||||
ItemDocument,
|
||||
Location,
|
||||
Movement,
|
||||
MovementType,
|
||||
Product,
|
||||
Shop,
|
||||
User,
|
||||
)
|
||||
from ..schemas import (
|
||||
ItemCreate,
|
||||
ItemDocumentUploadOut,
|
||||
ItemOut,
|
||||
ItemRemove,
|
||||
ItemUpdate,
|
||||
)
|
||||
from ..services.items import generate_uid, item_to_out
|
||||
from ..services.warranty import extract_pdf_text, guess_warranty_until
|
||||
|
||||
router = APIRouter(tags=["items"])
|
||||
|
||||
# Belege sind meist kleine PDFs; die Grenze schuetzt vor Ausreissern und passt
|
||||
# zur nginx-Grenze (client_max_body_size 12m).
|
||||
DOC_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _product_or_404(db: Session, product_id: int) -> Product:
|
||||
product = db.get(Product, product_id)
|
||||
@@ -72,6 +95,8 @@ def create_items(
|
||||
acquired_on=payload.acquired_on,
|
||||
warranty_until=payload.warranty_until,
|
||||
note=payload.note,
|
||||
price_cents=payload.price_cents,
|
||||
currency=payload.currency,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
@@ -156,3 +181,105 @@ def delete_item(
|
||||
"""Einzelstück ohne Grund löschen (Korrektur)."""
|
||||
db.delete(_item_or_404(db, item_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Belege (Rechnung/Garantieschein) je Einzelstück
|
||||
# --------------------------------------------------------------------------
|
||||
def _document_or_404(db: Session, item_id: int, doc_id: int) -> ItemDocument:
|
||||
doc = (
|
||||
db.query(ItemDocument)
|
||||
.filter(ItemDocument.id == doc_id, ItemDocument.item_id == item_id)
|
||||
.first()
|
||||
)
|
||||
if doc is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Beleg nicht gefunden")
|
||||
return doc
|
||||
|
||||
|
||||
def _safe_filename(name: str | None) -> str:
|
||||
"""Steuerzeichen/Anführungszeichen raus – sonst Header-Injektion im Download."""
|
||||
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
|
||||
return cleaned[:255] or "beleg"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/items/{item_id}/documents",
|
||||
response_model=ItemDocumentUploadOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def upload_item_document(
|
||||
item_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ItemDocumentUploadOut:
|
||||
"""Beleg (PDF oder Bild) hochladen. Bei PDF wird ein Garantieende vorgeschlagen."""
|
||||
item = _item_or_404(db, item_id)
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
|
||||
if len(data) > DOC_MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
f"Datei zu groß (max. {DOC_MAX_BYTES // (1024 * 1024)} MB).",
|
||||
)
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
if content_type != "application/pdf" and not content_type.startswith("image/"):
|
||||
raise HTTPException(
|
||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
|
||||
)
|
||||
|
||||
doc = ItemDocument(
|
||||
item_id=item.id,
|
||||
filename=_safe_filename(file.filename),
|
||||
content_type=content_type,
|
||||
data=data,
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
# Garantieende nur aus PDFs schätzen (Bilder haben keine Textebene).
|
||||
suggestion = None
|
||||
if content_type == "application/pdf":
|
||||
suggestion = guess_warranty_until(
|
||||
extract_pdf_text(data), acquired_on=item.acquired_on
|
||||
)
|
||||
|
||||
return ItemDocumentUploadOut(
|
||||
id=doc.id,
|
||||
filename=doc.filename,
|
||||
content_type=doc.content_type,
|
||||
uploaded_at=doc.uploaded_at,
|
||||
suggested_warranty_until=suggestion,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/documents/{doc_id}")
|
||||
def get_item_document(
|
||||
item_id: int,
|
||||
doc_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""Beleg anzeigen/herunterladen."""
|
||||
doc = _document_or_404(db, item_id, doc_id)
|
||||
return Response(
|
||||
content=doc.data,
|
||||
media_type=doc.content_type,
|
||||
headers={"Content-Disposition": f'inline; filename="{doc.filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/items/{item_id}/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
def delete_item_document(
|
||||
item_id: int,
|
||||
doc_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
db.delete(_document_or_404(db, item_id, doc_id))
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user