diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py
index 916236f..3777884 100644
--- a/backend/routers/csv_import.py
+++ b/backend/routers/csv_import.py
@@ -183,16 +183,19 @@ def copy_csv_mapping(
@router.post("/analyze")
async def analyze_csv(
file: UploadFile = File(...),
- module: str = Form(...),
+ module: Optional[str] = Form(default=None),
delimiter: Optional[str] = Form(default=None),
session: dict = Depends(require_auth),
):
"""
- Erste Zeilen parsen, Signatur bilden, System-Templates nach Ähnlichkeit ranken.
+ Erste Zeilen parsen, Signatur bilden, Vorlagen nach Ähnlichkeit ranken.
+ Ohne `module`: alle System- + eigene User-Vorlagen (echte Format-Erkennung über Spalten-Signatur).
+ Mit `module`: nur Vorlagen dieses Moduls (Abwärtskompatibilität).
"""
- if not get_module_definition(module):
+ if module and not get_module_definition(module):
raise HTTPException(400, f"Unbekanntes Modul: {module}")
+ pid = str(session["profile_id"])
raw = await file.read()
limits = _load_import_limits()
max_bytes = limits.get("max_file_bytes", 52_428_800)
@@ -213,20 +216,32 @@ async def analyze_csv(
headers, sample_rows, used_delim = parse_csv_sample(text, delimiter=delim, max_data_rows=5)
sig = column_signature(headers)
- mod_def = get_module_definition(module)
- available_fields = mod_def["fields"] if mod_def else {}
+ mod_def = get_module_definition(module) if module else None
+ available_fields = mod_def["fields"] if mod_def else None
with get_db() as conn:
cur = get_cursor(conn)
- cur.execute(
- """
- SELECT id, module, mapping_name, description, column_signature,
- delimiter, encoding, has_header, field_mappings, type_conversions, is_system
- FROM csv_field_mappings
- WHERE is_system = true AND module = %s
- """,
- (module,),
- )
+ if module:
+ cur.execute(
+ """
+ SELECT id, module, mapping_name, description, column_signature,
+ delimiter, encoding, has_header, field_mappings, type_conversions, is_system
+ FROM csv_field_mappings
+ WHERE module = %s
+ AND (is_system = true OR (is_system = false AND profile_id = %s::uuid))
+ """,
+ (module, pid),
+ )
+ else:
+ cur.execute(
+ """
+ SELECT id, module, mapping_name, description, column_signature,
+ delimiter, encoding, has_header, field_mappings, type_conversions, is_system
+ FROM csv_field_mappings
+ WHERE is_system = true OR (is_system = false AND profile_id = %s::uuid)
+ """,
+ (pid,),
+ )
templates = [r2d(r) for r in cur.fetchall()]
ranked = []
@@ -237,27 +252,38 @@ async def analyze_csv(
ranked.append(
{
"mapping_id": t["id"],
+ "module": t["module"],
"mapping_name": t["mapping_name"],
+ "is_system": bool(t.get("is_system")),
"confidence": round(score, 4),
"match_type": "signature_jaccard",
}
)
ranked.sort(key=lambda x: -x["confidence"])
+ top = ranked[:25]
+ recommended = top[0] if top and (top[0]["confidence"] or 0) > 0 else None
+
return {
- "module": module,
+ "module_filter": module,
"filename": file.filename,
"encoding_note": "utf-8/latin-1 mit BOM-Strip",
"delimiter": used_delim,
"columns": headers,
"column_signature_normalized": sig,
"sample_rows": sample_rows,
- "detected_mappings": ranked[:5],
+ "detected_mappings": top,
+ "recommended": recommended,
"available_fields": available_fields,
}
-def _fetch_mapping_row(cur, mapping_id: int, profile_id: str, module: str) -> dict:
+def _fetch_mapping_row(
+ cur,
+ mapping_id: int,
+ profile_id: str,
+ module: Optional[str] = None,
+) -> dict:
cur.execute(
"""
SELECT * FROM csv_field_mappings WHERE id = %s
@@ -267,7 +293,7 @@ def _fetch_mapping_row(cur, mapping_id: int, profile_id: str, module: str) -> di
m = r2d(cur.fetchone())
if not m:
raise HTTPException(404, "Mapping nicht gefunden")
- if m.get("module") != module:
+ if module is not None and m.get("module") != module:
raise HTTPException(400, "Mapping gehört zu einem anderen Modul")
if not m.get("is_system"):
if str(m.get("profile_id") or "") != profile_id:
@@ -297,24 +323,15 @@ def _check_module_feature_access(pid: str, module: str) -> None:
@router.post("/import")
async def csv_import_execute(
file: UploadFile = File(...),
- module: str = Form(...),
mapping_id: int = Form(...),
+ module: Optional[str] = Form(default=None),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""
- Universal-CSV-Import mit gespeichertem Mapping (Issue #21).
- Unterstützt: nutrition, weight, blood_pressure. activity: noch nicht.
+ Universal-CSV-Import: Zielmodul kommt aus der gewählten Vorlage (`mapping_id`).
+ Optional `module` dient nur der Absicherung (muss mit Vorlage übereinstimmen).
"""
- if module == "activity":
- raise HTTPException(
- 501,
- "Aktivitäts-CSV über den Universal-Importer ist noch nicht freigeschaltet "
- "(Training-Type-Mapping). Bitte weiterhin /api/activity/import nutzen.",
- )
- if not get_module_definition(module):
- raise HTTPException(400, f"Unbekanntes oder nicht unterstütztes Modul: {module}")
-
pid = get_pid(x_profile_id)
access_di = check_feature_access(pid, "data_import")
@@ -326,8 +343,6 @@ async def csv_import_execute(
f"{access_di.get('used')}/{access_di.get('limit')}",
)
- _check_module_feature_access(pid, module)
-
raw = await file.read()
limits = _load_import_limits()
max_bytes = limits.get("max_file_bytes", 52_428_800)
@@ -349,11 +364,28 @@ async def csv_import_execute(
log_id: int | None = None
err_response: HTTPException | None = None
result: dict | None = None
+ resolved_module: str | None = None
try:
with get_db() as conn:
cur = get_cursor(conn)
m = _fetch_mapping_row(cur, mapping_id, pid, module)
+ exec_module = m["module"]
+ resolved_module = exec_module
+
+ if exec_module == "activity":
+ raise HTTPException(
+ 501,
+ "Aktivitäts-CSV über den Universal-Importer ist noch nicht freigeschaltet "
+ "(Training-Type-Mapping). Bitte weiterhin /api/activity/import nutzen.",
+ )
+ if not get_module_definition(exec_module):
+ raise HTTPException(
+ 400,
+ f"Modul der Vorlage wird vom Importer noch nicht unterstützt: {exec_module}",
+ )
+
+ _check_module_feature_access(pid, exec_module)
cur.execute(
"""
@@ -367,7 +399,7 @@ async def csv_import_execute(
'running', NULL, NULL
) RETURNING id
""",
- (pid, mapping_id, module, file.filename or "upload.csv"),
+ (pid, mapping_id, exec_module, file.filename or "upload.csv"),
)
log_id = cur.fetchone()["id"]
@@ -376,7 +408,7 @@ async def csv_import_execute(
result = run_universal_csv_import(
cur,
pid,
- module,
+ exec_module,
text,
file.filename or "upload.csv",
m,
@@ -444,16 +476,17 @@ async def csv_import_execute(
increment_feature_usage(pid, "data_import")
ne = result.get("new_entries", result["rows_imported"])
- if module == "nutrition":
+ if resolved_module == "nutrition":
for _ in range(ne):
increment_feature_usage(pid, "nutrition_entries")
- elif module == "weight":
+ elif resolved_module == "weight":
for _ in range(ne):
increment_feature_usage(pid, "weight_entries")
return {
"success": True,
"import_log_id": log_id,
+ "module": resolved_module,
"stats": {
"total_rows": result["rows_total"],
"imported": result["rows_imported"],
diff --git a/backend/version.py b/backend/version.py
index 526d353..987a1a5 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -31,7 +31,7 @@ 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.2.0", # Issue #21: + POST /csv/import (nutrition, weight, blood_pressure)
+ "csv_import": "0.3.0", # Analyse ohne Modul-Filter; Import nur mapping_id (Modul aus Vorlage)
"admin_csv_templates": "0.1.0", # Issue #21: System-Templates + Import-Limits (Admin)
}
@@ -45,6 +45,7 @@ CHANGELOG = [
"API /api/csv: modules, limits, mappings, analyze, copy",
"API /api/admin/csv-templates: CRUD System-Templates, import-limits (system_config)",
"Issue #21: POST /api/csv/import + executor (nutrition Aggregat/Tag, weight, Blutdruck); activity 501",
+ "Issue #21: GET-Analyse über alle Vorlagen; Import nur mapping_id; GUI ohne Modul-Dropdown",
"v9c_cleanup_features.sql: FK-sichere csv_import→data_import Reihenfolge",
],
},
diff --git a/frontend/src/pages/UniversalCsvImportPage.jsx b/frontend/src/pages/UniversalCsvImportPage.jsx
index daf4b24..2eb1b38 100644
--- a/frontend/src/pages/UniversalCsvImportPage.jsx
+++ b/frontend/src/pages/UniversalCsvImportPage.jsx
@@ -1,10 +1,54 @@
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Loader2 } from 'lucide-react'
import { api } from '../utils/api'
-/** Module mit funktionierendem Universal-Executor; Aktivität: Legacy-Import auf der Aktivitätsseite. */
-const UNIVERSAL_IMPORT_IDS = new Set(['nutrition', 'weight', 'blood_pressure'])
+/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
+const EXECUTOR_READY = new Set(['nutrition', 'weight', 'blood_pressure'])
+
+const MODULE_LABEL = {
+ nutrition: 'Ernährung',
+ weight: 'Gewicht',
+ blood_pressure: 'Blutdruck',
+ activity: 'Aktivität',
+}
+
+function mergeMappingChoices(detected, mapData) {
+ const sys = mapData.system_templates || []
+ const usr = mapData.user_mappings || []
+ const flat = [
+ ...sys.map((m) => ({ ...m, _isSys: true })),
+ ...usr.map((m) => ({ ...m, _isSys: false })),
+ ]
+ const byId = new Map(flat.map((m) => [m.id, m]))
+ const out = []
+ const seen = new Set()
+ for (const d of detected || []) {
+ const row = byId.get(d.mapping_id)
+ if (row) {
+ out.push({
+ id: row.id,
+ module: row.module,
+ name: row.name,
+ is_system: row.is_system,
+ confidence: d.confidence ?? 0,
+ })
+ seen.add(row.id)
+ }
+ }
+ for (const row of flat) {
+ if (!seen.has(row.id)) {
+ out.push({
+ id: row.id,
+ module: row.module,
+ name: row.name,
+ is_system: row.is_system,
+ confidence: 0,
+ })
+ }
+ }
+ return out
+}
function SampleTable({ sampleRows, columns }) {
if (!sampleRows?.length || !columns?.length) return null
@@ -57,124 +101,75 @@ function SampleTable({ sampleRows, columns }) {
export default function UniversalCsvImportPage() {
const navigate = useNavigate()
- const [modules, setModules] = useState([])
- const [moduleId, setModuleId] = useState('')
const [file, setFile] = useState(null)
const [analyzeResult, setAnalyzeResult] = useState(null)
- const [mappingsList, setMappingsList] = useState({ system_templates: [], user_mappings: [] })
+ const [mappingChoices, setMappingChoices] = useState([])
const [mappingId, setMappingId] = useState('')
- const [loadingModules, setLoadingModules] = useState(true)
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
const [loadingImport, setLoadingImport] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
- const loadMappings = useCallback(async (mid) => {
- if (!mid) {
- setMappingsList({ system_templates: [], user_mappings: [] })
- return
- }
- const data = await api.getCsvMappings(mid)
- setMappingsList({
- system_templates: data.system_templates || [],
- user_mappings: data.user_mappings || [],
- })
- }, [])
-
- useEffect(() => {
- let cancel = false
- ;(async () => {
- setLoadingModules(true)
- setError(null)
- try {
- const data = await api.getCsvModules()
- const list = (data.modules || []).filter((m) => UNIVERSAL_IMPORT_IDS.has(m.id))
- if (!cancel) {
- setModules(list)
- if (list.length && !moduleId) setModuleId(list[0].id)
- }
- } catch (e) {
- if (!cancel) setError(e.message || 'Module konnten nicht geladen werden')
- } finally {
- if (!cancel) setLoadingModules(false)
- }
- })()
- return () => {
- cancel = true
- }
- }, [])
-
- useEffect(() => {
- if (moduleId) {
- loadMappings(moduleId)
- setAnalyzeResult(null)
- setMappingId('')
- setSuccess(null)
- }
- }, [moduleId, loadMappings])
-
- const allMappingOptions = [
- ...(mappingsList.system_templates || []).map((m) => ({
- id: m.id,
- label: `${m.name} (System)`,
- isSystem: true,
- })),
- ...(mappingsList.user_mappings || []).map((m) => ({
- id: m.id,
- label: m.name,
- isSystem: false,
- })),
- ]
+ const selectedChoice = useMemo(
+ () => mappingChoices.find((c) => String(c.id) === String(mappingId)),
+ [mappingChoices, mappingId],
+ )
+ const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
const handleAnalyze = async () => {
- if (!file || !moduleId) {
- setError('Bitte Modul und Datei wählen')
+ if (!file) {
+ setError('Bitte eine CSV-Datei wählen')
return
}
setLoadingAnalyze(true)
setError(null)
setSuccess(null)
+ setMappingId('')
try {
- const res = await api.analyzeCsv(file, moduleId)
+ const res = await api.analyzeCsv(file)
setAnalyzeResult(res)
- const mapData = await api.getCsvMappings(moduleId)
- const sys = mapData.system_templates || []
- const usr = mapData.user_mappings || []
- setMappingsList({ system_templates: sys, user_mappings: usr })
- const options = [
- ...sys.map((m) => ({ id: m.id, name: m.name, isSystem: true })),
- ...usr.map((m) => ({ id: m.id, name: m.name, isSystem: false })),
- ]
- const detected = res.detected_mappings || []
- const best = detected[0]
- let pick = options[0]?.id
- if (best && (best.confidence || 0) >= 0.5 && options.some((o) => o.id === best.mapping_id)) {
- pick = best.mapping_id
+ const mapData = await api.getCsvMappings()
+ const merged = mergeMappingChoices(res.detected_mappings || [], mapData)
+ setMappingChoices(merged)
+
+ const best = res.recommended || (res.detected_mappings || [])[0]
+ let pick = ''
+ if (best && merged.some((m) => m.id === best.mapping_id)) {
+ pick = String(best.mapping_id)
+ } else if (merged.length) {
+ const firstReady = merged.find((m) => EXECUTOR_READY.has(m.module))
+ pick = String((firstReady || merged[0]).id)
}
- if (pick != null) setMappingId(String(pick))
- else setMappingId('')
+ setMappingId(pick)
} catch (e) {
setError(e.message || 'Analyse fehlgeschlagen')
setAnalyzeResult(null)
+ setMappingChoices([])
} finally {
setLoadingAnalyze(false)
}
}
const handleImport = async () => {
- if (!file || !moduleId || !mappingId) {
- setError('Bitte Datei, Modul und Vorlage wählen')
+ if (!file || !mappingId) {
+ setError('Bitte Datei und Vorlage wählen')
+ return
+ }
+ if (!importAllowed) {
+ setError('Diese Vorlage kann hier noch nicht importiert werden (z. B. Aktivität → Seite „Aktivität“).')
return
}
setLoadingImport(true)
setError(null)
setSuccess(null)
try {
- const res = await api.importUniversalCsv(file, moduleId, Number(mappingId))
+ const res = await api.importUniversalCsv(file, Number(mappingId))
const st = res.stats || {}
+ const modLabel = MODULE_LABEL[res.module] || res.module || ''
setSuccess(
- `Import abgeschlossen: ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
- `${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`
+ (modLabel ? `${modLabel}: ` : '') +
+ `Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
+ `${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`,
)
} catch (e) {
setError(e.message || 'Import fehlgeschlagen')
@@ -204,9 +199,11 @@ export default function UniversalCsvImportPage() {
- Bekannte Formate (FDDB, Apple Health, Omron, …) per Vorlage zuordnen und importieren. - Trainingseinheiten weiterhin unter{' '} +
+ Datei hochladen: Die App vergleicht die Spalten-Struktur mit allen gespeicherten Vorlagen und schlägt + passende Ziele vor (Ernährung, Gewicht, Blutdruck, …). Du bestätigst nur noch die Vorlage — ohne + vorher ein „Modul“ raten zu müssen.{' '} + Später: eine Datei, mehrere Zieltabellen. Trainingseinheiten aktuell weiter unter{' '}