Implement Club Feature Access Probing and Inventory Count
All checks were successful
Deploy Development / deploy (push) Successful in 41s
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 1m42s
All checks were successful
Deploy Development / deploy (push) Successful in 41s
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 1m42s
- Introduced `probe_club_feature_access` to check club feature limits and log access attempts without blocking by default. - Added `_live_inventory_count` function to retrieve current counts for specific features, enhancing feature limit management. - Updated various endpoints to utilize the new probing functionality, ensuring compliance with club feature access rules. - Incremented version to 1.1.0 in version.py to reflect these enhancements in club feature management.
This commit is contained in:
parent
c294c27de8
commit
7cfbca40bb
74
backend/club_feature_logger.py
Normal file
74
backend/club_feature_logger.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block).
|
||||||
|
|
||||||
|
Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 — analog Mitai feature_logger.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _log_dir() -> Path:
|
||||||
|
custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return Path(custom)
|
||||||
|
return Path("/app/logs")
|
||||||
|
|
||||||
|
|
||||||
|
feature_usage_logger = logging.getLogger("shinkan.club_feature_usage")
|
||||||
|
feature_usage_logger.setLevel(logging.INFO)
|
||||||
|
feature_usage_logger.propagate = False
|
||||||
|
|
||||||
|
if not feature_usage_logger.handlers:
|
||||||
|
log_dir = _log_dir()
|
||||||
|
try:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / "club-feature-usage.log"
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
feature_usage_logger.addHandler(file_handler)
|
||||||
|
except OSError:
|
||||||
|
# Dev ohne /app/logs: Fallback stderr
|
||||||
|
stream_handler = logging.StreamHandler()
|
||||||
|
stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s"))
|
||||||
|
feature_usage_logger.addHandler(stream_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def log_club_feature_usage(
|
||||||
|
*,
|
||||||
|
club_id: Optional[int],
|
||||||
|
profile_id: Optional[int],
|
||||||
|
feature_id: str,
|
||||||
|
action: str,
|
||||||
|
access: Dict[str, Any],
|
||||||
|
endpoint: Optional[str] = None,
|
||||||
|
phase: str = "probe",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Strukturiertes JSON-Log eines Feature-Checks.
|
||||||
|
|
||||||
|
phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid)
|
||||||
|
"""
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"club_id": club_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"feature": feature_id,
|
||||||
|
"action": action,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"phase": phase,
|
||||||
|
"plan_id": access.get("plan_id"),
|
||||||
|
"used": access.get("used", 0),
|
||||||
|
"limit": access.get("limit"),
|
||||||
|
"remaining": access.get("remaining"),
|
||||||
|
"allowed": access.get("allowed", True),
|
||||||
|
"reason": access.get("reason", "unknown"),
|
||||||
|
"enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1",
|
||||||
|
}
|
||||||
|
feature_usage_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||||
|
|
@ -2,17 +2,29 @@
|
||||||
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
|
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
|
||||||
|
|
||||||
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
|
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
|
||||||
Enforcement in Routern folgt in M2+ (Phase 2–4); M1 liefert Schema + Prüf-Helfer.
|
Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block.
|
||||||
|
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment.
|
||||||
|
|
||||||
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
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]:
|
def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]:
|
||||||
"""Nächster Reset-Zeitpunkt; None bei 'never'."""
|
"""Nächster Reset-Zeitpunkt; None bei 'never'."""
|
||||||
|
|
@ -113,6 +125,61 @@ def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -
|
||||||
return _normalize_limit(feature_row.get("default_limit"))
|
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:
|
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."""
|
"""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
|
used = int(usage_row.get("usage_count") or 0) if usage_row else 0
|
||||||
|
|
@ -210,6 +277,12 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
|
||||||
usage = cur.fetchone()
|
usage = cur.fetchone()
|
||||||
used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage)
|
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:
|
if limit is None:
|
||||||
return {
|
return {
|
||||||
"allowed": True,
|
"allowed": True,
|
||||||
|
|
@ -244,6 +317,76 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def club_feature_enforcement_enabled() -> bool:
|
||||||
|
"""Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1)."""
|
||||||
|
return os.getenv("CLUB_FEATURE_ENFORCE", "0").strip() == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def probe_club_feature_access(
|
||||||
|
*,
|
||||||
|
feature_id: str,
|
||||||
|
action: str,
|
||||||
|
club_id: Optional[int] = None,
|
||||||
|
profile_id: Optional[int] = None,
|
||||||
|
endpoint: Optional[str] = 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": True,
|
||||||
|
"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="probe",
|
||||||
|
)
|
||||||
|
return access
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
access = check_club_feature_access(club_id, feature_id, conn=conn)
|
||||||
|
else:
|
||||||
|
with get_db() as c:
|
||||||
|
access = check_club_feature_access(club_id, feature_id, conn=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 increment_club_feature_usage(
|
def increment_club_feature_usage(
|
||||||
club_id: int,
|
club_id: int,
|
||||||
feature_id: str,
|
feature_id: str,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar
|
||||||
from media_legal_hold import assert_not_under_legal_hold
|
from media_legal_hold import assert_not_under_legal_hold
|
||||||
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
||||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||||
|
from club_features import 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,
|
||||||
|
|
@ -2317,7 +2318,13 @@ def exercise_ai_suggest_endpoint(
|
||||||
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
|
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
|
||||||
OPENROUTER_API_KEY erforderlich.
|
OPENROUTER_API_KEY erforderlich.
|
||||||
"""
|
"""
|
||||||
_ = tenant.profile_id
|
probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="suggest",
|
||||||
|
club_id=resolve_club_id_for_probe(tenant),
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
endpoint="POST /exercises/ai/suggest",
|
||||||
|
)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
payload = run_exercise_form_ai_suggestion(
|
payload = run_exercise_form_ai_suggestion(
|
||||||
|
|
@ -2337,6 +2344,13 @@ def exercise_ai_regenerate_endpoint(
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""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)."""
|
||||||
|
probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="regenerate",
|
||||||
|
club_id=resolve_club_id_for_probe(tenant),
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
endpoint="POST /exercises/{id}/ai/regenerate",
|
||||||
|
)
|
||||||
want_summary = "summary" in body.regenerate
|
want_summary = "summary" in body.regenerate
|
||||||
want_skills = "skills" in body.regenerate
|
want_skills = "skills" in body.regenerate
|
||||||
want_instructions = "instructions" in body.regenerate
|
want_instructions = "instructions" in body.regenerate
|
||||||
|
|
@ -2421,6 +2435,15 @@ def create_exercise(
|
||||||
if body.visibility == "club" and club_id is None:
|
if body.visibility == "club" and club_id is None:
|
||||||
club_id = tenant.effective_club_id
|
club_id = tenant.effective_club_id
|
||||||
|
|
||||||
|
if club_id is not None:
|
||||||
|
probe_club_feature_access(
|
||||||
|
feature_id="exercises",
|
||||||
|
action="create",
|
||||||
|
club_id=int(club_id),
|
||||||
|
profile_id=profile_id,
|
||||||
|
endpoint="POST /exercises",
|
||||||
|
)
|
||||||
|
|
||||||
# §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich
|
# §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich
|
||||||
create_ids: set[int] = set()
|
create_ids: set[int] = set()
|
||||||
for fld in sorted(RICH_HTML_EXERCISE_FIELDS):
|
for fld in sorted(RICH_HTML_EXERCISE_FIELDS):
|
||||||
|
|
@ -3214,6 +3237,23 @@ async def upload_exercise_media(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
|
|
||||||
|
if has_file:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT club_id FROM exercises WHERE id = %s",
|
||||||
|
(exercise_id,),
|
||||||
|
)
|
||||||
|
ex_club = cur.fetchone()
|
||||||
|
media_club_id = ex_club.get("club_id") if ex_club else None
|
||||||
|
if media_club_id is not None:
|
||||||
|
probe_club_feature_access(
|
||||||
|
feature_id="exercise_media",
|
||||||
|
action="upload",
|
||||||
|
club_id=int(media_club_id),
|
||||||
|
profile_id=profile_id,
|
||||||
|
endpoint="POST /exercises/{id}/media",
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
|
||||||
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
|
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from db import get_db, get_cursor
|
||||||
from tenant_context import TenantContext, get_tenant_context
|
from tenant_context import TenantContext, get_tenant_context
|
||||||
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
||||||
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
||||||
|
from club_features import 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"])
|
||||||
|
|
||||||
|
|
@ -16,6 +17,14 @@ 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:
|
||||||
|
probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="planning_suggest",
|
||||||
|
club_id=resolve_club_id_for_probe(tenant),
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
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)
|
return suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||||
|
|
@ -26,6 +35,18 @@ def post_progression_path_suggest(
|
||||||
body: ProgressionPathSuggestRequest,
|
body: ProgressionPathSuggestRequest,
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
|
if (
|
||||||
|
body.include_llm_intent
|
||||||
|
or body.include_llm_path_qa
|
||||||
|
or body.include_ai_gap_fill
|
||||||
|
):
|
||||||
|
probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="progression_path_suggest",
|
||||||
|
club_id=resolve_club_id_for_probe(tenant),
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
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)
|
return suggest_progression_path(cur, tenant=tenant, body=body)
|
||||||
|
|
|
||||||
62
backend/tests/test_club_feature_logger.py
Normal file
62
backend/tests/test_club_feature_logger.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Tests für club_feature_logger und probe (ohne DB)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from club_feature_logger import feature_usage_logger, log_club_feature_usage
|
||||||
|
from club_features import club_feature_enforcement_enabled, probe_club_feature_access
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_club_feature_usage_json(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(feature_usage_logger, "info", lambda msg: captured.append(msg))
|
||||||
|
access = {
|
||||||
|
"allowed": False,
|
||||||
|
"limit": 0,
|
||||||
|
"used": 3,
|
||||||
|
"remaining": 0,
|
||||||
|
"reason": "feature_disabled",
|
||||||
|
"plan_id": "free",
|
||||||
|
}
|
||||||
|
log_club_feature_usage(
|
||||||
|
club_id=12,
|
||||||
|
profile_id=7,
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="suggest",
|
||||||
|
access=access,
|
||||||
|
endpoint="POST /exercises/ai/suggest",
|
||||||
|
)
|
||||||
|
assert captured
|
||||||
|
payload = json.loads(captured[-1])
|
||||||
|
assert payload["club_id"] == 12
|
||||||
|
assert payload["profile_id"] == 7
|
||||||
|
assert payload["feature"] == "ai_calls"
|
||||||
|
assert payload["allowed"] is False
|
||||||
|
assert payload["plan_id"] == "free"
|
||||||
|
assert payload["phase"] == "probe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_no_club_context_logs_without_db(monkeypatch):
|
||||||
|
logged = []
|
||||||
|
|
||||||
|
def _capture(**kwargs):
|
||||||
|
logged.append(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", _capture)
|
||||||
|
|
||||||
|
access = probe_club_feature_access(
|
||||||
|
feature_id="ai_calls",
|
||||||
|
action="suggest",
|
||||||
|
club_id=None,
|
||||||
|
profile_id=1,
|
||||||
|
endpoint="test",
|
||||||
|
)
|
||||||
|
assert access["reason"] == "no_club_context"
|
||||||
|
assert access["allowed"] is True
|
||||||
|
assert len(logged) == 1
|
||||||
|
assert logged[0]["club_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_club_feature_enforcement_default_off(monkeypatch):
|
||||||
|
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
||||||
|
assert club_feature_enforcement_enabled() is False
|
||||||
|
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
|
||||||
|
assert club_feature_enforcement_enabled() is True
|
||||||
|
|
@ -13,7 +13,7 @@ MODULE_VERSIONS = {
|
||||||
"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)
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"admin_users": "1.0.0", # GET /api/admin/users
|
||||||
"club_features": "1.0.0", # Migration 078: features v9c + club_plans/subscriptions; check_club_feature_access
|
"club_features": "1.1.0", # M2: probe_club_feature_access + JSON-Log (Phase 2, non-blocking)
|
||||||
"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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user