Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72d101415f | |||
| ae7d0ac1d9 | |||
| 79ec249bc3 |
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
|
|
@ -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)
|
||||
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
|
|
@ -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`
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -58,6 +58,9 @@ coverage/
|
|||
# Temp
|
||||
tmp/
|
||||
*.tmp
|
||||
test-results/
|
||||
screenshots/
|
||||
tests/.auth/
|
||||
|
||||
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
||||
.claude/**
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
110
backend/tests/test_activity_import_feature_enforcement.py
Normal file
110
backend/tests/test_activity_import_feature_enforcement.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Feature-Enforcement für Legacy Activity CSV-Import (#37)."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routers.activity import import_activity_csv
|
||||
|
||||
|
||||
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:
|
||||
asyncio.run(
|
||||
import_activity_csv(
|
||||
file=upload,
|
||||
x_profile_id=None,
|
||||
session={"profile_id": "profile-1"},
|
||||
)
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
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 = asyncio.run(
|
||||
import_activity_csv(
|
||||
file=upload,
|
||||
x_profile_id=None,
|
||||
session={"profile_id": "profile-1"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result["inserted"] == 1
|
||||
assert increments == ["activity_entries"]
|
||||
26
backend/tests/test_csv_mapping_validation.py
Normal file
26
backend/tests/test_csv_mapping_validation.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
76
frontend/src/components/CsvImportErrorDetails.jsx
Normal file
76
frontend/src/components/CsvImportErrorDetails.jsx
Normal file
|
|
@ -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 (
|
||||
<details
|
||||
className="card"
|
||||
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
|
||||
open={defaultOpen}
|
||||
>
|
||||
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
||||
{title || `Zeilenfehler (${errors.length})`}
|
||||
</summary>
|
||||
<ul
|
||||
style={{
|
||||
margin: '12px 0 0 0',
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
borderLeft: '3px solid var(--danger)',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--text1)' }}>
|
||||
{row != null && (
|
||||
<strong style={{ marginRight: 6 }}>Zeile {row}:</strong>
|
||||
)}
|
||||
{message}
|
||||
{err.code && (
|
||||
<code
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 11,
|
||||
color: 'var(--text3)',
|
||||
background: 'var(--surface)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{err.code}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{err.hint && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text2)' }}>{err.hint}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{rest > 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
|
||||
… und {rest} weitere Fehler (Details in der Import-Historie).
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">📥 Apple Health Import</div>
|
||||
<div className="card-title badge-container-right">
|
||||
<span>📥 Apple Health Import</span>
|
||||
{usage && <UsageBadge {...usage} />}
|
||||
</div>
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
||||
<strong>Health Auto Export App</strong> → Workouts exportieren → CSV → hier hochladen.<br/>
|
||||
Nur die <em>Workouts-…csv</em> Datei wird benötigt (nicht die Detaildateien).
|
||||
</p>
|
||||
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
|
||||
disabled={atLimit}
|
||||
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
|
||||
<div
|
||||
onDragOver={e=>{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'}}>
|
||||
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
|
||||
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
||||
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'}
|
||||
{atLimit ? '🔒 Limit erreicht — Import nicht möglich' : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen'}
|
||||
</div>
|
||||
</div>
|
||||
{status==='loading' && (
|
||||
|
|
@ -945,7 +952,12 @@ export default function ActivityPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab==='import' && <ImportPanel onImported={load}/>}
|
||||
{tab==='import' && (
|
||||
<ImportPanel
|
||||
usage={activityUsage}
|
||||
onImported={() => { load(); loadUsage() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab==='categorize' && (
|
||||
<div className="card section-gap">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
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 ? <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.
|
||||
Inkl. Zeilenaggregation (wie 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.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{e.code}</code> : null}
|
||||
{e.message}
|
||||
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
|
||||
</li>
|
||||
|
|
@ -1510,6 +1571,7 @@ export default function AdminCsvTemplateEditorPage() {
|
|||
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
|
||||
{validationReport.warnings.map((w, i) => (
|
||||
<li key={`w-${i}`}>
|
||||
{w.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{w.code}</code> : null}
|
||||
{w.message}
|
||||
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">📥 FDDB CSV Import</div>
|
||||
<div className="card-title badge-container-right">
|
||||
<span>📥 FDDB CSV Import</span>
|
||||
{usage && <UsageBadge {...usage} />}
|
||||
</div>
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
||||
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
|
||||
</p>
|
||||
|
|
@ -498,23 +504,26 @@ function ImportPanel({ onImported }) {
|
|||
<>
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDragOver={e=>{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',
|
||||
}}>
|
||||
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
|
||||
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
||||
{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'}
|
||||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} onChange={handleFile}/>
|
||||
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} disabled={atLimit} onChange={handleFile}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -523,14 +532,20 @@ function ImportPanel({ onImported }) {
|
|||
<textarea
|
||||
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
|
||||
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
||||
color:'var(--text1)',resize:'vertical',boxSizing:'border-box'}}
|
||||
color:'var(--text1)',resize:'vertical',boxSizing:'border-box',
|
||||
opacity:atLimit?0.65:1}}
|
||||
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung; 13.03.2026 21:54;50 g Hähnchen;..."
|
||||
value={pasteText}
|
||||
onChange={e=>setPasteText(e.target.value)}
|
||||
disabled={atLimit}
|
||||
/>
|
||||
<button className="btn btn-primary btn-full" style={{marginTop:8}}
|
||||
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}>
|
||||
{status==='loading'
|
||||
onClick={handlePasteImport}
|
||||
disabled={status==='loading'||!pasteText.trim()||atLimit}
|
||||
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}>
|
||||
{atLimit
|
||||
? '🔒 Limit erreicht'
|
||||
: status==='loading'
|
||||
? <><div className="spinner" style={{width:14,height:14}}/> Importiere…</>
|
||||
: <><Upload size={15}/> CSV-Text importieren</>}
|
||||
</button>
|
||||
|
|
@ -781,6 +796,14 @@ export default function NutritionPage() {
|
|||
const [loading, setLoad] = useState(true)
|
||||
const [hasData, setHasData]= useState(false)
|
||||
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
||||
const [nutritionUsage, setNutritionUsage] = useState(null)
|
||||
|
||||
const loadUsage = () => {
|
||||
nutritionApi.getFeatureUsage().then(features => {
|
||||
const nutritionFeature = features.find(f => f.feature_id === 'nutrition_entries')
|
||||
setNutritionUsage(nutritionFeature ?? null)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
setLoad(true)
|
||||
|
|
@ -800,7 +823,7 @@ export default function NutritionPage() {
|
|||
finally { setLoad(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
useEffect(() => { load(); loadUsage() }, [])
|
||||
|
||||
return (
|
||||
<div className="capture-page">
|
||||
|
|
@ -822,7 +845,10 @@ export default function NutritionPage() {
|
|||
{/* Import Panel + History */}
|
||||
{inputTab==='import' && (
|
||||
<>
|
||||
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
|
||||
<ImportPanel
|
||||
usage={nutritionUsage}
|
||||
onImported={() => { load(); loadUsage(); setImportHistoryKey(Date.now()) }}
|
||||
/>
|
||||
<ImportHistory key={importHistoryKey}/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
|
||||
import CsvImportErrorDetails from '../components/CsvImportErrorDetails'
|
||||
|
||||
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
||||
const EXECUTOR_READY = new Set([
|
||||
|
|
@ -288,29 +289,11 @@ export default function UniversalCsvImportPage() {
|
|||
)}
|
||||
|
||||
{lastImport?.error_details?.length > 0 && (
|
||||
<details
|
||||
className="card"
|
||||
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
|
||||
open
|
||||
>
|
||||
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
||||
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) — zum Kopieren aufklappen
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
maxHeight: 320,
|
||||
background: 'var(--surface2)',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
color: 'var(--text1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(lastImport.error_details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
<CsvImportErrorDetails
|
||||
errors={lastImport.error_details}
|
||||
title={`Zeilenfehler vom letzten Import (${lastImport.error_details.length})`}
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ export function formatFastApiDetail(detail, fallback = '') {
|
|||
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
|
||||
}
|
||||
if (typeof detail === 'object') {
|
||||
if (detail.validation?.errors?.length) {
|
||||
const parts = detail.validation.errors
|
||||
.map((e) => (e && typeof e === 'object' ? e.message || e.msg || '' : String(e)))
|
||||
.filter(Boolean)
|
||||
const prefix = detail.message || 'Vorlage ungültig'
|
||||
return parts.length ? `${prefix}: ${parts.join(' · ')}` : prefix
|
||||
}
|
||||
if (Array.isArray(detail.errors) && detail.errors.length > 0) {
|
||||
const parts = detail.errors
|
||||
.map((e) => {
|
||||
|
|
@ -713,6 +720,7 @@ export const api = {
|
|||
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
||||
copyCsvMapping: (mappingId, body = null) =>
|
||||
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
||||
validateCsvMapping: (mappingId) => req(`/csv/mappings/${mappingId}/validate`),
|
||||
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
|
||||
/** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */
|
||||
diagnoseUniversalCsv: async (file, mappingId, module = null) => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
module.exports = {
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
workers: 1,
|
||||
globalSetup: require.resolve('./tests/global-setup.js'),
|
||||
use: {
|
||||
channel: 'chrome',
|
||||
headless: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
screenshot: 'only-on-failure',
|
||||
baseURL: 'https://dev.mitai.jinkendo.de',
|
||||
storageState: './tests/.auth/user.json',
|
||||
},
|
||||
reporter: 'list',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,61 +1,53 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
const { loginViaForm, clickBottomNav } = require('./helpers');
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
||||
test.describe('Login-Flow', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||
await page.click('button:has-text("Anmelden")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
test('1. Login funktioniert', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||
await page.click('button:has-text("Anmelden")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const loginButton = page.locator('button:has-text("Anmelden")');
|
||||
await expect(loginButton).toHaveCount(0, { timeout: 10000 });
|
||||
test('1. Login funktioniert', async ({ page }) => {
|
||||
await loginViaForm(page);
|
||||
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
|
||||
console.log('Login erfolgreich');
|
||||
});
|
||||
});
|
||||
|
||||
test('2. Dashboard laedt ohne Fehler', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
||||
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
|
||||
console.log('Dashboard OK');
|
||||
});
|
||||
|
||||
test('3. Erfassung erreichbar', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click('text=Erfassung');
|
||||
test('3. Erfassen erreichbar', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await clickBottomNav(page, 'Erfassen');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page).toHaveURL(/\/capture/);
|
||||
await page.screenshot({ path: 'screenshots/03-erfassung.png' });
|
||||
console.log('Erfassung OK');
|
||||
console.log('Erfassen OK');
|
||||
});
|
||||
|
||||
test('4. Analyse erreichbar', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click('text=Analyse');
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await clickBottomNav(page, 'Analyse');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page).toHaveURL(/\/analysis/);
|
||||
await page.screenshot({ path: 'screenshots/04-analyse.png' });
|
||||
console.log('Analyse OK');
|
||||
});
|
||||
|
||||
test('5. Keine kritischen Console-Fehler', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('console', msg => {
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
await login(page);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const kritisch = errors.filter(e =>
|
||||
!e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404')
|
||||
const kritisch = errors.filter(
|
||||
(e) => !e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404')
|
||||
);
|
||||
if (kritisch.length > 0) {
|
||||
console.log('Console-Fehler:', kritisch.join(', '));
|
||||
|
|
|
|||
64
tests/global-setup.js
Normal file
64
tests/global-setup.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
const { chromium } = require('@playwright/test');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
||||
const AUTH_FILE = path.join(__dirname, '.auth', 'user.json');
|
||||
|
||||
async function tryLogin(page, baseURL) {
|
||||
await page.goto(`${baseURL}/`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const loginBtn = page.locator('button:has-text("Anmelden")');
|
||||
if ((await loginBtn.count()) === 0) {
|
||||
return { ok: true, status: 200 };
|
||||
}
|
||||
|
||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||
|
||||
const responsePromise = page
|
||||
.waitForResponse(
|
||||
(r) => r.url().includes('/api/auth/login') && r.request().method() === 'POST',
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
await page.click('button:has-text("Anmelden")');
|
||||
const response = await responsePromise;
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const loginVisible = await page.locator('button:has-text("Anmelden")').count();
|
||||
return { ok: loginVisible === 0, status: response?.status() ?? null };
|
||||
}
|
||||
|
||||
module.exports = async (config) => {
|
||||
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const page = await browser.newPage();
|
||||
const baseURL = config.projects?.[0]?.use?.baseURL || 'https://dev.mitai.jinkendo.de';
|
||||
|
||||
const waits = [0, 15000, 30000, 45000];
|
||||
let lastStatus = null;
|
||||
|
||||
for (let i = 0; i < waits.length; i++) {
|
||||
if (waits[i] > 0) {
|
||||
console.log(`Global setup: warte ${waits[i] / 1000}s (Login-Rate-Limit 5/min)…`);
|
||||
await page.waitForTimeout(waits[i]);
|
||||
}
|
||||
const result = await tryLogin(page, baseURL);
|
||||
lastStatus = result.status;
|
||||
if (result.ok) {
|
||||
await page.context().storageState({ path: AUTH_FILE });
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
throw new Error(
|
||||
`Global setup: Login fehlgeschlagen (letzter HTTP-Status: ${lastStatus ?? 'unbekannt'})`
|
||||
);
|
||||
};
|
||||
23
tests/helpers.js
Normal file
23
tests/helpers.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const { expect } = require('@playwright/test');
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
||||
|
||||
/** Nutzt storageState aus global-setup; nur für Tests nötig, die explizit Login prüfen. */
|
||||
async function loginViaForm(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||
await page.click('button:has-text("Anmelden")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('button:has-text("Anmelden")')).toHaveCount(0, { timeout: 15000 });
|
||||
}
|
||||
|
||||
async function clickBottomNav(page, label) {
|
||||
const link = page.locator('nav.bottom-nav a.nav-item').filter({ hasText: label });
|
||||
await expect(link).toBeVisible({ timeout: 10000 });
|
||||
await link.click();
|
||||
}
|
||||
|
||||
module.exports = { TEST_EMAIL, TEST_PASSWORD, loginViaForm, clickBottomNav };
|
||||
51
tests/issue-audit.spec.js
Normal file
51
tests/issue-audit.spec.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Gitea Issue-Audit — UI-Verifikation gegen dev.mitai.jinkendo.de
|
||||
* Einmaliger Audit-Lauf; keine dauerhafte CI-Pflicht.
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Issue-Audit UI', () => {
|
||||
test('#40 Logout-Button im Mobile-Header sichtbar', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const logoutBtn = page.locator('header.app-header--mobile button[title="Abmelden"]');
|
||||
await expect(logoutBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('#25 Goals-Seite erreichbar und rendert Inhalt', async ({ page }) => {
|
||||
await page.goto('/goals');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).not.toContainText('404', { timeout: 5000 });
|
||||
const hasGoalsUi =
|
||||
(await page.getByText(/Ziel/i).count()) > 0 ||
|
||||
(await page.locator('.card').count()) > 0;
|
||||
expect(hasGoalsUi).toBeTruthy();
|
||||
});
|
||||
|
||||
test('#65 Dashboard-Layout-Konfiguration erreichbar', async ({ page }) => {
|
||||
await page.goto('/settings/dashboard-layout');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).toMatch(/Widget|Übersicht|Layout|Dashboard/i);
|
||||
});
|
||||
|
||||
test('#30 Desktop-Sidebar bei breitem Viewport', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await expect(page.locator('aside.desktop-sidebar')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('#38 Nutrition CSV Import — FDDB-Panel mit Usage-Integration', async ({ page }) => {
|
||||
await page.goto('/nutrition');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByRole('button', { name: /Import/i }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const importCard = page.locator('.card').filter({ hasText: 'FDDB CSV Import' });
|
||||
await expect(importCard).toBeVisible();
|
||||
const titleRow = importCard.locator('.card-title.badge-container-right');
|
||||
if (await titleRow.count()) {
|
||||
await expect(titleRow).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user