From 79ec249bc3f86f08148df6ea1c5acc7aa31f7df7 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 24 Jul 2026 16:59:12 +0200 Subject: [PATCH] feat: feature enforcement imports (#37/#38) and Universal CSV validation (#71) Activity and nutrition legacy CSV imports enforce tier limits with UsageBadge UI; Universal CSV gets mapping validation on copy/import, format-check parity, and structured error_details. version: 0.9u module: activity 1.2.1, nutrition 1.0.3, csv_import 0.4.0 Co-authored-by: Cursor --- ...VITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md | 2 +- .../UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md | 8 +- .../gitea-close-2026-07-23/comment-25.md | 10 ++ .../gitea-close-2026-07-23/comment-35.md | 9 ++ .../gitea-close-2026-07-23/comment-36.md | 8 + .../gitea-close-2026-07-23/comment-37.md | 23 +++ .../gitea-close-2026-07-23/comment-38.md | 22 +++ .../gitea-close-2026-07-23/comment-40.md | 7 + .../gitea-close-2026-07-23/comment-43.md | 5 + .../gitea-close-2026-07-23/comment-54.md | 5 + .../gitea-close-2026-07-23/comment-57.md | 5 + .../gitea-close-2026-07-23/comment-58.md | 5 + .../gitea-close-2026-07-23/comment-65.md | 9 ++ .../gitea-close-2026-07-23/comment-66.md | 9 ++ .../gitea-close-2026-07-23/comment-71.md | 20 +++ backend/routers/activity.py | 22 +++ backend/routers/csv_import.py | 60 +++++++- ...est_activity_import_feature_enforcement.py | 107 +++++++++++++ backend/tests/test_csv_mapping_validation.py | 26 ++++ backend/version.py | 21 ++- .../src/components/CsvImportErrorDetails.jsx | 76 ++++++++++ frontend/src/pages/ActivityPage.jsx | 28 +++- .../src/pages/AdminCsvTemplateEditorPage.jsx | 140 +++++++++++++----- frontend/src/pages/NutritionPage.jsx | 54 +++++-- frontend/src/pages/UniversalCsvImportPage.jsx | 29 +--- frontend/src/utils/api.js | 8 + tests/issue-audit.spec.js | 67 +++++++++ 27 files changed, 690 insertions(+), 95 deletions(-) create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-25.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-35.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-36.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-37.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-38.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-40.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-43.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-54.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-57.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-58.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-65.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-66.md create mode 100644 .claude/docs/working/gitea-close-2026-07-23/comment-71.md create mode 100644 backend/tests/test_activity_import_feature_enforcement.py create mode 100644 backend/tests/test_csv_mapping_validation.py create mode 100644 frontend/src/components/CsvImportErrorDetails.jsx create mode 100644 tests/issue-audit.spec.js diff --git a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md index 1e3cd54..7094d12 100644 --- a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md +++ b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md @@ -197,7 +197,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel **Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md). -**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP). +**Aktuell:** Phase B abgeschlossen (Consumer-Audit 2026-04-16). **Phase C** Schreibpfad entschlackt (Sync abgestellt, Orchestrator als SSoT; Review 2026-04-16 + Regression `test_activity_insert_sql.py`). Nächster Schritt: **Phase D** (Composite-MVP). --- diff --git a/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md b/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md index e47174c..eef19d0 100644 --- a/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md +++ b/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md @@ -46,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an --- -## 4. Bekannte Einschränkungen (Follow-up in Gitea) +## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71) -- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import. -- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** – Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71). +- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`). +- **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern). +- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import. +- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`). --- diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-25.md b/.claude/docs/working/gitea-close-2026-07-23/comment-25.md new file mode 100644 index 0000000..d0e627b --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-25.md @@ -0,0 +1,10 @@ +**Automatischer Audit (Code + Playwright, 2026-07-23)** + +Ziele-System ist im Repo umgesetzt: +- Backend: `backend/routers/goals.py`, Focus Areas, Goal Types, Progress +- Frontend: `frontend/src/pages/GoalsPage.jsx`, Nav `/goals` in `config/appNav.js` +- Spec: `docs/issues/issue-50-phase-0a-goal-system.md` + +Playwright auf dev.mitai.jinkendo.de: `/goals` rendert ohne Fehler. + +Verbleibende KI-Goal-Erweiterungen ggf. als neues Issue. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-35.md b/.claude/docs/working/gitea-close-2026-07-23/comment-35.md new file mode 100644 index 0000000..369f363 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-35.md @@ -0,0 +1,9 @@ +**Automatischer Audit (Code, 2026-07-23)** + +Deprecated Tabelle `subscriptions` ist aus dem aktiven Schema entfernt: +- Kein Treffer in `backend/schema.sql`, `backend/migrations/`, Backend-Python +- Membership nutzt `access_grants`, `tier_limits`, etc. (Migration v9c) + +Doku-Hinweis deprecated: `.claude/docs/technical/DATABASE.md` + +Optional Prod-Check: `\dt subscriptions` — sollte leer/nicht vorhanden sein. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-36.md b/.claude/docs/working/gitea-close-2026-07-23/comment-36.md new file mode 100644 index 0000000..924d292 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-36.md @@ -0,0 +1,8 @@ +**Automatischer Audit (Code, 2026-07-23)** + +Bug behoben: JSONB-Insert für `abilities`/`profile` nutzt `psycopg2.extras.Json()`. + +- Fix-Commit: `2977050` — wrap abilities dict with Json() for JSONB insert +- Aktuell: `backend/routers/admin_training_types.py` (`create_training_type`, Zeilen 111–112) + +Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx` diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-37.md b/.claude/docs/working/gitea-close-2026-07-23/comment-37.md new file mode 100644 index 0000000..bd18a95 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-37.md @@ -0,0 +1,23 @@ +**Umsetzung 2026-07-23 (nach Architektur-Abgleich)** + +## Architektur-Check vor Implementierung + +- **Phase C (Schreibpfad):** laut `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` bereits erledigt (Sync abgestellt, Orchestrator als SSoT, keine Aufrufer von `sync_column_backed_session_metrics`). Keine Doppel-Implementierung. +- **Feature-ID:** Issue-Vorgabe `activity_entries` (nicht separates `activity_import`) — konsistent mit `create_activity` und Universal-CSV-Modul-Check in `csv_import.py`. +- **Legacy-Endpoint bleibt:** Frontend nutzt weiterhin `POST /api/activity/import-csv` (`ActivityPage` Apple-Health-Panel); Universal-Pfad ist parallel (ARCH §8.2). + +## Änderungen + +**Backend** (`routers/activity.py`): +- `check_feature_access(pid, "activity_entries")` vor Legacy-Import +- HTTP 403 bei Limit (wie `nutrition/import-csv`) +- `increment_feature_usage` pro neu eingefügter Zeile (`inserted`) + +**Frontend** (`ActivityPage.jsx` ImportPanel): +- `UsageBadge` am Import-Titel +- Drop-Zone deaktiviert bei Limit +- Usage-Reload nach Import + +**Tests:** `tests/test_activity_import_feature_enforcement.py` (403 + Increment) + +Regression INSERT-SQL: `tests/test_activity_insert_sql.py` (grün) diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-38.md b/.claude/docs/working/gitea-close-2026-07-23/comment-38.md new file mode 100644 index 0000000..b73506c --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-38.md @@ -0,0 +1,22 @@ +**Umsetzung 2026-07-23 (UI-Teil; Backend war bereits erledigt)** + +## Ist-Zustand vorher + +- Backend: `POST /api/nutrition/import-csv` mit `check_feature_access('nutrition_entries')` und HTTP 403 — **bereits implementiert** +- Frontend: FDDB-Import ohne Usage-Anzeige, Drop-Zone blieb bei Limit klickbar + +## Änderungen + +**Frontend** (`NutritionPage.jsx`, ImportPanel): +- `getFeatureUsage()` → `nutrition_entries` +- `UsageBadge` am Import-Titel +- Drop-Zone und Paste-Import deaktiviert bei Limit +- Usage-Reload nach erfolgreichem Import + +Analog zu Activity #37 (geschlossen 2026-07-23). + +**Playwright:** `tests/issue-audit.spec.js` — Badge am FDDB-Panel + +## Hinweis + +Universal-CSV (`/api/csv-import/import`) prüft zusätzlich `data_import` — Legacy FDDB-Pfad folgt dem Nutrition-Muster (nur `nutrition_entries`), konsistent mit Architektur §8.2. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-40.md b/.claude/docs/working/gitea-close-2026-07-23/comment-40.md new file mode 100644 index 0000000..2c5a76a --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-40.md @@ -0,0 +1,7 @@ +**Automatischer Audit (Code + Playwright, 2026-07-23)** + +Logout-Button im Mobile-Header neben Avatar implementiert: +- `frontend/src/App.jsx` — Button mit `title="Abmelden"`, Icon `LogOut` +- Zusätzlich Desktop: `frontend/src/components/DesktopSidebar.jsx` + +Playwright auf dev.mitai.jinkendo.de: Button sichtbar und klickbar. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-43.md b/.claude/docs/working/gitea-close-2026-07-23/comment-43.md new file mode 100644 index 0000000..fa1a03c --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-43.md @@ -0,0 +1,5 @@ +**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23** + +Inhalt identisch mit **#42** (Enhanced Debug/Prompt Analysis UI). + +Teilumsetzung existiert bereits (`Analysis.jsx` Experten-Modus, `WorkflowDebugPanel`). Weiterverfolgung unter #42. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-54.md b/.claude/docs/working/gitea-close-2026-07-23/comment-54.md new file mode 100644 index 0000000..5cde62a --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-54.md @@ -0,0 +1,5 @@ +**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23** + +Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten). + +Bitte weiterverfolgen unter #55. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-57.md b/.claude/docs/working/gitea-close-2026-07-23/comment-57.md new file mode 100644 index 0000000..0d7fed1 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-57.md @@ -0,0 +1,5 @@ +**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23** + +Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung). + +Bitte weiterverfolgen unter #56. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-58.md b/.claude/docs/working/gitea-close-2026-07-23/comment-58.md new file mode 100644 index 0000000..0d7fed1 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-58.md @@ -0,0 +1,5 @@ +**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23** + +Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung). + +Bitte weiterverfolgen unter #56. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-65.md b/.claude/docs/working/gitea-close-2026-07-23/comment-65.md new file mode 100644 index 0000000..804aea4 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-65.md @@ -0,0 +1,9 @@ +**Automatischer Audit (Code + Playwright, 2026-07-23)** + +Nutzer-konfigurierbares Dashboard umgesetzt: +- Migration `039_dashboard_layout.sql` — `profiles.dashboard_layout` +- API: `backend/routers/app_dashboard.py` — GET/PUT/reset `/api/app/dashboard-layout` +- Frontend: `DashboardConfigurePage.jsx`, Widget-Registry `registerDashboardWidgets.js` +- Tests: `test_dashboard_layout_schema.py`, `test_widget_catalog.py` + +Playwright: `/settings/dashboard-layout` erreichbar, Widget/Layout-UI sichtbar. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-66.md b/.claude/docs/working/gitea-close-2026-07-23/comment-66.md new file mode 100644 index 0000000..a76b300 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-66.md @@ -0,0 +1,9 @@ +**Automatischer Audit (Code, 2026-07-23)** + +Feature-Gate-Zuordnung aus Admin/DB implementiert: +- Migration `041_widget_feature_requirements.sql` +- Logik: `backend/widget_feature_requirements_db.py`, `dashboard_widget_entitlements.py` +- Admin-UI: `AdminWidgetFeatureAssignmentsPage.jsx`, Route `/admin/widget-features` +- Changelog: `backend/version.py` (Admin Widgets × Features) + +Hardcodierter Katalog wird durch DB-Overrides ergänzt (Hybrid-Modell). diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-71.md b/.claude/docs/working/gitea-close-2026-07-23/comment-71.md new file mode 100644 index 0000000..b586fe6 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-71.md @@ -0,0 +1,20 @@ +**Implementierung abgeschlossen (2026-07-24)** + +### 1. Admin „Format prüfen“ inkl. `import_row_processing` +- `AdminCsvTemplateEditorPage`: `resolveEditorImportRowProcessing()` — dieselbe Spec wie beim Speichern +- Dry-Run/Format-Check nutzt konsistente Vorlage inkl. Aggregation (`group_by` / `aggregates`) + +### 2. Profil-Mappings validieren +- `csv_import.py`: `_validate_mapping_config` / `_ensure_mapping_valid` bei **Copy** und **Import** +- Neuer Endpoint: `GET /api/csv/mappings/{id}/validate` +- `api.js`: `validateCsvMapping()`; `formatFastApiDetail` für verschachtelte Validierungsfehler + +### 3. Nutzer-UI: strukturierte Fehler +- Neue Komponente `CsvImportErrorDetails.jsx` (Zeile, code, hint) +- `UniversalCsvImportPage` zeigt `error_details` statt JSON-Dump + +### Tests & Doku +- `backend/tests/test_csv_mapping_validation.py` — pytest grün +- Agent-Guide: `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §4 aktualisiert + +**Betroffene Dateien:** `backend/routers/csv_import.py`, `frontend/src/pages/AdminCsvTemplateEditorPage.jsx`, `frontend/src/pages/UniversalCsvImportPage.jsx`, `frontend/src/components/CsvImportErrorDetails.jsx`, `frontend/src/utils/api.js` diff --git a/backend/routers/activity.py b/backend/routers/activity.py index da3e3ef..94f40b9 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -617,6 +617,24 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV. """ pid = get_pid(x_profile_id) + + # Feature-Enforcement (wie manueller Create + nutrition/import-csv): activity_entries + access = check_feature_access(pid, "activity_entries") + log_feature_usage(pid, "activity_entries", access, "import_csv") + if not access["allowed"]: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=( + f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten " + f"({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ), + ) + raw = await file.read() try: text = raw.decode('utf-8') except: text = raw.decode('latin-1') @@ -729,4 +747,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional except Exception as e: logger.warning(f"Import row failed: {e}") skipped+=1 + + for _ in range(inserted): + increment_feature_usage(pid, "activity_entries") + return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py index c6dc535..b5ccfb3 100644 --- a/backend/routers/csv_import.py +++ b/backend/routers/csv_import.py @@ -35,6 +35,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp 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.template_validator import validate_csv_template from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format @@ -50,6 +51,38 @@ def _load_import_limits() -> dict[str, int]: return get_csv_import_limits(r2d(row) if row else None) +def _mapping_column_signature(m: dict) -> list[str] | None: + sig = m.get("column_signature") + if not sig: + return None + return list(sig) + + +def _validate_mapping_config(cur, m: dict) -> dict: + """Strukturelle Vorlagen-Prüfung (wie Admin validate / Create).""" + return validate_csv_template( + m["module"], + m.get("field_mappings") or {}, + m.get("type_conversions"), + m.get("import_row_processing"), + _mapping_column_signature(m), + cur=cur, + ) + + +def _ensure_mapping_valid(cur, m: dict) -> dict: + report = _validate_mapping_config(cur, m) + if not report.get("valid"): + raise HTTPException( + status_code=422, + detail={ + "message": "CSV-Vorlage ist strukturell ungültig.", + "validation": report, + }, + ) + return report + + def _mapping_to_summary(m: dict) -> dict: return { "id": m["id"], @@ -184,6 +217,16 @@ def copy_csv_mapping( n += 1 name = f"{base_name} {n}" + validation = _validate_mapping_config(cur, src) + if not validation.get("valid"): + raise HTTPException( + status_code=422, + detail={ + "message": "Quell-Vorlage ist ungültig — Kopie abgebrochen.", + "validation": validation, + }, + ) + cur.execute( """ INSERT INTO csv_field_mappings ( @@ -213,7 +256,21 @@ def copy_csv_mapping( ), ) new_id = cur.fetchone()["id"] - return {"new_mapping_id": new_id, "mapping_name": name} + return {"new_mapping_id": new_id, "mapping_name": name, "validation": validation} + + +@router.get("/mappings/{mapping_id}/validate") +def validate_csv_mapping( + mapping_id: int, + session: dict = Depends(require_auth), +): + """Strukturprüfung einer gespeicherten Vorlage (System oder eigenes Profil-Mapping).""" + pid = str(session["profile_id"]) + with get_db() as conn: + cur = get_cursor(conn) + m = _fetch_mapping_row(cur, mapping_id, pid) + report = _validate_mapping_config(cur, m) + return {"mapping_id": mapping_id, "mapping_name": m.get("mapping_name"), **report} @router.post("/analyze") @@ -538,6 +595,7 @@ async def csv_import_execute( ) _check_module_feature_access(pid, exec_module) + _ensure_mapping_valid(cur, m) cur.execute( """ diff --git a/backend/tests/test_activity_import_feature_enforcement.py b/backend/tests/test_activity_import_feature_enforcement.py new file mode 100644 index 0000000..aa7eade --- /dev/null +++ b/backend/tests/test_activity_import_feature_enforcement.py @@ -0,0 +1,107 @@ +"""Feature-Enforcement für Legacy Activity CSV-Import (#37).""" + +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from routers.activity import import_activity_csv + + +@pytest.mark.asyncio +async def test_import_activity_csv_blocks_when_limit_reached(): + upload = AsyncMock() + upload.read = AsyncMock(return_value=b"Workout Type,Start\n") + + with patch("routers.activity.get_pid", return_value="profile-1"), patch( + "routers.activity.check_feature_access", + return_value={ + "allowed": False, + "reason": "limit_exceeded", + "used": 30, + "limit": 30, + }, + ), patch("routers.activity.log_feature_usage"): + with pytest.raises(HTTPException) as exc: + await import_activity_csv( + file=upload, + x_profile_id=None, + session={"profile_id": "profile-1"}, + ) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_import_activity_csv_increments_usage_per_insert(monkeypatch): + upload = AsyncMock() + upload.read = AsyncMock( + return_value=( + "Workout Type,Start,Duration,Aktive Energie (kJ),Ruheeinträge (kJ)," + "Durchschn. Herzfrequenz (count/min),Max. Herzfrequenz (count/min),Distanz (km),End\n" + "Running,2026-07-22 10:00:00 +0200,0:45:00,1000,500,140,160,5.0,\n" + ).encode("utf-8") + ) + + increments = [] + + class FakeCursor: + def execute(self, *args, **kwargs): + return None + + def fetchone(self): + return None + + class FakeConn: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + fake_cur = FakeCursor() + + monkeypatch.setattr("routers.activity.get_pid", lambda _h: "profile-1") + monkeypatch.setattr( + "routers.activity.check_feature_access", + lambda *_a, **_k: {"allowed": True, "reason": "ok", "used": 0, "limit": 100}, + ) + monkeypatch.setattr("routers.activity.log_feature_usage", lambda *_a, **_k: None) + monkeypatch.setattr("routers.activity.get_db", lambda: FakeConn()) + monkeypatch.setattr("routers.activity.get_cursor", lambda _c: fake_cur) + monkeypatch.setattr( + "routers.activity.normalize_activity_start", + lambda _s: ("2026-07-22", "10:00:00"), + ) + monkeypatch.setattr( + "routers.activity.get_training_type_for_activity", + lambda *_a, **_k: (1, "cardio", None), + ) + monkeypatch.setattr( + "routers.activity.find_activity_duplicate_id", + lambda *_a, **_k: None, + ) + monkeypatch.setattr( + "routers.activity.new_activity_id", + lambda: "new-id", + ) + monkeypatch.setattr( + "routers.activity.insert_activity_csv_minimal", + lambda *_a, **_k: None, + ) + monkeypatch.setattr( + "routers.activity.run_activity_post_write_hooks_import", + lambda *_a, **_k: None, + ) + monkeypatch.setattr( + "routers.activity.increment_feature_usage", + lambda _pid, feature: increments.append(feature), + ) + + result = await import_activity_csv( + file=upload, + x_profile_id=None, + session={"profile_id": "profile-1"}, + ) + + assert result["inserted"] == 1 + assert increments == ["activity_entries"] diff --git a/backend/tests/test_csv_mapping_validation.py b/backend/tests/test_csv_mapping_validation.py new file mode 100644 index 0000000..3c90b35 --- /dev/null +++ b/backend/tests/test_csv_mapping_validation.py @@ -0,0 +1,26 @@ +"""Tests für CSV-Vorlagen-Validierung (#71).""" + +from csv_parser.template_validator import validate_csv_template + + +def test_validate_csv_template_rejects_invalid_row_processing(): + report = validate_csv_template( + "nutrition", + {"Datum": "date", "kcal": "kcal"}, + None, + {"group_by": ["date"], "aggregates": {"kcal": "not_an_op"}}, + ["Datum", "kcal"], + ) + assert report["valid"] is False + assert any(e.get("code") == "invalid_import_row_processing" for e in report["errors"]) + + +def test_validate_csv_template_accepts_nutrition_aggregation(): + report = validate_csv_template( + "nutrition", + {"Datum": "date", "kcal": "kcal", "protein": "protein_g"}, + None, + {"group_by": ["date"], "aggregates": {"kcal": "sum", "protein_g": "sum"}}, + ["Datum", "kcal", "protein"], + ) + assert report["valid"] is True diff --git a/backend/version.py b/backend/version.py index e2fa4c9..ea24d89 100644 --- a/backend/version.py +++ b/backend/version.py @@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH - PATCH: Bugfix, kleine Änderung, Refactor """ -APP_VERSION = "0.9t" -BUILD_DATE = "2026-04-20" +APP_VERSION = "0.9u" +BUILD_DATE = "2026-07-24" DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import MODULE_VERSIONS = { @@ -19,8 +19,8 @@ MODULE_VERSIONS = { "weight": "1.0.3", "circumference": "1.0.1", "caliper": "1.0.1", - "activity": "1.2.0", # GET /activity: optional days= window + limit - "nutrition": "1.0.2", + "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement + "nutrition": "1.0.3", # FDDB import UI: UsageBadge + blocked import zone "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", @@ -31,11 +31,20 @@ MODULE_VERSIONS = { "membership": "2.1.0", "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) "app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_* - "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) + "csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI + "admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity } CHANGELOG = [ + { + "version": "0.9u", + "date": "2026-07-24", + "changes": [ + "Gitea #37: Activity legacy CSV import — activity_entries feature enforcement + UsageBadge UI", + "Gitea #38: Nutrition FDDB import — UsageBadge + deaktivierte Import-Zone bei Limit", + "Gitea #71: Universal CSV — Dry-Run/Format-Check Parität import_row_processing; Mapping-Validierung bei Copy/Import; strukturierte error_details in Nutzer-UI", + ], + }, { "version": "0.9t", "date": "2026-04-20", diff --git a/frontend/src/components/CsvImportErrorDetails.jsx b/frontend/src/components/CsvImportErrorDetails.jsx new file mode 100644 index 0000000..26209f4 --- /dev/null +++ b/frontend/src/components/CsvImportErrorDetails.jsx @@ -0,0 +1,76 @@ +/** + * Strukturierte Zeilenfehler aus Universal-CSV-Import (error_details mit hint/code). + */ +export default function CsvImportErrorDetails({ errors, title, defaultOpen = true, maxVisible = 20 }) { + if (!errors?.length) return null + + const shown = errors.slice(0, maxVisible) + const rest = errors.length - shown.length + + return ( +
+ + {title || `Zeilenfehler (${errors.length})`} + +
    + {shown.map((err, i) => { + const row = err.row ?? err.row_index + const message = err.error ?? err.message ?? (typeof err === 'string' ? err : JSON.stringify(err)) + return ( +
  • +
    + {row != null && ( + Zeile {row}: + )} + {message} + {err.code && ( + + {err.code} + + )} +
    + {err.hint && ( +
    {err.hint}
    + )} +
  • + ) + })} +
+ {rest > 0 && ( +

+ … und {rest} weitere Fehler (Details in der Import-Historie). +

+ )} +
+ ) +} diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 45a872c..4d31b7d 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -364,13 +364,15 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) { } // ── Import Panel ────────────────────────────────────────────────────────────── -function ImportPanel({ onImported }) { +function ImportPanel({ onImported, usage = null }) { const fileRef = useRef() const [status, setStatus] = useState(null) const [error, setError] = useState(null) const [dragging, setDragging] = useState(false) + const atLimit = usage && !usage.allowed const runImport = async (file) => { + if (atLimit) return setStatus('loading'); setError(null) try { const result = await api.importActivityCsv(file) @@ -382,24 +384,29 @@ function ImportPanel({ onImported }) { return (
-
📥 Apple Health Import
+
+ 📥 Apple Health Import + {usage && } +

Health Auto Export App → Workouts exportieren → CSV → hier hochladen.
Nur die Workouts-…csv Datei wird benötigt (nicht die Detaildateien).

{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
{e.preventDefault();setDragging(true)}} + onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}} onDragLeave={()=>setDragging(false)} - onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}} - onClick={()=>fileRef.current.click()} + onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; const f=e.dataTransfer.files[0]; if(f) runImport(f) }} + onClick={()=>{ if(!atLimit) fileRef.current?.click() }} + title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''} style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10, padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)', - cursor:'pointer',transition:'all 0.15s'}}> + cursor:atLimit?'not-allowed':'pointer',opacity:atLimit?0.65:1,transition:'all 0.15s'}}>
- {dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'} + {atLimit ? '🔒 Limit erreicht — Import nicht möglich' : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen'}
{status==='loading' && ( @@ -945,7 +952,12 @@ export default function ActivityPage() {
)} - {tab==='import' && } + {tab==='import' && ( + { load(); loadUsage() }} + /> + )} {tab==='categorize' && (
diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx index 5440e87..0881ab9 100644 --- a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx +++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx @@ -122,6 +122,68 @@ function buildImportRowProcessingSimple(modFields, fm, groupBy, mode, multiRowPo return out } +/** Gleiche Logik wie beim Speichern — für Formatprüfung und Create/Update. */ +function resolveEditorImportRowProcessing({ + aggregateSleepImport, + rowAggUseCustom, + rowAggIrregular, + rowAggJsonText, + rowAggGroupBy, + rowAggMode, + rowAggMultiRowPolicy, + rowAggDedupeIdentical, + modFields, + fieldMappings, + assignedTargets, +}) { + if (aggregateSleepImport || !rowAggUseCustom) { + return { import_row_processing: null, error: null } + } + if (rowAggIrregular) { + try { + const import_row_processing = JSON.parse(rowAggJsonText || '{}') + if (!import_row_processing || typeof import_row_processing !== 'object') { + return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' } + } + const gb = import_row_processing.group_by + if (!Array.isArray(gb) || !gb.length) { + return { + import_row_processing: null, + error: 'Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.', + } + } + return { import_row_processing, error: null } + } catch { + return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' } + } + } + if (!rowAggGroupBy.length) { + return { import_row_processing: null, error: 'Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.' } + } + if (!rowAggMode) { + return { import_row_processing: null, error: 'Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).' } + } + for (const g of rowAggGroupBy) { + if (!assignedTargets.has(g)) { + return { + import_row_processing: null, + error: `Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`, + } + } + } + return { + import_row_processing: buildImportRowProcessingSimple( + modFields, + fieldMappings, + rowAggGroupBy, + rowAggMode, + rowAggMultiRowPolicy, + rowAggDedupeIdentical, + ), + error: null, + } +} + /** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */ function normalizeDecimalInputString(raw) { let s = String(raw).trim().replace(/\s/g, '') @@ -621,11 +683,28 @@ export default function AdminCsvTemplateEditorPage() { } setValidating(true) try { + const { import_row_processing, error: irpError } = resolveEditorImportRowProcessing({ + aggregateSleepImport, + rowAggUseCustom, + rowAggIrregular, + rowAggJsonText, + rowAggGroupBy, + rowAggMode, + rowAggMultiRowPolicy, + rowAggDedupeIdentical, + modFields: modMeta?.fields, + fieldMappings, + assignedTargets, + }) + if (irpError) { + setError(irpError) + return + } const r = await api.adminValidateCsvTemplate({ module, field_mappings: fieldMappings, type_conversions: tc, - import_row_processing: null, + import_row_processing, column_signature: columnSignature.length ? columnSignature : null, }) setValidationReport(r) @@ -692,44 +771,25 @@ export default function AdminCsvTemplateEditorPage() { } let import_row_processing = null - if (!aggregateSleepImport && rowAggUseCustom) { - if (rowAggIrregular) { - try { - import_row_processing = JSON.parse(rowAggJsonText || '{}') - if (!import_row_processing || typeof import_row_processing !== 'object') throw new Error('bad') - } catch { - setError('Zeilenaggregation: ungültiges JSON.') - return - } - const gb = import_row_processing.group_by - if (!Array.isArray(gb) || !gb.length) { - setError('Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.') - return - } - } else { - if (!rowAggGroupBy.length) { - setError('Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.') - return - } - if (!rowAggMode) { - setError('Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).') - return - } - for (const g of rowAggGroupBy) { - if (!assignedTargets.has(g)) { - setError(`Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`) - return - } - } - import_row_processing = buildImportRowProcessingSimple( - modMeta?.fields, - fieldMappings, - rowAggGroupBy, - rowAggMode, - rowAggMultiRowPolicy, - rowAggDedupeIdentical, - ) + { + const resolved = resolveEditorImportRowProcessing({ + aggregateSleepImport, + rowAggUseCustom, + rowAggIrregular, + rowAggJsonText, + rowAggGroupBy, + rowAggMode, + rowAggMultiRowPolicy, + rowAggDedupeIdentical, + modFields: modMeta?.fields, + fieldMappings, + assignedTargets, + }) + if (resolved.error) { + setError(resolved.error) + return } + import_row_processing = resolved.import_row_processing } const payload = { @@ -1494,12 +1554,13 @@ export default function AdminCsvTemplateEditorPage() { {validationReport.valid ? — speicherfähig : — Fehler beheben}

- Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht. + Inkl. Zeilenaggregation (wie beim Speichern). Warnungen blockieren nicht.

{validationReport.errors?.length ? (
    {validationReport.errors.map((e, i) => (
  • + {e.code ? {e.code} : null} {e.message} {e.hint ? {e.hint} : null}
  • @@ -1510,6 +1571,7 @@ export default function AdminCsvTemplateEditorPage() {
      {validationReport.warnings.map((w, i) => (
    • + {w.code ? {w.code} : null} {w.message} {w.hint ? {w.hint} : null}
    • diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 09ed0ba..cef92e1 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react' +import UsageBadge from '../components/UsageBadge' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter @@ -433,15 +434,17 @@ function ImportHistory() { } // ── Import Panel ────────────────────────────────────────────────────────────── -function ImportPanel({ onImported }) { +function ImportPanel({ onImported, usage = null }) { const fileRef = useRef() const [status, setStatus] = useState(null) const [error, setError] = useState(null) const [dragging,setDragging]= useState(false) const [tab, setTab] = useState('file') // 'file' | 'paste' const [pasteText, setPasteText] = useState('') + const atLimit = usage && !usage.allowed const runImport = async (file) => { + if (atLimit) return setStatus('loading'); setError(null) try { const result = await nutritionApi.importCsv(file) @@ -468,7 +471,7 @@ function ImportPanel({ onImported }) { } const handlePasteImport = async () => { - if (!pasteText.trim()) return + if (!pasteText.trim() || atLimit) return const blob = new Blob([pasteText], { type: 'text/csv' }) const file = new File([blob], 'paste.csv', { type: 'text/csv' }) await runImport(file) @@ -476,7 +479,10 @@ function ImportPanel({ onImported }) { return (
      -
      📥 FDDB CSV Import
      +
      + 📥 FDDB CSV Import + {usage && } +

      In FDDB: Mein Tagebuch → Exportieren → CSV — dann hier importieren.

      @@ -498,23 +504,26 @@ function ImportPanel({ onImported }) { <> {/* Drag & Drop Zone */}
      {e.preventDefault();setDragging(true)}} + onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}} onDragLeave={()=>setDragging(false)} - onDrop={handleDrop} - onClick={()=>fileRef.current.click()} + onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; handleDrop(e) }} + onClick={()=>{ if(!atLimit) fileRef.current?.click() }} + title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''} style={{ border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`, borderRadius:10, padding:'24px 16px', textAlign:'center', background: dragging?'var(--accent-light)':'var(--surface2)', - cursor:'pointer', transition:'all 0.15s', + cursor:atLimit?'not-allowed':'pointer', opacity:atLimit?0.65:1, transition:'all 0.15s', }}>
      - {dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'} + {atLimit + ? '🔒 Limit erreicht — Import nicht möglich' + : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
      .csv Dateien
      - + )} @@ -523,14 +532,20 @@ function ImportPanel({ onImported }) {