shinkan-jinkendo/backend/club_features.py
Lars c294c27de8
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m12s
Update Access Layer and Governance Documentation
- Enhanced the ACCESS_LAYER_AND_GOVERNANCE_PLAN.md with new specifications for capability documentation and community features.
- Added references to new documents detailing capability IDs and club membership features.
- Updated MULTI_TENANCY_RBAC_ARCHITECTURE.md to include links to the new specifications.
- Marked certain features as deprecated in backend/auth.py, indicating migration paths for club feature access.
- Incremented DB_SCHEMA_VERSION to 20260606078 in version.py to reflect recent changes.
2026-06-06 20:44:51 +02:00

337 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Enforcement in Routern folgt in M2+ (Phase 24); M1 liefert Schema + Prüf-Helfer.
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from db import get_db, get_cursor
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 _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)
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 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),
)
c.commit()
if conn is not None:
_run(conn)
else:
with get_db() as c:
_run(c)
def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (für API/UI)."""
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, cur.connection)
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"),
}
)
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}