From 4b8e6755dc2293cf4ddd88310f9f7828844e035b Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 07:40:37 +0100 Subject: [PATCH] feat: complete Phase 4 enforcement for all features (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle 11 Features blockieren jetzt bei Limit-Überschreitung: Batch 1 (bereits erledigt): - weight_entries, circumference_entries, caliper_entries Batch 2: - activity_entries - nutrition_entries (CSV import) - photos Batch 3: - ai_calls (einzelne Analysen) - ai_pipeline (3-stufige Gesamtanalyse) - data_export (CSV, JSON, ZIP) - data_import (ZIP) Entfernt: Alte check_ai_limit() Calls (ersetzt durch neue Feature-Limits) Co-Authored-By: Claude Opus 4.6 --- backend/routers/activity.py | 9 +++++-- backend/routers/exportdata.py | 48 ++++++++++++++++------------------- backend/routers/importdata.py | 10 +++++--- backend/routers/insights.py | 32 +++++++++++++---------- backend/routers/nutrition.py | 9 +++++-- backend/routers/photos.py | 9 +++++-- 6 files changed, 69 insertions(+), 48 deletions(-) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 2eb4889..0d12000 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -37,15 +37,20 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default """Create new activity entry.""" pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'activity_entries') log_feature_usage(pid, 'activity_entries', access, 'create') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + 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 ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) eid = str(uuid.uuid4()) d = e.model_dump() diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index fb32326..d4e6998 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -33,24 +33,20 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D """Export all data as CSV.""" pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'data_export') log_feature_usage(pid, 'data_export', access, 'export_csv') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) - # NOTE: Phase 2 does NOT block - just logs! - - # Old permission check (keep for now) - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) - prof = cur.fetchone() - if not prof or not prof['export_enabled']: - raise HTTPException(403, "Export ist für dieses Profil deaktiviert") + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) # Build CSV output = io.StringIO() @@ -104,23 +100,20 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= """Export all data as JSON.""" pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'data_export') log_feature_usage(pid, 'data_export', access, 'export_json') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) - - # Old permission check (keep for now) - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) - prof = cur.fetchone() - if not prof or not prof['export_enabled']: - raise HTTPException(403, "Export ist für dieses Profil deaktiviert") + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) # Collect all data data = {} @@ -170,23 +163,26 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D """Export all data as ZIP (CSV + JSON + photos) per specification.""" pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'data_export') log_feature_usage(pid, 'data_export', access, 'export_zip') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) - # Old permission check & get profile + # Get profile with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) prof = r2d(cur.fetchone()) - if not prof or not prof.get('export_enabled'): - raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Helper: CSV writer with UTF-8 BOM + semicolon def write_csv(zf, filename, rows, columns): diff --git a/backend/routers/importdata.py b/backend/routers/importdata.py index 75a3d41..d98a48a 100644 --- a/backend/routers/importdata.py +++ b/backend/routers/importdata.py @@ -44,16 +44,20 @@ async def import_zip( """ pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'data_import') log_feature_usage(pid, 'data_import', access, 'import_zip') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) - # NOTE: Phase 2 does NOT block - just logs! + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Importe überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) # Read uploaded file content = await file.read() diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 2b0a83f..12a342e 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -255,19 +255,20 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa """Run AI analysis with specified prompt template.""" pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'ai_calls') log_feature_usage(pid, 'ai_calls', access, 'analyze') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) - # NOTE: Phase 2 does NOT block - just logs! - - # Old check (keep for now, but will be replaced in Phase 4) - check_ai_limit(pid) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) # Get prompt template with get_db() as conn: @@ -330,16 +331,19 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses """Run 3-stage pipeline analysis.""" pid = get_pid(x_profile_id) - # Phase 2: Check pipeline feature access (boolean - enabled/disabled) + # Phase 4: Check pipeline feature access (boolean - enabled/disabled) access_pipeline = check_feature_access(pid, 'ai_pipeline') log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline') if not access_pipeline['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"ai_pipeline {access_pipeline['reason']}" ) - # NOTE: Phase 2 does NOT block - just logs! + raise HTTPException( + status_code=403, + detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin." + ) # Also check ai_calls (pipeline uses API calls too) access_calls = check_feature_access(pid, 'ai_calls') @@ -347,12 +351,14 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses if not access_calls['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})" ) - - # Old check (keep for now) - check_ai_limit(pid) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) data = _get_profile_data(pid) vars = _prepare_template_vars(data) diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 4b6af1a..3c6d46a 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -34,16 +34,21 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona """Import FDDB nutrition CSV.""" pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE # Note: CSV import can create many entries - we check once before import access = check_feature_access(pid, 'nutrition_entries') log_feature_usage(pid, 'nutrition_entries', access, 'import_csv') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({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') diff --git a/backend/routers/photos.py b/backend/routers/photos.py index 6570f8a..6fc06f0 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -31,15 +31,20 @@ async def upload_photo(file: UploadFile=File(...), date: str="", """Upload progress photo.""" pid = get_pid(x_profile_id) - # Phase 2: Check feature access (non-blocking, log only) + # Phase 4: Check feature access and ENFORCE access = check_feature_access(pid, 'photos') log_feature_usage(pid, 'photos', access, 'upload') if not access['allowed']: logger.warning( - f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"[FEATURE-LIMIT] User {pid} blocked: " f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})" ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) fid = str(uuid.uuid4()) ext = Path(file.filename).suffix or '.jpg'