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>
240 lines
7.8 KiB
Python
240 lines
7.8 KiB
Python
"""Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten."""
|
||
|
||
import json
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from .config import get_settings
|
||
from .models import (
|
||
Category,
|
||
CategoryTracking,
|
||
EtikettenVorlage,
|
||
FieldDefinition,
|
||
PackageType,
|
||
Role,
|
||
Unit,
|
||
UnitKind,
|
||
User,
|
||
)
|
||
from .security import hash_password
|
||
from .services.fields import slugify
|
||
|
||
# (Name, Art, Faktor zur kanonischen Basiseinheit)
|
||
BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
|
||
("Stück", UnitKind.count, 1.0),
|
||
("Gramm", UnitKind.weight, 1.0),
|
||
("Kilogramm", UnitKind.weight, 1000.0),
|
||
("Milliliter", UnitKind.volume, 1.0),
|
||
("Liter", UnitKind.volume, 1000.0),
|
||
]
|
||
|
||
|
||
# Gebinde in Einzahl und Mehrzahl. Bewusst nur die gaengigen - eigene legt man
|
||
# unter Verwaltung > Gebinde an. Wo Einzahl und Mehrzahl gleich lauten
|
||
# ("2 Becher"), steht das hier ausdruecklich, damit es niemand fuer vergessen
|
||
# haelt und "Bechers" daraus macht.
|
||
BUILTIN_PACKAGE_TYPES: list[tuple[str, str]] = [
|
||
("Packung", "Packungen"),
|
||
("Glas", "Gläser"),
|
||
("Flasche", "Flaschen"),
|
||
("Dose", "Dosen"),
|
||
("Tüte", "Tüten"),
|
||
("Beutel", "Beutel"),
|
||
("Becher", "Becher"),
|
||
("Tube", "Tuben"),
|
||
("Karton", "Kartons"),
|
||
("Riegel", "Riegel"),
|
||
("Rolle", "Rollen"),
|
||
("Sack", "Säcke"),
|
||
("Kiste", "Kisten"),
|
||
("Bund", "Bund"),
|
||
("Stück", "Stück"),
|
||
]
|
||
|
||
|
||
def ensure_builtin_package_types(db: Session) -> None:
|
||
changed = False
|
||
for singular, plural in BUILTIN_PACKAGE_TYPES:
|
||
if not db.query(PackageType).filter(PackageType.singular == singular).first():
|
||
db.add(PackageType(singular=singular, plural=plural, is_builtin=True))
|
||
changed = True
|
||
if changed:
|
||
db.commit()
|
||
|
||
|
||
def ensure_builtin_units(db: Session) -> None:
|
||
changed = False
|
||
for name, kind, factor in BUILTIN_UNITS:
|
||
if not db.query(Unit).filter(Unit.name == name).first():
|
||
db.add(Unit(name=name, kind=kind, factor=factor, is_builtin=True))
|
||
changed = True
|
||
if changed:
|
||
db.commit()
|
||
|
||
|
||
# Startliste für den Überblick über den Vorrat. Zweistufig, damit die
|
||
# Verschachtelung von Anfang an sichtbar ist. Alles frei änderbar –
|
||
# umbenennen, löschen und eigene ergänzen ist ausdrücklich vorgesehen.
|
||
BUILTIN_CATEGORIES: list[tuple[str, list[str]]] = [
|
||
("Obst & Gemüse", []),
|
||
("Molkereiprodukte", ["Käse", "Joghurt & Quark"]),
|
||
("Fleisch & Fisch", []),
|
||
("Brot & Backwaren", []),
|
||
("Trockenwaren & Teigwaren", ["Mehl & Zucker", "Nudeln & Reis"]),
|
||
("Konserven & Gläser", []),
|
||
("Tiefkühl", []),
|
||
("Süßwaren & Snacks", ["Schokolade", "Chips & Salziges"]),
|
||
("Getränke", ["Kaffee & Tee"]),
|
||
("Gewürze & Saucen", []),
|
||
("Frühstück", []),
|
||
("Haushalt & Hygiene", []),
|
||
]
|
||
|
||
|
||
def ensure_builtin_categories(db: Session) -> None:
|
||
"""Legt nur an, was fehlt – gelöschte Kategorien kommen nicht zurück.
|
||
|
||
Anders als bei den Einheiten prüfen wir zusätzlich, ob überhaupt schon
|
||
Kategorien existieren: Wer die Startliste bewusst leergeräumt hat, soll sie
|
||
beim nächsten Start nicht wiederfinden.
|
||
"""
|
||
if db.query(Category).first() is not None:
|
||
return
|
||
food = CategoryTracking.food.value
|
||
for name, children in BUILTIN_CATEGORIES:
|
||
parent = Category(name=name, is_builtin=True, tracking=food)
|
||
db.add(parent)
|
||
db.flush()
|
||
for child in children:
|
||
db.add(Category(name=child, parent_id=parent.id, is_builtin=True, tracking=food))
|
||
db.commit()
|
||
|
||
|
||
# Beispiel-Kategorien für Gegenstände (Non-Food) mit ein paar sinnvollen Feldern.
|
||
# Frei änderbar; wer sie löscht, bekommt sie nicht zurück (siehe Guard unten).
|
||
OBJECT_EXAMPLES: list[dict] = [
|
||
{
|
||
"name": "Kleidung",
|
||
"fields": [
|
||
{"label": "Größe", "type": "select",
|
||
"options": ["XS", "S", "M", "L", "XL", "XXL"]},
|
||
{"label": "Farbe", "type": "text"},
|
||
],
|
||
"children": [
|
||
{"name": "Schuhe",
|
||
"fields": [{"label": "Schuhgröße", "type": "number", "unit": "EU"}]},
|
||
],
|
||
},
|
||
{
|
||
# Kaufdatum/Garantie sind je Einzelstück (Item), nicht am Modell – deshalb
|
||
# hier bewusst keine solchen Felder. Modell-Felder = gemeinsame Merkmale.
|
||
"name": "Elektronik",
|
||
"fields": [],
|
||
"children": [
|
||
{"name": "Powerbank",
|
||
"fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]},
|
||
{"name": "Kabel", "fields": [
|
||
{"label": "Länge", "type": "number", "unit": "cm"},
|
||
{"label": "Anschluss", "type": "text"},
|
||
]},
|
||
],
|
||
},
|
||
{"name": "Werkzeug", "fields": []},
|
||
{"name": "Bücher & Medien", "fields": []},
|
||
]
|
||
|
||
|
||
def _add_fields(db: Session, category_id: int, felder: list[dict]) -> None:
|
||
for pos, f in enumerate(felder):
|
||
optionen = f.get("options")
|
||
db.add(
|
||
FieldDefinition(
|
||
category_id=category_id,
|
||
label=f["label"],
|
||
key=slugify(f["label"]),
|
||
field_type=f.get("type", "text"),
|
||
unit=f.get("unit"),
|
||
options=json.dumps(optionen) if optionen else None,
|
||
required=bool(f.get("required")),
|
||
position=pos,
|
||
is_builtin=True,
|
||
)
|
||
)
|
||
|
||
|
||
def ensure_example_object_categories(db: Session) -> None:
|
||
"""Legt Beispiel-Gegenstandskategorien mit Feldern an – nur, wenn es noch keine gibt.
|
||
|
||
So sieht man das Gegenstands-Feature gleich in Aktion; wer die Beispiele
|
||
bewusst entfernt, bekommt sie beim nächsten Start nicht zurück.
|
||
"""
|
||
if (
|
||
db.query(Category)
|
||
.filter(Category.tracking == CategoryTracking.object.value)
|
||
.first()
|
||
is not None
|
||
):
|
||
return
|
||
for top in OBJECT_EXAMPLES:
|
||
parent = Category(
|
||
name=top["name"], tracking=CategoryTracking.object.value, is_builtin=True
|
||
)
|
||
db.add(parent)
|
||
db.flush()
|
||
_add_fields(db, parent.id, top.get("fields", []))
|
||
for child in top.get("children", []):
|
||
node = Category(
|
||
name=child["name"],
|
||
parent_id=parent.id,
|
||
tracking=CategoryTracking.object.value,
|
||
is_builtin=True,
|
||
)
|
||
db.add(node)
|
||
db.flush()
|
||
_add_fields(db, node.id, child.get("fields", []))
|
||
db.commit()
|
||
|
||
|
||
def ensure_first_admin(db: Session) -> None:
|
||
settings = get_settings()
|
||
has_users = db.query(User).first() is not None
|
||
if has_users:
|
||
return
|
||
admin = User(
|
||
username=settings.admin_username,
|
||
password_hash=hash_password(settings.admin_password),
|
||
role=Role.admin,
|
||
)
|
||
db.add(admin)
|
||
db.commit()
|
||
|
||
|
||
# Die zwei Vorlagen, die den frueheren festen Etikettenexport nachbilden: Wer
|
||
# nichts einstellt, bekommt genau dieselben Spalten wie bisher.
|
||
BUILTIN_ETIKETTEN_VORLAGEN = [
|
||
("Einzelstücke", "items",
|
||
["qr", "uid", "produkt", "marke", "kategorie", "lagerort"]),
|
||
("Lagerorte", "locations", ["qr", "id", "name", "pfad"]),
|
||
]
|
||
|
||
|
||
def ensure_builtin_etiketten_vorlagen(db: Session) -> None:
|
||
"""Legt die mitgelieferten Etiketten-Vorlagen an – einmalig.
|
||
|
||
Nur wenn noch GAR KEINE Vorlage existiert: Wer seine eigenen gebaut und die
|
||
mitgelieferten geloescht hat, soll sie nicht bei jedem Start zurueckbekommen.
|
||
"""
|
||
if db.query(EtikettenVorlage).first() is not None:
|
||
return
|
||
for position, (name, art, spalten) in enumerate(BUILTIN_ETIKETTEN_VORLAGEN):
|
||
db.add(EtikettenVorlage(
|
||
name=name,
|
||
art=art,
|
||
position=position,
|
||
konfiguration=json.dumps(
|
||
{"spalten": spalten, "kategorie_ids": [], "trennzeichen": ","},
|
||
ensure_ascii=False,
|
||
),
|
||
))
|
||
db.commit()
|