macOS legt Dateinamen zerlegt ab - das "ae" in "Lagerplaetze.lbx" ist dort nicht ein Zeichen, sondern "a" plus ein kombinierender Trema-Akzent. Genau diese Form landet beim Hochladen in der Datenbank. Starlette schreibt Header aber als latin-1, und fuer den Akzent gibt es dort nichts - die Route starb mit UnicodeEncodeError und antwortete 500, obwohl der Name am Bildschirm voellig harmlos aussah. Der Header wird jetzt nach RFC 6266 zweigleisig gebaut: filename= traegt eine ASCII-Fassung als Rueckfall, filename*= den echten Namen prozentkodiert. Browser bevorzugen filename* und zeigen den Umlaut richtig. Derselbe Riegel liegt auf dem CSV-Download, wo der frei getippte Vorlagenname in den Header geht - gleicher Defekt, nur noch nicht aufgefallen. Fuer die Download-Route gab es bisher keinen Test; zwei kommen dazu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""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 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,
|
||
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"],
|
||
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/<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",
|
||
# Derselbe Riegel wie beim .lbx-Download: Der Vorlagenname ist frei
|
||
# getippt und kann genauso Zeichen tragen, die latin-1 nicht kennt.
|
||
headers={"Content-Disposition": _disposition(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": _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)
|