Files
Vorrania/backend/app/routers/etiketten.py
T
ScarriffleandClaude Opus 5 dd57d3356a Eigene Felder: Spalten in der Artikelliste und Export nach Feldwerten
Eigene Felder waren bisher nur im Artikelformular zu sehen. Zwei Luecken
daraus: In den Listen tauchten sie gar nicht auf, und es gab keinen Weg,
gezielt nach einem Feldwert zu exportieren.

SPALTEN. Je eigenem Feld eine Spalte in der Artikelliste, Schluessel
feld:<id> wie im Etikettenexport (die ID und nicht key, der ist nur je
Kategorie eindeutig). Reine Oberflaechenarbeit: GET /products liefert die
Werte laengst mit, Products.jsx warf sie nur weg. Filtern, Sortieren,
Ein-/Ausblenden und das Merken erbt die Spalte von DataTable.

Ja/Nein erscheint als Haken, liefert zum Filtern aber "Ja"/"Nein" - und
"Nein" auch ohne gespeicherten Wert: Wer den Haken wegnimmt, loescht die
Zeile, "nein" und "nie gesetzt" sind in den Daten dasselbe. Zahlen sortieren
als Zahl, sonst stuende "10" vor "9".

DataTable lernt dafuer defaultHidden: Alle Feldspalten starten verborgen und
sind erst auf Wunsch da - sonst hinge die Liste je nach Haushalt voller
Spalten, die fuer die meisten Zeilen gar nicht gelten.

Dabei einen vorhandenen Fehler gefunden: urlState las die Sortierung mit
split(":") und nahm das erste Stueck als Spalte. Bei "feld:12:asc" kam
dadurch "feld" heraus, und die Sortierung fiel beim Neuladen still weg. Jetzt
am LETZTEN Doppelpunkt getrennt.

EXPORT. Die Etikettenvorlagen bekommen einen Wertefilter: feldfilter in der
Konfiguration, mehrere Bedingungen UND-verknuepft, genau verglichen ohne
Ruecksicht auf Gross-/Kleinschreibung, leerer Wert heisst "ohne Wert".
Gefiltert wird in Python ueber die ohnehin geladenen Feldwerte - auf value
liegt kein Index, und "fehlende Zeile zaehlt als leer" braeuchte in SQL je
Bedingung ein NOT EXISTS.

Der Filter gilt auch fuer Einzelstuecke; der Wert haengt am Artikel des
Stuecks. Ein geloeschtes Feld laesst seine Bedingung wegfallen, genau wie
eine Spalte zu einem geloeschten Feld still verschwindet.

Die Sicherung schreibt den Filter wie die Spalten auf Kategorie::Label um -
ohne das zeigte er nach dem Zurueckspielen auf ein fremdes Feld, und der
Export gaebe mehr Zeilen aus als das Original. Dafuer ist _konfiguration aus
dem Router als konfiguration_schreiben in den Dienst gezogen: Sonst haetten
DREI Stellen denselben JSON-Text gebaut.

Zehn neue Tests, 540 gruen. Keine Schemaaenderung - konfiguration ist freies
JSON, alte Vorlagen lesen sich als leerer Filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:02:53 +02:00

328 lines
12 KiB
Python
Raw 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",
# 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)