""" Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id). Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block. Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment. Verbrauch-Standard für Router: probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen. """ from __future__ import annotations import os from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional, TYPE_CHECKING from fastapi import HTTPException from db import get_db, get_cursor if TYPE_CHECKING: from tenant_context import TenantContext # Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage) _INVENTORY_FEATURES = frozenset( {"exercises", "training_groups", "active_members", "training_programs"} ) def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]: """Nächster Reset-Zeitpunkt; None bei 'never'.""" ref = now or datetime.now(timezone.utc) if reset_period == "never": return None if reset_period == "daily": tomorrow = ref.date() + timedelta(days=1) return datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc) if reset_period == "monthly": if ref.month == 12: return datetime(ref.year + 1, 1, 1, tzinfo=timezone.utc) return datetime(ref.year, ref.month + 1, 1, tzinfo=timezone.utc) return None def _normalize_limit(raw: Any) -> Optional[int]: """NULL = unbegrenzt; -1 (Legacy 001) wird als unbegrenzt behandelt.""" if raw is None: return None try: v = int(raw) except (TypeError, ValueError): return None if v < 0: return None return v def get_effective_club_plan(cur, club_id: int) -> str: """ Effektiver Plan für einen Verein. 1. Aktiver club_access_grants mit plan_id (Zeitfenster, neueste ends_at) 2. club_subscriptions.status = 'active' → plan_id 3. Fallback 'free' """ cur.execute( """ SELECT plan_id FROM club_access_grants WHERE club_id = %s AND plan_id IS NOT NULL AND starts_at <= NOW() AND ends_at > NOW() ORDER BY ends_at DESC LIMIT 1 """, (club_id,), ) grant = cur.fetchone() if grant and grant.get("plan_id"): return str(grant["plan_id"]) cur.execute( """ SELECT plan_id FROM club_subscriptions WHERE club_id = %s AND status = 'active' LIMIT 1 """, (club_id,), ) sub = cur.fetchone() if sub and sub.get("plan_id"): return str(sub["plan_id"]) return "free" def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -> Optional[int]: """Limit-Wert: Override > Plan > Feature-Default.""" cur.execute( """ SELECT limit_value FROM club_feature_overrides WHERE club_id = %s AND feature_id = %s """, (club_id, feature_id), ) override = cur.fetchone() if override is not None: return _normalize_limit(override.get("limit_value")) plan_id = get_effective_club_plan(cur, club_id) cur.execute( """ SELECT limit_value FROM club_plan_limits WHERE plan_id = %s AND feature_id = %s """, (plan_id, feature_id), ) plan_lim = cur.fetchone() if plan_lim is not None: return _normalize_limit(plan_lim.get("limit_value")) return _normalize_limit(feature_row.get("default_limit")) def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]: """Aktueller Bestand für reset_period=never Features.""" if feature_id == "exercises": cur.execute( """ SELECT COUNT(*)::int AS c FROM exercises WHERE club_id = %s AND status != 'archived' """, (club_id,), ) elif feature_id == "training_groups": cur.execute( "SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s", (club_id,), ) elif feature_id == "active_members": cur.execute( """ SELECT COUNT(*)::int AS c FROM club_members WHERE club_id = %s AND status = 'active' """, (club_id,), ) elif feature_id == "training_programs": cur.execute( """ SELECT COUNT(*)::int AS c FROM ( SELECT id FROM training_framework_programs WHERE club_id = %s UNION ALL SELECT id FROM training_modules WHERE club_id = %s ) t """, (club_id, club_id), ) else: return None row = cur.fetchone() return int(row["c"] or 0) if row else 0 def resolve_club_id_for_probe( tenant: "TenantContext", *, object_club_id: Optional[int] = None, ) -> Optional[int]: """Verein für Feature-Probe: explizites Objekt > effective_club_id.""" if object_club_id is not None: return int(object_club_id) eff = getattr(tenant, "effective_club_id", None) return int(eff) if eff is not None else None def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int: """Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück.""" used = int(usage_row.get("usage_count") or 0) if usage_row else 0 reset_at = usage_row.get("reset_at") if usage_row else None period = (feature_row.get("reset_period") or "never").strip().lower() if not usage_row or not reset_at or period == "never": return used now = datetime.now(timezone.utc) ra = reset_at if hasattr(ra, "tzinfo") and ra.tzinfo is None: ra = ra.replace(tzinfo=timezone.utc) if ra and now > ra: next_reset = _calculate_next_reset(period, now=now) cur.execute( """ UPDATE club_feature_usage SET usage_count = 0, reset_at = %s, updated_at = NOW() WHERE club_id = %s AND feature_id = %s """, (next_reset, club_id, feature_id), ) conn.commit() return 0 return used def check_club_feature_access( club_id: int, feature_id: str, *, conn=None, ) -> Dict[str, Any]: """ Prüft Vereins-Kontingent für ein Feature. Returns: allowed, limit, used, remaining, reason, plan_id, reset_at (optional) """ if conn is not None: return _check_club_impl(club_id, feature_id, conn) with get_db() as c: return _check_club_impl(club_id, feature_id, c) def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]: cur = get_cursor(conn) cur.execute( """ SELECT id, limit_type, reset_period, default_limit, active, enforcement_subject FROM features WHERE id = %s AND app = 'shinkan' """, (feature_id,), ) feature = cur.fetchone() if not feature or not feature.get("active"): return { "allowed": False, "limit": None, "used": 0, "remaining": None, "reason": "feature_not_found", "plan_id": get_effective_club_plan(cur, club_id), } plan_id = get_effective_club_plan(cur, club_id) limit = _resolve_club_limit(cur, club_id, feature_id, feature) limit_type = (feature.get("limit_type") or "count").strip().lower() if limit_type == "boolean": allowed = limit == 1 return { "allowed": allowed, "limit": limit, "used": 0, "remaining": None, "reason": "enabled" if allowed else "feature_disabled", "plan_id": plan_id, } cur.execute( """ SELECT usage_count, reset_at FROM club_feature_usage WHERE club_id = %s AND feature_id = %s """, (club_id, feature_id), ) usage = cur.fetchone() used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage) period = (feature.get("reset_period") or "never").strip().lower() if period == "never" and feature_id in _INVENTORY_FEATURES: inv = _live_inventory_count(cur, club_id, feature_id) if inv is not None: used = inv if limit is None: return { "allowed": True, "limit": None, "used": used, "remaining": None, "reason": "unlimited", "plan_id": plan_id, "reset_at": usage.get("reset_at") if usage else None, } if limit == 0: return { "allowed": False, "limit": 0, "used": used, "remaining": 0, "reason": "feature_disabled", "plan_id": plan_id, "reset_at": usage.get("reset_at") if usage else None, } allowed = used < limit return { "allowed": allowed, "limit": limit, "used": used, "remaining": max(0, limit - used), "reason": "within_limit" if allowed else "limit_exceeded", "plan_id": plan_id, "reset_at": usage.get("reset_at") if usage else None, } def club_feature_enforcement_enabled() -> bool: """Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes).""" v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower() return v in ("1", "true", "yes") def probe_club_feature_access( *, feature_id: str, action: str, club_id: Optional[int] = None, profile_id: Optional[int] = None, portal_role: Optional[str] = None, endpoint: Optional[str] = None, tenant: Optional["TenantContext"] = None, conn=None, ) -> Dict[str, Any]: """ Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht. Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed. """ from club_feature_logger import log_club_feature_usage if club_id is None: access = { "allowed": not club_feature_enforcement_enabled(), "limit": None, "used": 0, "remaining": None, "reason": "no_club_context", "plan_id": None, } log_club_feature_usage( club_id=None, profile_id=profile_id, feature_id=feature_id, action=action, access=access, endpoint=endpoint, phase="enforce" if club_feature_enforcement_enabled() else "probe", ) if club_feature_enforcement_enabled() and not access.get("allowed"): raise HTTPException( status_code=403, detail=( f"Kein Vereinskontext für {feature_id} — " "aktiven Verein wählen (X-Active-Club-Id)." ), ) 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, tenant=tenant, ): 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: access = _resolve_access(conn) else: with get_db() as c: access = _resolve_access(c) log_club_feature_usage( club_id=club_id, profile_id=profile_id, feature_id=feature_id, action=action, access=access, endpoint=endpoint, phase="enforce" if club_feature_enforcement_enabled() else "probe", ) if club_feature_enforcement_enabled() and not access.get("allowed"): limit = access.get("limit") used = access.get("used", 0) detail = ( f"Kontingent überschritten für {feature_id} " f"({used}/{limit if limit is not None else '∞'}). " f"Grund: {access.get('reason', 'limit_exceeded')}." ) raise HTTPException(status_code=403, detail=detail) 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 _log_consume(connection) -> None: from club_feature_logger import log_club_feature_usage access = check_club_feature_access(int(club_id), feature_id, conn=connection) log_club_feature_usage( club_id=int(club_id), profile_id=profile_id, feature_id=feature_id, action=action or "consume", access=access, phase="consume", ) if conn is not None: _log_consume(conn) else: with get_db() as c: _log_consume(c) def consume_club_feature_with_usage( *, feature_id: str, club_id: Optional[int], profile_id: Optional[int] = None, portal_role: Optional[str] = None, action: Optional[str] = None, amount: int = 1, cur, tenant: Optional["TenantContext"] = None, conn=None, ) -> Optional[Dict[str, Dict[str, Any]]]: """ Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response. Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und ``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route. """ consume_club_feature( feature_id=feature_id, club_id=club_id, profile_id=profile_id, portal_role=portal_role, action=action, amount=amount, conn=conn, ) if club_id is None: return None return { feature_id: club_feature_usage_for_api( cur, club_id=int(club_id), feature_id=feature_id, profile_id=profile_id, portal_role=portal_role, tenant=tenant, conn=conn, ), } def merge_feature_usage_into_response( payload: Any, feature_usage: Optional[Dict[str, Dict[str, Any]]], ) -> Any: """Standard-Einbettung ``feature_usage`` in JSON-Responses.""" if not feature_usage or not isinstance(payload, dict): return payload return {**payload, "feature_usage": feature_usage} def club_feature_usage_for_api( cur, *, club_id: int, feature_id: str, profile_id: Optional[int] = None, portal_role: Optional[str] = None, tenant: Optional["TenantContext"] = None, conn=None, ) -> Dict[str, Any]: """Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch).""" from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access db_conn = conn if conn is not None else cur.connection access = check_club_feature_access(int(club_id), feature_id, conn=db_conn) plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id)) if is_club_feature_quota_bypassed( cur, profile_id=profile_id, portal_role=portal_role, feature_id=feature_id, tenant=tenant, ): ex = quota_bypass_access( feature_id=feature_id, club_id=int(club_id), plan_id=plan_id, ) reset_at = access.get("reset_at") return { "allowed": True, "used": access.get("used"), "limit": None, "remaining": None, "reason": ex.get("reason"), "platform_exempt": True, "reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at, } return { "allowed": access.get("allowed"), "used": access.get("used"), "limit": access.get("limit"), "remaining": access.get("remaining"), "reason": access.get("reason"), "platform_exempt": False, "reset_at": access.get("reset_at").isoformat() if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat") else access.get("reset_at"), } def increment_club_feature_usage( club_id: int, feature_id: str, *, profile_id: Optional[int] = None, action: Optional[str] = None, conn=None, ) -> None: """Erhöht Vereins-Zähler (nur bei neuem Verbrauch / INSERT-Pfad aufrufen).""" def _run(c): cur = get_cursor(c) cur.execute( """ SELECT reset_period, limit_type FROM features WHERE id = %s AND app = 'shinkan' AND active = true """, (feature_id,), ) feature = cur.fetchone() if not feature: return if (feature.get("limit_type") or "count").strip().lower() == "boolean": return period = (feature.get("reset_period") or "never").strip().lower() next_reset = _calculate_next_reset(period) cur.execute( """ INSERT INTO club_feature_usage (club_id, feature_id, usage_count, reset_at, last_used_at) VALUES (%s, %s, 1, %s, NOW()) ON CONFLICT (club_id, feature_id) DO UPDATE SET usage_count = club_feature_usage.usage_count + 1, last_used_at = NOW(), updated_at = NOW() """, (club_id, feature_id, next_reset), ) if profile_id is not None or action: cur.execute( """ INSERT INTO club_feature_usage_events (club_id, feature_id, profile_id, action) VALUES (%s, %s, %s, %s) """, (club_id, feature_id, profile_id, action or feature_id), ) if conn is not None: _run(conn) else: with get_db() as c: _run(c) def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]: """Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern).""" db_conn = conn if conn is not None else cur.connection plan_id = get_effective_club_plan(cur, club_id) cur.execute( """ SELECT id, name, category, limit_type, reset_period FROM features WHERE app = 'shinkan' AND active = true ORDER BY category, id """ ) rows = cur.fetchall() features_out = [] for row in rows: fid = row["id"] access = _check_club_impl(club_id, fid, db_conn) features_out.append( { "id": fid, "name": row.get("name"), "category": row.get("category"), "limit_type": row.get("limit_type"), "reset_period": row.get("reset_period"), "allowed": access.get("allowed"), "limit": access.get("limit"), "used": access.get("used"), "remaining": access.get("remaining"), "reason": access.get("reason"), "reset_at": access.get("reset_at"), } ) return {"club_id": club_id, "plan_id": plan_id, "features": features_out} def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]: """Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements).""" raw = list_club_entitlements(cur, club_id, conn=conn) features_dict: Dict[str, Any] = {} for row in raw.get("features") or []: fid = row["id"] features_dict[fid] = { "name": row.get("name"), "category": row.get("category"), "limit_type": row.get("limit_type"), "reset_period": row.get("reset_period"), "allowed": row.get("allowed"), "limit": row.get("limit"), "used": row.get("used"), "remaining": row.get("remaining"), "reason": row.get("reason"), "reset_at": row.get("reset_at"), } return { "club_id": raw.get("club_id"), "plan_id": raw.get("plan_id"), "features": features_dict, }