feat(csv-templates): Add CSV template validation endpoint and enhance error handling
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Introduced a new endpoint for validating CSV templates without saving, allowing users to check field mappings and type conversions.
- Updated the `create_system_template` and `update_system_template` functions to include validation reports in responses.
- Enhanced error handling in CSV import processes by integrating `enrich_row_error` for more informative error messages.
- Improved the AdminCsvTemplateEditorPage to support format checking and display validation results, enhancing user experience.
- Incremented version numbers for `csv_import` and `admin_csv_templates` to reflect these updates.
This commit is contained in:
Lars 2026-04-11 06:47:27 +02:00
parent 6945b748cb
commit 0629f88b37
10 changed files with 486 additions and 39 deletions

View File

@ -18,6 +18,7 @@ from csv_parser.import_row_processing import (
validate_import_row_processing,
)
from csv_parser.module_registry import get_module_definition
from csv_parser.import_errors import enrich_row_error
from csv_parser.type_converter import build_row_after_mapping
logger = logging.getLogger(__name__)
@ -756,8 +757,9 @@ def _import_vitals_baseline(
cur.execute("ROLLBACK TO SAVEPOINT vitals_csv_row")
except Exception:
pass
err = enrich_row_error(str(e), module="vitals_baseline")
error_details.append(
{"row": rows_total, "error": str(e), "context": "vitals_baseline upsert"},
{"row": rows_total, "context": "vitals_baseline upsert", **err},
)
return {
@ -1003,7 +1005,8 @@ def _import_activity(
cur.execute("ROLLBACK TO SAVEPOINT csv_activity_row")
except Exception:
pass
error_details.append({"row": rows_total, "error": str(e)})
err = enrich_row_error(str(e), module="activity")
error_details.append({"row": rows_total, **err})
return {
"rows_total": rows_total,

View File

@ -0,0 +1,53 @@
"""
Menschenlesbare Hinweise zu typischen Import-/DB-Fehlern (Universal-CSV).
"""
from __future__ import annotations
def enrich_row_error(message: str, module: str | None = None) -> dict[str, str | None]:
"""
Ergänzt eine Rohexception-Zeichenkette um ``code`` und ``hint`` für die Fehlerliste im Import.
"""
low = (message or "").lower()
out: dict[str, str | None] = {"error": message, "code": None, "hint": None}
if "numeric field overflow" in low or "numeric value out of range" in low:
out["code"] = "db_numeric_overflow"
out["hint"] = (
"Wert passt nicht in die Datenbank-Spalte (z. B. NUMERIC mit begrenzter Größe). "
"Häufig: Kilojoule aus dem Export landen im Kalorien-Feld in der Vorlage für kcal_active/kcal_resting "
'"source_unit": "kj" setzen. Oder eine falsche CSV-Spalte ist einem kleinen Zielfeld zugeordnet '
"(z. B. große Zahl in einem HF-Feld)."
)
return out
if "violates check constraint" in low and "source" in low:
out["code"] = "db_check_constraint_source"
out["hint"] = (
"Die Tabelle erlaubt den gesetzten «source»-Wert nicht. "
"System-Vorlage / Migration zur erlaubten Quelle prüfen (z. B. csv für Universal-Import)."
)
return out
if "current transaction is aborted" in low:
out["code"] = "transaction_aborted"
out["hint"] = (
"Eine frühere Zeile hat einen Datenbankfehler ausgelöst. "
"Zuerst die niedrigste Zeilennummer in error_details beheben (Vorlage/Daten prüfen)."
)
return out
if "invalid input syntax" in low and "time" in low:
out["code"] = "db_time_cast"
out["hint"] = (
"start_time/end_time passen nicht zum erwarteten Zeitformat in der Datenbank. "
"Vorlage: Datums- und Zeitanteil konsistent (oft nur Uhrzeit, wenn date separat)."
)
return out
if module == "activity" and "foreign key" in low:
out["code"] = "db_foreign_key"
out["hint"] = "Verknüpfung zur Datenbank verletzt (z. B. training_type). Support kontaktieren."
return out

View File

@ -0,0 +1,223 @@
"""
Formatprüfung für CSV-Import-Vorlagen (field_mappings, type_conversions).
Liefert strukturierte Fehler/Warnungen für Admin-UI und Speicher-Guards.
"""
from __future__ import annotations
from typing import Any, Mapping
from csv_parser.import_row_processing import validate_import_row_processing as validate_import_row_processing_spec
from csv_parser.module_registry import (
get_module_definition,
validate_field_mappings,
validate_required_field_targets,
)
ALLOWED_SPEC_TYPES = frozenset(
{"string", "float", "number", "int", "date", "time", "datetime", "duration"}
)
def _issue(
severity: str,
code: str,
message: str,
*,
hint: str | None = None,
field: str | None = None,
csv_columns: list[str] | None = None,
) -> dict[str, Any]:
out: dict[str, Any] = {
"severity": severity,
"code": code,
"message": message,
}
if hint:
out["hint"] = hint
if field:
out["field"] = field
if csv_columns:
out["csv_columns"] = csv_columns
return out
def validate_csv_template(
module: str,
field_mappings: Mapping[str, Any] | None,
type_conversions: Mapping[str, Any] | None = None,
import_row_processing: Mapping[str, Any] | None = None,
column_signature: list[str] | None = None,
) -> dict[str, Any]:
"""
Prüft eine Vorlage ohne Datei-Upload.
Returns:
``{"valid": bool, "errors": [...], "warnings": [...]}``
"""
errors: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
fm = dict(field_mappings or {})
tc: dict[str, Any] = dict(type_conversions or {}) if type_conversions else {}
mod = get_module_definition(module)
if not mod:
errors.append(
_issue(
"error",
"unknown_module",
f"Unbekanntes Modul «{module}».",
hint="Nur registrierte Module in module_registry sind erlaubt.",
)
)
return {"valid": False, "errors": errors, "warnings": warnings}
try:
validate_field_mappings(module, fm)
except ValueError as e:
errors.append(
_issue(
"error",
"invalid_field_mapping",
str(e),
hint="Jede Zuordnung muss auf ein bekanntes Zielfeld des Moduls zeigen (oder „–“ / ignorieren).",
)
)
try:
validate_required_field_targets(module, fm)
except ValueError as e:
errors.append(
_issue(
"error",
"missing_required_target",
str(e),
hint="Pflichtfelder des Moduls müssen mindestens einer CSV-Spalte zugeordnet sein.",
)
)
if import_row_processing:
try:
validate_import_row_processing_spec(module, import_row_processing, fm)
except ValueError as e:
errors.append(
_issue(
"error",
"invalid_import_row_processing",
str(e),
hint="import_row_processing: group_by und aggregates prüfen (siehe Doku Issue #21).",
)
)
field_defs = mod.get("fields") or {}
for db_field, spec in tc.items():
if db_field not in field_defs:
errors.append(
_issue(
"error",
"unknown_type_conversion_field",
f"type_conversions enthält unbekanntes Zielfeld «{db_field}».",
hint="Nur Felder aus der Moduldefinition sind erlaubt.",
field=db_field,
)
)
continue
if not isinstance(spec, Mapping):
errors.append(
_issue(
"error",
"type_conversion_not_object",
f"type_conversions[\"{db_field}\"] muss ein JSON-Objekt sein.",
field=db_field,
)
)
continue
stype = spec.get("type", "string")
if stype not in ALLOWED_SPEC_TYPES:
warnings.append(
_issue(
"warning",
"unusual_conversion_type",
f"Ungewöhnlicher Typ «{stype}» für «{db_field}» (erwartet u. a. string, float, date, datetime).",
field=db_field,
)
)
finfo = field_defs.get(db_field) or {}
expected = finfo.get("type")
if expected == "date" and stype not in ("date", "datetime"):
warnings.append(
_issue(
"warning",
"date_field_conversion",
f"Zielfeld «{db_field}» ist ein Datum; der Konvertierungstyp ist «{stype}».",
hint="Meist «date» oder «datetime» mit passendem format.",
field=db_field,
)
)
if expected == "float" and stype == "int" and db_field in ("hr_avg", "hr_max"):
warnings.append(
_issue(
"warning",
"hr_as_int",
"Herzfrequenz als «int» konvertiert; Nachkommastellen aus Apple-Export gehen verloren.",
hint="Optional «float» mit flexible: true verwenden.",
field=db_field,
)
)
# Mehrere CSV-Spalten → dasselbe Zielfeld
by_target: dict[str, list[str]] = {}
for csv_col, dbf in fm.items():
if dbf in (None, "", "-", "_skip"):
continue
by_target.setdefault(str(dbf), []).append(str(csv_col))
for dbf, cols in by_target.items():
if len(cols) > 1:
warnings.append(
_issue(
"warning",
"duplicate_target_columns",
f"Mehrere Spalten mappen auf «{dbf}»: {', '.join(cols)}.",
hint="Beim Import gewinnt die letzte Spalte in der CSV-Kopfzeilen-Reihenfolge.",
field=dbf,
csv_columns=cols,
)
)
# Kilojoule in kcal-Feldern (häufiger Apple-DE-Fehler)
for csv_col, dbf in fm.items():
if dbf not in ("kcal_active", "kcal_resting"):
continue
col_l = str(csv_col).lower()
if "kj" in col_l or "kilojoule" in col_l:
sub = tc.get(dbf)
su = (sub or {}).get("source_unit") if isinstance(sub, Mapping) else None
if str(su or "").strip().lower() != "kj":
warnings.append(
_issue(
"warning",
"energy_kj_without_source_unit",
f"Spalte «{csv_col}» deutet auf Kilojoule, Zielfeld «{dbf}» speichert kcal.",
hint='In type_conversions für dieses Feld "source_unit": "kj" setzen (Faktor 1/4.184).',
field=str(dbf),
csv_columns=[str(csv_col)],
)
)
# Signatur vs. gemappte Spalten (nur Hinweis)
if column_signature:
sig_norm = {str(c).strip() for c in column_signature if str(c).strip()}
mapped_cols = {str(k).strip() for k in fm.keys()}
if sig_norm and not sig_norm.intersection(mapped_cols):
warnings.append(
_issue(
"warning",
"signature_vs_mappings_mismatch",
"column_signature und die Schlüssel in field_mappings haben keine gemeinsame Spalte.",
hint="Signatur dient dem Ranking; für den Import müssen die Kopfzeilen der Datei zu den Keys in field_mappings passen (oder Aliase greifen).",
)
)
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}

View File

@ -23,11 +23,8 @@ from csv_parser.mapping_suggest import build_type_conversions_for_mapping, sugge
from csv_parser.import_row_processing import (
validate_import_row_processing as validate_import_row_processing_spec,
)
from csv_parser.module_registry import (
get_module_definition,
validate_field_mappings,
validate_required_field_targets,
)
from csv_parser.module_registry import get_module_definition
from csv_parser.template_validator import validate_csv_template
router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"])
@ -62,6 +59,16 @@ class CsvImportLimitsBody(BaseModel):
max_file_bytes: int = Field(..., ge=10_000, le=2_147_483_648)
class CsvTemplateValidateBody(BaseModel):
"""Formatprüfung ohne Speichern (field_mappings + type_conversions + optional row_processing)."""
module: str
field_mappings: dict = Field(default_factory=dict)
type_conversions: Optional[dict] = None
import_row_processing: Optional[dict] = None
column_signature: Optional[List[str]] = None
def _row_full(m: dict) -> dict:
return {
"id": m["id"],
@ -255,6 +262,23 @@ def _admin_csv_limits() -> dict[str, int]:
return get_csv_import_limits(r2d(row) if row else None)
@router.post("/validate")
def validate_system_template_dry_run(body: CsvTemplateValidateBody, session: dict = Depends(require_admin)):
"""
Validatorlauf für eine Vorlagen-Konfiguration (ohne DB-Schreiben).
Nutzbar aus dem Admin-Editor vor dem Speichern.
"""
if not get_module_definition(body.module):
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
return validate_csv_template(
body.module,
body.field_mappings,
body.type_conversions,
body.import_row_processing,
body.column_signature,
)
@router.get("/{template_id}")
def get_system_template(template_id: int, session: dict = Depends(require_admin)):
with get_db() as conn:
@ -273,17 +297,15 @@ def get_system_template(template_id: int, session: dict = Depends(require_admin)
def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)):
if not get_module_definition(body.module):
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
try:
validate_field_mappings(body.module, body.field_mappings)
validate_required_field_targets(body.module, body.field_mappings)
except ValueError as e:
raise HTTPException(400, str(e))
if body.import_row_processing:
try:
validate_import_row_processing_spec(body.module, body.import_row_processing, body.field_mappings)
except ValueError as e:
raise HTTPException(400, str(e))
report = validate_csv_template(
body.module,
body.field_mappings,
body.type_conversions,
body.import_row_processing,
body.column_signature,
)
if not report["valid"]:
raise HTTPException(status_code=422, detail=report)
with get_db() as conn:
cur = get_cursor(conn)
@ -311,7 +333,7 @@ def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depend
),
)
new_id = cur.fetchone()["id"]
return {"id": new_id}
return {"id": new_id, "validation": report}
@router.put("/{template_id}")
@ -335,12 +357,19 @@ def update_system_template(
return _row_full(existing)
fm = patch.get("field_mappings", existing["field_mappings"])
if "field_mappings" in patch:
try:
validate_field_mappings(existing["module"], fm)
validate_required_field_targets(existing["module"], fm)
except ValueError as e:
raise HTTPException(400, str(e))
tc_eff = patch.get("type_conversions", existing.get("type_conversions"))
irp_eff = patch.get("import_row_processing", existing.get("import_row_processing"))
col_eff = patch.get("column_signature", existing.get("column_signature"))
report = validate_csv_template(
existing["module"],
fm,
tc_eff,
irp_eff,
col_eff if isinstance(col_eff, list) else None,
)
if not report["valid"]:
raise HTTPException(status_code=422, detail=report)
fields_sql = []
vals: list = []
@ -371,15 +400,6 @@ def update_system_template(
vals.append(Json(tc) if tc is not None else None)
if "import_row_processing" in patch:
irp = patch["import_row_processing"]
if irp:
try:
validate_import_row_processing_spec(
existing["module"],
irp,
patch.get("field_mappings", existing["field_mappings"]),
)
except ValueError as e:
raise HTTPException(400, str(e))
fields_sql.append("import_row_processing = %s")
vals.append(Json(irp) if irp is not None else None)
@ -393,7 +413,7 @@ def update_system_template(
cur.execute("SELECT * FROM csv_field_mappings WHERE id = %s", (template_id,))
m = r2d(cur.fetchone())
return _row_full(m)
return {**_row_full(m), "validation": report}
@router.delete("/{template_id}")

View File

@ -32,6 +32,7 @@ from csv_parser.core import (
)
from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapping
from csv_parser.field_units import source_unit_choices_for_field
from csv_parser.import_errors import enrich_row_error
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
@ -554,6 +555,7 @@ async def csv_import_execute(
except Exception as exec_err:
logger.exception("Universal-CSV-Import fehlgeschlagen: %s", exec_err)
cur.execute("ROLLBACK TO SAVEPOINT csv_import_exec")
err_payload = {"error": str(exec_err), **enrich_row_error(str(exec_err), exec_module)}
cur.execute(
"""
UPDATE csv_import_log SET
@ -562,9 +564,13 @@ async def csv_import_execute(
error_details = %s
WHERE id = %s
""",
(Json([{"error": str(exec_err)}]), log_id),
(Json([err_payload]), log_id),
)
err_response = HTTPException(500, f"Import fehlgeschlagen: {exec_err}")
hint = err_payload.get("hint")
msg = f"Import fehlgeschlagen: {exec_err}"
if hint:
msg = f"{msg} ({hint})"
err_response = HTTPException(500, msg)
else:
cur.execute("RELEASE SAVEPOINT csv_import_exec")
cur.execute(

View File

@ -0,0 +1,10 @@
from csv_parser.import_errors import enrich_row_error
def test_enrich_numeric_overflow():
d = enrich_row_error(
"numeric field overflow\nDETAIL: A field with precision 5, scale 2\n",
module="activity",
)
assert d["code"] == "db_numeric_overflow"
assert d["hint"] and "kj" in d["hint"].lower()

View File

@ -0,0 +1,45 @@
"""Formatprüfung CSV-Vorlagen (template_validator)."""
from __future__ import annotations
from csv_parser.template_validator import validate_csv_template
def test_validate_kj_column_warns_without_source_unit():
r = validate_csv_template(
"activity",
{"Aktive Energie (kJ)": "kcal_active", "Start": "start_time", "Trainingsart": "activity_type"},
{"start_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True}},
None,
None,
)
assert r["valid"] is True
codes = {w["code"] for w in r["warnings"]}
assert "energy_kj_without_source_unit" in codes
def test_validate_invalid_target_error():
r = validate_csv_template(
"activity",
{"X": "not_a_field"},
{},
None,
None,
)
assert r["valid"] is False
assert any(e["code"] == "invalid_field_mapping" for e in r["errors"])
def test_validate_duplicate_target_warning():
r = validate_csv_template(
"weight",
{"A": "weight", "B": "weight", "Tag": "date"},
{
"weight": {"type": "float", "decimal_separator": "."},
"date": {"type": "date", "format": "yyyy-mm-dd", "flexible": True},
},
None,
None,
)
assert r["valid"] is True
assert any(w["code"] == "duplicate_target_columns" for w in r["warnings"])

View File

@ -31,8 +31,8 @@ MODULE_VERSIONS = {
"membership": "2.1.0",
"workflow": "0.6.0", # Phase 4: End Node Template Engine
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
"csv_import": "0.3.1", # GET /csv/modules: import_row_processing_default pro Modul
"admin_csv_templates": "0.2.0", # Admin-Editor: Zeilenaggregation (Schlüssel + gemeinsame Funktion)
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
}
CHANGELOG = [

View File

@ -278,6 +278,8 @@ export default function AdminCsvTemplateEditorPage() {
const [loading, setLoading] = useState(!isNew)
const [analyzing, setAnalyzing] = useState(false)
const [saving, setSaving] = useState(false)
const [validating, setValidating] = useState(false)
const [validationReport, setValidationReport] = useState(null)
const [error, setError] = useState(null)
/** Entwurf für „Quelle entspricht Ziel“ (nur source_unit custom); Commit bei Blur/Speichern. */
const [customEquivalenceDraftByField, setCustomEquivalenceDraftByField] = useState({})
@ -593,6 +595,38 @@ export default function AdminCsvTemplateEditorPage() {
setFieldMappings((prev) => ({ ...prev, [col]: dbField || '-' }))
}
const handleFormatCheck = async () => {
setError(null)
setValidationReport(null)
let tc
try {
tc = JSON.parse(typeConversionsText || '{}')
if (tc !== null && typeof tc !== 'object') throw new Error()
} catch {
setError('type_conversions: ungültiges JSON.')
return
}
if (!module) {
setError('Modul wählen.')
return
}
setValidating(true)
try {
const r = await api.adminValidateCsvTemplate({
module,
field_mappings: fieldMappings,
type_conversions: tc,
import_row_processing: null,
column_signature: columnSignature.length ? columnSignature : null,
})
setValidationReport(r)
} catch (e) {
setError(e.message || 'Formatprüfung fehlgeschlagen')
} finally {
setValidating(false)
}
}
const handleSave = async () => {
setError(null)
let textForTc = typeConversionsText
@ -1432,7 +1466,49 @@ export default function AdminCsvTemplateEditorPage() {
/>
</div>
{validationReport ? (
<div className="card" style={{ padding: 16, marginBottom: 16, borderColor: validationReport.valid ? 'var(--accent)' : 'var(--danger)' }}>
<div className="form-label" style={{ marginBottom: 8 }}>
Formatprüfung (Vorlage){' '}
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}> speicherfähig</span> : <span style={{ color: 'var(--danger)' }}> Fehler beheben</span>}
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht.
</p>
{validationReport.errors?.length ? (
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
{validationReport.errors.map((e, i) => (
<li key={`e-${i}`}>
{e.message}
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
</li>
))}
</ul>
) : null}
{validationReport.warnings?.length ? (
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
{validationReport.warnings.map((w, i) => (
<li key={`w-${i}`}>
{w.message}
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
</li>
))}
</ul>
) : null}
</div>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<button type="button" className="btn btn-secondary" disabled={validating || saving} onClick={handleFormatCheck} style={{ minWidth: 160 }}>
{validating ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} />
Prüfen
</>
) : (
'Format prüfen'
)}
</button>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave} style={{ flex: 1, minWidth: 160 }}>
{saving ? (
<>

View File

@ -29,6 +29,15 @@ export function formatFastApiDetail(detail, fallback = '') {
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
}
if (typeof detail === 'object') {
if (Array.isArray(detail.errors) && detail.errors.length > 0) {
const parts = detail.errors
.map((e) => {
if (e && typeof e === 'object') return e.message || e.msg || ''
return String(e)
})
.filter(Boolean)
if (parts.length) return parts.join(' · ')
}
if (typeof detail.msg === 'string') return detail.msg
if (typeof detail.message === 'string') return detail.message
}
@ -554,6 +563,8 @@ export const api = {
return j
},
adminGetCsvTemplate: (id) => req(`/admin/csv-templates/${id}`),
/** Formatprüfung (ohne Speichern); body wie Create, import_row_processing optional */
adminValidateCsvTemplate: (d) => req('/admin/csv-templates/validate', json(d)),
adminCreateCsvTemplate: (d) => req('/admin/csv-templates', json(d)),
adminUpdateCsvTemplate: (id, d) => req(`/admin/csv-templates/${id}`, jput(d)),
adminDeleteCsvTemplate: (id) => req(`/admin/csv-templates/${id}`, { method: 'DELETE' }),