Bisher tippte man den Wert immer von Hand ein - auch bei einem Haken, wo man wissen musste, dass intern "true" steht, und bei einer Auswahl, wo ein Vertipper stillschweigend nichts fand. Das ist der unangenehmste Fehlschlag: Die CSV kommt einfach leer heraus, ohne Hinweis warum. Jetzt bringt jede Bedingung die passende Eingabe mit: Ja/Nein ein Haken Auswahl "ist gleich" / "ist nicht gleich" plus das Dropdown der Optionen Zahl "ist groesser =" / "ist =" / "ist kleiner =" plus Zahlenfeld Text wie bisher freie Eingabe, leer = ohne Wert Damit die Oberflaeche das kann, traegt /etiketten/spalten jetzt Art und Auswahlwerte je Feld mit - vorher gab es nur Schluessel und Titel. Der Filter bekommt dafuer ein op (=, !=, >=, <=), Vorgabe "=", damit bestehende Vorlagen unveraendert weiterlaufen. Groesser/kleiner vergleicht als Zahl und nimmt Komma wie Punkt; Zeilen ohne Wert treffen dabei NIE - "leer" ist nicht kleiner als drei, es ist unvergleichbar. Eine unbekannte Vergleichsart faellt auf "=" zurueck statt den Export zu zerreissen. Die Sicherung traegt das op mit, wie schon das Feld selbst. Sieben neue Tests, 547 gruen. Diesmal zusaetzlich in der laufenden Oberflaeche durchgespielt: Alle vier Feldarten zeigen die richtige Eingabe, der Filter wird gespeichert, und die CSV enthaelt bei "Laenge >= 5" genau die Artikel mit 5 und 12 - nicht den mit 2 und nicht den ohne Wert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
479 lines
18 KiB
Python
479 lines
18 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 .fields import options_list
|
||
from ..models import (
|
||
Category,
|
||
EtikettenVorlage,
|
||
FieldDefinition,
|
||
FieldType,
|
||
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
|
||
# Art und Auswahlwerte kommen mit: Erst damit kann die Oberflaeche beim
|
||
# Wertefilter eine Checkbox, ein Dropdown oder ein Zahlenfeld anbieten
|
||
# statt eines nackten Textfelds, in das man sich vertippt.
|
||
spalten.append({
|
||
"schluessel": f"{FELD_PRAEFIX}{fd.id}",
|
||
"titel": titel,
|
||
"typ": fd.field_type,
|
||
"optionen": options_list(fd),
|
||
})
|
||
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 = ","
|
||
# Kaputte Eintraege fallen einzeln weg, nicht der ganze Filter - sonst
|
||
# entschiede ein Tippfehler darueber, ob ein Export alles ausgibt.
|
||
feldfilter = []
|
||
for eintrag in roh.get("feldfilter", []) if isinstance(roh.get("feldfilter"), list) else []:
|
||
if not isinstance(eintrag, dict):
|
||
continue
|
||
fid = eintrag.get("feld_id")
|
||
if not str(fid).lstrip("-").isdigit():
|
||
continue
|
||
wert = eintrag.get("wert", "")
|
||
op = eintrag.get("op", "=")
|
||
if op not in ("=", "!=", ">=", "<="):
|
||
op = "="
|
||
feldfilter.append({
|
||
"feld_id": int(fid),
|
||
"op": op,
|
||
"wert": str(wert) if wert is not None else "",
|
||
})
|
||
return {
|
||
"spalten": spalten,
|
||
"kategorie_ids": kategorie_ids,
|
||
"trennzeichen": trenn,
|
||
"feldfilter": feldfilter,
|
||
}
|
||
|
||
|
||
def konfiguration_schreiben(
|
||
spalten: list[str],
|
||
kategorie_ids: list[int],
|
||
trennzeichen: str,
|
||
feldfilter: list[dict] | None = None,
|
||
) -> str:
|
||
"""Gegenstueck zu konfiguration_lesen.
|
||
|
||
Lag frueher als _konfiguration im Router. Hierher gezogen, weil sonst DREI
|
||
Stellen denselben JSON-Text bauen (POST, PATCH und der Sicherungs-Import)
|
||
und ein neuer Schluessel an jeder einzeln nachgetragen werden muesste.
|
||
"""
|
||
return json.dumps(
|
||
{
|
||
"spalten": spalten,
|
||
"kategorie_ids": kategorie_ids,
|
||
"trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",",
|
||
"feldfilter": feldfilter or [],
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
|
||
def _als_zahl(text: str | None) -> float | None:
|
||
"""Zahl aus dem gespeicherten Text – Komma gilt wie Punkt."""
|
||
try:
|
||
return float(str(text).replace(",", "."))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _feld_passt(fd: FieldDefinition, op: str, bedingung: str, ist: str | None) -> bool:
|
||
"""Trifft der gespeicherte Wert die Bedingung?
|
||
|
||
Ja/Nein ist der heikle Fall: Nimmt man den Haken weg, loescht
|
||
apply_field_values die Zeile (services/fields.py). „nein" und „nie gesetzt"
|
||
sind in den Daten also dasselbe - beide muessen treffen, sonst faende
|
||
„alle mit Nein" so gut wie nichts.
|
||
|
||
coerce_value wird NUR fuer Ja/Nein benutzt: Bei Auswahl, Zahl und Datum
|
||
wirft es bei unpassendem Text, und ein vertippter Filterwert soll keinen
|
||
Serverfehler beim Herunterladen ausloesen.
|
||
"""
|
||
if fd.field_type == FieldType.boolean.value:
|
||
from .fields import coerce_value
|
||
|
||
gleich = (coerce_value(fd, ist) or "false") == (coerce_value(fd, bedingung) or "false")
|
||
return not gleich if op == "!=" else gleich
|
||
|
||
if op in (">=", "<="):
|
||
# Groesser/kleiner gibt es nur auf Zahlen. Ohne Wert oder mit
|
||
# unlesbarem Text trifft die Bedingung nie - „leer" ist nicht kleiner
|
||
# als drei, es ist unvergleichbar.
|
||
links, rechts = _als_zahl(ist), _als_zahl(bedingung)
|
||
if links is None or rechts is None:
|
||
return False
|
||
return links >= rechts if op == ">=" else links <= rechts
|
||
|
||
gleich = (ist or "").casefold() == (bedingung or "").strip().casefold()
|
||
return not gleich if op == "!=" else gleich
|
||
|
||
|
||
def _nach_feldwerten(db: Session, art: str, zeilen: list, feldfilter: list[dict]) -> list:
|
||
"""Zeilen auf die reduzieren, die ALLE Bedingungen erfuellen.
|
||
|
||
In Python und nicht in SQL: Auf ProductFieldValue.value liegt kein Index,
|
||
„fehlende Zeile zaehlt als leer" braeuchte je Bedingung ein NOT EXISTS, und
|
||
die Mengen sind haushaltsgross. _feldwerte holt ohnehin alles in einem Zug.
|
||
|
||
Ein geloeschtes Feld laesst seine Bedingung wegfallen - genau wie eine
|
||
Spalte zu einem geloeschten Feld still verschwindet.
|
||
"""
|
||
bedingungen = []
|
||
for eintrag in feldfilter:
|
||
fd = db.get(FieldDefinition, eintrag["feld_id"])
|
||
if fd is not None:
|
||
bedingungen.append((fd, eintrag.get("op", "="), eintrag["wert"]))
|
||
if not bedingungen:
|
||
return zeilen
|
||
|
||
werte = _feldwerte(db, art, zeilen)
|
||
passend = []
|
||
for zeile in zeilen:
|
||
produkt_id = zeile.product_id if art == "items" else getattr(zeile, "id", None)
|
||
eigene = werte.get(produkt_id, {})
|
||
if all(_feld_passt(fd, op, wert, eigene.get(fd.id)) for fd, op, wert in bedingungen):
|
||
passend.append(zeile)
|
||
return passend
|
||
|
||
|
||
def _zeilen(
|
||
db: Session, art: str, kategorie_ids: list[int], feldfilter: list[dict] | None = None
|
||
) -> 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}))
|
||
zeilen = frage.order_by(Product.name).all()
|
||
else:
|
||
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})
|
||
)
|
||
zeilen = frage.order_by(Item.id).all()
|
||
|
||
return _nach_feldwerten(db, art, zeilen, feldfilter) if feldfilter else zeilen
|
||
|
||
|
||
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"], konf["feldfilter"])
|
||
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()
|