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>
354 lines
11 KiB
Python
354 lines
11 KiB
Python
"""Etiketten-Vorlagen: Spaltenwahl, eigene Felder, CSV-Form und .lbx-Prüfung.
|
||
|
||
Der Export ist eine reine Funktion über einer Datenbank-Sitzung – geprüft wird
|
||
deshalb ohne HTTP, direkt am Ergebnis.
|
||
"""
|
||
|
||
import io
|
||
import json
|
||
import zipfile
|
||
|
||
import pytest
|
||
from fastapi import HTTPException
|
||
|
||
from app.models import (
|
||
Category,
|
||
CategoryTracking,
|
||
EtikettenVorlage,
|
||
FieldDefinition,
|
||
Item,
|
||
Location,
|
||
Product,
|
||
ProductFieldValue,
|
||
Role,
|
||
User,
|
||
)
|
||
from app.routers.etiketten import _pruefe_lbx, spalten
|
||
from app.services.etiketten import csv_erzeugen, konfiguration_lesen
|
||
|
||
|
||
@pytest.fixture()
|
||
def admin(db):
|
||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||
db.add(person)
|
||
db.commit()
|
||
db.refresh(person)
|
||
return person
|
||
|
||
|
||
@pytest.fixture()
|
||
def kabel(db):
|
||
"""Kategorie „Kabel" mit eigenem Feld „Länge" in Metern."""
|
||
cat = Category(name="Kabel", tracking=CategoryTracking.object.value)
|
||
db.add(cat)
|
||
db.flush()
|
||
fd = FieldDefinition(
|
||
category_id=cat.id, label="Länge", key="laenge", field_type="number", unit="m"
|
||
)
|
||
db.add(fd)
|
||
db.commit()
|
||
db.refresh(cat)
|
||
db.refresh(fd)
|
||
return cat, fd
|
||
|
||
|
||
def _vorlage(db, spalten, art="items", kategorie_ids=None, trenn=","):
|
||
v = EtikettenVorlage(
|
||
name=f"Test {len(db.query(EtikettenVorlage).all())}",
|
||
art=art,
|
||
konfiguration=json.dumps({
|
||
"spalten": spalten,
|
||
"kategorie_ids": kategorie_ids or [],
|
||
"trennzeichen": trenn,
|
||
}),
|
||
)
|
||
db.add(v)
|
||
db.commit()
|
||
db.refresh(v)
|
||
return v
|
||
|
||
|
||
def _stueck(db, cat, name="HDMI-Kabel", laenge=None, fd=None, ort=None):
|
||
product = Product(name=name, category_id=cat.id, individual=True, package_size=1)
|
||
db.add(product)
|
||
db.flush()
|
||
if laenge is not None and fd is not None:
|
||
db.add(ProductFieldValue(
|
||
product_id=product.id, field_definition_id=fd.id, value=laenge
|
||
))
|
||
item = Item(uid=f"UID{product.id:05d}", product_id=product.id,
|
||
location_id=(ort.id if ort else None))
|
||
db.add(item)
|
||
db.commit()
|
||
db.refresh(item)
|
||
return item
|
||
|
||
|
||
def _zeilen(csv_text):
|
||
"""Ohne BOM, als Liste von Zeilen."""
|
||
assert csv_text.startswith(""), "BOM fehlt – Excel zeigt sonst kaputte Umlaute"
|
||
return csv_text[1:].rstrip("\r\n").split("\r\n")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Spalten und Reihenfolge
|
||
# --------------------------------------------------------------------------
|
||
def test_spalten_kommen_in_gespeicherter_reihenfolge(db, kabel):
|
||
cat, _ = kabel
|
||
_stueck(db, cat)
|
||
v = _vorlage(db, ["produkt", "uid", "qr"])
|
||
|
||
kopf = _zeilen(csv_erzeugen(db, v))[0]
|
||
assert kopf == "Produkt,UID,QR-Inhalt"
|
||
|
||
|
||
def test_eigenes_feld_landet_in_der_csv(db, kabel):
|
||
cat, fd = kabel
|
||
_stueck(db, cat, laenge="3", fd=fd)
|
||
v = _vorlage(db, ["uid", f"feld:{fd.id}"])
|
||
|
||
kopf, zeile = _zeilen(csv_erzeugen(db, v))
|
||
assert kopf == "UID,Länge (m)"
|
||
assert zeile.endswith(",3")
|
||
|
||
|
||
def test_fehlender_feldwert_gibt_eine_leere_zelle(db, kabel):
|
||
"""Nicht etwa eine Luecke – sonst verrutschen alle folgenden Spalten."""
|
||
cat, fd = kabel
|
||
_stueck(db, cat) # ohne Wert
|
||
v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"])
|
||
|
||
zeile = _zeilen(csv_erzeugen(db, v))[1]
|
||
assert zeile.count(",") == 2
|
||
assert ",," in zeile
|
||
|
||
|
||
def test_geloeschtes_feld_zerreisst_den_export_nicht(db, kabel):
|
||
cat, fd = kabel
|
||
_stueck(db, cat)
|
||
v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"])
|
||
db.delete(fd)
|
||
db.commit()
|
||
|
||
kopf = _zeilen(csv_erzeugen(db, v))[0]
|
||
assert kopf == "UID,Produkt" # die tote Spalte faellt still weg
|
||
|
||
|
||
def test_ohne_gueltige_spalte_greifen_die_festen(db, kabel):
|
||
cat, _ = kabel
|
||
_stueck(db, cat)
|
||
v = _vorlage(db, ["gibtsnicht"])
|
||
|
||
kopf = _zeilen(csv_erzeugen(db, v))[0]
|
||
assert kopf.startswith("QR-Inhalt,UID,Produkt")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# CSV-Form
|
||
# --------------------------------------------------------------------------
|
||
def test_trennzeichen_semikolon_und_tab(db, kabel):
|
||
cat, _ = kabel
|
||
_stueck(db, cat)
|
||
assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn=";")))[0] \
|
||
== "UID;Produkt"
|
||
assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn="\t")))[0] \
|
||
== "UID\tProdukt"
|
||
|
||
|
||
def test_zelle_mit_trennzeichen_wird_eingefasst(db, kabel):
|
||
cat, _ = kabel
|
||
_stueck(db, cat, name="Kabel, lang")
|
||
v = _vorlage(db, ["produkt"])
|
||
|
||
assert _zeilen(csv_erzeugen(db, v))[1] == '"Kabel, lang"'
|
||
|
||
|
||
def test_formel_wird_entschaerft(db, kabel):
|
||
"""Ohne das fuehrt Excel den Zellinhalt als Formel aus."""
|
||
cat, _ = kabel
|
||
_stueck(db, cat, name="=1+1")
|
||
v = _vorlage(db, ["produkt"])
|
||
|
||
assert _zeilen(csv_erzeugen(db, v))[1] == "'=1+1"
|
||
|
||
|
||
def test_zeilenende_ist_crlf(db, kabel):
|
||
cat, _ = kabel
|
||
_stueck(db, cat)
|
||
assert "\r\n" in csv_erzeugen(db, _vorlage(db, ["uid"]))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Bezugsarten und Filter
|
||
# --------------------------------------------------------------------------
|
||
def test_kategoriefilter_greift_samt_unterkategorien(db, kabel):
|
||
cat, _ = kabel
|
||
unter = Category(name="HDMI", tracking=CategoryTracking.object.value, parent_id=cat.id)
|
||
andere = Category(name="Werkzeug", tracking=CategoryTracking.object.value)
|
||
db.add_all([unter, andere])
|
||
db.commit()
|
||
_stueck(db, unter, name="HDMI 2.1")
|
||
_stueck(db, andere, name="Hammer")
|
||
|
||
v = _vorlage(db, ["produkt"], kategorie_ids=[cat.id])
|
||
zeilen = _zeilen(csv_erzeugen(db, v))
|
||
assert zeilen[1:] == ["HDMI 2.1"]
|
||
|
||
|
||
def test_lagerorte_brauchen_die_basis_adresse_fuer_den_qr(db):
|
||
ort = Location(name="Keller")
|
||
db.add(ort)
|
||
db.commit()
|
||
v = _vorlage(db, ["qr", "name"], art="locations")
|
||
|
||
zeile = _zeilen(csv_erzeugen(db, v, basis_url="https://haus.example/"))[1]
|
||
assert zeile.startswith(f"https://haus.example/l/{ort.id},")
|
||
|
||
|
||
def test_artikel_als_bezugsart(db, kabel):
|
||
cat, fd = kabel
|
||
_stueck(db, cat, name="USB-C", laenge="1", fd=fd)
|
||
v = _vorlage(db, ["name", f"feld:{fd.id}"], art="products")
|
||
|
||
kopf, zeile = _zeilen(csv_erzeugen(db, v))
|
||
assert kopf == "Name,Länge (m)"
|
||
assert zeile == "USB-C,1"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Konfiguration lesen
|
||
# --------------------------------------------------------------------------
|
||
def test_kaputte_konfiguration_ergibt_vorgaben(db):
|
||
v = EtikettenVorlage(name="Kaputt", art="items", konfiguration="{nicht json")
|
||
db.add(v)
|
||
db.commit()
|
||
|
||
konf = konfiguration_lesen(v)
|
||
assert konf == {"spalten": [], "kategorie_ids": [], "trennzeichen": ","}
|
||
|
||
|
||
def test_unerlaubtes_trennzeichen_faellt_auf_komma_zurueck(db):
|
||
v = _vorlage(db, ["uid"], trenn="|")
|
||
assert konfiguration_lesen(v)["trennzeichen"] == ","
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Spaltenliste für die Oberfläche
|
||
# --------------------------------------------------------------------------
|
||
def test_spaltenliste_nennt_eigene_felder_der_kategorie(db, admin, kabel):
|
||
cat, fd = kabel
|
||
liste = spalten(art="items", kategorie_ids=str(cat.id), db=db, _=admin)
|
||
schluessel = {s.schluessel for s in liste}
|
||
|
||
assert "qr" in schluessel and "uid" in schluessel
|
||
assert f"feld:{fd.id}" in schluessel
|
||
assert next(s.titel for s in liste if s.schluessel == f"feld:{fd.id}") == "Länge (m)"
|
||
|
||
|
||
def test_lagerorte_haben_keine_eigenen_felder(db, admin, kabel):
|
||
liste = spalten(art="locations", kategorie_ids=None, db=db, _=admin)
|
||
assert not any(s.schluessel.startswith("feld:") for s in liste)
|
||
|
||
|
||
def test_unbekannte_bezugsart_wird_abgelehnt(db, admin):
|
||
with pytest.raises(HTTPException) as fehler:
|
||
spalten(art="quatsch", kategorie_ids=None, db=db, _=admin)
|
||
assert fehler.value.status_code == 400
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# .lbx-Prüfung
|
||
# --------------------------------------------------------------------------
|
||
def _zip(namen):
|
||
puffer = io.BytesIO()
|
||
with zipfile.ZipFile(puffer, "w") as z:
|
||
for n in namen:
|
||
z.writestr(n, "<xml/>")
|
||
return puffer.getvalue()
|
||
|
||
|
||
def test_echte_lbx_wird_angenommen(db):
|
||
_pruefe_lbx("etikett.lbx", _zip(["label.xml", "prop.xml"])) # wirft nicht
|
||
|
||
|
||
def test_zip_ohne_label_xml_wird_abgelehnt(db):
|
||
with pytest.raises(HTTPException) as fehler:
|
||
_pruefe_lbx("etikett.lbx", _zip(["irgendwas.txt"]))
|
||
assert fehler.value.status_code == 415
|
||
|
||
|
||
def test_nicht_zip_wird_abgelehnt(db):
|
||
with pytest.raises(HTTPException) as fehler:
|
||
_pruefe_lbx("etikett.lbx", b"\x89PNG\r\n\x1a\n und noch was")
|
||
assert fehler.value.status_code == 415
|
||
|
||
|
||
def test_falsche_endung_wird_abgelehnt(db):
|
||
with pytest.raises(HTTPException) as fehler:
|
||
_pruefe_lbx("bild.png", _zip(["label.xml"]))
|
||
assert fehler.value.status_code == 415
|
||
|
||
|
||
def test_leere_datei_wird_abgelehnt(db):
|
||
with pytest.raises(HTTPException) as fehler:
|
||
_pruefe_lbx("etikett.lbx", b"")
|
||
assert fehler.value.status_code == 400
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Backup-Umlauf
|
||
# --------------------------------------------------------------------------
|
||
def test_vorlage_uebersteht_die_sicherung(db, admin, kabel):
|
||
"""Kategorien als Pfad, eigene Felder als Label – IDs sind zwischen zwei
|
||
Instanzen nicht dieselben."""
|
||
import json as _json
|
||
|
||
from app.routers.transfer import _import_json, export_backup_json
|
||
from app.seed import ensure_builtin_units
|
||
|
||
cat, fd = kabel
|
||
_stueck(db, cat, laenge="5", fd=fd)
|
||
v = _vorlage(db, ["uid", f"feld:{fd.id}"], kategorie_ids=[cat.id], trenn=";")
|
||
v.name = "Kabelexport"
|
||
v.datei_name = "kabel.lbx"
|
||
v.datei = _zip(["label.xml"])
|
||
db.commit()
|
||
|
||
inhalt = export_backup_json(db=db, _=admin).body
|
||
daten = _json.loads(inhalt)
|
||
gesichert = daten["etiketten_vorlagen"]
|
||
assert [e["name"] for e in gesichert] == ["Kabelexport"]
|
||
# Das Feld steht als Pfad::Label drin, nicht als ID.
|
||
assert gesichert[0]["spalten"][1] == "feld:Kabel::Länge"
|
||
|
||
# In eine zweite, leere Instanz einspielen.
|
||
from app.database import Base
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
from sqlalchemy.pool import StaticPool
|
||
|
||
zweiter_motor = create_engine(
|
||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||
)
|
||
Base.metadata.create_all(bind=zweiter_motor)
|
||
zweite = sessionmaker(bind=zweiter_motor)()
|
||
ensure_builtin_units(zweite)
|
||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||
zweite.add(person)
|
||
zweite.commit()
|
||
|
||
_import_json(zweite, inhalt, person, "add")
|
||
|
||
neu = zweite.query(EtikettenVorlage).filter(
|
||
EtikettenVorlage.name == "Kabelexport"
|
||
).one()
|
||
konf = konfiguration_lesen(neu)
|
||
assert konf["trennzeichen"] == ";"
|
||
assert neu.datei_name == "kabel.lbx"
|
||
assert neu.datei == _zip(["label.xml"])
|
||
# Das Feld wurde ueber Pfad und Label wiedergefunden – mit NEUER ID.
|
||
feld_spalte = [s for s in konf["spalten"] if s.startswith("feld:")][0]
|
||
neue_fd = zweite.query(FieldDefinition).filter(FieldDefinition.label == "Länge").one()
|
||
assert feld_spalte == f"feld:{neue_fd.id}"
|
||
zweite.close()
|