feat(access): enhance visibility handling for club-related content
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 23s
Test Suite / pytest-backend (pull_request) Successful in 23s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 6s
Test Suite / playwright-tests (pull_request) Successful in 30s

- Updated visibility logic for exercises, media assets, and training programs to ensure access is correctly managed based on active club memberships.
- Refactored SQL queries to streamline visibility checks for platform admins and club members, ensuring only relevant content is displayed.
- Improved user interface elements to reflect the status of club memberships, including visual indicators for inactive memberships.
- Enhanced test cases to validate the new visibility logic and ensure proper access control across various components.
This commit is contained in:
Lars 2026-05-09 10:55:58 +02:00
parent 24c70c5ea0
commit 30c1c259d2
10 changed files with 269 additions and 137 deletions

View File

@ -203,25 +203,45 @@ def exercise_visible_to_profile(
created_by: Optional[int],
global_role: Optional[str],
) -> bool:
"""Leserechte einer Übung. Für neue Codepfade lieber `library_content_visible_to_profile` verwenden."""
if is_platform_admin(global_role):
"""
Leserechte einer Übung (und analoger Bibliotheksobjekte).
Vereinsbezogene Inhalte (visibility club): aktiv nur mit **aktiver** Mitgliedschaft in diesem Verein.
Mitgliedschaft mit status **inactive** sperrt auch für Plattform-/Super-Admins solange eine
Mitgliedschaft existiert.
Ist man kein Mitglied dieses Vereins, behalten Plattform-Admins den bisherigen Audit-Zugang
zum Vereinskontext ohne eigene Mitgliedschaft.
"""
vis = (visibility or "").strip().lower()
plat = is_platform_admin(global_role)
pid = int(profile_id)
if vis == "official":
return True
if visibility == "official":
if created_by is not None and int(created_by) == pid:
return True
if created_by is not None and created_by == profile_id:
return True
if visibility == "private":
if vis == "private":
return plat
if vis != "club":
return False
if visibility == "club":
if exercise_club_id is None:
return False
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = %s AND status = 'active'
LIMIT 1
""",
(profile_id, exercise_club_id),
)
return cur.fetchone() is not None
return False
if exercise_club_id is None:
return False
try:
ecid = int(exercise_club_id)
except (TypeError, ValueError):
return False
cur.execute(
"""
SELECT cm.status
FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = %s
LIMIT 1
""",
(pid, ecid),
)
row = cur.fetchone()
if row is None:
return plat
st_raw = row["status"] if isinstance(row, dict) else row[0]
return str(st_raw or "active").strip().lower() == "active"

View File

@ -206,32 +206,22 @@ def list_progression_graphs(tenant: TenantContext = Depends(get_tenant_context))
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if is_platform_admin(role):
cur.execute(
"""
SELECT g.*,
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
FROM exercise_progression_graphs g
ORDER BY g.updated_at DESC NULLS LAST, g.name
"""
)
else:
vis_sql, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT g.*,
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
FROM exercise_progression_graphs g
WHERE ({vis_sql})
ORDER BY g.updated_at DESC NULLS LAST, g.name
""",
vis_params,
)
vis_sql, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT g.*,
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
FROM exercise_progression_graphs g
WHERE ({vis_sql})
ORDER BY g.updated_at DESC NULLS LAST, g.name
""",
vis_params,
)
return [r2d(r) for r in cur.fetchall()]

View File

@ -1542,15 +1542,14 @@ def list_exercises(
params = []
role = tenant.global_role
if not is_platform_admin(role):
vis_sql, vis_params = library_content_visibility_sql(
alias="e",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
where.append(vis_sql)
params.extend(vis_params)
vis_sql, vis_params = library_content_visibility_sql(
alias="e",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
where.append(vis_sql)
params.extend(vis_params)
if created_by_me:
where.append("e.created_by = %s")

View File

@ -380,25 +380,42 @@ def _relocate_asset_file_if_governance_changed(
def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str, list[Any]]:
"""Sichtbare aktive Einträge: Plattform-Admin alles; sonst official + eigene private + Verein als Mitglied."""
sql = """(
%s
OR lower(trim(ma.visibility)) = 'official'
OR (
lower(trim(ma.visibility)) = 'private'
AND ma.uploaded_by_profile_id = %s
)
OR (
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)
"""Sichtbare aktive Einträge: official; private (eigen oder Plattform-Admin); Verein wie Bibliotheks-SQL."""
parts = ["lower(trim(ma.visibility)) = 'official'"]
vals: list[Any] = []
if is_plat:
parts.append("lower(trim(ma.visibility)) = 'private'")
else:
parts.append("(lower(trim(ma.visibility)) = 'private' AND ma.uploaded_by_profile_id = %s)")
vals.append(profile_id)
club_plat = (
"(lower(trim(ma.visibility)) = 'club' AND ma.club_id IS NOT NULL AND ("
"EXISTS (SELECT 1 FROM club_members cm WHERE cm.profile_id = %s AND cm.club_id = ma.club_id "
"AND cm.status = 'active') OR NOT EXISTS (SELECT 1 FROM club_members cm2 "
"WHERE cm2.profile_id = %s AND cm2.club_id = ma.club_id)))"
)
if is_plat:
parts.append(club_plat)
vals.extend([profile_id, profile_id])
else:
parts.append(
"""(
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)"""
return sql, [is_plat, profile_id, profile_id]
)
vals.append(profile_id)
sql = "(" + " OR ".join(parts) + ")"
return sql, vals
def _list_trash_visibility_clause(

View File

@ -350,21 +350,18 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
"""
if is_platform_admin(role):
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
else:
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
base_sel
+ f""" WHERE ({vis_clause})
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
base_sel
+ f""" WHERE ({vis_clause})
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
vis_params,
)
vis_params,
)
return [r2d(r) for r in cur.fetchall()]

View File

@ -896,25 +896,14 @@ def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_cont
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if is_platform_admin(role):
cur.execute(
"""
SELECT t.*,
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
AS sections_count
FROM training_plan_templates t
ORDER BY t.updated_at DESC NULLS LAST, t.name
"""
)
else:
vis_clause, vis_params = library_content_visibility_sql(
alias="t",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
vis_clause, vis_params = library_content_visibility_sql(
alias="t",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT t.*,
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
AS sections_count
@ -922,8 +911,8 @@ def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_cont
WHERE ({vis_clause})
ORDER BY t.updated_at DESC NULLS LAST, t.name
""",
vis_params,
)
vis_params,
)
return [r2d(r) for r in cur.fetchall()]

View File

@ -61,21 +61,36 @@ def library_content_visibility_sql(
effective_club_id: Optional[int],
) -> tuple[str, List[Any]]:
"""
WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme):
official, eigene private, club nur im aktiven Vereinskontext (effective_club_id).
Plattform-Admin: keine Einschränkung (TRUE).
Ohne effective_club_id: kein club-Zweig (nur official + private).
WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme, ):
- official immer
- private: eigene (Norm) bzw. alle (Plattform-Admin/Superadmin)
- club: nur mit **aktivem** Vereinszugang; Existenz einer nur **inactive** Mitgliedschaft schließt
aus auch bei Plattform-Rolle. Ist man **kein** Mitglied des Vereins, behalten Plattform-Admins
Zugriff (Audit).
Für Nicht-Plattform: club-Zweig nur mit effective_club_id (Mandantenfilter).
"""
if is_platform_admin(role):
return "TRUE", []
plat = is_platform_admin(role)
parts: List[str] = [f"{alias}.visibility = 'official'"]
params: List[Any] = []
parts: List[str] = [
f"{alias}.visibility = 'official'",
f"({alias}.visibility = 'private' AND {alias}.created_by = %s)",
]
params: List[Any] = [profile_id]
if plat:
parts.append(f"({alias}.visibility = 'private')")
else:
parts.append(f"({alias}.visibility = 'private' AND {alias}.created_by = %s)")
params.append(profile_id)
if effective_club_id is not None:
club_ok_plat = (
f"({alias}.visibility = 'club' AND {alias}.club_id IS NOT NULL AND ("
f"EXISTS (SELECT 1 FROM club_members cm WHERE cm.profile_id = %s AND cm.club_id = {alias}.club_id "
f"AND cm.status = 'active') OR NOT EXISTS (SELECT 1 FROM club_members cm2 WHERE cm2.profile_id = %s "
f"AND cm2.club_id = {alias}.club_id)))"
)
if plat:
parts.append(club_ok_plat)
params.extend([profile_id, profile_id])
elif effective_club_id is not None:
parts.append(
f"""(
{alias}.visibility = 'club'

View File

@ -5,26 +5,27 @@ from fastapi import HTTPException
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_restricts_club_by_membership():
sql, params = library_content_visibility_sql(
alias="e",
profile_id=1,
role="admin",
effective_club_id=None,
)
assert sql == "TRUE"
assert params == []
assert "e.visibility = 'official'" in sql
assert "club_members" in sql
assert params == [1, 1]
def test_library_visibility_sql_superadmin():
def test_library_visibility_sql_superadmin_uses_membership_clause_for_club_visibility():
sql, params = library_content_visibility_sql(
alias="fp",
profile_id=2,
role="superadmin",
effective_club_id=100,
)
assert sql == "TRUE"
assert params == []
assert "club_members" in sql
assert params == [2, 2]
def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branch():

View File

@ -562,7 +562,10 @@ export default function AdminUsersPage() {
{c.abbreviation ? ` (${c.abbreviation})` : ''} {' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
<span style={{ color: 'var(--warning, #d4a012)', fontSize: '0.8rem', fontWeight: 600 }}>
{' '}
(Vereinszugang deaktiviert)
</span>
) : null}{' '}
<button
type="button"
@ -588,6 +591,33 @@ export default function AdminUsersPage() {
>
bearbeiten
</button>
{isSuperadminViewer && c.membership_status === 'inactive' ? (
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--accent, #0366d6)',
background: 'var(--surface)',
cursor: 'pointer',
}}
onClick={async () => {
try {
await api.updateClubMember(c.id, row.id, {
roles: [...(c.roles || [])],
status: 'active',
})
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}}
>
Zugang aktivieren
</button>
) : null}
</li>
))}
</ul>

View File

@ -196,6 +196,28 @@ function ClubsPage() {
}
}
const toggleMembersAdminClubAccess = async (m, activate) => {
if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return
const st = activate ? 'active' : 'inactive'
if (
!activate &&
!confirm(
`Vereinszugang für „${m.name || m.email || '#' + m.profile_id}“ hier deaktivieren? Login bleibt möglich, Vereinsinhalte nicht — auch bei Super-Admins.`,
)
) {
return
}
try {
await api.updateClubMember(membersAdminClubId, m.profile_id, {
roles: [...(m.roles || [])],
status: st,
})
await reloadMembersAdmin()
} catch (err) {
alert(err.message || String(err))
}
}
const handleEdit = (item, type) => {
setEditing(item)
setModalType(type)
@ -667,17 +689,41 @@ function ClubsPage() {
<div className="card">
<h3 style={{ marginTop: 0 }}>Mitglieder</h3>
{isPlatformAdmin ? (
<p
className="muted"
style={{ fontSize: '0.85rem', marginTop: '-0.35rem', marginBottom: '0.85rem', lineHeight: 1.45 }}
>
Liste enthält <strong style={{ color: 'var(--text1)' }}>aktive und deaktivierte</strong> Vereinszugänge.
Deaktiviert gilt pro Verein (ohne Kontosperre) auch für Super-Admins ohne aktive Mitgliedschaft in diesem
Verein kein Zugriff auf dessen Vereinsinhalte. Wiederherstellen über die Schaltflächen oder Mitglied
bearbeiten.
</p>
) : (
<p
className="muted"
style={{ fontSize: '0.85rem', marginTop: '-0.35rem', marginBottom: '0.85rem', lineHeight: 1.45 }}
>
Deaktivierte Vereinszugänge sind hervorgehoben {' '}
<strong>Anmeldung</strong> bleibt möglich, <strong>Vereinsinhalte</strong> dieser Zuordnung nicht.
</p>
)}
{clubMembersAdmin.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Noch keine Mitglieder erfasst.</p>
) : (
<div style={{ display: 'grid', gap: '0.65rem' }}>
{clubMembersAdmin.map((m) => (
{clubMembersAdmin.map((m) => {
const memStatus = (m.status || 'active').toLowerCase()
const inactiveRow = memStatus === 'inactive'
const portalLabel = (m.portal_role || '').trim()
return (
<div
key={m.membership_id}
style={{
padding: '0.65rem',
borderRadius: '8px',
background: 'var(--surface2)',
background: inactiveRow ? 'color-mix(in srgb, var(--warning, #884400) 12%, var(--surface2))' : 'var(--surface2)',
border: inactiveRow ? '1px solid color-mix(in srgb, var(--warning, #d4a012) 40%, transparent)' : undefined,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
@ -687,21 +733,49 @@ function ClubsPage() {
<div>
<strong>{m.name || m.email}</strong>
<div style={{ fontSize: '0.8rem', color: 'var(--text2)' }}>
{m.email} · #{m.profile_id} · {m.status}
{m.email} · #{m.profile_id}
{' · '}
<span style={{ fontWeight: 600 }}>
Vereinszugang:{' '}
<span style={{ color: inactiveRow ? 'var(--warning, #d4a012)' : 'inherit' }}>
{inactiveRow ? 'deaktiviert' : 'aktiv'}
</span>
</span>
{portalLabel ? (
<span style={{ color: 'var(--text3)', marginLeft: '0.35rem' }}> · Portal: {portalLabel}</span>
) : null}
</div>
<div style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
</div>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => setEditMemberModal(m)}
>
Bearbeiten
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'stretch' }}>
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(m)}>
Mitglied bearbeiten
</button>
{m.profile_id !== user?.id ? (
inactiveRow ? (
<button
type="button"
className="btn btn-primary"
onClick={() => toggleMembersAdminClubAccess(m, true)}
>
Vereinszugang aktivieren
</button>
) : (
<button
type="button"
className="btn btn-secondary"
onClick={() => toggleMembersAdminClubAccess(m, false)}
>
Vereinszugang deaktivieren
</button>
)
) : null}
</div>
</div>
))}
)
})}
</div>
)}
</div>