Eigene Felder: Spalten in der Artikelliste und Export nach Feldwerten

Eigene Felder waren bisher nur im Artikelformular zu sehen. Zwei Luecken
daraus: In den Listen tauchten sie gar nicht auf, und es gab keinen Weg,
gezielt nach einem Feldwert zu exportieren.

SPALTEN. Je eigenem Feld eine Spalte in der Artikelliste, Schluessel
feld:<id> wie im Etikettenexport (die ID und nicht key, der ist nur je
Kategorie eindeutig). Reine Oberflaechenarbeit: GET /products liefert die
Werte laengst mit, Products.jsx warf sie nur weg. Filtern, Sortieren,
Ein-/Ausblenden und das Merken erbt die Spalte von DataTable.

Ja/Nein erscheint als Haken, liefert zum Filtern aber "Ja"/"Nein" - und
"Nein" auch ohne gespeicherten Wert: Wer den Haken wegnimmt, loescht die
Zeile, "nein" und "nie gesetzt" sind in den Daten dasselbe. Zahlen sortieren
als Zahl, sonst stuende "10" vor "9".

DataTable lernt dafuer defaultHidden: Alle Feldspalten starten verborgen und
sind erst auf Wunsch da - sonst hinge die Liste je nach Haushalt voller
Spalten, die fuer die meisten Zeilen gar nicht gelten.

Dabei einen vorhandenen Fehler gefunden: urlState las die Sortierung mit
split(":") und nahm das erste Stueck als Spalte. Bei "feld:12:asc" kam
dadurch "feld" heraus, und die Sortierung fiel beim Neuladen still weg. Jetzt
am LETZTEN Doppelpunkt getrennt.

EXPORT. Die Etikettenvorlagen bekommen einen Wertefilter: feldfilter in der
Konfiguration, mehrere Bedingungen UND-verknuepft, genau verglichen ohne
Ruecksicht auf Gross-/Kleinschreibung, leerer Wert heisst "ohne Wert".
Gefiltert wird in Python ueber die ohnehin geladenen Feldwerte - auf value
liegt kein Index, und "fehlende Zeile zaehlt als leer" braeuchte in SQL je
Bedingung ein NOT EXISTS.

Der Filter gilt auch fuer Einzelstuecke; der Wert haengt am Artikel des
Stuecks. Ein geloeschtes Feld laesst seine Bedingung wegfallen, genau wie
eine Spalte zu einem geloeschten Feld still verschwindet.

Die Sicherung schreibt den Filter wie die Spalten auf Kategorie::Label um -
ohne das zeigte er nach dem Zurueckspielen auf ein fremdes Feld, und der
Export gaebe mehr Zeilen aus als das Original. Dafuer ist _konfiguration aus
dem Router als konfiguration_schreiben in den Dienst gezogen: Sonst haetten
DREI Stellen denselben JSON-Text gebaut.

Zehn neue Tests, 540 gruen. Keine Schemaaenderung - konfiguration ist freies
JSON, alte Vorlagen lesen sich als leerer Filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-16 16:02:53 +02:00
co-authored by Claude Opus 5
parent 83f7134459
commit dd57d3356a
10 changed files with 590 additions and 45 deletions
+101 -10
View File
@@ -24,6 +24,7 @@ from ..models import (
Category,
EtikettenVorlage,
FieldDefinition,
FieldType,
Item,
Location,
Product,
@@ -231,10 +232,98 @@ def konfiguration_lesen(vorlage: EtikettenVorlage) -> dict:
trenn = roh.get("trennzeichen") or ","
if trenn not in (",", ";", "\t"):
trenn = ","
return {"spalten": spalten, "kategorie_ids": kategorie_ids, "trennzeichen": 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", "")
feldfilter.append({"feld_id": int(fid), "wert": str(wert) if wert is not None else ""})
return {
"spalten": spalten,
"kategorie_ids": kategorie_ids,
"trennzeichen": trenn,
"feldfilter": feldfilter,
}
def _zeilen(db: Session, art: str, kategorie_ids: list[int]) -> list:
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 _feld_passt(fd: FieldDefinition, 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
return (coerce_value(fd, ist) or "false") == (coerce_value(fd, bedingung) or "false")
return (ist or "").casefold() == (bedingung or "").strip().casefold()
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["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, wert, eigene.get(fd.id)) for fd, 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()
@@ -250,14 +339,16 @@ def _zeilen(db: Session, art: str, kategorie_ids: list[int]) -> list:
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()
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()
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()
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]]:
@@ -335,7 +426,7 @@ def csv_erzeugen(db: Session, vorlage: EtikettenVorlage, basis_url: str = "") ->
# Lieber die festen Spalten der Bezugsart als eine leere Datei.
gueltig = list(SPALTEN.get(art, {}).keys())
zeilen = _zeilen(db, art, konf["kategorie_ids"])
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()