feat(tenant_context): enhance effective_club_id resolution for platform admins without header
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 31s

- Updated resolve_tenant_context to use stored active_club_id if the club exists when no header is provided.
- Adjusted comments for clarity regarding platform admin behavior.
- Added unit tests to verify new behavior for platform admins in test_access_layer.py.

version bump to 1.0.5 for tenant_context module.
This commit is contained in:
Lars 2026-05-08 10:45:58 +02:00
parent d3055f6f2f
commit 3ff47779e0
7 changed files with 121 additions and 14 deletions

View File

@ -80,7 +80,7 @@ def library_content_visibility_sql(
class TenantContext:
profile_id: int
global_role: str
# Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header oft None
# Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header: Profil-Verein wenn existent
effective_club_id: Optional[int]
club_ids: frozenset[int]
memberships: List[Dict[str, Any]]
@ -100,7 +100,8 @@ def resolve_tenant_context(
Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften).
Auflösung effective_club_id:
- Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header None.
- Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header gespeichertes
active_club_id falls der Verein existiert, sonst None.
- Sonst: gültiger Header zwingend Mitgliedschaft bei ``reject`` sonst 403, bei ``ignore`` wie ohne Header.
Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein;
bei mehreren ohne gültige Vorgabe min(club_ids) (Fallback).
@ -118,6 +119,11 @@ def resolve_tenant_context(
if not _club_exists(cur, header_cid):
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
effective = header_cid
elif (
stored_active_club_id is not None
and _club_exists(cur, stored_active_club_id)
):
effective = stored_active_club_id
else:
effective = None
return TenantContext(

View File

@ -2,7 +2,7 @@
import pytest
from fastapi import HTTPException
from tenant_context import library_content_visibility_sql, parse_active_club_header
from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context
def test_library_visibility_sql_platform_admin_no_filter():
@ -74,3 +74,54 @@ def test_parse_active_club_header_non_positive():
with pytest.raises(HTTPException) as exc:
parse_active_club_header("0")
assert exc.value.status_code == 400
def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch):
"""Ohne X-Active-Club-Id: effective wie gespeichertes Profil (Sync mit Dropdown/DB)."""
cur = object()
def fake_exists(c, cid):
return cid == 99
monkeypatch.setattr("tenant_context._club_exists", fake_exists)
ctx = resolve_tenant_context(
cur,
profile_id=1,
global_role="admin",
header_raw=None,
memberships=[{"id": 10}],
stored_active_club_id=99,
)
assert ctx.effective_club_id == 99
def test_resolve_platform_admin_header_overrides_stored(monkeypatch):
cur = object()
def fake_exists(c, cid):
return cid in (5, 99)
monkeypatch.setattr("tenant_context._club_exists", fake_exists)
ctx = resolve_tenant_context(
cur,
profile_id=1,
global_role="superadmin",
header_raw="5",
memberships=[{"id": 10}],
stored_active_club_id=99,
)
assert ctx.effective_club_id == 5
def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
cur = object()
monkeypatch.setattr("tenant_context._club_exists", lambda c, cid: False)
ctx = resolve_tenant_context(
cur,
profile_id=1,
global_role="admin",
header_raw=None,
memberships=[{"id": 1}],
stored_active_club_id=123,
)
assert ctx.effective_club_id is None

View File

@ -1,13 +1,13 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.58"
APP_VERSION = "0.8.59"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260508049"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
@ -29,6 +29,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.59",
"date": "2026-05-07",
"changes": [
"Aktiver Verein: Backend resolve_tenant_context für Plattform-Admin ohne X-Active-Club-Id nutzt gespeichertes active_club_id wenn der Verein existiert (kein effective=null nach hartem Reload mehr)",
"Frontend: Nach Vereinswechsel Profil neu laden; Dropdown-Wert aus effective_club_id / active_club_id / LocalStorage abgestimmt (getResolvedActiveClubIdForUi)",
],
},
{
"version": "0.8.58",
"date": "2026-05-07",

View File

@ -1,4 +1,5 @@
import { useAuth } from '../context/AuthContext'
import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
/**
* Zeigt einen Vereins-Umschalter, wenn der Nutzer mehreren Vereinen zugeordnet ist.
@ -9,10 +10,7 @@ export default function ActiveClubSwitcher({ variant = 'sidebar' }) {
const clubs = user?.clubs || []
if (clubs.length <= 1) return null
const selectClubId =
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
? user.active_club_id
: clubs[0]?.id
const selectClubId = getResolvedActiveClubIdForUi(user)
const isMobile = variant === 'mobile'

View File

@ -1,5 +1,6 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
function Navigation() {
const location = useLocation()
@ -7,10 +8,7 @@ function Navigation() {
const { user, logout, setActiveClub } = useAuth()
const clubs = user?.clubs || []
const selectClubId =
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
? user.active_club_id
: clubs[0]?.id
const selectClubId = getResolvedActiveClubIdForUi(user)
const handleLogout = async () => {
await logout()

View File

@ -64,11 +64,25 @@ export function AuthProvider({ children }) {
const uid = userRef.current?.id
if (!Number.isFinite(cid) || cid < 1 || !uid) return
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(cid))
setUser((prev) => (prev?.id ? { ...prev, active_club_id: cid } : prev))
setUser((prev) =>
prev?.id
? { ...prev, active_club_id: cid, effective_club_id: cid }
: prev
)
try {
await api.updateProfile(uid, { active_club_id: cid })
const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile)
setUser(profile)
} catch (e) {
console.error(e)
try {
const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile)
setUser(profile)
} catch {
/* ignore */
}
}
}, [])

View File

@ -0,0 +1,32 @@
import { ACTIVE_CLUB_STORAGE_KEY } from './api'
/**
* Einheitliche Anzeige des aktiven Vereins: Abgleich mit effective_club_id, active_club_id,
* LocalStorage (Request-Header-Quelle), sonst erster Verein der Liste.
*/
export function getResolvedActiveClubIdForUi(user) {
const clubs = user?.clubs || []
if (!clubs.length) return null
const idInClubs = (id) =>
id != null &&
id !== '' &&
clubs.some((c) => Number(c.id) === Number(id))
const eff = user?.effective_club_id
if (idInClubs(eff)) return Number(eff)
const ac = user?.active_club_id
if (idInClubs(ac)) return Number(ac)
try {
const ls = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (ls && /^\d+$/.test(ls.trim()) && clubs.some((c) => String(c.id) === ls.trim())) {
return Number(ls.trim())
}
} catch {
/* ignore */
}
return Number(clubs[0].id)
}