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>
328 lines
12 KiB
Python
328 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 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)
|