Files
ScarriffleandClaude Opus 5 0d3724766f Etiketten: CSV-Download nicht zwischenspeichern lassen
Der Sinn dieses Downloads ist der aktuelle Stand. Die Antwort trug weder
Cache-Control noch ETag noch Last-Modified - damit darf ein Browser oder
ein zwischengeschalteter Proxy selbst entscheiden, ob er eine aeltere
Antwort wiederverwendet, und dann fehlen neu angelegte Zeilen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 16:36:49 +01:00

334 lines
12 KiB
Python
Raw Permalink 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.
"""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 re
import unicodedata
import zipfile
from urllib.parse import quote
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,
konfiguration_schreiben,
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 _disposition(name: str | None) -> str:
"""``Content-Disposition`` fuer einen Dateinamen aus einem Upload.
Starlette kodiert Header-Werte als latin-1. Ein Dateiname kommt aber vom
Dateisystem des Hochladenden und darf alles enthalten, was dort erlaubt
ist: macOS legt ein "ä" ZERLEGT ab, als "a" + U+0308, und fuer diesen
kombinierenden Akzent gibt es keine latin-1-Entsprechung. Roh eingesetzt
sprengt so ein Name die Kodierung – die Route antwortet dann mit 500
statt mit der Datei, obwohl der Name am Bildschirm voellig harmlos
aussieht (".../Lagerplätze.lbx" von einem Mac ist genau dieser Fall).
Deshalb nach RFC 6266 zweigleisig: ``filename`` traegt eine ASCII-Fassung
als Rueckfall, ``filename*`` den echten Namen prozentkodiert. Browser
bevorzugen ``filename*`` und zeigen damit die Umlaute richtig an.
"""
sicher = _safe_filename(name)
# NFKD zerlegt "ä" in "a" + Akzent, "ignore" wirft dann nur den Akzent
# weg: "Lagerplätze.lbx" wird zu "Lagerplatze.lbx" und bleibt lesbar.
ascii_fassung = (
unicodedata.normalize("NFKD", sicher).encode("ascii", "ignore").decode("ascii")
)
# Ein Name ganz ohne ASCII-Anteil (z. B. rein kyrillisch) bliebe sonst leer.
return (
f'attachment; filename="{ascii_fassung.strip() or "vorlage.lbx"}"; '
f"filename*=UTF-8''{quote(sicher, safe='')}"
)
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"],
feldfilter=konf["feldfilter"],
datei_name=vorlage.datei_name,
updated_at=vorlage.updated_at,
)
@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_schreiben(
payload.spalten,
payload.kategorie_ids,
payload.trennzeichen,
# json.dumps kann mit Pydantic-Modellen nichts anfangen.
[f.model_dump() for f in payload.feldfilter],
),
)
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 Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur
# einen schickt, darf die anderen nicht verlieren.
if {"spalten", "kategorie_ids", "trennzeichen", "feldfilter"} & data.keys():
alt = konfiguration_lesen(vorlage)
vorlage.konfiguration = konfiguration_schreiben(
data.get("spalten", alt["spalten"]),
data.get("kategorie_ids", alt["kategorie_ids"]),
data.get("trennzeichen", alt["trennzeichen"]),
data.get("feldfilter", alt["feldfilter"]),
)
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/<id>); Einzelstuecke tragen
vorrania://i/<UID> 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 = f"{vorlage.name}.csv".replace("/", "-")
return Response(
content=inhalt.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={
# Derselbe Riegel wie beim .lbx-Download: Der Vorlagenname ist frei
# getippt und kann genauso Zeichen tragen, die latin-1 nicht kennt.
"Content-Disposition": _disposition(dateiname),
# Der Sinn dieses Downloads ist der aktuelle Stand. Ohne Angabe
# darf ein Browser oder Proxy selbst entscheiden, ob er eine alte
# Antwort wiederverwendet - und dann fehlen neue Zeilen.
"Cache-Control": "no-store",
},
)
@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": _disposition(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)