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
+12 -17
View File
@@ -12,7 +12,6 @@ vorbehalten, Exportieren darf jeder Angemeldete – es ist ein Lesevorgang.
from __future__ import annotations
import io
import json
import re
import unicodedata
import zipfile
@@ -34,6 +33,7 @@ from ..services.etiketten import (
ARTEN,
csv_erzeugen,
konfiguration_lesen,
konfiguration_schreiben,
verfuegbare_spalten,
)
@@ -129,22 +129,12 @@ def _als_out(vorlage: EtikettenVorlage) -> EtikettenVorlageOut:
spalten=konf["spalten"],
kategorie_ids=konf["kategorie_ids"],
trennzeichen=konf["trennzeichen"],
feldfilter=konf["feldfilter"],
datei_name=vorlage.datei_name,
updated_at=vorlage.updated_at,
)
def _konfiguration(spalten: list[str], kategorie_ids: list[int], trennzeichen: str) -> str:
return json.dumps(
{
"spalten": spalten,
"kategorie_ids": kategorie_ids,
"trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",",
},
ensure_ascii=False,
)
@router.get("/spalten", response_model=list[SpalteOut])
def spalten(
art: str = "items",
@@ -194,8 +184,12 @@ def vorlage_anlegen(
name=name,
art=payload.art,
position=(hoechste.position + 1) if hoechste else 0,
konfiguration=_konfiguration(
payload.spalten, payload.kategorie_ids, payload.trennzeichen
konfiguration=konfiguration_schreiben(
payload.spalten,
payload.kategorie_ids,
payload.trennzeichen,
# json.dumps kann mit Pydantic-Modellen nichts anfangen.
[f.model_dump() for f in payload.feldfilter],
),
)
db.add(vorlage)
@@ -231,14 +225,15 @@ def vorlage_aendern(
if "position" in data:
vorlage.position = data["position"]
# Die drei Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur
# Die Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur
# einen schickt, darf die anderen nicht verlieren.
if {"spalten", "kategorie_ids", "trennzeichen"} & data.keys():
if {"spalten", "kategorie_ids", "trennzeichen", "feldfilter"} & data.keys():
alt = konfiguration_lesen(vorlage)
vorlage.konfiguration = _konfiguration(
vorlage.konfiguration = konfiguration_schreiben(
data.get("spalten", alt["spalten"]),
data.get("kategorie_ids", alt["kategorie_ids"]),
data.get("trennzeichen", alt["trennzeichen"]),
data.get("feldfilter", alt["feldfilter"]),
)
db.commit()
+38 -6
View File
@@ -23,7 +23,7 @@ from ..services.dates import MONTH, clean_precision, normalize_best_before
from ..services import gruppen as gruppen_graph
from ..services.group_codes import sync as sync_group_code
from ..services.min_stock import lies_ueberall, schreibe_ueberall
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen, konfiguration_schreiben
from ..models import (
BaseUnit,
Category,
@@ -493,6 +493,17 @@ def _vorlagen_exportieren(db: Session) -> list[dict]:
)
else:
spalten.append(schluessel)
# Der Wertefilter zeigt ebenfalls auf Feld-IDs und braucht dieselbe
# Umschreibung wie die Spalten - sonst zeigt er nach dem Zurueckspielen
# auf ein fremdes Feld oder ist still weg.
feldfilter = []
for bedingung in konf["feldfilter"]:
fd = db.get(FieldDefinition, bedingung["feld_id"])
if fd is None:
continue
pfad = _category_path(db, db.get(Category, fd.category_id))
feldfilter.append({"feld": f"{pfad}::{fd.label}", "wert": bedingung["wert"]})
zeilen.append({
"name": v.name,
"art": v.art,
@@ -502,6 +513,7 @@ def _vorlagen_exportieren(db: Session) -> list[dict]:
_category_path(db, db.get(Category, kid)) for kid in konf["kategorie_ids"]
],
"trennzeichen": konf["trennzeichen"],
"feldfilter": feldfilter,
"datei_name": v.datei_name,
"datei_base64": base64.b64encode(v.datei).decode() if v.datei else None,
})
@@ -544,6 +556,25 @@ def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int:
if fd is not None:
spalten.append(f"{FELD_PRAEFIX}{fd.id}")
feldfilter: list[dict] = []
for bedingung in entry.get("feldfilter", []):
if not isinstance(bedingung, dict):
continue
pfad, _, label = str(bedingung.get("feld", "")).rpartition("::")
kat_id = pfad_zu_id.get(pfad)
fd = (
db.query(FieldDefinition)
.filter(
FieldDefinition.category_id == kat_id,
FieldDefinition.label == label,
).first()
if kat_id else None
)
# Wie bei den Spalten: fehlt das Feld hier, faellt die Bedingung
# weg statt die ganze Vorlage scheitern zu lassen.
if fd is not None:
feldfilter.append({"feld_id": fd.id, "wert": bedingung.get("wert", "")})
kategorie_ids = [
pfad_zu_id[p] for p in entry.get("kategorien", []) if p in pfad_zu_id
]
@@ -552,11 +583,12 @@ def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int:
name=name,
art=entry.get("art") or "items",
position=int(entry.get("position") or 0),
konfiguration=json.dumps({
"spalten": spalten,
"kategorie_ids": kategorie_ids,
"trennzeichen": entry.get("trennzeichen") or ",",
}, ensure_ascii=False),
konfiguration=konfiguration_schreiben(
spalten,
kategorie_ids,
entry.get("trennzeichen") or ",",
feldfilter,
),
datei_name=entry.get("datei_name"),
datei=base64.b64decode(roh_datei) if roh_datei else None,
))