"""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/). Einzelstuecke tragen vorrania://i/ 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()