shinkan-jinkendo/backend/entitlements.py
Lars 8404a42b6c
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
Implement Club Feature Quota Bypass and Update Versioning
- 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.
2026-06-07 07:43:35 +02:00

114 lines
3.7 KiB
Python

"""
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional, TYPE_CHECKING
from fastapi import HTTPException
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_tenancy import is_platform_admin
from tenant_context import _club_exists
if TYPE_CHECKING:
from tenant_context import TenantContext
def _serialize_reset_at(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=None).isoformat() + "Z"
return value.isoformat()
return str(value)
def _resolve_target_club_id(
cur,
tenant: "TenantContext",
club_id: Optional[int],
) -> Optional[int]:
"""Effektiver Verein für Entitlements (Query > Tenant)."""
target = int(club_id) if club_id is not None else tenant.effective_club_id
if target is None:
return None
if is_platform_admin(tenant.global_role):
if not _club_exists(cur, target):
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
return target
if target not in tenant.club_ids:
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
return target
def build_me_entitlements(
cur,
tenant: "TenantContext",
*,
club_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
"""
target_club = _resolve_target_club_id(cur, tenant, club_id)
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
features: Dict[str, Any] = {}
plan_id = None
if target_club is not None:
raw = club_features_map(cur, target_club)
plan_id = raw.get("plan_id")
for fid, row in (raw.get("features") or {}).items():
if is_club_feature_quota_bypassed(
cur,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
feature_id=fid,
tenant=tenant,
):
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 {
"account_state": tenant.account_state,
"portal_role": tenant.global_role,
"club_id": target_club,
"plan_id": plan_id,
"club_roles": club_roles,
"capabilities": capabilities,
"features": features,
}