Implement Club Feature Quota Bypass and Update Versioning
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 42s
Test Suite / playwright-tests (push) Successful in 1m19s
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 42s
Test Suite / playwright-tests (push) Successful in 1m19s
- Added support for club feature quota bypass based on portal roles and profile grants in the capabilities check. - Introduced new functions to handle quota bypass logic in club feature access and consumption. - Updated the FeatureUsageBadge component to reflect platform exemptions for features. - Incremented application version to 0.8.195 and database schema version to 20260606083 to reflect these changes. - Enhanced backend routers to include new logic for consuming club features during AI-related actions.
This commit is contained in:
parent
fa10450315
commit
8404a42b6c
|
|
@ -81,6 +81,49 @@ def check_capability(
|
||||||
|
|
||||||
domain = (cap.get("domain") or "").strip().lower()
|
domain = (cap.get("domain") or "").strip().lower()
|
||||||
|
|
||||||
|
# Kontingent-Bypass (konfigurierbar per portal_role / profile grants, ohne Plattform-Admin-Pflicht)
|
||||||
|
if domain == "quota_bypass":
|
||||||
|
role_lc = (tenant.global_role or "").lower()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role_lc, capability_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "quota_bypass_portal_grant",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(tenant.profile_id, capability_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "quota_bypass_profile_grant",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "quota_bypass_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
|
|
||||||
# Plattform-Capabilities
|
# Plattform-Capabilities
|
||||||
if domain == "platform" or capability_id.startswith("platform."):
|
if domain == "platform" or capability_id.startswith("platform."):
|
||||||
role_lc = (tenant.global_role or "").lower()
|
role_lc = (tenant.global_role or "").lower()
|
||||||
|
|
@ -101,13 +144,22 @@ def check_capability(
|
||||||
(role_lc, capability_id),
|
(role_lc, capability_id),
|
||||||
)
|
)
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
return {
|
cur.execute(
|
||||||
"allowed": False,
|
"""
|
||||||
"reason": "portal_capability_denied",
|
SELECT 1 FROM profile_capability_grants
|
||||||
"account_state": account_state,
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
"club_roles": club_roles,
|
LIMIT 1
|
||||||
"linked_feature_id": cap.get("linked_feature_id"),
|
""",
|
||||||
}
|
(tenant.profile_id, capability_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "portal_capability_denied",
|
||||||
|
"account_state": account_state,
|
||||||
|
"club_roles": club_roles,
|
||||||
|
"linked_feature_id": cap.get("linked_feature_id"),
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"allowed": True,
|
"allowed": True,
|
||||||
"reason": "portal_granted",
|
"reason": "portal_granted",
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,7 @@ def probe_club_feature_access(
|
||||||
action: str,
|
action: str,
|
||||||
club_id: Optional[int] = None,
|
club_id: Optional[int] = None,
|
||||||
profile_id: Optional[int] = None,
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
endpoint: Optional[str] = None,
|
endpoint: Optional[str] = None,
|
||||||
conn=None,
|
conn=None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
@ -358,11 +359,29 @@ def probe_club_feature_access(
|
||||||
)
|
)
|
||||||
return access
|
return access
|
||||||
|
|
||||||
|
def _resolve_access(connection):
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
|
|
||||||
|
cur = get_cursor(connection)
|
||||||
|
if is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
):
|
||||||
|
plan_id = get_effective_club_plan(cur, int(club_id))
|
||||||
|
return quota_bypass_access(
|
||||||
|
feature_id=feature_id,
|
||||||
|
club_id=int(club_id),
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
return check_club_feature_access(club_id, feature_id, conn=connection)
|
||||||
|
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
access = check_club_feature_access(club_id, feature_id, conn=conn)
|
access = _resolve_access(conn)
|
||||||
else:
|
else:
|
||||||
with get_db() as c:
|
with get_db() as c:
|
||||||
access = check_club_feature_access(club_id, feature_id, conn=c)
|
access = _resolve_access(c)
|
||||||
|
|
||||||
log_club_feature_usage(
|
log_club_feature_usage(
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
|
|
@ -387,6 +406,58 @@ def probe_club_feature_access(
|
||||||
return access
|
return access
|
||||||
|
|
||||||
|
|
||||||
|
def consume_club_feature(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
portal_role: Optional[str] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
amount: int = 1,
|
||||||
|
conn=None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Phase 4 (M5): Zähler nach erfolgreichem Verbrauch erhöhen.
|
||||||
|
Nur wenn club_id gesetzt (Vereins-Kontingent); amount = Anzahl LLM/API-Verbrauchseinheiten.
|
||||||
|
Plattform-Ausnahmen (superadmin, konfigurierte Rollen/Profile) werden nicht gezählt.
|
||||||
|
"""
|
||||||
|
if club_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _is_exempt(connection) -> bool:
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed
|
||||||
|
|
||||||
|
cur = get_cursor(connection)
|
||||||
|
return is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
portal_role=portal_role,
|
||||||
|
feature_id=feature_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
if _is_exempt(conn):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
if _is_exempt(c):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
n = int(amount)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
if n < 1:
|
||||||
|
return
|
||||||
|
for _ in range(n):
|
||||||
|
increment_club_feature_usage(
|
||||||
|
int(club_id),
|
||||||
|
feature_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
action=action,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def increment_club_feature_usage(
|
def increment_club_feature_usage(
|
||||||
club_id: int,
|
club_id: int,
|
||||||
feature_id: str,
|
feature_id: str,
|
||||||
|
|
|
||||||
180
backend/club_quota_bypass.py
Normal file
180
backend/club_quota_bypass.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
Vereins-Kontingent-Bypass über das Capability-System (kein Parallel-Rechtemodell).
|
||||||
|
|
||||||
|
Capabilities:
|
||||||
|
- platform.club_quota.bypass — alle Vereins-Features (Portal-Admin, Grant via portal_role)
|
||||||
|
- platform.club_quota.bypass.{feature_id} — ein Feature (domain quota_bypass, auch für Nicht-Admins per Grant)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
QUOTA_BYPASS_ALL = "platform.club_quota.bypass"
|
||||||
|
QUOTA_BYPASS_FEATURE_PREFIX = "platform.club_quota.bypass."
|
||||||
|
|
||||||
|
|
||||||
|
def quota_bypass_capability_id_for_feature(feature_id: str) -> str:
|
||||||
|
return f"{QUOTA_BYPASS_FEATURE_PREFIX}{feature_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_quota_bypass_capability(cur, feature_id: str) -> str:
|
||||||
|
"""Legt feature-spezifische Bypass-Capability an falls nötig."""
|
||||||
|
cap_id = quota_bypass_capability_id_for_feature(feature_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (%s, %s, 'quota_bypass', 'active_member', %s)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(cap_id, f"Vereins-Kontingent umgehen: {feature_id}", feature_id),
|
||||||
|
)
|
||||||
|
return cap_id
|
||||||
|
|
||||||
|
|
||||||
|
def _bypass_capability_ids(cur, feature_id: str) -> List[str]:
|
||||||
|
ids: List[str] = [QUOTA_BYPASS_ALL, quota_bypass_capability_id_for_feature(feature_id)]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM capabilities
|
||||||
|
WHERE active = true
|
||||||
|
AND domain = 'quota_bypass'
|
||||||
|
AND linked_feature_id = %s
|
||||||
|
AND id <> %s
|
||||||
|
""",
|
||||||
|
(feature_id, quota_bypass_capability_id_for_feature(feature_id)),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
cid = row.get("id")
|
||||||
|
if cid and cid not in ids:
|
||||||
|
ids.append(str(cid))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_role_has_grant(cur, portal_role: str, capability_id: str) -> bool:
|
||||||
|
role = (portal_role or "").strip().lower()
|
||||||
|
if not role:
|
||||||
|
return False
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role, capability_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_has_grant(cur, profile_id: int, capability_id: str) -> bool:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(profile_id), capability_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_club_feature_quota_bypassed(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
profile_id: Optional[int],
|
||||||
|
portal_role: Optional[str],
|
||||||
|
feature_id: str,
|
||||||
|
tenant: Optional["TenantContext"] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
True wenn ein konfigurierter Capability-Grant das Vereins-Kontingent für feature_id umgeht.
|
||||||
|
"""
|
||||||
|
if tenant is not None:
|
||||||
|
from capabilities import check_capability
|
||||||
|
|
||||||
|
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||||
|
if check_capability(cur, tenant, cap_id).get("allowed"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||||
|
if _portal_role_has_grant(cur, portal_role or "", cap_id):
|
||||||
|
return True
|
||||||
|
if profile_id is not None and _profile_has_grant(cur, int(profile_id), cap_id):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def quota_bypass_access(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
plan_id: Optional[str] = None,
|
||||||
|
capability_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"limit": None,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": None,
|
||||||
|
"reason": "capability_quota_bypass",
|
||||||
|
"platform_exempt": True,
|
||||||
|
"quota_bypass_capability": capability_id,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"club_id": club_id,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_quota_bypass_grants(cur) -> Dict[str, Any]:
|
||||||
|
"""Admin: alle Grants zu Kontingent-Bypass-Capabilities."""
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.portal_role, g.capability_id, c.name AS capability_name,
|
||||||
|
c.linked_feature_id, c.domain
|
||||||
|
FROM portal_role_capability_grants g
|
||||||
|
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||||
|
WHERE g.capability_id = %s
|
||||||
|
OR g.capability_id LIKE %s
|
||||||
|
OR c.domain = 'quota_bypass'
|
||||||
|
ORDER BY g.portal_role, g.capability_id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
portal_grants = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT g.profile_id, p.email, p.name AS profile_name,
|
||||||
|
g.capability_id, c.name AS capability_name, c.linked_feature_id,
|
||||||
|
g.reason, g.granted_by_profile_id, g.created_at
|
||||||
|
FROM profile_capability_grants g
|
||||||
|
INNER JOIN profiles p ON p.id = g.profile_id
|
||||||
|
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||||
|
WHERE g.capability_id = %s
|
||||||
|
OR g.capability_id LIKE %s
|
||||||
|
OR c.domain = 'quota_bypass'
|
||||||
|
ORDER BY g.profile_id, g.capability_id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
profile_grants = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, domain, linked_feature_id
|
||||||
|
FROM capabilities
|
||||||
|
WHERE id = %s OR id LIKE %s OR domain = 'quota_bypass'
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||||
|
)
|
||||||
|
capabilities = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"portal_role_grants": portal_grants,
|
||||||
|
"profile_grants": profile_grants,
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from capabilities import club_roles_in_club, resolve_capabilities_map
|
from capabilities import club_roles_in_club, resolve_capabilities_map
|
||||||
|
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||||
from club_features import club_features_map
|
from club_features import club_features_map
|
||||||
from club_tenancy import is_platform_admin
|
from club_tenancy import is_platform_admin
|
||||||
from tenant_context import _club_exists
|
from tenant_context import _club_exists
|
||||||
|
|
@ -69,14 +70,37 @@ def build_me_entitlements(
|
||||||
raw = club_features_map(cur, target_club)
|
raw = club_features_map(cur, target_club)
|
||||||
plan_id = raw.get("plan_id")
|
plan_id = raw.get("plan_id")
|
||||||
for fid, row in (raw.get("features") or {}).items():
|
for fid, row in (raw.get("features") or {}).items():
|
||||||
features[fid] = {
|
if is_club_feature_quota_bypassed(
|
||||||
"allowed": row.get("allowed"),
|
cur,
|
||||||
"used": row.get("used"),
|
profile_id=tenant.profile_id,
|
||||||
"limit": row.get("limit"),
|
portal_role=tenant.global_role,
|
||||||
"remaining": row.get("remaining"),
|
feature_id=fid,
|
||||||
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
tenant=tenant,
|
||||||
"reason": row.get("reason"),
|
):
|
||||||
}
|
ex = quota_bypass_access(
|
||||||
|
feature_id=fid,
|
||||||
|
club_id=target_club,
|
||||||
|
plan_id=plan_id,
|
||||||
|
)
|
||||||
|
features[fid] = {
|
||||||
|
"allowed": True,
|
||||||
|
"used": row.get("used"),
|
||||||
|
"limit": None,
|
||||||
|
"remaining": None,
|
||||||
|
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||||
|
"reason": ex.get("reason"),
|
||||||
|
"platform_exempt": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
features[fid] = {
|
||||||
|
"allowed": row.get("allowed"),
|
||||||
|
"used": row.get("used"),
|
||||||
|
"limit": row.get("limit"),
|
||||||
|
"remaining": row.get("remaining"),
|
||||||
|
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||||
|
"reason": row.get("reason"),
|
||||||
|
"platform_exempt": False,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"account_state": tenant.account_state,
|
"account_state": tenant.account_state,
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_club_feature_exemptions, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -233,6 +233,7 @@ app.include_router(club_join_requests.router)
|
||||||
app.include_router(club_creation_requests.router)
|
app.include_router(club_creation_requests.router)
|
||||||
app.include_router(admin_users.router)
|
app.include_router(admin_users.router)
|
||||||
app.include_router(admin_user_content.router)
|
app.include_router(admin_user_content.router)
|
||||||
|
app.include_router(admin_club_feature_exemptions.router)
|
||||||
app.include_router(me_entitlements.router)
|
app.include_router(me_entitlements.router)
|
||||||
app.include_router(platform_media_storage.router)
|
app.include_router(platform_media_storage.router)
|
||||||
app.include_router(media_assets.router)
|
app.include_router(media_assets.router)
|
||||||
|
|
|
||||||
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Migration 082: Plattform-/Profil-Ausnahmen vom Vereins-Kontingent (M5+)
|
||||||
|
-- Superadmin & konfigurierbare Rollen/Profile verbrauchen kein club_feature_usage.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_role_club_feature_exemptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
portal_role TEXT NOT NULL,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_role_club_feat_exempt
|
||||||
|
ON platform_role_club_feature_exemptions (portal_role, COALESCE(feature_id, '*'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_club_feature_exemptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_profile_club_feat_exempt
|
||||||
|
ON profile_club_feature_exemptions (profile_id, COALESCE(feature_id, '*'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_club_feat_exempt_profile
|
||||||
|
ON profile_club_feature_exemptions (profile_id);
|
||||||
|
|
||||||
|
-- Superadmin: alle Vereins-Features ohne Kontingent-Verbrauch
|
||||||
|
INSERT INTO platform_role_club_feature_exemptions (portal_role, feature_id, note)
|
||||||
|
SELECT 'superadmin', NULL, 'Plattform-Administrator: kein Vereins-Kontingent'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM platform_role_club_feature_exemptions
|
||||||
|
WHERE portal_role = 'superadmin' AND feature_id IS NULL
|
||||||
|
);
|
||||||
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
-- Migration 083: Vereins-Kontingent-Bypass über Capability-System (kein Parallel-Schema)
|
||||||
|
-- Ersetzt platform_role_club_feature_exemptions / profile_club_feature_exemptions aus 082.
|
||||||
|
|
||||||
|
-- Einzelprofil-Grants (ergänzt portal_role_capability_grants)
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_capability_grants (
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
granted_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (profile_id, capability_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_capability_grants_cap
|
||||||
|
ON profile_capability_grants(capability_id);
|
||||||
|
|
||||||
|
-- Bypass-Capabilities (CAPABILITY_CATALOG — konfigurierbar via portal/profile grants)
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'platform.club_quota.bypass',
|
||||||
|
'Vereins-Kontingent umgehen (alle Features)',
|
||||||
|
'platform',
|
||||||
|
'platform_admin',
|
||||||
|
NULL
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Superadmin: alle Plattform-Capabilities inkl. bypass (079-Seed deckt domain=platform ab)
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
SELECT 'superadmin', 'platform.club_quota.bypass'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = 'superadmin' AND capability_id = 'platform.club_quota.bypass'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Daten aus 082 übernehmen (falls vorhanden) ─────────────────────────────
|
||||||
|
DO $migrate082$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
cap_id TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'platform_role_club_feature_exemptions'
|
||||||
|
) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT portal_role, feature_id, note
|
||||||
|
FROM platform_role_club_feature_exemptions
|
||||||
|
LOOP
|
||||||
|
IF r.feature_id IS NULL THEN
|
||||||
|
cap_id := 'platform.club_quota.bypass';
|
||||||
|
ELSE
|
||||||
|
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (
|
||||||
|
cap_id,
|
||||||
|
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||||
|
'quota_bypass',
|
||||||
|
'active_member',
|
||||||
|
r.feature_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
VALUES (lower(trim(r.portal_role)), cap_id)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT profile_id, feature_id, reason, set_by_profile_id
|
||||||
|
FROM profile_club_feature_exemptions
|
||||||
|
LOOP
|
||||||
|
IF r.feature_id IS NULL THEN
|
||||||
|
cap_id := 'platform.club_quota.bypass';
|
||||||
|
ELSE
|
||||||
|
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||||
|
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||||
|
VALUES (
|
||||||
|
cap_id,
|
||||||
|
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||||
|
'quota_bypass',
|
||||||
|
'active_member',
|
||||||
|
r.feature_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO profile_capability_grants (
|
||||||
|
profile_id, capability_id, reason, granted_by_profile_id
|
||||||
|
)
|
||||||
|
VALUES (r.profile_id, cap_id, r.reason, r.set_by_profile_id)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS profile_club_feature_exemptions;
|
||||||
|
DROP TABLE IF EXISTS platform_role_club_feature_exemptions;
|
||||||
|
END
|
||||||
|
$migrate082$;
|
||||||
227
backend/routers/admin_club_feature_exemptions.py
Normal file
227
backend/routers/admin_club_feature_exemptions.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
"""
|
||||||
|
Superadmin: Kontingent-Bypass über Capability-Grants (portal_role / profile).
|
||||||
|
|
||||||
|
Kein separates Exemption-Schema — nutzt portal_role_capability_grants und
|
||||||
|
profile_capability_grants mit IDs platform.club_quota.bypass[.feature_id].
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from club_quota_bypass import (
|
||||||
|
QUOTA_BYPASS_ALL,
|
||||||
|
ensure_quota_bypass_capability,
|
||||||
|
list_quota_bypass_grants,
|
||||||
|
quota_bypass_capability_id_for_feature,
|
||||||
|
)
|
||||||
|
from club_tenancy import is_superadmin
|
||||||
|
from db import get_db, get_cursor, r2d
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin", tags=["admin_capability_grants"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_superadmin(session: dict) -> None:
|
||||||
|
if not is_superadmin(session.get("role")):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur Super-Administratoren")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_capability_id(cur, feature_id: Optional[str]) -> str:
|
||||||
|
fid = (feature_id or "").strip() or None
|
||||||
|
if not fid:
|
||||||
|
return QUOTA_BYPASS_ALL
|
||||||
|
cur.execute("SELECT 1 FROM features WHERE id = %s", (fid,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=400, detail="Unbekanntes Feature")
|
||||||
|
return ensure_quota_bypass_capability(cur, fid)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalGrantBody(BaseModel):
|
||||||
|
portal_role: str = Field(..., min_length=1, max_length=50)
|
||||||
|
feature_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Feature-ID oder leer = alle Vereins-Features (platform.club_quota.bypass)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileGrantBody(BaseModel):
|
||||||
|
feature_id: Optional[str] = Field(None, description="Feature-ID oder leer = alle Features")
|
||||||
|
reason: Optional[str] = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/club-feature-exemptions")
|
||||||
|
def list_club_feature_exemptions(session: dict = Depends(require_auth)):
|
||||||
|
"""Übersicht Kontingent-Bypass-Grants (Capability-System)."""
|
||||||
|
_require_superadmin(session)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
return list_quota_bypass_grants(cur)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capability-grants/club-quota-bypass")
|
||||||
|
def list_quota_bypass_capability_grants(session: dict = Depends(require_auth)):
|
||||||
|
"""Alias — gleiche Daten wie /club-feature-exemptions."""
|
||||||
|
return list_club_feature_exemptions(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/club-feature-exemptions/roles", status_code=201)
|
||||||
|
@router.post("/capability-grants/club-quota-bypass/portal-roles", status_code=201)
|
||||||
|
def add_portal_quota_bypass_grant(body: PortalGrantBody, session: dict = Depends(require_auth)):
|
||||||
|
_require_superadmin(session)
|
||||||
|
role = body.portal_role.strip().lower()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cap_id = _resolve_capability_id(cur, body.feature_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(role, cap_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
raise HTTPException(status_code=409, detail="Grant existiert bereits")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING portal_role, capability_id
|
||||||
|
""",
|
||||||
|
(role, cap_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
out = r2d(row)
|
||||||
|
out["capability_id"] = cap_id
|
||||||
|
if body.feature_id:
|
||||||
|
out["feature_id"] = body.feature_id.strip()
|
||||||
|
else:
|
||||||
|
out["feature_id"] = None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/club-feature-exemptions/roles/{exemption_id}")
|
||||||
|
def delete_legacy_role_exemption(exemption_id: int, session: dict = Depends(require_auth)):
|
||||||
|
"""Legacy-Pfad: exemption_id = portal_role_capability_grants nicht unterstützt — 410."""
|
||||||
|
_require_superadmin(session)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=410,
|
||||||
|
detail="Bitte DELETE /api/admin/capability-grants/club-quota-bypass/portal-roles nutzen",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/capability-grants/club-quota-bypass/portal-roles")
|
||||||
|
def delete_portal_quota_bypass_grant(
|
||||||
|
portal_role: str,
|
||||||
|
capability_id: Optional[str] = None,
|
||||||
|
feature_id: Optional[str] = None,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
_require_superadmin(session)
|
||||||
|
role = portal_role.strip().lower()
|
||||||
|
cap_id = capability_id
|
||||||
|
if not cap_id:
|
||||||
|
cap_id = QUOTA_BYPASS_ALL if not (feature_id or "").strip() else quota_bypass_capability_id_for_feature(
|
||||||
|
feature_id.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM portal_role_capability_grants
|
||||||
|
WHERE portal_role = %s AND capability_id = %s
|
||||||
|
RETURNING portal_role, capability_id
|
||||||
|
""",
|
||||||
|
(role, cap_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/club-feature-exemptions/profiles/{profile_id}", status_code=201)
|
||||||
|
@router.post("/capability-grants/club-quota-bypass/profiles/{profile_id}", status_code=201)
|
||||||
|
def add_profile_quota_bypass_grant(
|
||||||
|
profile_id: int,
|
||||||
|
body: ProfileGrantBody,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
_require_superadmin(session)
|
||||||
|
admin_pid = int(session["profile_id"])
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (profile_id,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||||
|
|
||||||
|
cap_id = _resolve_capability_id(cur, body.feature_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(profile_id, cap_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
raise HTTPException(status_code=409, detail="Grant existiert bereits")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO profile_capability_grants (
|
||||||
|
profile_id, capability_id, reason, granted_by_profile_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING profile_id, capability_id, reason, granted_by_profile_id, created_at
|
||||||
|
""",
|
||||||
|
(profile_id, cap_id, (body.reason or "").strip() or None, admin_pid),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return r2d(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/club-feature-exemptions/profiles/{exemption_id}")
|
||||||
|
def delete_legacy_profile_exemption(exemption_id: int, session: dict = Depends(require_auth)):
|
||||||
|
_require_superadmin(session)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=410,
|
||||||
|
detail="Bitte DELETE /api/admin/capability-grants/club-quota-bypass/profiles nutzen",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/capability-grants/club-quota-bypass/profiles")
|
||||||
|
def delete_profile_quota_bypass_grant(
|
||||||
|
profile_id: int,
|
||||||
|
capability_id: Optional[str] = None,
|
||||||
|
feature_id: Optional[str] = None,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
_require_superadmin(session)
|
||||||
|
cap_id = capability_id
|
||||||
|
if not cap_id:
|
||||||
|
cap_id = QUOTA_BYPASS_ALL if not (feature_id or "").strip() else quota_bypass_capability_id_for_feature(
|
||||||
|
feature_id.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM profile_capability_grants
|
||||||
|
WHERE profile_id = %s AND capability_id = %s
|
||||||
|
RETURNING profile_id, capability_id
|
||||||
|
""",
|
||||||
|
(profile_id, cap_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
@ -39,7 +39,7 @@ 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 probe_club_feature_access, resolve_club_id_for_probe
|
from club_features import consume_club_feature, 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,
|
||||||
|
|
@ -2321,18 +2321,20 @@ def exercise_ai_suggest_endpoint(
|
||||||
OPENROUTER_API_KEY erforderlich.
|
OPENROUTER_API_KEY erforderlich.
|
||||||
"""
|
"""
|
||||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest")
|
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest")
|
||||||
|
club_id = resolve_club_id_for_probe(tenant)
|
||||||
probe_capability(
|
probe_capability(
|
||||||
tenant,
|
tenant,
|
||||||
"exercises.ai.suggest",
|
"exercises.ai.suggest",
|
||||||
action="suggest",
|
action="suggest",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
endpoint="POST /exercises/ai/suggest",
|
endpoint="POST /exercises/ai/suggest",
|
||||||
)
|
)
|
||||||
probe_club_feature_access(
|
probe_club_feature_access(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
action="suggest",
|
action="suggest",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
endpoint="POST /exercises/ai/suggest",
|
endpoint="POST /exercises/ai/suggest",
|
||||||
)
|
)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -2344,6 +2346,14 @@ 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(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
|
action="suggest",
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2355,18 +2365,20 @@ def exercise_ai_regenerate_endpoint(
|
||||||
):
|
):
|
||||||
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
|
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
|
||||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate")
|
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate")
|
||||||
|
club_id = resolve_club_id_for_probe(tenant)
|
||||||
probe_capability(
|
probe_capability(
|
||||||
tenant,
|
tenant,
|
||||||
"exercises.ai.regenerate",
|
"exercises.ai.regenerate",
|
||||||
action="regenerate",
|
action="regenerate",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
endpoint="POST /exercises/{id}/ai/regenerate",
|
endpoint="POST /exercises/{id}/ai/regenerate",
|
||||||
)
|
)
|
||||||
probe_club_feature_access(
|
probe_club_feature_access(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
action="regenerate",
|
action="regenerate",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
endpoint="POST /exercises/{id}/ai/regenerate",
|
endpoint="POST /exercises/{id}/ai/regenerate",
|
||||||
)
|
)
|
||||||
want_summary = "summary" in body.regenerate
|
want_summary = "summary" in body.regenerate
|
||||||
|
|
@ -2400,6 +2412,14 @@ def exercise_ai_regenerate_endpoint(
|
||||||
want_skills=want_skills,
|
want_skills=want_skills,
|
||||||
want_instructions=want_instructions,
|
want_instructions=want_instructions,
|
||||||
)
|
)
|
||||||
|
consume_club_feature(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
|
action="regenerate",
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2467,6 +2487,7 @@ def create_exercise(
|
||||||
action="create",
|
action="create",
|
||||||
club_id=int(club_id),
|
club_id=int(club_id),
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
endpoint="POST /exercises",
|
endpoint="POST /exercises",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -3285,6 +3306,7 @@ async def upload_exercise_media(
|
||||||
action="upload",
|
action="upload",
|
||||||
club_id=int(media_club_id),
|
club_id=int(media_club_id),
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
endpoint="POST /exercises/{id}/media",
|
endpoint="POST /exercises/{id}/media",
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ 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 probe_club_feature_access, resolve_club_id_for_probe
|
from club_features import consume_club_feature, 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"])
|
||||||
|
|
||||||
|
|
@ -19,25 +19,38 @@ def post_planning_exercise_suggest(
|
||||||
body: PlanningExerciseSuggestRequest,
|
body: PlanningExerciseSuggestRequest,
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
if body.include_llm_intent or body.include_llm_rank:
|
uses_ai = body.include_llm_intent or body.include_llm_rank
|
||||||
|
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
|
||||||
|
if uses_ai:
|
||||||
assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest")
|
assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest")
|
||||||
probe_capability(
|
probe_capability(
|
||||||
tenant,
|
tenant,
|
||||||
"planning.ai.suggest",
|
"planning.ai.suggest",
|
||||||
action="planning_suggest",
|
action="planning_suggest",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
endpoint="POST /planning/exercise-suggest",
|
endpoint="POST /planning/exercise-suggest",
|
||||||
)
|
)
|
||||||
probe_club_feature_access(
|
probe_club_feature_access(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
action="planning_suggest",
|
action="planning_suggest",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
endpoint="POST /planning/exercise-suggest",
|
endpoint="POST /planning/exercise-suggest",
|
||||||
)
|
)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
return suggest_planning_exercises(cur, tenant=tenant, body=body)
|
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||||
|
if uses_ai:
|
||||||
|
consume_club_feature(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
|
action="planning_suggest",
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/progression-path-suggest")
|
@router.post("/progression-path-suggest")
|
||||||
|
|
@ -45,11 +58,13 @@ def post_progression_path_suggest(
|
||||||
body: ProgressionPathSuggestRequest,
|
body: ProgressionPathSuggestRequest,
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
if (
|
uses_ai = (
|
||||||
body.include_llm_intent
|
body.include_llm_intent
|
||||||
or body.include_llm_path_qa
|
or body.include_llm_path_qa
|
||||||
or body.include_ai_gap_fill
|
or body.include_ai_gap_fill
|
||||||
):
|
)
|
||||||
|
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
|
||||||
|
if uses_ai:
|
||||||
assert_min_account_state(
|
assert_min_account_state(
|
||||||
tenant, "active_member", endpoint="POST /planning/progression-path-suggest"
|
tenant, "active_member", endpoint="POST /planning/progression-path-suggest"
|
||||||
)
|
)
|
||||||
|
|
@ -57,16 +72,27 @@ def post_progression_path_suggest(
|
||||||
tenant,
|
tenant,
|
||||||
"planning.ai.progression_path",
|
"planning.ai.progression_path",
|
||||||
action="progression_path_suggest",
|
action="progression_path_suggest",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
endpoint="POST /planning/progression-path-suggest",
|
endpoint="POST /planning/progression-path-suggest",
|
||||||
)
|
)
|
||||||
probe_club_feature_access(
|
probe_club_feature_access(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
action="progression_path_suggest",
|
action="progression_path_suggest",
|
||||||
club_id=resolve_club_id_for_probe(tenant),
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
endpoint="POST /planning/progression-path-suggest",
|
endpoint="POST /planning/progression-path-suggest",
|
||||||
)
|
)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
return suggest_progression_path(cur, tenant=tenant, body=body)
|
result = suggest_progression_path(cur, tenant=tenant, body=body)
|
||||||
|
if uses_ai:
|
||||||
|
consume_club_feature(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
club_id=club_id,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
portal_role=tenant.global_role,
|
||||||
|
action="progression_path_suggest",
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
|
||||||
138
backend/tests/test_club_feature_exemptions.py
Normal file
138
backend/tests/test_club_feature_exemptions.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Kontingent-Bypass über Capability-Grants (kein Env-Hardcoding)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from club_quota_bypass import (
|
||||||
|
QUOTA_BYPASS_ALL,
|
||||||
|
is_club_feature_quota_bypassed,
|
||||||
|
quota_bypass_access,
|
||||||
|
)
|
||||||
|
from club_features import consume_club_feature, probe_club_feature_access
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCur:
|
||||||
|
def __init__(self, *, portal_grants=None, profile_grants=None):
|
||||||
|
self._portal_grants = set(portal_grants or ())
|
||||||
|
self._profile_grants = set(profile_grants or ())
|
||||||
|
self._last_sql = ""
|
||||||
|
self._last_params = ()
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
self._last_sql = (sql or "").lower()
|
||||||
|
self._last_params = params or ()
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
if "portal_role_capability_grants" in self._last_sql:
|
||||||
|
role, cap = self._last_params[:2]
|
||||||
|
if (role, cap) in self._portal_grants:
|
||||||
|
return {"1": 1}
|
||||||
|
if "profile_capability_grants" in self._last_sql:
|
||||||
|
pid, cap = self._last_params[:2]
|
||||||
|
if (int(pid), cap) in self._profile_grants:
|
||||||
|
return {"1": 1}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_role_grant_bypasses():
|
||||||
|
cur = _FakeCur(portal_grants={("superadmin", QUOTA_BYPASS_ALL)})
|
||||||
|
assert is_club_feature_quota_bypassed(
|
||||||
|
cur, profile_id=1, portal_role="superadmin", feature_id="ai_calls"
|
||||||
|
)
|
||||||
|
assert not is_club_feature_quota_bypassed(
|
||||||
|
cur, profile_id=1, portal_role="trainer", feature_id="ai_calls"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_grant_bypasses():
|
||||||
|
cap = "platform.club_quota.bypass.ai_calls"
|
||||||
|
cur = _FakeCur(profile_grants={(42, cap)})
|
||||||
|
assert is_club_feature_quota_bypassed(
|
||||||
|
cur, profile_id=42, portal_role="trainer", feature_id="ai_calls"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_superadmin_bypasses_enforce(monkeypatch):
|
||||||
|
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
|
||||||
|
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||||
|
|
||||||
|
class Cur:
|
||||||
|
def execute(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return {"1": 1}
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("club_features.get_cursor", lambda c: Cur())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"club_features.get_effective_club_plan",
|
||||||
|
lambda cur, club_id: "free",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"club_quota_bypass._portal_role_has_grant",
|
||||||
|
lambda cur, role, cap: role == "superadmin",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"club_quota_bypass._profile_has_grant",
|
||||||
|
lambda cur, pid, cap: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
access = probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="suggest",
|
||||||
|
club_id=5,
|
||||||
|
profile_id=1,
|
||||||
|
portal_role="superadmin",
|
||||||
|
conn=object(),
|
||||||
|
)
|
||||||
|
assert access["allowed"] is True
|
||||||
|
assert access["reason"] == "capability_quota_bypass"
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_skips_for_bypass_grant(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"club_features.increment_club_feature_usage",
|
||||||
|
lambda *a, **k: calls.append(1),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Cur:
|
||||||
|
def execute(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return {"1": 1}
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("club_features.get_cursor", lambda c: Cur())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"club_quota_bypass._portal_role_has_grant",
|
||||||
|
lambda cur, role, cap: role == "superadmin",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"club_quota_bypass._profile_has_grant",
|
||||||
|
lambda cur, pid, cap: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
consume_club_feature(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
club_id=9,
|
||||||
|
profile_id=1,
|
||||||
|
portal_role="superadmin",
|
||||||
|
conn=object(),
|
||||||
|
)
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_quota_bypass_access_shape():
|
||||||
|
row = quota_bypass_access(feature_id="ai_calls", club_id=3, plan_id="free")
|
||||||
|
assert row["platform_exempt"] is True
|
||||||
|
assert row["limit"] is None
|
||||||
|
assert row["allowed"] is True
|
||||||
|
assert row["reason"] == "capability_quota_bypass"
|
||||||
122
backend/tests/test_club_feature_m5.py
Normal file
122
backend/tests/test_club_feature_m5.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""M5: ai_calls Verbrauch + Hard-Block (CLUB_FEATURE_ENFORCE)."""
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from club_features import (
|
||||||
|
club_feature_enforcement_enabled,
|
||||||
|
consume_club_feature,
|
||||||
|
probe_club_feature_access,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_cur():
|
||||||
|
class C:
|
||||||
|
def execute(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return C()
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_blocks_when_enforce_and_limit_exceeded(monkeypatch):
|
||||||
|
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
|
||||||
|
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.check_club_feature_access",
|
||||||
|
lambda club_id, feature_id, conn=None: {
|
||||||
|
"allowed": False,
|
||||||
|
"limit": 0,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": 0,
|
||||||
|
"reason": "feature_disabled",
|
||||||
|
"plan_id": "free",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="suggest",
|
||||||
|
club_id=12,
|
||||||
|
profile_id=3,
|
||||||
|
endpoint="POST /exercises/ai/suggest",
|
||||||
|
conn=object(),
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert "ai_calls" in str(exc.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_allows_when_enforce_off(monkeypatch):
|
||||||
|
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "0")
|
||||||
|
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.check_club_feature_access",
|
||||||
|
lambda club_id, feature_id, conn=None: {
|
||||||
|
"allowed": False,
|
||||||
|
"limit": 0,
|
||||||
|
"used": 0,
|
||||||
|
"remaining": 0,
|
||||||
|
"reason": "feature_disabled",
|
||||||
|
"plan_id": "free",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||||
|
|
||||||
|
access = probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="suggest",
|
||||||
|
club_id=12,
|
||||||
|
profile_id=3,
|
||||||
|
conn=object(),
|
||||||
|
)
|
||||||
|
assert access["allowed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_skips_without_club_id(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def _inc(*args, **kwargs):
|
||||||
|
calls.append(1)
|
||||||
|
|
||||||
|
monkeypatch.setattr("club_features.increment_club_feature_usage", _inc)
|
||||||
|
consume_club_feature(feature_id="ai_calls", club_id=None, profile_id=1)
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_increments_once_per_call(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def _inc(club_id, feature_id, **kwargs):
|
||||||
|
calls.append((club_id, feature_id, kwargs.get("action")))
|
||||||
|
|
||||||
|
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", _inc)
|
||||||
|
consume_club_feature(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
club_id=5,
|
||||||
|
profile_id=9,
|
||||||
|
portal_role="trainer",
|
||||||
|
action="suggest",
|
||||||
|
conn=object(),
|
||||||
|
)
|
||||||
|
assert calls == [(5, "ai_calls", "suggest")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_club_feature_enforcement_env_default_off(monkeypatch):
|
||||||
|
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
||||||
|
assert club_feature_enforcement_enabled() is False
|
||||||
|
|
@ -58,6 +58,10 @@ def test_build_me_entitlements_with_club(monkeypatch):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True)
|
monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"entitlements.is_club_feature_quota_bypassed",
|
||||||
|
lambda *a, **k: False,
|
||||||
|
)
|
||||||
|
|
||||||
tenant = TenantContext(
|
tenant = TenantContext(
|
||||||
profile_id=3,
|
profile_id=3,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.192"
|
APP_VERSION = "0.8.195"
|
||||||
BUILD_DATE = "2026-06-06"
|
BUILD_DATE = "2026-06-07"
|
||||||
DB_SCHEMA_VERSION = "20260606081"
|
DB_SCHEMA_VERSION = "20260606083"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
|
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
|
||||||
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
||||||
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
||||||
"capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements
|
"capabilities": "1.1.0", # quota_bypass-Domain + profile_capability_grants in check_capability
|
||||||
"account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware
|
"account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware
|
||||||
"clubs": "0.4.2", # delete_club: Gründungsanträge → superseded
|
"clubs": "0.4.2", # delete_club: Gründungsanträge → superseded
|
||||||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||||
"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.2.0", # M4: club_features_map für /me/entitlements
|
"club_features": "1.5.0", # Kontingent-Bypass via Capability-Grants (probe/consume)
|
||||||
"entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features
|
"club_quota_bypass": "1.0.0", # platform.club_quota.bypass* + Admin-Grants-API
|
||||||
|
"entitlements": "1.2.0", # capability_quota_bypass in Feature-Map für /me/entitlements
|
||||||
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
|
"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_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)
|
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
|
||||||
|
|
@ -34,7 +35,7 @@ MODULE_VERSIONS = {
|
||||||
"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.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||||
"planning_exercise_suggest": "0.16.0", # E3: gap_fill_offers, Off-Topic, QA→KI-Pipeline
|
"planning_exercise_suggest": "0.16.1", # M5: consume_club_feature ai_calls nach KI-Erfolg
|
||||||
"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
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ services:
|
||||||
APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}"
|
APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}"
|
||||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
|
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
|
||||||
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
||||||
|
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
|
||||||
|
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-0}"
|
||||||
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
|
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
|
||||||
MEDIAWIKI_USER: "${MEDIAWIKI_USER:-Jinkendo}"
|
MEDIAWIKI_USER: "${MEDIAWIKI_USER:-Jinkendo}"
|
||||||
MEDIAWIKI_PASSWORD: "${MEDIAWIKI_PASSWORD:-CHANGE_ME}"
|
MEDIAWIKI_PASSWORD: "${MEDIAWIKI_PASSWORD:-CHANGE_ME}"
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,20 @@ export default function FeatureUsageBadge({ featureId = 'ai_calls', label = 'KI-
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { used = 0, limit, remaining, allowed } = feat
|
const { used = 0, limit, remaining, allowed, platform_exempt: platformExempt, reason } = feat
|
||||||
|
|
||||||
|
if (platformExempt || reason === 'platform_exempt' || reason === 'capability_quota_bypass') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="feature-usage-badge"
|
||||||
|
style={{ fontSize: '0.8rem', color: 'var(--accent-dark)' }}
|
||||||
|
title="Plattform-Ausnahme: zählt nicht gegen das Vereins-Kontingent"
|
||||||
|
>
|
||||||
|
{label}: Plattform (unbegrenzt)
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// limit === 0 (z. B. Free-Plan ai_calls) anzeigen; nur echtes Unbegrenzt (null) ausblenden
|
// limit === 0 (z. B. Free-Plan ai_calls) anzeigen; nur echtes Unbegrenzt (null) ausblenden
|
||||||
if (limit == null) return null
|
if (limit == null) return null
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user