Enhance Club Feature Consumption Logic and Update Versioning
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m40s

- 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.
This commit is contained in:
Lars 2026-06-07 10:32:49 +02:00
parent 40641594ac
commit b68185842e
9 changed files with 323 additions and 16 deletions

View File

@ -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 |

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(). */

View File

@ -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 (

View File

@ -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)
}
}