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
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:
parent
d3055f6f2f
commit
3ff47779e0
|
|
@ -80,7 +80,7 @@ def library_content_visibility_sql(
|
||||||
class TenantContext:
|
class TenantContext:
|
||||||
profile_id: int
|
profile_id: int
|
||||||
global_role: str
|
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]
|
effective_club_id: Optional[int]
|
||||||
club_ids: frozenset[int]
|
club_ids: frozenset[int]
|
||||||
memberships: List[Dict[str, Any]]
|
memberships: List[Dict[str, Any]]
|
||||||
|
|
@ -100,7 +100,8 @@ def resolve_tenant_context(
|
||||||
Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften).
|
Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften).
|
||||||
|
|
||||||
Auflösung effective_club_id:
|
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.
|
- 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;
|
Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein;
|
||||||
bei mehreren ohne gültige Vorgabe → min(club_ids) (Fallback).
|
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):
|
if not _club_exists(cur, header_cid):
|
||||||
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||||
effective = header_cid
|
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:
|
else:
|
||||||
effective = None
|
effective = None
|
||||||
return TenantContext(
|
return TenantContext(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
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():
|
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:
|
with pytest.raises(HTTPException) as exc:
|
||||||
parse_active_club_header("0")
|
parse_active_club_header("0")
|
||||||
assert exc.value.status_code == 400
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.58"
|
APP_VERSION = "0.8.59"
|
||||||
BUILD_DATE = "2026-05-07"
|
BUILD_DATE = "2026-05-07"
|
||||||
DB_SCHEMA_VERSION = "20260508049"
|
DB_SCHEMA_VERSION = "20260508049"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
|
"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()
|
"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
|
"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_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)
|
||||||
|
|
@ -29,6 +29,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.58",
|
||||||
"date": "2026-05-07",
|
"date": "2026-05-07",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeigt einen Vereins-Umschalter, wenn der Nutzer mehreren Vereinen zugeordnet ist.
|
* 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 || []
|
const clubs = user?.clubs || []
|
||||||
if (clubs.length <= 1) return null
|
if (clubs.length <= 1) return null
|
||||||
|
|
||||||
const selectClubId =
|
const selectClubId = getResolvedActiveClubIdForUi(user)
|
||||||
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
|
|
||||||
? user.active_club_id
|
|
||||||
: clubs[0]?.id
|
|
||||||
|
|
||||||
const isMobile = variant === 'mobile'
|
const isMobile = variant === 'mobile'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
|
||||||
|
|
||||||
function Navigation() {
|
function Navigation() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
@ -7,10 +8,7 @@ function Navigation() {
|
||||||
const { user, logout, setActiveClub } = useAuth()
|
const { user, logout, setActiveClub } = useAuth()
|
||||||
|
|
||||||
const clubs = user?.clubs || []
|
const clubs = user?.clubs || []
|
||||||
const selectClubId =
|
const selectClubId = getResolvedActiveClubIdForUi(user)
|
||||||
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
|
|
||||||
? user.active_club_id
|
|
||||||
: clubs[0]?.id
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,25 @@ export function AuthProvider({ children }) {
|
||||||
const uid = userRef.current?.id
|
const uid = userRef.current?.id
|
||||||
if (!Number.isFinite(cid) || cid < 1 || !uid) return
|
if (!Number.isFinite(cid) || cid < 1 || !uid) return
|
||||||
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(cid))
|
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 {
|
try {
|
||||||
await api.updateProfile(uid, { active_club_id: cid })
|
await api.updateProfile(uid, { active_club_id: cid })
|
||||||
|
const profile = await api.getCurrentProfile()
|
||||||
|
syncStoredActiveClub(profile)
|
||||||
|
setUser(profile)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
try {
|
||||||
|
const profile = await api.getCurrentProfile()
|
||||||
|
syncStoredActiveClub(profile)
|
||||||
|
setUser(profile)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
|
||||||
32
frontend/src/utils/activeClub.js
Normal file
32
frontend/src/utils/activeClub.js
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user