Etiketten-Vorlagen: gespeicherte Exporte mit frei waehlbaren Spalten

Der Etikettenexport konnte genau eine Sache, und zwar unveraenderlich: sechs
feste Spalten ueber Einzelstuecke. Was fehlte, war genau das, was ein Etikett
brauchbar macht - die eigenen Felder. Eine Kabellaenge oder die Kapazitaet einer
Powerbank steht laengst am Artikel, kam aber in keiner CSV vor. Und jede
Zusammenstellung war beim naechsten Mal wieder weg: Kategorien im React-State,
Trennzeichen im localStorage, Spalten hartkodiert.

Jetzt gibt es benannte Vorlagen mit eigener Spaltenwahl und Reihenfolge, fuer
Einzelstuecke, Artikel und Lagerorte. Eine Tabelle nach dem Vorbild von
DashboardLayout: benannt, Nutzlast als JSON-Text. Ohne user_id - eine Vorlage
gehoert allen, nicht einem Konto.

Spaltenschluessel fuer eigene Felder ist die ID, nicht der key: FieldDefinition.key
ist nur JE KATEGORIE eindeutig, zwei Kategorien duerfen beide "kapazitaet" fuehren.

Die CSV entsteht jetzt im Server statt im Browser. Mit waehlbaren Spalten,
eigenen Feldern und drei Bezugsarten gehoert das an eine Stelle - und zwar
dorthin, wo es sich pruefen laesst. Formel-Injektionsschutz, BOM und CRLF sind
aus der Browser-Fassung mitgenommen. Der Grund, aus dem der QR-Inhalt bisher im
Browser entstand ("nur die Oberflaeche kennt die oeffentliche Adresse"), gilt
allein fuer Lagerorte; dafuer reicht sie ihre Herkunft als Parameter durch.

Die beiden alten Abschnitte weichen den Vorlagen. Beim ersten Start entstehen
zwei mitgelieferte, die genau die bisherigen Spalten fuehren - wer nichts
einstellt, bekommt dasselbe Ergebnis wie vorher.

Dazu laesst sich eine P-touch-Datei hinterlegen. Geprueft wird ueber Endung und
ZIP-Inhalt (label.xml), nicht ueber den Content-Type: Fuer .lbx meldet der
Browser meist application/octet-stream. Eine .lbx merkt sich den Pfad zu ihrer
Datenquelle - auf einem fremden Rechner muss die CSV in P-touch einmal neu
verknuepft werden. Das laesst sich von hier aus nicht loesen; die Datei
dabeizuhaben erspart trotzdem das Suchen.

Sicherung auf Version 4: Vorlagen kommen mit, Kategorien als Pfad und eigene
Felder als Label, weil IDs zwischen zwei Instanzen nicht dieselben sind. Die
.lbx als Base64 - das erste Binaerformat in dieser Sicherung, vertretbar bei
wenigen Kilobyte und einer Vorlage, die ohne ihre Datei nur eine halbe waere.

23 neue Tests (343 gesamt), dazu im Browser durchgespielt: Kabel-Kategorie mit
eigenem Feld, Spalte hinzugefuegt, CSV mit "Länge (m)" und dem Wert, .lbx
hoch- und wieder heruntergeladen (Pruefsumme identisch), PNG und Nicht-ZIP
sauber abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-22 16:07:49 +02:00
co-authored by Claude Opus 5
parent 7372590171
commit c741df5963
12 changed files with 1601 additions and 81 deletions
+300
View File
@@ -0,0 +1,300 @@
"""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/<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 = _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)
+113 -2
View File
@@ -6,6 +6,7 @@ werden ergänzt – es wird nie etwas gelöscht oder überschrieben.
from __future__ import annotations
import base64
import csv
import io
import json
@@ -22,11 +23,13 @@ from ..services.dates import MONTH, clean_precision, normalize_best_before
from ..services import gruppen as gruppen_graph
from ..services.group_codes import sync as sync_group_code
from ..services.min_stock import lies_ueberall, schreibe_ueberall
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen
from ..models import (
BaseUnit,
Category,
CategoryTracking,
DatePrecision,
EtikettenVorlage,
FieldDefinition,
Group,
GroupLocationMinStock,
@@ -166,10 +169,11 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations}
data = {
# v4: Etiketten-Vorlagen samt hinterlegter P-touch-Datei.
# v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort
# (Ort null = „Ueberall") in Basiseinheiten. v2-Sicherungen bleiben
# (Ort null = „Ueberall") in Basiseinheiten. Aeltere Sicherungen bleiben
# lesbar – ihnen fehlen diese Listen einfach.
"version": 3,
"version": 4,
"exported_at": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [
@@ -262,6 +266,8 @@ def export_backup_json(
}
)
data["etiketten_vorlagen"] = _vorlagen_exportieren(db)
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json; charset=utf-8",
@@ -435,6 +441,106 @@ def _get_or_create_location(db: Session, name: str | None) -> Location | None:
return loc
def _vorlagen_exportieren(db: Session) -> list[dict]:
"""Etiketten-Vorlagen fuer die Sicherung.
Kategorien als PFAD und eigene Felder als LABEL – IDs sind zwischen zwei
Instanzen nicht dieselben, genau wie bei Gruppen und Artikeln.
Die hinterlegte .lbx kommt als Base64 mit. Das ist das erste Binaerformat in
dieser Sicherung; vertretbar, weil eine .lbx wenige Kilobyte gross ist und
eine Vorlage ohne ihre Datei nur eine halbe Vorlage waere.
"""
zeilen: list[dict] = []
for v in db.query(EtikettenVorlage).order_by(EtikettenVorlage.position).all():
konf = konfiguration_lesen(v)
spalten: list[str] = []
for schluessel in konf["spalten"]:
if schluessel.startswith(FELD_PRAEFIX):
rest = schluessel[len(FELD_PRAEFIX):]
fd = db.get(FieldDefinition, int(rest)) if rest.isdigit() else None
# Kategoriepfad mit dazu: Zwei Kategorien duerfen dasselbe Label
# fuehren, erst beides zusammen ist eindeutig.
spalten.append(
f"{FELD_PRAEFIX}{_category_path(db, db.get(Category, fd.category_id))}"
f"::{fd.label}" if fd else schluessel
)
else:
spalten.append(schluessel)
zeilen.append({
"name": v.name,
"art": v.art,
"position": v.position,
"spalten": spalten,
"kategorien": [
_category_path(db, db.get(Category, kid)) for kid in konf["kategorie_ids"]
],
"trennzeichen": konf["trennzeichen"],
"datei_name": v.datei_name,
"datei_base64": base64.b64encode(v.datei).decode() if v.datei else None,
})
return zeilen
def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int:
"""Vorlagen additiv einspielen – Bestehendes bleibt unberuehrt."""
angelegt = 0
pfad_zu_id: dict[str, int] = {}
for cat in db.query(Category).all():
pfad_zu_id[_category_path(db, cat)] = cat.id
for entry in zeilen:
try:
name = (entry.get("name") or "").strip()
if not name or db.query(EtikettenVorlage).filter(
EtikettenVorlage.name == name
).first():
continue
spalten: list[str] = []
for roh in entry.get("spalten", []):
if not str(roh).startswith(FELD_PRAEFIX):
spalten.append(str(roh))
continue
rest = str(roh)[len(FELD_PRAEFIX):]
pfad, _, label = rest.rpartition("::")
kat_id = pfad_zu_id.get(pfad)
fd = (
db.query(FieldDefinition)
.filter(
FieldDefinition.category_id == kat_id,
FieldDefinition.label == label,
).first()
if kat_id else None
)
# Gibt es das Feld hier nicht, faellt die Spalte weg statt die
# ganze Vorlage scheitern zu lassen.
if fd is not None:
spalten.append(f"{FELD_PRAEFIX}{fd.id}")
kategorie_ids = [
pfad_zu_id[p] for p in entry.get("kategorien", []) if p in pfad_zu_id
]
roh_datei = entry.get("datei_base64")
db.add(EtikettenVorlage(
name=name,
art=entry.get("art") or "items",
position=int(entry.get("position") or 0),
konfiguration=json.dumps({
"spalten": spalten,
"kategorie_ids": kategorie_ids,
"trennzeichen": entry.get("trennzeichen") or ",",
}, ensure_ascii=False),
datei_name=entry.get("datei_name"),
datei=base64.b64decode(roh_datei) if roh_datei else None,
))
angelegt += 1
except Exception as exc:
errors.append(f"Etiketten-Vorlage {entry.get('name')!r}: {exc}")
db.commit()
return angelegt
def _category_path(db: Session, category: Category | None) -> str:
"""Unterkategorien als "Süßwaren & Snacks > Schokolade" in EINE Spalte.
@@ -901,9 +1007,14 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
except Exception as exc:
errors.append(f"Produkt {entry.get('name')!r}: {exc}")
vorlagen_created = _vorlagen_importieren(
db, data.get("etiketten_vorlagen", []), errors
)
return {
"format": "json",
"mode": mode,
"etiketten_vorlagen_created": vorlagen_created,
"units_created": units_created,
"products_created": len(created_products),
"products_cleared": len(cleared),