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
+351
View File
@@ -0,0 +1,351 @@
"""Etikettenexport: welche Spalten es gibt und wie daraus eine CSV wird.
Frueher entstand die CSV in der Weboberflaeche, mit sechs festen Spalten. Mit
frei waehlbaren Spalten, eigenen Feldern und drei Bezugsarten gehoert das an
eine Stelle – und zwar hierher, wo es sich pruefen laesst: Fuer die Oberflaeche
gibt es keine Tests, fuer das Backend ueber dreihundert.
Alles hier ist bewusst frei von HTTP: Eine Vorlage, eine Datenbank-Sitzung und
eine Basis-Adresse hinein, fertige CSV heraus.
"""
from __future__ import annotations
import csv
import io
import json
import re
from dataclasses import dataclass
from typing import Any, Callable
from sqlalchemy.orm import Session
from ..models import (
Category,
EtikettenVorlage,
FieldDefinition,
Item,
Location,
Product,
ProductFieldValue,
)
# Bezugsarten einer Vorlage.
ARTEN = ("items", "products", "locations")
# Zellen, die damit beginnen, deutet Excel als Formel. Ein vorangestelltes
# Apostroph macht sie zu reinem Text. Uebernommen aus der fruaeheren
# Browser-Fassung (web/src/pages/Transfer.jsx).
_FORMEL_START = re.compile(r"^[=+\-@\t\r]")
# Praefix eines selbst definierten Feldes. Die ID und nicht der key: FieldDefinition.key
# ist nur JE KATEGORIE eindeutig, zwei Kategorien duerfen beide "kapazitaet" fuehren.
FELD_PRAEFIX = "feld:"
@dataclass
class Kontext:
"""Was die Spaltenfunktionen ausser der Zeile selbst brauchen."""
db: Session
# Oeffentliche Adresse der Oberflaeche – nur fuer Lagerort-QR noetig
# (…/l/<id>). Einzelstuecke tragen vorrania://i/<UID> ohne jede Adresse.
basis_url: str = ""
# Werte der eigenen Felder, vorab gesammelt: {product_id: {fd_id: wert}}.
feldwerte: dict[int, dict[int, str | None]] | None = None
def _kategorie_pfad(db: Session, category: Category | None) -> str:
"""„Elektronik > Powerbanks" in einer Spalte."""
if category is None:
return ""
teile = [category.name]
aktuell, tiefe = category, 0
while aktuell.parent_id is not None and tiefe < 10: # Schutz vor einem Ring
aktuell = db.get(Category, aktuell.parent_id)
if aktuell is None:
break
teile.append(aktuell.name)
tiefe += 1
return " > ".join(reversed(teile))
def _lagerort_pfad(db: Session, ort: Location | None) -> str:
if ort is None:
return ""
teile = [ort.name]
aktuell, tiefe = ort, 0
while aktuell.parent_id is not None and tiefe < 10:
aktuell = db.get(Location, aktuell.parent_id)
if aktuell is None:
break
teile.append(aktuell.name)
tiefe += 1
return " > ".join(reversed(teile))
# Signatur wie alle Spaltenfunktionen (Zeile, Kontext) – auch wenn der
# Kontext hier nicht gebraucht wird.
def _preis(item: Item, _kontext: "Kontext") -> str:
if item.price_cents is None:
return ""
return f"{item.price_cents / 100:.2f}"
def _offener_verleih(item: Item):
for loan in item.loans:
if loan.returned_on is None:
return loan
return None
# --------------------------------------------------------------------------
# Spalten-Registry je Bezugsart: Schluessel -> (Titel, Funktion)
# --------------------------------------------------------------------------
SPALTEN: dict[str, dict[str, tuple[str, Callable[[Any, Kontext], str]]]] = {
"items": {
"qr": ("QR-Inhalt", lambda i, k: f"vorrania://i/{i.uid}"),
"uid": ("UID", lambda i, k: i.uid),
"produkt": ("Produkt", lambda i, k: i.product.name if i.product else ""),
"marke": ("Marke", lambda i, k: (i.product.brand if i.product else "") or ""),
"kategorie": (
"Kategorie",
lambda i, k: _kategorie_pfad(k.db, i.product.category if i.product else None),
),
"lagerort": ("Lagerort", lambda i, k: i.location.name if i.location else ""),
"lagerort_pfad": ("Lagerort (Pfad)", lambda i, k: _lagerort_pfad(k.db, i.location)),
"gekauft_am": ("Gekauft am", lambda i, k: str(i.acquired_on or "")),
"garantie_bis": ("Garantie bis", lambda i, k: str(i.warranty_until or "")),
"gekauft_bei": ("Gekauft bei", lambda i, k: i.shop.name if i.shop else ""),
"kaufpreis": ("Kaufpreis", _preis),
"waehrung": ("Währung", lambda i, k: i.currency or ""),
"notiz": ("Notiz", lambda i, k: i.note or ""),
"verliehen_an": (
"Verliehen an",
lambda i, k: (_offener_verleih(i).borrower if _offener_verleih(i) else ""),
),
"gehoert_zu": (
"Gehört zu",
lambda i, k: (
f"{i.gehoert_zu.product.name if i.gehoert_zu.product else ''} "
f"({i.gehoert_zu.uid})".strip()
if i.gehoert_zu else ""
),
),
},
"products": {
"name": ("Name", lambda p, k: p.name),
"marke": ("Marke", lambda p, k: p.brand or ""),
"kategorie": ("Kategorie", lambda p, k: _kategorie_pfad(k.db, p.category)),
"barcode": ("Barcode", lambda p, k: p.barcode or ""),
"gebinde": ("Gebinde", lambda p, k: p.package_label or ""),
"gebindegroesse": (
"Gebindegröße",
lambda p, k: "" if p.package_size is None else _zahl(p.package_size),
),
"mindestbestand": (
"Mindestbestand",
lambda p, k: "" if p.min_stock is None else _zahl(p.min_stock),
),
"gekauft_bei": ("Gekauft bei", lambda p, k: p.shop.name if p.shop else ""),
"produktlink": ("Produktlink", lambda p, k: p.product_url or ""),
},
"locations": {
"qr": ("QR-Inhalt", lambda o, k: f"{k.basis_url.rstrip('/')}/l/{o.id}"),
"id": ("ID", lambda o, k: o.id),
"name": ("Lagerort", lambda o, k: o.name),
"pfad": ("Pfad", lambda o, k: _lagerort_pfad(k.db, o)),
},
}
def _zahl(wert: float) -> str:
"""Ganze Zahlen ohne „.0" – auf einem Etikett stoert das."""
return str(int(wert)) if float(wert).is_integer() else str(wert)
def feld_spalten(db: Session, art: str, kategorie_ids: list[int]) -> list[dict]:
"""Die eigenen Felder, die als Spalten zur Wahl stehen.
Eigene Felder haengen an Kategorien und damit am ARTIKEL – nicht am
Einzelstueck. Alle Stuecke eines Artikels teilen sich denselben Wert; fuer
ein Etikett ist genau das gewollt.
"""
if art == "locations":
return [] # Lagerorte haben keine eigenen Felder
frage = db.query(FieldDefinition)
if kategorie_ids:
# Ein Feld gilt auch fuer Unterkategorien; umgekehrt heisst das: alle
# Definitionen entlang der Vorfahren der gewaehlten Kategorien.
from ..routers.categories import descendant_ids
erlaubt: set[int] = set()
for kid in kategorie_ids:
erlaubt |= descendant_ids(db, kid)
erlaubt |= {c.id for c in _vorfahren(db, kid)}
frage = frage.filter(FieldDefinition.category_id.in_(erlaubt or {-1}))
gesehen: set[int] = set()
spalten: list[dict] = []
for fd in frage.order_by(FieldDefinition.position, FieldDefinition.id).all():
if fd.id in gesehen:
continue
gesehen.add(fd.id)
titel = f"{fd.label} ({fd.unit})" if fd.unit else fd.label
spalten.append({"schluessel": f"{FELD_PRAEFIX}{fd.id}", "titel": titel})
return spalten
def _vorfahren(db: Session, category_id: int) -> list[Category]:
kette: list[Category] = []
aktuell = db.get(Category, category_id)
gesehen: set[int] = set()
while aktuell is not None and aktuell.id not in gesehen:
gesehen.add(aktuell.id)
kette.append(aktuell)
aktuell = db.get(Category, aktuell.parent_id) if aktuell.parent_id else None
return kette
def verfuegbare_spalten(db: Session, art: str, kategorie_ids: list[int]) -> list[dict]:
"""Feste Spalten der Bezugsart plus die passenden eigenen Felder."""
fest = [
{"schluessel": s, "titel": titel}
for s, (titel, _) in SPALTEN.get(art, {}).items()
]
return fest + feld_spalten(db, art, kategorie_ids)
# --------------------------------------------------------------------------
# Konfiguration
# --------------------------------------------------------------------------
def konfiguration_lesen(vorlage: EtikettenVorlage) -> dict:
"""JSON-Text zu einem Woerterbuch – kaputter Inhalt ergibt Vorgaben."""
try:
roh = json.loads(vorlage.konfiguration or "{}")
except (ValueError, TypeError):
roh = {}
if not isinstance(roh, dict):
roh = {}
spalten = [str(s) for s in roh.get("spalten", []) if isinstance(s, (str, int))]
kategorie_ids = [int(k) for k in roh.get("kategorie_ids", []) if str(k).lstrip("-").isdigit()]
trenn = roh.get("trennzeichen") or ","
if trenn not in (",", ";", "\t"):
trenn = ","
return {"spalten": spalten, "kategorie_ids": kategorie_ids, "trennzeichen": trenn}
def _zeilen(db: Session, art: str, kategorie_ids: list[int]) -> list:
if art == "locations":
return db.query(Location).order_by(Location.name).all()
from ..routers.categories import descendant_ids
ids: set[int] | None = None
if kategorie_ids:
ids = set()
for kid in kategorie_ids:
ids |= descendant_ids(db, kid)
if art == "products":
frage = db.query(Product)
if ids is not None:
frage = frage.filter(Product.category_id.in_(ids or {-1}))
return frage.order_by(Product.name).all()
frage = db.query(Item)
if ids is not None:
frage = frage.join(Product, Item.product_id == Product.id).filter(
Product.category_id.in_(ids or {-1})
)
return frage.order_by(Item.id).all()
def _feldwerte(db: Session, art: str, zeilen: list) -> dict[int, dict[int, str | None]]:
"""Alle Feldwerte der beteiligten Artikel in EINER Abfrage.
Ohne das fragt jede Zeile einzeln nach – bei ein paar hundert Etiketten
ist das der Unterschied zwischen einer Abfrage und Hunderten.
"""
if art == "locations":
return {}
produkt_ids = {
(z.product_id if art == "items" else z.id) for z in zeilen
}
if not produkt_ids:
return {}
werte: dict[int, dict[int, str | None]] = {}
for pfv in (
db.query(ProductFieldValue)
.filter(ProductFieldValue.product_id.in_(produkt_ids))
.all()
):
werte.setdefault(pfv.product_id, {})[pfv.field_definition_id] = pfv.value
return werte
def _zelle(schluessel: str, zeile, art: str, kontext: Kontext) -> str | None:
"""Wert einer Spalte – None, wenn es die Spalte nicht (mehr) gibt."""
if schluessel.startswith(FELD_PRAEFIX):
rest = schluessel[len(FELD_PRAEFIX):]
if not rest.isdigit():
return None
fd_id = int(rest)
if kontext.db.get(FieldDefinition, fd_id) is None:
return None # Feld wurde geloescht – Spalte faellt still weg
produkt_id = zeile.product_id if art == "items" else getattr(zeile, "id", None)
return (kontext.feldwerte or {}).get(produkt_id, {}).get(fd_id) or ""
eintrag = SPALTEN.get(art, {}).get(schluessel)
if eintrag is None:
return None
return eintrag[1](zeile, kontext)
def _titel(schluessel: str, art: str, db: Session) -> str | None:
if schluessel.startswith(FELD_PRAEFIX):
rest = schluessel[len(FELD_PRAEFIX):]
if not rest.isdigit():
return None
fd = db.get(FieldDefinition, int(rest))
if fd is None:
return None
return f"{fd.label} ({fd.unit})" if fd.unit else fd.label
eintrag = SPALTEN.get(art, {}).get(schluessel)
return eintrag[0] if eintrag else None
def _schuetzen(wert: str) -> str:
"""Formel-Injektion verhindern (=, +, -, @ am Zellenanfang)."""
text = "" if wert is None else str(wert)
return f"'{text}" if _FORMEL_START.match(text) else text
def csv_erzeugen(db: Session, vorlage: EtikettenVorlage, basis_url: str = "") -> str:
"""Die fertige CSV zu einer Vorlage.
Mit BOM und CRLF, damit Excel und P-touch die Umlaute richtig lesen.
Unbekannte Spalten (geloeschtes Feld, geaenderte Bezugsart) fallen still
weg, statt den Export zu zerreissen – so wie die Tabellen in der
Oberflaeche verschwundene Spalten aussortieren.
"""
art = vorlage.art if vorlage.art in ARTEN else "items"
konf = konfiguration_lesen(vorlage)
gueltig = [s for s in konf["spalten"] if _titel(s, art, db) is not None]
if not gueltig:
# Lieber die festen Spalten der Bezugsart als eine leere Datei.
gueltig = list(SPALTEN.get(art, {}).keys())
zeilen = _zeilen(db, art, konf["kategorie_ids"])
kontext = Kontext(db=db, basis_url=basis_url, feldwerte=_feldwerte(db, art, zeilen))
puffer = io.StringIO()
schreiber = csv.writer(
puffer, delimiter=konf["trennzeichen"], lineterminator="\r\n",
quoting=csv.QUOTE_MINIMAL,
)
schreiber.writerow([_schuetzen(_titel(s, art, db) or s) for s in gueltig])
for zeile in zeilen:
schreiber.writerow(
[_schuetzen(_zelle(s, zeile, art, kontext) or "") for s in gueltig]
)
return "" + puffer.getvalue()