"""Etiketten-Vorlagen: gespeicherte Zusammenstellungen fuer den Export. Eine Vorlage haelt fest, worauf sie sich bezieht (Einzelstuecke, Artikel oder Lagerorte), welche Spalten in welcher Reihenfolge exportiert werden und wie gefiltert wird. Dazu kann eine P-touch-Datei (.lbx) hinterlegt werden, damit man sie auf einem fremden Rechner nicht suchen muss. Vorlagen gehoeren allen: Anlegen und Aendern bleibt Administratoren vorbehalten, Exportieren darf jeder Angemeldete – es ist ein Lesevorgang. """ from __future__ import annotations import io import json import re import zipfile from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, require_admin from ..models import EtikettenVorlage, User from ..schemas import ( EtikettenVorlageCreate, EtikettenVorlageOut, EtikettenVorlageUpdate, SpalteOut, ) from ..services.etiketten import ( ARTEN, csv_erzeugen, konfiguration_lesen, verfuegbare_spalten, ) router = APIRouter(prefix="/etiketten", tags=["etiketten"]) # Eine .lbx ist ein ZIP von wenigen Kilobyte. Die Grenze ist grosszuegig und # bleibt weit unter der nginx-Grenze (client_max_body_size 12m). LBX_MAX_BYTES = 2 * 1024 * 1024 def _vorlage_oder_404(db: Session, vorlage_id: int) -> EtikettenVorlage: vorlage = db.get(EtikettenVorlage, vorlage_id) if vorlage is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Vorlage nicht gefunden") return vorlage def _safe_filename(name: str | None) -> str: """Steuerzeichen und Anfuehrungszeichen raus – sonst Header-Injektion.""" cleaned = re.sub(r'[\r\n"]', "", (name or "").strip()) return cleaned[:255] or "vorlage.lbx" def _pruefe_lbx(dateiname: str, daten: bytes) -> None: """Ist das plausibel eine P-touch-Vorlage? Eine .lbx ist ein ZIP mit label.xml (und prop.xml) darin. Geprueft wird genau das – nicht der vom Browser gemeldete Content-Type, denn fuer .lbx meldet der meist application/octet-stream oder gar nichts. """ if not daten: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.") if len(daten) > LBX_MAX_BYTES: raise HTTPException( status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, f"Datei zu groß (max. {LBX_MAX_BYTES // (1024 * 1024)} MB).", ) if not dateiname.lower().endswith(".lbx"): raise HTTPException( status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur .lbx-Dateien aus dem P-touch Editor.", ) try: with zipfile.ZipFile(io.BytesIO(daten)) as archiv: namen = {n.lower() for n in archiv.namelist()} except zipfile.BadZipFile: raise HTTPException( status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Das ist keine P-touch-Vorlage (die Datei ist kein ZIP-Archiv).", ) from None if "label.xml" not in namen: raise HTTPException( status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Das ist keine P-touch-Vorlage (label.xml fehlt).", ) def _als_out(vorlage: EtikettenVorlage) -> EtikettenVorlageOut: konf = konfiguration_lesen(vorlage) return EtikettenVorlageOut( id=vorlage.id, name=vorlage.name, art=vorlage.art, position=vorlage.position, spalten=konf["spalten"], kategorie_ids=konf["kategorie_ids"], trennzeichen=konf["trennzeichen"], datei_name=vorlage.datei_name, updated_at=vorlage.updated_at, ) def _konfiguration(spalten: list[str], kategorie_ids: list[int], trennzeichen: str) -> str: return json.dumps( { "spalten": spalten, "kategorie_ids": kategorie_ids, "trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",", }, ensure_ascii=False, ) @router.get("/spalten", response_model=list[SpalteOut]) def spalten( art: str = "items", kategorie_ids: str | None = None, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[SpalteOut]: """Welche Spalten fuer diese Bezugsart und Kategorien zur Wahl stehen. Die eigenen Felder haengen an den Kategorien – ohne diesen Endpunkt wuesste die Oberflaeche nicht, dass es „Kapazität (mAh)" ueberhaupt gibt. """ if art not in ARTEN: raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unbekannte Bezugsart „{art}“") ids = [int(t) for t in (kategorie_ids or "").split(",") if t.strip().isdigit()] return [SpalteOut(**s) for s in verfuegbare_spalten(db, art, ids)] @router.get("/vorlagen", response_model=list[EtikettenVorlageOut]) def vorlagen_auflisten( db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[EtikettenVorlageOut]: rows = ( db.query(EtikettenVorlage) .order_by(EtikettenVorlage.position, EtikettenVorlage.id) .all() ) return [_als_out(v) for v in rows] @router.post( "/vorlagen", response_model=EtikettenVorlageOut, status_code=status.HTTP_201_CREATED ) def vorlage_anlegen( payload: EtikettenVorlageCreate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> EtikettenVorlageOut: name = payload.name.strip() if db.query(EtikettenVorlage).filter(EtikettenVorlage.name == name).first(): raise HTTPException(status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen") hoechste = ( db.query(EtikettenVorlage).order_by(EtikettenVorlage.position.desc()).first() ) vorlage = EtikettenVorlage( name=name, art=payload.art, position=(hoechste.position + 1) if hoechste else 0, konfiguration=_konfiguration( payload.spalten, payload.kategorie_ids, payload.trennzeichen ), ) db.add(vorlage) db.commit() db.refresh(vorlage) return _als_out(vorlage) @router.patch("/vorlagen/{vorlage_id}", response_model=EtikettenVorlageOut) def vorlage_aendern( vorlage_id: int, payload: EtikettenVorlageUpdate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> EtikettenVorlageOut: vorlage = _vorlage_oder_404(db, vorlage_id) data = payload.model_dump(exclude_unset=True) if "name" in data: neuer = data["name"].strip() doppelt = ( db.query(EtikettenVorlage) .filter(EtikettenVorlage.name == neuer, EtikettenVorlage.id != vorlage.id) .first() ) if doppelt: raise HTTPException( status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen" ) vorlage.name = neuer if "art" in data: vorlage.art = data["art"] if "position" in data: vorlage.position = data["position"] # Die drei Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur # einen schickt, darf die anderen nicht verlieren. if {"spalten", "kategorie_ids", "trennzeichen"} & data.keys(): alt = konfiguration_lesen(vorlage) vorlage.konfiguration = _konfiguration( data.get("spalten", alt["spalten"]), data.get("kategorie_ids", alt["kategorie_ids"]), data.get("trennzeichen", alt["trennzeichen"]), ) db.commit() db.refresh(vorlage) return _als_out(vorlage) @router.delete("/vorlagen/{vorlage_id}") def vorlage_loeschen( vorlage_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> Response: db.delete(_vorlage_oder_404(db, vorlage_id)) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/vorlagen/{vorlage_id}/csv") def vorlage_csv( vorlage_id: int, basis_url: str = "", db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> Response: """Die CSV zu einer Vorlage. ``basis_url`` braucht nur der Lagerort-QR (…/l/); Einzelstuecke tragen vorrania://i/ ohne jede Adresse. Die Oberflaeche reicht ihre eigene Herkunft durch, weil nur sie weiss, unter welcher Adresse sie erreichbar ist. """ vorlage = _vorlage_oder_404(db, vorlage_id) inhalt = csv_erzeugen(db, vorlage, basis_url=basis_url) dateiname = _safe_filename(f"{vorlage.name}.csv".replace("/", "-")) return Response( content=inhalt.encode("utf-8"), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{dateiname}"'}, ) @router.post("/vorlagen/{vorlage_id}/datei", response_model=EtikettenVorlageOut) async def datei_hochladen( vorlage_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> EtikettenVorlageOut: """Die P-touch-Vorlage (.lbx) hinterlegen.""" vorlage = _vorlage_oder_404(db, vorlage_id) daten = await file.read() name = _safe_filename(file.filename) _pruefe_lbx(name, daten) vorlage.datei_name = name vorlage.datei = daten db.commit() db.refresh(vorlage) return _als_out(vorlage) @router.get("/vorlagen/{vorlage_id}/datei") def datei_herunterladen( vorlage_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> Response: vorlage = _vorlage_oder_404(db, vorlage_id) if vorlage.datei is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Zu dieser Vorlage ist keine Datei hinterlegt") return Response( content=vorlage.datei, # attachment und nicht inline: Die Datei soll gespeichert werden, der # Browser kann mit ihr ohnehin nichts anfangen. media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{vorlage.datei_name}"'}, ) @router.delete("/vorlagen/{vorlage_id}/datei") def datei_entfernen( vorlage_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> Response: vorlage = _vorlage_oder_404(db, vorlage_id) vorlage.datei = None vorlage.datei_name = None db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT)