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>
352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""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()
|