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
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:
parent
40641594ac
commit
b68185842e
|
|
@ -331,10 +331,13 @@ def check_club_feature_access(
|
||||||
5. assert_content_governance(...) # nur bei Objekt-Endpoints
|
5. assert_content_governance(...) # nur bei Objekt-Endpoints
|
||||||
6. check_club_feature_access(club_id, feature_id)
|
6. check_club_feature_access(club_id, feature_id)
|
||||||
7. … Business-Logik …
|
7. … Business-Logik …
|
||||||
8. increment_club_feature_usage(club_id, feature_id) # nur bei INSERT / KI-Execute
|
8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage)
|
||||||
9. optional: log club_feature_usage_events (profile_id)
|
# 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?
|
### 7.4 Wer zählt als Verbrauch?
|
||||||
|
|
||||||
| Aktion | increment | Subjekt |
|
| Aktion | increment | Subjekt |
|
||||||
|
|
|
||||||
|
|
@ -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 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block.
|
||||||
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment.
|
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.
|
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -457,6 +460,130 @@ def consume_club_feature(
|
||||||
conn=conn,
|
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(
|
def increment_club_feature_usage(
|
||||||
club_id: int,
|
club_id: int,
|
||||||
|
|
@ -508,8 +635,6 @@ def increment_club_feature_usage(
|
||||||
(club_id, feature_id, profile_id, action or feature_id),
|
(club_id, feature_id, profile_id, action or feature_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
c.commit()
|
|
||||||
|
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
_run(conn)
|
_run(conn)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,12 @@ from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContex
|
||||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||||
from account_lifecycle import assert_min_account_state
|
from account_lifecycle import assert_min_account_state
|
||||||
from capabilities import probe_capability
|
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 (
|
from exercise_rich_text import (
|
||||||
RICH_HTML_EXERCISE_FIELDS,
|
RICH_HTML_EXERCISE_FIELDS,
|
||||||
|
|
@ -2346,14 +2351,17 @@ def exercise_ai_suggest_endpoint(
|
||||||
want_skills=body.include_skills,
|
want_skills=body.include_skills,
|
||||||
want_instructions=body.include_instructions,
|
want_instructions=body.include_instructions,
|
||||||
)
|
)
|
||||||
consume_club_feature(
|
usage = consume_club_feature_with_usage(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
portal_role=tenant.global_role,
|
portal_role=tenant.global_role,
|
||||||
action="suggest",
|
action="suggest",
|
||||||
|
cur=cur,
|
||||||
|
tenant=tenant,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
payload = merge_feature_usage_into_response(payload, usage)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2412,14 +2420,17 @@ def exercise_ai_regenerate_endpoint(
|
||||||
want_skills=want_skills,
|
want_skills=want_skills,
|
||||||
want_instructions=want_instructions,
|
want_instructions=want_instructions,
|
||||||
)
|
)
|
||||||
consume_club_feature(
|
usage = consume_club_feature_with_usage(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
portal_role=tenant.global_role,
|
portal_role=tenant.global_role,
|
||||||
action="regenerate",
|
action="regenerate",
|
||||||
|
cur=cur,
|
||||||
|
tenant=tenant,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
payload = merge_feature_usage_into_response(payload, usage)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_pl
|
||||||
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
||||||
from account_lifecycle import assert_min_account_state
|
from account_lifecycle import assert_min_account_state
|
||||||
from capabilities import probe_capability
|
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"])
|
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
|
||||||
|
|
||||||
|
|
@ -42,14 +47,17 @@ def post_planning_exercise_suggest(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
|
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||||
if uses_ai:
|
if uses_ai:
|
||||||
consume_club_feature(
|
usage = consume_club_feature_with_usage(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
portal_role=tenant.global_role,
|
portal_role=tenant.global_role,
|
||||||
action="planning_suggest",
|
action="planning_suggest",
|
||||||
|
cur=cur,
|
||||||
|
tenant=tenant,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
result = merge_feature_usage_into_response(result, usage)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -87,12 +95,15 @@ def post_progression_path_suggest(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
result = suggest_progression_path(cur, tenant=tenant, body=body)
|
result = suggest_progression_path(cur, tenant=tenant, body=body)
|
||||||
if uses_ai:
|
if uses_ai:
|
||||||
consume_club_feature(
|
usage = consume_club_feature_with_usage(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
portal_role=tenant.global_role,
|
portal_role=tenant.global_role,
|
||||||
action="progression_path_suggest",
|
action="progression_path_suggest",
|
||||||
|
cur=cur,
|
||||||
|
tenant=tenant,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
result = merge_feature_usage_into_response(result, usage)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from fastapi import HTTPException
|
||||||
from club_features import (
|
from club_features import (
|
||||||
club_feature_enforcement_enabled,
|
club_feature_enforcement_enabled,
|
||||||
consume_club_feature,
|
consume_club_feature,
|
||||||
|
consume_club_feature_with_usage,
|
||||||
|
merge_feature_usage_into_response,
|
||||||
probe_club_feature_access,
|
probe_club_feature_access,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -94,6 +96,45 @@ def test_consume_skips_without_club_id(monkeypatch):
|
||||||
assert calls == []
|
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):
|
def test_consume_increments_once_per_call(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
|
@ -106,6 +147,18 @@ def test_consume_increments_once_per_call(monkeypatch):
|
||||||
lambda *a, **k: False,
|
lambda *a, **k: False,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("club_features.increment_club_feature_usage", _inc)
|
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(
|
consume_club_feature(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
club_id=5,
|
club_id=5,
|
||||||
|
|
@ -117,6 +170,40 @@ def test_consume_increments_once_per_call(monkeypatch):
|
||||||
assert calls == [(5, "ai_calls", "suggest")]
|
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):
|
def test_club_feature_enforcement_env_default_off(monkeypatch):
|
||||||
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
||||||
assert club_feature_enforcement_enabled() is False
|
assert club_feature_enforcement_enabled() is False
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.197"
|
APP_VERSION = "0.8.199"
|
||||||
BUILD_DATE = "2026-06-07"
|
BUILD_DATE = "2026-06-07"
|
||||||
DB_SCHEMA_VERSION = "20260606083"
|
DB_SCHEMA_VERSION = "20260606083"
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ MODULE_VERSIONS = {
|
||||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||||
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
|
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"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
|
"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
|
"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
|
"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
|
"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
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
|
||||||
"planning_exercise_suggest": "0.16.1", # M5: consume_club_feature ai_calls nach KI-Erfolg
|
"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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
* Alle API-Aufrufe laufen über request() — siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js).
|
* 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 || ''
|
export const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
/** LocalStorage + Request-Header für Mandanten-Kontext */
|
/** LocalStorage + Request-Header für Mandanten-Kontext */
|
||||||
|
|
@ -80,7 +82,9 @@ async function _fetchWithAuth(endpoint, options = {}) {
|
||||||
*/
|
*/
|
||||||
export async function request(endpoint, options = {}) {
|
export async function request(endpoint, options = {}) {
|
||||||
const response = await _fetchWithAuth(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(). */
|
/** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,23 @@ import {
|
||||||
getDefaultClubIdForGovernanceForms,
|
getDefaultClubIdForGovernanceForms,
|
||||||
getResolvedActiveClubIdForUi,
|
getResolvedActiveClubIdForUi,
|
||||||
} from '../utils/activeClub'
|
} from '../utils/activeClub'
|
||||||
|
import {
|
||||||
|
registerFeatureUsageSyncHandler,
|
||||||
|
unregisterFeatureUsageSyncHandler,
|
||||||
|
} from '../utils/featureUsageSync'
|
||||||
import { useAuth } from './AuthContext'
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
const EntitlementsContext = createContext(null)
|
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 }) {
|
export function EntitlementsProvider({ children }) {
|
||||||
const { user, isAuthenticated, loading: authLoading } = useAuth()
|
const { user, isAuthenticated, loading: authLoading } = useAuth()
|
||||||
const [entitlements, setEntitlements] = useState(null)
|
const [entitlements, setEntitlements] = useState(null)
|
||||||
|
|
@ -38,6 +51,32 @@ export function EntitlementsProvider({ children }) {
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, clubId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return
|
if (authLoading) return
|
||||||
refreshEntitlements()
|
refreshEntitlements()
|
||||||
|
|
@ -49,10 +88,11 @@ export function EntitlementsProvider({ children }) {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refreshEntitlements,
|
refreshEntitlements,
|
||||||
|
refreshEntitlementsQuiet,
|
||||||
hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]),
|
hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]),
|
||||||
getFeature: (featureId) => entitlements?.features?.[featureId] ?? null,
|
getFeature: (featureId) => entitlements?.features?.[featureId] ?? null,
|
||||||
}),
|
}),
|
||||||
[entitlements, loading, error, refreshEntitlements],
|
[entitlements, loading, error, refreshEntitlements, refreshEntitlementsQuiet],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
26
frontend/src/utils/featureUsageSync.js
Normal file
26
frontend/src/utils/featureUsageSync.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user