diff --git a/backend/club_feature_logger.py b/backend/club_feature_logger.py new file mode 100644 index 0000000..ce5cff8 --- /dev/null +++ b/backend/club_feature_logger.py @@ -0,0 +1,74 @@ +""" +JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block). + +Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 — analog Mitai feature_logger.py. +""" +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + + +def _log_dir() -> Path: + custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip() + if custom: + return Path(custom) + return Path("/app/logs") + + +feature_usage_logger = logging.getLogger("shinkan.club_feature_usage") +feature_usage_logger.setLevel(logging.INFO) +feature_usage_logger.propagate = False + +if not feature_usage_logger.handlers: + log_dir = _log_dir() + try: + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "club-feature-usage.log" + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter("%(message)s")) + feature_usage_logger.addHandler(file_handler) + except OSError: + # Dev ohne /app/logs: Fallback stderr + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s")) + feature_usage_logger.addHandler(stream_handler) + + +def log_club_feature_usage( + *, + club_id: Optional[int], + profile_id: Optional[int], + feature_id: str, + action: str, + access: Dict[str, Any], + endpoint: Optional[str] = None, + phase: str = "probe", +) -> None: + """ + Strukturiertes JSON-Log eines Feature-Checks. + + phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid) + """ + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "club_id": club_id, + "profile_id": profile_id, + "feature": feature_id, + "action": action, + "endpoint": endpoint, + "phase": phase, + "plan_id": access.get("plan_id"), + "used": access.get("used", 0), + "limit": access.get("limit"), + "remaining": access.get("remaining"), + "allowed": access.get("allowed", True), + "reason": access.get("reason", "unknown"), + "enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1", + } + feature_usage_logger.info(json.dumps(entry, ensure_ascii=False)) diff --git a/backend/club_features.py b/backend/club_features.py index 8ac2f77..0bda5df 100644 --- a/backend/club_features.py +++ b/backend/club_features.py @@ -2,17 +2,29 @@ Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id). Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md -Enforcement in Routern folgt in M2+ (Phase 2–4); M1 liefert Schema + Prüf-Helfer. +Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block. +Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment. Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen. """ from __future__ import annotations +import os from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, TYPE_CHECKING + +from fastapi import HTTPException from db import get_db, get_cursor +if TYPE_CHECKING: + from tenant_context import TenantContext + +# Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage) +_INVENTORY_FEATURES = frozenset( + {"exercises", "training_groups", "active_members", "training_programs"} +) + def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]: """Nächster Reset-Zeitpunkt; None bei 'never'.""" @@ -113,6 +125,61 @@ def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) - return _normalize_limit(feature_row.get("default_limit")) +def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]: + """Aktueller Bestand für reset_period=never Features.""" + if feature_id == "exercises": + cur.execute( + """ + SELECT COUNT(*)::int AS c + FROM exercises + WHERE club_id = %s AND status != 'archived' + """, + (club_id,), + ) + elif feature_id == "training_groups": + cur.execute( + "SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s", + (club_id,), + ) + elif feature_id == "active_members": + cur.execute( + """ + SELECT COUNT(*)::int AS c + FROM club_members + WHERE club_id = %s AND status = 'active' + """, + (club_id,), + ) + elif feature_id == "training_programs": + cur.execute( + """ + SELECT COUNT(*)::int AS c FROM ( + SELECT id FROM training_framework_programs WHERE club_id = %s + UNION ALL + SELECT id FROM training_modules WHERE club_id = %s + ) t + """, + (club_id, club_id), + ) + else: + return None + + row = cur.fetchone() + return int(row["c"] or 0) if row else 0 + + +def resolve_club_id_for_probe( + tenant: "TenantContext", + *, + object_club_id: Optional[int] = None, +) -> Optional[int]: + """Verein für Feature-Probe: explizites Objekt > effective_club_id.""" + if object_club_id is not None: + return int(object_club_id) + eff = getattr(tenant, "effective_club_id", None) + return int(eff) if eff is not None else None + + def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int: """Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück.""" used = int(usage_row.get("usage_count") or 0) if usage_row else 0 @@ -210,6 +277,12 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]: usage = cur.fetchone() used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage) + period = (feature.get("reset_period") or "never").strip().lower() + if period == "never" and feature_id in _INVENTORY_FEATURES: + inv = _live_inventory_count(cur, club_id, feature_id) + if inv is not None: + used = inv + if limit is None: return { "allowed": True, @@ -244,6 +317,76 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]: } +def club_feature_enforcement_enabled() -> bool: + """Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1).""" + return os.getenv("CLUB_FEATURE_ENFORCE", "0").strip() == "1" + + +def probe_club_feature_access( + *, + feature_id: str, + action: str, + club_id: Optional[int] = None, + profile_id: Optional[int] = None, + endpoint: Optional[str] = None, + conn=None, +) -> Dict[str, Any]: + """ + Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht. + + Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed. + """ + from club_feature_logger import log_club_feature_usage + + if club_id is None: + access = { + "allowed": True, + "limit": None, + "used": 0, + "remaining": None, + "reason": "no_club_context", + "plan_id": None, + } + log_club_feature_usage( + club_id=None, + profile_id=profile_id, + feature_id=feature_id, + action=action, + access=access, + endpoint=endpoint, + phase="probe", + ) + return access + + if conn is not None: + access = check_club_feature_access(club_id, feature_id, conn=conn) + else: + with get_db() as c: + access = check_club_feature_access(club_id, feature_id, conn=c) + + log_club_feature_usage( + club_id=club_id, + profile_id=profile_id, + feature_id=feature_id, + action=action, + access=access, + endpoint=endpoint, + phase="enforce" if club_feature_enforcement_enabled() else "probe", + ) + + if club_feature_enforcement_enabled() and not access.get("allowed"): + limit = access.get("limit") + used = access.get("used", 0) + detail = ( + f"Kontingent überschritten für {feature_id} " + f"({used}/{limit if limit is not None else '∞'}). " + f"Grund: {access.get('reason', 'limit_exceeded')}." + ) + raise HTTPException(status_code=403, detail=detail) + + return access + + def increment_club_feature_usage( club_id: int, feature_id: str, diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index e6cb706..879296e 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -37,6 +37,7 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar from media_legal_hold import assert_not_under_legal_hold from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext from ai_prompt_job import run_exercise_form_ai_suggestion +from club_features import probe_club_feature_access, resolve_club_id_for_probe from exercise_rich_text import ( RICH_HTML_EXERCISE_FIELDS, @@ -2317,7 +2318,13 @@ def exercise_ai_suggest_endpoint( KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern. OPENROUTER_API_KEY erforderlich. """ - _ = tenant.profile_id + probe_club_feature_access( + feature_id="ai_calls", + action="suggest", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /exercises/ai/suggest", + ) with get_db() as conn: cur = get_cursor(conn) payload = run_exercise_form_ai_suggestion( @@ -2337,6 +2344,13 @@ def exercise_ai_regenerate_endpoint( tenant: TenantContext = Depends(get_tenant_context), ): """Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT).""" + probe_club_feature_access( + feature_id="ai_calls", + action="regenerate", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /exercises/{id}/ai/regenerate", + ) want_summary = "summary" in body.regenerate want_skills = "skills" in body.regenerate want_instructions = "instructions" in body.regenerate @@ -2421,6 +2435,15 @@ def create_exercise( if body.visibility == "club" and club_id is None: club_id = tenant.effective_club_id + if club_id is not None: + probe_club_feature_access( + feature_id="exercises", + action="create", + club_id=int(club_id), + profile_id=profile_id, + endpoint="POST /exercises", + ) + # §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich create_ids: set[int] = set() for fld in sorted(RICH_HTML_EXERCISE_FIELDS): @@ -3214,6 +3237,23 @@ async def upload_exercise_media( cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, tenant) + if has_file: + cur.execute( + "SELECT club_id FROM exercises WHERE id = %s", + (exercise_id,), + ) + ex_club = cur.fetchone() + media_club_id = ex_club.get("club_id") if ex_club else None + if media_club_id is not None: + probe_club_feature_access( + feature_id="exercise_media", + action="upload", + club_id=int(media_club_id), + profile_id=profile_id, + endpoint="POST /exercises/{id}/media", + conn=conn, + ) + if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA: raise HTTPException( status_code=400, diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index d1f33af..229219e 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -7,6 +7,7 @@ from db import get_db, get_cursor from tenant_context import TenantContext, get_tenant_context from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path +from club_features import probe_club_feature_access, resolve_club_id_for_probe router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"]) @@ -16,6 +17,14 @@ def post_planning_exercise_suggest( body: PlanningExerciseSuggestRequest, tenant: TenantContext = Depends(get_tenant_context), ): + if body.include_llm_intent or body.include_llm_rank: + probe_club_feature_access( + feature_id="ai_calls", + action="planning_suggest", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /planning/exercise-suggest", + ) with get_db() as conn: cur = get_cursor(conn) return suggest_planning_exercises(cur, tenant=tenant, body=body) @@ -26,6 +35,18 @@ def post_progression_path_suggest( body: ProgressionPathSuggestRequest, tenant: TenantContext = Depends(get_tenant_context), ): + if ( + body.include_llm_intent + or body.include_llm_path_qa + or body.include_ai_gap_fill + ): + probe_club_feature_access( + feature_id="ai_calls", + action="progression_path_suggest", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /planning/progression-path-suggest", + ) with get_db() as conn: cur = get_cursor(conn) return suggest_progression_path(cur, tenant=tenant, body=body) diff --git a/backend/tests/test_club_feature_logger.py b/backend/tests/test_club_feature_logger.py new file mode 100644 index 0000000..1653cc9 --- /dev/null +++ b/backend/tests/test_club_feature_logger.py @@ -0,0 +1,62 @@ +"""Tests für club_feature_logger und probe (ohne DB).""" +import json + +from club_feature_logger import feature_usage_logger, log_club_feature_usage +from club_features import club_feature_enforcement_enabled, probe_club_feature_access + + +def test_log_club_feature_usage_json(monkeypatch): + captured = [] + monkeypatch.setattr(feature_usage_logger, "info", lambda msg: captured.append(msg)) + access = { + "allowed": False, + "limit": 0, + "used": 3, + "remaining": 0, + "reason": "feature_disabled", + "plan_id": "free", + } + log_club_feature_usage( + club_id=12, + profile_id=7, + feature_id="ai_calls", + action="suggest", + access=access, + endpoint="POST /exercises/ai/suggest", + ) + assert captured + payload = json.loads(captured[-1]) + assert payload["club_id"] == 12 + assert payload["profile_id"] == 7 + assert payload["feature"] == "ai_calls" + assert payload["allowed"] is False + assert payload["plan_id"] == "free" + assert payload["phase"] == "probe" + + +def test_probe_no_club_context_logs_without_db(monkeypatch): + logged = [] + + def _capture(**kwargs): + logged.append(kwargs) + + monkeypatch.setattr("club_feature_logger.log_club_feature_usage", _capture) + + access = probe_club_feature_access( + feature_id="ai_calls", + action="suggest", + club_id=None, + profile_id=1, + endpoint="test", + ) + assert access["reason"] == "no_club_context" + assert access["allowed"] is True + assert len(logged) == 1 + assert logged[0]["club_id"] is None + + +def test_club_feature_enforcement_default_off(monkeypatch): + monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False) + assert club_feature_enforcement_enabled() is False + monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1") + assert club_feature_enforcement_enabled() is True diff --git a/backend/version.py b/backend/version.py index 2fbfe81..bab4dec 100644 --- a/backend/version.py +++ b/backend/version.py @@ -13,7 +13,7 @@ MODULE_VERSIONS = { "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users - "club_features": "1.0.0", # Migration 078: features v9c + club_plans/subscriptions; check_club_feature_access + "club_features": "1.1.0", # M2: probe_club_feature_access + JSON-Log (Phase 2, non-blocking) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen) "media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)