shinkan-jinkendo/backend/routers/admin_club_feature_exemptions.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

228 lines
7.6 KiB
Python

"""
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}