From b68185842e75c13502ecf95aa48428ff168ed3a5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 7 Jun 2026 10:32:49 +0200 Subject: [PATCH] Enhance Club Feature Consumption Logic and Update Versioning - Introduced the `consume_club_feature_with_usage` function to standardize feature consumption across endpoints, improving code reusability and clarity. - Implemented `merge_feature_usage_into_response` to embed feature usage data in API responses, streamlining frontend integration. - Updated various backend routers to utilize the new consumption logic, ensuring consistent feature usage tracking during AI-related actions. - Enhanced tests to validate the new consumption and logging behavior. - Incremented application version to 0.8.199 and updated module version for 'club_features' to 1.6.0 to reflect these changes. --- .../CLUB_MEMBERSHIP_AND_FEATURES.v1.md | 7 +- backend/club_features.py | 129 +++++++++++++++++- backend/routers/exercises.py | 17 ++- backend/routers/planning_exercise_suggest.py | 17 ++- backend/tests/test_club_feature_m5.py | 87 ++++++++++++ backend/version.py | 8 +- frontend/src/api/client.js | 6 +- frontend/src/context/EntitlementsContext.jsx | 42 +++++- frontend/src/utils/featureUsageSync.js | 26 ++++ 9 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 frontend/src/utils/featureUsageSync.js diff --git a/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md index 813cc53..5c897f2 100644 --- a/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md +++ b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md @@ -331,10 +331,13 @@ def check_club_feature_access( 5. assert_content_governance(...) # nur bei Objekt-Endpoints 6. check_club_feature_access(club_id, feature_id) 7. … Business-Logik … -8. increment_club_feature_usage(club_id, feature_id) # nur bei INSERT / KI-Execute -9. optional: log club_feature_usage_events (profile_id) +8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage) + # Standard: zählen, JSON-Log phase=consume, feature_usage in Response +9. optional: club_feature_usage_events (profile_id, action) ``` +**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode. + ### 7.4 Wer zählt als Verbrauch? | Aktion | increment | Subjekt | diff --git a/backend/club_features.py b/backend/club_features.py index db744d9..8495bbe 100644 --- a/backend/club_features.py +++ b/backend/club_features.py @@ -5,6 +5,9 @@ Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block. Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment. +Verbrauch-Standard für Router: + probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response + Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen. """ from __future__ import annotations @@ -457,6 +460,130 @@ def consume_club_feature( conn=conn, ) + def _log_consume(connection) -> None: + from club_feature_logger import log_club_feature_usage + + access = check_club_feature_access(int(club_id), feature_id, conn=connection) + log_club_feature_usage( + club_id=int(club_id), + profile_id=profile_id, + feature_id=feature_id, + action=action or "consume", + access=access, + phase="consume", + ) + + if conn is not None: + _log_consume(conn) + else: + with get_db() as c: + _log_consume(c) + + +def consume_club_feature_with_usage( + *, + feature_id: str, + club_id: Optional[int], + profile_id: Optional[int] = None, + portal_role: Optional[str] = None, + action: Optional[str] = None, + amount: int = 1, + cur, + tenant: Optional["TenantContext"] = None, + conn=None, +) -> Optional[Dict[str, Dict[str, Any]]]: + """ + Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response. + + Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und + ``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route. + """ + consume_club_feature( + feature_id=feature_id, + club_id=club_id, + profile_id=profile_id, + portal_role=portal_role, + action=action, + amount=amount, + conn=conn, + ) + if club_id is None: + return None + return { + feature_id: club_feature_usage_for_api( + cur, + club_id=int(club_id), + feature_id=feature_id, + profile_id=profile_id, + portal_role=portal_role, + tenant=tenant, + conn=conn, + ), + } + + +def merge_feature_usage_into_response( + payload: Any, + feature_usage: Optional[Dict[str, Dict[str, Any]]], +) -> Any: + """Standard-Einbettung ``feature_usage`` in JSON-Responses.""" + if not feature_usage or not isinstance(payload, dict): + return payload + return {**payload, "feature_usage": feature_usage} + + +def club_feature_usage_for_api( + cur, + *, + club_id: int, + feature_id: str, + profile_id: Optional[int] = None, + portal_role: Optional[str] = None, + tenant: Optional["TenantContext"] = None, + conn=None, +) -> Dict[str, Any]: + """Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch).""" + from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access + + db_conn = conn if conn is not None else cur.connection + access = check_club_feature_access(int(club_id), feature_id, conn=db_conn) + plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id)) + + if is_club_feature_quota_bypassed( + cur, + profile_id=profile_id, + portal_role=portal_role, + feature_id=feature_id, + tenant=tenant, + ): + ex = quota_bypass_access( + feature_id=feature_id, + club_id=int(club_id), + plan_id=plan_id, + ) + reset_at = access.get("reset_at") + return { + "allowed": True, + "used": access.get("used"), + "limit": None, + "remaining": None, + "reason": ex.get("reason"), + "platform_exempt": True, + "reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at, + } + + return { + "allowed": access.get("allowed"), + "used": access.get("used"), + "limit": access.get("limit"), + "remaining": access.get("remaining"), + "reason": access.get("reason"), + "platform_exempt": False, + "reset_at": access.get("reset_at").isoformat() + if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat") + else access.get("reset_at"), + } + def increment_club_feature_usage( club_id: int, @@ -508,8 +635,6 @@ def increment_club_feature_usage( (club_id, feature_id, profile_id, action or feature_id), ) - c.commit() - if conn is not None: _run(conn) else: diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index aed8039..48a1461 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -39,7 +39,12 @@ from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContex from ai_prompt_job import run_exercise_form_ai_suggestion from account_lifecycle import assert_min_account_state from capabilities import probe_capability -from club_features import consume_club_feature, probe_club_feature_access, resolve_club_id_for_probe +from club_features import ( + consume_club_feature_with_usage, + merge_feature_usage_into_response, + probe_club_feature_access, + resolve_club_id_for_probe, +) from exercise_rich_text import ( RICH_HTML_EXERCISE_FIELDS, @@ -2346,14 +2351,17 @@ def exercise_ai_suggest_endpoint( want_skills=body.include_skills, want_instructions=body.include_instructions, ) - consume_club_feature( + usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="suggest", + cur=cur, + tenant=tenant, conn=conn, ) + payload = merge_feature_usage_into_response(payload, usage) return payload @@ -2412,14 +2420,17 @@ def exercise_ai_regenerate_endpoint( want_skills=want_skills, want_instructions=want_instructions, ) - consume_club_feature( + usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="regenerate", + cur=cur, + tenant=tenant, conn=conn, ) + payload = merge_feature_usage_into_response(payload, usage) return payload diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index b691eb3..f13402f 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -9,7 +9,12 @@ from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_pl from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path from account_lifecycle import assert_min_account_state from capabilities import probe_capability -from club_features import consume_club_feature, probe_club_feature_access, resolve_club_id_for_probe +from club_features import ( + consume_club_feature_with_usage, + merge_feature_usage_into_response, + probe_club_feature_access, + resolve_club_id_for_probe, +) router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"]) @@ -42,14 +47,17 @@ def post_planning_exercise_suggest( cur = get_cursor(conn) result = suggest_planning_exercises(cur, tenant=tenant, body=body) if uses_ai: - consume_club_feature( + usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="planning_suggest", + cur=cur, + tenant=tenant, conn=conn, ) + result = merge_feature_usage_into_response(result, usage) return result @@ -87,12 +95,15 @@ def post_progression_path_suggest( cur = get_cursor(conn) result = suggest_progression_path(cur, tenant=tenant, body=body) if uses_ai: - consume_club_feature( + usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="progression_path_suggest", + cur=cur, + tenant=tenant, conn=conn, ) + result = merge_feature_usage_into_response(result, usage) return result diff --git a/backend/tests/test_club_feature_m5.py b/backend/tests/test_club_feature_m5.py index 4557d35..a31caaa 100644 --- a/backend/tests/test_club_feature_m5.py +++ b/backend/tests/test_club_feature_m5.py @@ -5,6 +5,8 @@ from fastapi import HTTPException from club_features import ( club_feature_enforcement_enabled, consume_club_feature, + consume_club_feature_with_usage, + merge_feature_usage_into_response, probe_club_feature_access, ) @@ -94,6 +96,45 @@ def test_consume_skips_without_club_id(monkeypatch): assert calls == [] +def test_consume_logs_usage_after_increment(monkeypatch): + logs = [] + + monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur()) + monkeypatch.setattr( + "club_quota_bypass.is_club_feature_quota_bypassed", + lambda *a, **k: False, + ) + monkeypatch.setattr("club_features.increment_club_feature_usage", lambda *a, **k: None) + monkeypatch.setattr( + "club_features.check_club_feature_access", + lambda club_id, feature_id, conn=None: { + "allowed": True, + "used": 1, + "limit": 30, + "remaining": 29, + "plan_id": "club", + "reason": "within_limit", + }, + ) + monkeypatch.setattr( + "club_feature_logger.log_club_feature_usage", + lambda **kwargs: logs.append(kwargs), + ) + + consume_club_feature( + feature_id="ai_calls", + club_id=5, + profile_id=9, + portal_role="trainer", + action="suggest", + conn=object(), + ) + assert len(logs) == 1 + assert logs[0]["phase"] == "consume" + assert logs[0]["feature_id"] == "ai_calls" + assert logs[0]["club_id"] == 5 + + def test_consume_increments_once_per_call(monkeypatch): calls = [] @@ -106,6 +147,18 @@ def test_consume_increments_once_per_call(monkeypatch): lambda *a, **k: False, ) monkeypatch.setattr("club_features.increment_club_feature_usage", _inc) + monkeypatch.setattr( + "club_features.check_club_feature_access", + lambda club_id, feature_id, conn=None: { + "allowed": True, + "used": 1, + "limit": 30, + "remaining": 29, + "plan_id": "club", + "reason": "within_limit", + }, + ) + monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None) consume_club_feature( feature_id="ai_calls", club_id=5, @@ -117,6 +170,40 @@ def test_consume_increments_once_per_call(monkeypatch): assert calls == [(5, "ai_calls", "suggest")] +def test_merge_feature_usage_into_response(): + out = merge_feature_usage_into_response( + {"ok": True}, + {"ai_calls": {"used": 3, "limit": 30}}, + ) + assert out["ok"] is True + assert out["feature_usage"]["ai_calls"]["used"] == 3 + assert merge_feature_usage_into_response({"x": 1}, None) == {"x": 1} + + +def test_consume_with_usage_returns_snapshot(monkeypatch): + monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur()) + monkeypatch.setattr( + "club_quota_bypass.is_club_feature_quota_bypassed", + lambda *a, **k: False, + ) + monkeypatch.setattr("club_features.consume_club_feature", lambda **kwargs: None) + monkeypatch.setattr( + "club_features.club_feature_usage_for_api", + lambda cur, **kwargs: {"used": 4, "limit": 30, "allowed": True}, + ) + + usage = consume_club_feature_with_usage( + feature_id="ai_calls", + club_id=7, + profile_id=1, + portal_role="trainer", + action="suggest", + cur=_fake_cur(), + conn=object(), + ) + assert usage["ai_calls"]["used"] == 4 + + def test_club_feature_enforcement_env_default_off(monkeypatch): monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False) assert club_feature_enforcement_enabled() is False diff --git a/backend/version.py b/backend/version.py index aa915a9..d54ef57 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.197" +APP_VERSION = "0.8.199" BUILD_DATE = "2026-06-07" DB_SCHEMA_VERSION = "20260606083" @@ -16,7 +16,7 @@ MODULE_VERSIONS = { "club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht "admin_users": "1.0.0", # GET /api/admin/users - "club_features": "1.5.0", # Kontingent-Bypass via Capability-Grants (probe/consume) + "club_features": "1.6.0", # Standard consume_club_feature_with_usage + merge_feature_usage_into_response "club_quota_bypass": "1.0.0", # platform.club_quota.bypass* + Admin-Grants-API "admin_rights": "1.0.0", # M6: Rollen/Rechte — Capabilities, Bypass, Vereins-Kontingente "entitlements": "1.2.0", # capability_quota_bypass in Feature-Map für /me/entitlements @@ -35,8 +35,8 @@ MODULE_VERSIONS = { "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", - "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.16.1", # M5: consume_club_feature ai_calls nach KI-Erfolg + "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume + "planning_exercise_suggest": "0.16.2", # feature_usage in KI-Responses nach consume "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 4bc7edb..571cda4 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -3,6 +3,8 @@ * Alle API-Aufrufe laufen über request() — siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js). */ +import { syncFeatureUsageFromApiResponse } from '../utils/featureUsageSync.js' + export const API_URL = import.meta.env.VITE_API_URL || '' /** LocalStorage + Request-Header für Mandanten-Kontext */ @@ -80,7 +82,9 @@ async function _fetchWithAuth(endpoint, options = {}) { */ export async function request(endpoint, options = {}) { const response = await _fetchWithAuth(endpoint, options) - return response.json() + const data = await response.json() + syncFeatureUsageFromApiResponse(data) + return data } /** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */ diff --git a/frontend/src/context/EntitlementsContext.jsx b/frontend/src/context/EntitlementsContext.jsx index 449c944..a714d1e 100644 --- a/frontend/src/context/EntitlementsContext.jsx +++ b/frontend/src/context/EntitlementsContext.jsx @@ -4,10 +4,23 @@ import { getDefaultClubIdForGovernanceForms, getResolvedActiveClubIdForUi, } from '../utils/activeClub' +import { + registerFeatureUsageSyncHandler, + unregisterFeatureUsageSyncHandler, +} from '../utils/featureUsageSync' import { useAuth } from './AuthContext' const EntitlementsContext = createContext(null) +function mergeFeatureUsage(entitlements, featureUsage) { + if (!entitlements || !featureUsage) return entitlements + const features = { ...entitlements.features } + for (const [fid, row] of Object.entries(featureUsage)) { + if (row) features[fid] = { ...features[fid], ...row } + } + return { ...entitlements, features } +} + export function EntitlementsProvider({ children }) { const { user, isAuthenticated, loading: authLoading } = useAuth() const [entitlements, setEntitlements] = useState(null) @@ -38,6 +51,32 @@ export function EntitlementsProvider({ children }) { } }, [isAuthenticated, clubId]) + const refreshEntitlementsQuiet = useCallback(async () => { + if (!isAuthenticated) return null + try { + const data = await getMeEntitlements(clubId) + setEntitlements(data) + return data + } catch { + return null + } + }, [isAuthenticated, clubId]) + + const applyFeatureUsageFromResponse = useCallback( + async (apiResponse) => { + if (apiResponse?.feature_usage) { + setEntitlements((prev) => mergeFeatureUsage(prev, apiResponse.feature_usage)) + } + return refreshEntitlementsQuiet() + }, + [refreshEntitlementsQuiet], + ) + + useEffect(() => { + registerFeatureUsageSyncHandler(applyFeatureUsageFromResponse) + return () => unregisterFeatureUsageSyncHandler() + }, [applyFeatureUsageFromResponse]) + useEffect(() => { if (authLoading) return refreshEntitlements() @@ -49,10 +88,11 @@ export function EntitlementsProvider({ children }) { loading, error, refreshEntitlements, + refreshEntitlementsQuiet, hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]), getFeature: (featureId) => entitlements?.features?.[featureId] ?? null, }), - [entitlements, loading, error, refreshEntitlements], + [entitlements, loading, error, refreshEntitlements, refreshEntitlementsQuiet], ) return ( diff --git a/frontend/src/utils/featureUsageSync.js b/frontend/src/utils/featureUsageSync.js new file mode 100644 index 0000000..e6cf752 --- /dev/null +++ b/frontend/src/utils/featureUsageSync.js @@ -0,0 +1,26 @@ +/** + * Zentraler Abgleich Vereins-Kontingente nach API-Responses. + * + * Backend-Standard: Jeder Endpoint mit Verbrauch liefert ``feature_usage`` + * (siehe club_features.consume_club_feature_with_usage). ``request()`` in + * client.js ruft syncFeatureUsageFromApiResponse() — UI-Komponenten müssen + * nichts Einzelnes tun. + */ + +let usageHandler = null + +export function registerFeatureUsageSyncHandler(handler) { + usageHandler = typeof handler === 'function' ? handler : null +} + +export function unregisterFeatureUsageSyncHandler() { + usageHandler = null +} + +/** Wird von request() nach jedem erfolgreichen JSON-Response aufgerufen. */ +export function syncFeatureUsageFromApiResponse(data) { + if (!data || typeof data !== 'object' || !data.feature_usage) return + if (typeof usageHandler === 'function') { + usageHandler(data) + } +}