feat(club): ClubRole, RBAC access service and role sync
Add ClubRole (ADMIN / MEMBER / EDITOR, one per member per club) and complete club/services/access.py — the single module all authorisation routes through: - teams_managed_by / can_edit_event -> authority: a *management* StaffAssignment in the *current season*; ADMIN overrides club-wide. A StaffAssignment is per-season, so a former coach's authority expires with it. - teams_staffed_by -> visibility: *any* staff position, so support staff (physio, kit manager) can see the roster they work with without gaining authority. - members_visible_to -> ADMIN sees everyone linked to the club; otherwise self + children (family graph) + the current-season players and staff of the teams they're staffed on. - can_edit_event -> ADMIN/EDITOR, the event's owner, or a manager of one of its teams for that event's season. - can_manage_shop -> ADMIN. Fix roles_in_club, which called .unique() — not a QuerySet method, so it would have raised AttributeError on first use. Keep ClubRole in sync with membership status: an active ClubMembership grants the MEMBER role and losing it withdraws that role — but an elevated role (ADMIN/EDITOR) is never downgraded or removed, so a lapsed membership or a season rollover can never lock an admin out. Validate ClubMembership.season against the membership's club. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Club, ClubMembership, Season
|
||||
from .models import Club, ClubMembership, ClubRole, Season
|
||||
|
||||
|
||||
@admin.register(Club)
|
||||
@@ -31,3 +31,11 @@ class ClubMembershipAdmin(admin.ModelAdmin):
|
||||
[_("Membership"), {"fields": ["license", "status", "fee_status"]}],
|
||||
[_("Dates"), {"fields": ["signed_up_at", "activated_at"]}],
|
||||
]
|
||||
|
||||
|
||||
@admin.register(ClubRole)
|
||||
class ClubRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ["club__name", "member__last_name", "member__first_name", "role"]
|
||||
search_fields = ["club__name", "member__last_name", "member__first_name"]
|
||||
list_filter = ["club", "role"]
|
||||
raw_id_fields = ["member"]
|
||||
|
||||
@@ -3,3 +3,6 @@ from django.apps import AppConfig
|
||||
|
||||
class ClubConfig(AppConfig):
|
||||
name = "club"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
|
||||
31
club/migrations/0009_clubrole.py
Normal file
31
club/migrations/0009_clubrole.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 21:43
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('members', '0002_alter_familymembership_unique_together_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubRole',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('role', models.CharField(choices=[('admin', 'admin'), ('member', 'member'), ('editor', 'editor')], default='member', max_length=250, verbose_name='role')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='members.member', verbose_name='member')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'club role',
|
||||
'verbose_name_plural': 'club roles',
|
||||
'ordering': ['club', 'member__last_name', 'member__first_name'],
|
||||
'constraints': [models.UniqueConstraint(fields=('club', 'member'), name='unique_member_per_club')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -4,7 +4,7 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from clubmanager.base import ClubScopedModel, UUIDModel, unique_slugify
|
||||
from clubmanager.base import ClubScopedModel, UUIDModel, unique_slugify, validate_club_scope
|
||||
from members.models import Member
|
||||
|
||||
|
||||
@@ -102,3 +102,27 @@ class ClubMembership(ClubScopedModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} - {self.member}"
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, same_club_fields=("season",))
|
||||
|
||||
|
||||
class ClubRole(ClubScopedModel):
|
||||
class Roles(models.TextChoices):
|
||||
ADMIN = "admin", _("admin")
|
||||
MEMBER = "member", _("member")
|
||||
EDITOR = "editor", _("editor")
|
||||
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="roles", verbose_name=_("member"))
|
||||
role = models.CharField(_("role"), max_length=250, choices=Roles.choices, default=Roles.MEMBER)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("club role")
|
||||
verbose_name_plural = _("club roles")
|
||||
ordering = ["club", "member__last_name", "member__first_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "member"], name="unique_member_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.club} - {self.member}"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from .access import can_edit_event, can_manage_shop, has_club_role, members_visible_to, roles_in_club, teams_managed_by
|
||||
|
||||
__all__ = [
|
||||
"can_edit_event",
|
||||
"can_manage_shop",
|
||||
"has_club_role",
|
||||
"members_visible_to",
|
||||
"roles_in_club",
|
||||
"teams_managed_by",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Per-club access decisions.
|
||||
|
||||
All authorisation goes through this module (ARCHITECTURE §3): stored
|
||||
``ClubRole`` rows plus object-scoped facts — coach/manager is *derived* from
|
||||
``StaffAssignment`` (never a ClubRole), and parent/guardian from the family
|
||||
graph. ADMIN is the club-wide override.
|
||||
|
||||
Two axes, deliberately separate:
|
||||
|
||||
* **Authority** (``teams_managed_by``, ``can_edit_event``) requires a
|
||||
*management* position, and only for the **current season** — a StaffAssignment
|
||||
is per-season, so a former coach's authority expires with it. (A ``ClubRole``,
|
||||
by contrast, is permanent and survives a lapsed membership.)
|
||||
* **Visibility** (``teams_staffed_by`` → ``members_visible_to``) covers *any*
|
||||
staff position, so support staff (physio, kit manager) can see the roster they
|
||||
work with without gaining any authority over it.
|
||||
"""
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from authentication.models import User
|
||||
from club.models import Club, ClubRole, Season
|
||||
from events.models import Event
|
||||
from members.models import FamilyMembership, Member
|
||||
from teams.models import StaffAssignment, Team
|
||||
|
||||
#: Derived (never stored) roles.
|
||||
COACH = "coach"
|
||||
MANAGER = "manager"
|
||||
COACH_MANAGER = "coach_manager"
|
||||
|
||||
|
||||
def current_season(club: Club) -> Season | None:
|
||||
"""The club's season covering today. Staff authority is scoped to it."""
|
||||
return Season.covering(club, timezone.localdate())
|
||||
|
||||
|
||||
def event_season(event: Event) -> Season | None:
|
||||
"""The season an event belongs to (explicit, else derived from its start)."""
|
||||
return event.season or Season.covering(event.club, event.start.date())
|
||||
|
||||
|
||||
def has_club_role(user: User, club: Club, role: ClubRole.Roles) -> bool:
|
||||
return ClubRole.objects.filter(member__user=user, club=club, role=role).exists()
|
||||
|
||||
|
||||
def is_club_admin(user: User, club: Club) -> bool:
|
||||
return has_club_role(user, club, ClubRole.Roles.ADMIN)
|
||||
|
||||
|
||||
def is_coach_manager(user: User, club: Club) -> bool:
|
||||
"""Derived from a current-season StaffAssignment in a *management* position."""
|
||||
return StaffAssignment.objects.filter(
|
||||
member__user=user,
|
||||
team__club=club,
|
||||
position__management_position=True,
|
||||
season=current_season(club),
|
||||
).exists()
|
||||
|
||||
|
||||
def roles_in_club(user: User, club: Club) -> set[str]:
|
||||
"""The user's roles in ``club``, including the derived COACH_MANAGER role."""
|
||||
roles = set(ClubRole.objects.filter(member__user=user, club=club).values_list("role", flat=True))
|
||||
if is_coach_manager(user, club):
|
||||
roles.add(COACH_MANAGER)
|
||||
return roles
|
||||
|
||||
|
||||
def teams_managed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
"""Teams the user has authority over: all for an ADMIN, else the ones they
|
||||
manage *this season* (management position only)."""
|
||||
if is_club_admin(user, club):
|
||||
return Team.objects.filter(club=club)
|
||||
return Team.objects.filter(
|
||||
club=club,
|
||||
staff_assignments__member__user=user,
|
||||
staff_assignments__position__management_position=True,
|
||||
staff_assignments__season=current_season(club),
|
||||
).distinct()
|
||||
|
||||
|
||||
def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
|
||||
"""Teams the user is on the staff of this season, management or not.
|
||||
|
||||
Visibility only — being a team's physio grants sight of the roster, never
|
||||
authority over it.
|
||||
"""
|
||||
return Team.objects.filter(
|
||||
club=club,
|
||||
staff_assignments__member__user=user,
|
||||
staff_assignments__season=current_season(club),
|
||||
).distinct()
|
||||
|
||||
|
||||
def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
|
||||
"""Members the user may see.
|
||||
|
||||
ADMIN: everyone linked to the club (membership, roster, staff or role).
|
||||
Otherwise: themselves, their children, and the current-season players *and*
|
||||
staff of every team they're staffed on.
|
||||
"""
|
||||
if is_club_admin(user, club):
|
||||
return Member.objects.filter(
|
||||
Q(member_of__club=club) | Q(team_memberships__team__club=club) | Q(staff_assignments__team__club=club) | Q(roles__club=club)
|
||||
).distinct()
|
||||
|
||||
me = Member.objects.filter(user=user).first()
|
||||
if me is None:
|
||||
return Member.objects.none()
|
||||
|
||||
children = Member.objects.filter(
|
||||
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
|
||||
family_memberships__family__memberships__member=me,
|
||||
family_memberships__family__memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
|
||||
)
|
||||
|
||||
season = current_season(club)
|
||||
teams = teams_staffed_by(user, club)
|
||||
roster = Member.objects.filter(
|
||||
Q(team_memberships__team__in=teams, team_memberships__season=season) | Q(staff_assignments__team__in=teams, staff_assignments__season=season)
|
||||
)
|
||||
|
||||
visible = {me.pk} | set(children.values_list("pk", flat=True)) | set(roster.values_list("pk", flat=True))
|
||||
return Member.objects.filter(pk__in=visible)
|
||||
|
||||
|
||||
def can_edit_event(user: User, event: Event) -> bool:
|
||||
"""ADMIN/EDITOR in the club, the event's owner, or a manager of one of its
|
||||
teams *for that event's season*."""
|
||||
club = event.club
|
||||
if is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR):
|
||||
return True
|
||||
if event.created_by_id is not None and event.created_by.user_id == user.pk:
|
||||
return True
|
||||
return StaffAssignment.objects.filter(
|
||||
member__user=user,
|
||||
team__in=event.teams.all(),
|
||||
position__management_position=True,
|
||||
season=event_season(event),
|
||||
).exists()
|
||||
|
||||
|
||||
def can_manage_shop(user: User, club: Club) -> bool:
|
||||
return is_club_admin(user, club)
|
||||
|
||||
39
club/signals.py
Normal file
39
club/signals.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Keep ClubRole in sync with membership status.
|
||||
|
||||
An **active** ClubMembership grants the member a ``MEMBER`` ClubRole; when no
|
||||
active membership remains in that club (status changed away from active, or the
|
||||
membership was deleted), the ``MEMBER`` role is withdrawn.
|
||||
|
||||
A member holds at most one ClubRole per club (``unique_member_per_club``), so an
|
||||
elevated role (ADMIN / EDITOR) is never downgraded or removed by this sync — it
|
||||
simply takes precedence.
|
||||
"""
|
||||
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import ClubMembership, ClubRole
|
||||
|
||||
|
||||
@receiver(post_save, sender=ClubMembership)
|
||||
@receiver(post_delete, sender=ClubMembership)
|
||||
def sync_member_role(sender, instance, **kwargs):
|
||||
has_active = ClubMembership.objects.filter(
|
||||
club_id=instance.club_id,
|
||||
member_id=instance.member_id,
|
||||
status=ClubMembership.StatusChoices.ACTIVE,
|
||||
).exists()
|
||||
|
||||
if has_active:
|
||||
# get_or_create keeps an existing ADMIN/EDITOR role untouched.
|
||||
ClubRole.objects.get_or_create(
|
||||
club_id=instance.club_id,
|
||||
member_id=instance.member_id,
|
||||
defaults={"role": ClubRole.Roles.MEMBER},
|
||||
)
|
||||
else:
|
||||
ClubRole.objects.filter(
|
||||
club_id=instance.club_id,
|
||||
member_id=instance.member_id,
|
||||
role=ClubRole.Roles.MEMBER,
|
||||
).delete()
|
||||
415
club/tests.py
415
club/tests.py
@@ -4,15 +4,28 @@ from contextlib import contextmanager
|
||||
|
||||
from django.contrib import admin as django_admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import ProtectedError
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from members.models import Member
|
||||
from events.models import Event
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||
|
||||
from .models import Club, ClubMembership, Season
|
||||
from .models import Club, ClubMembership, ClubRole, Season
|
||||
from .services.access import (
|
||||
COACH_MANAGER,
|
||||
can_edit_event,
|
||||
can_manage_shop,
|
||||
has_club_role,
|
||||
members_visible_to,
|
||||
roles_in_club,
|
||||
teams_managed_by,
|
||||
teams_staffed_by,
|
||||
)
|
||||
from .tenancy import (
|
||||
ClubTenantMiddleware,
|
||||
get_current_club,
|
||||
@@ -455,7 +468,7 @@ class AdminRegistrationSmokeTests(TestCase):
|
||||
|
||||
registered = set(django_admin.site._registry)
|
||||
# Concrete, non-auto-created models in these apps should all be registered.
|
||||
project_apps = {"authentication", "club", "members", "teams", "events", "formbuilder"}
|
||||
project_apps = {"authentication", "club", "members", "teams", "events", "formbuilder", "shop"}
|
||||
for model in apps.get_models():
|
||||
if model._meta.app_label not in project_apps or model._meta.auto_created:
|
||||
continue
|
||||
@@ -473,3 +486,399 @@ class AdminRegistrationSmokeTests(TestCase):
|
||||
url = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_add")
|
||||
with self.subTest(model=model.__name__):
|
||||
self.assertEqual(self.client.get(url).status_code, 200)
|
||||
|
||||
|
||||
class ClubRoleTests(TestCase):
|
||||
def test_str(self):
|
||||
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||
role = ClubRole.objects.create(club=club, member=member)
|
||||
|
||||
self.assertEqual(str(role), f"{club} - {member}")
|
||||
|
||||
|
||||
class ClubMembershipCleanTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
today = timezone.localdate()
|
||||
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300))
|
||||
self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + datetime.timedelta(days=300))
|
||||
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||
|
||||
def test_rejects_cross_club_season(self):
|
||||
membership = ClubMembership(club=self.club, member=self.member, season=self.other_season)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
membership.full_clean()
|
||||
self.assertIn("season", ctx.exception.error_dict)
|
||||
|
||||
def test_accepts_same_club_season(self):
|
||||
ClubMembership(club=self.club, member=self.member, season=self.season).full_clean()
|
||||
|
||||
|
||||
class AccessServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
today = timezone.localdate()
|
||||
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300))
|
||||
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
||||
self.second_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
|
||||
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW")
|
||||
# Management staff (coach / team manager) vs. non-management staff (e.g. physio).
|
||||
self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
|
||||
self.physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
|
||||
|
||||
def make_user_member(self, email):
|
||||
user = get_user_model().objects.create_user(email=email, password="pw")
|
||||
member = Member.objects.create(user=user, first_name=email.split("@")[0].title(), last_name="Doe")
|
||||
return user, member
|
||||
|
||||
def grant(self, member, role):
|
||||
return ClubRole.objects.create(club=self.club, member=member, role=role)
|
||||
|
||||
def make_coach(self, member, team=None):
|
||||
return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.coach_position)
|
||||
|
||||
def make_support_staff(self, member, team=None):
|
||||
"""Staff on the team, but in a non-management position."""
|
||||
return StaffAssignment.objects.create(team=team or self.team, member=member, season=self.season, position=self.physio_position)
|
||||
|
||||
def make_event(self, **kwargs):
|
||||
kwargs.setdefault("club", self.club)
|
||||
kwargs.setdefault("title", "Match")
|
||||
kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1))
|
||||
return Event.objects.create(**kwargs)
|
||||
|
||||
# --- has_club_role / roles_in_club ---
|
||||
def test_has_club_role_is_scoped_to_role_and_club(self):
|
||||
user, member = self.make_user_member("admin@example.com")
|
||||
self.grant(member, ClubRole.Roles.ADMIN)
|
||||
|
||||
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
|
||||
self.assertFalse(has_club_role(user, self.club, ClubRole.Roles.EDITOR))
|
||||
self.assertFalse(has_club_role(user, self.other_club, ClubRole.Roles.ADMIN))
|
||||
|
||||
def test_roles_in_club_includes_derived_coach_manager(self):
|
||||
user, member = self.make_user_member("editor@example.com")
|
||||
self.grant(member, ClubRole.Roles.EDITOR)
|
||||
self.make_coach(member)
|
||||
|
||||
self.assertEqual(roles_in_club(user, self.club), {ClubRole.Roles.EDITOR, COACH_MANAGER})
|
||||
|
||||
def test_non_management_staff_gets_no_derived_role(self):
|
||||
user, member = self.make_user_member("physio@example.com")
|
||||
self.make_support_staff(member)
|
||||
|
||||
self.assertEqual(roles_in_club(user, self.club), set())
|
||||
|
||||
def test_roles_in_club_is_empty_for_outsider(self):
|
||||
user, _ = self.make_user_member("nobody@example.com")
|
||||
|
||||
self.assertEqual(roles_in_club(user, self.club), set())
|
||||
|
||||
# --- teams_managed_by ---
|
||||
def test_admin_manages_every_team(self):
|
||||
user, member = self.make_user_member("admin@example.com")
|
||||
self.grant(member, ClubRole.Roles.ADMIN)
|
||||
|
||||
self.assertEqual(set(teams_managed_by(user, self.club)), {self.team, self.second_team})
|
||||
|
||||
def test_coach_manages_only_their_team(self):
|
||||
user, member = self.make_user_member("coach@example.com")
|
||||
self.make_coach(member)
|
||||
|
||||
self.assertEqual(list(teams_managed_by(user, self.club)), [self.team])
|
||||
|
||||
def test_plain_member_manages_no_teams(self):
|
||||
user, _ = self.make_user_member("plain@example.com")
|
||||
|
||||
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
||||
|
||||
def test_non_management_staff_manages_no_teams(self):
|
||||
user, member = self.make_user_member("physio@example.com")
|
||||
self.make_support_staff(member)
|
||||
|
||||
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
||||
|
||||
def test_non_management_staff_does_not_inherit_a_managers_team(self):
|
||||
# The team has BOTH a manager and a non-management staffer. The staffer
|
||||
# must not pick up the team just because *someone else* manages it.
|
||||
_, manager = self.make_user_member("coach@example.com")
|
||||
self.make_coach(manager)
|
||||
physio_user, physio = self.make_user_member("physio@example.com")
|
||||
self.make_support_staff(physio)
|
||||
|
||||
self.assertEqual(list(teams_managed_by(physio_user, self.club)), [])
|
||||
|
||||
def make_past_season(self):
|
||||
return Season.objects.create(
|
||||
club=self.club,
|
||||
start_date=self.season.start_date - datetime.timedelta(days=400),
|
||||
end_date=self.season.start_date - datetime.timedelta(days=1),
|
||||
)
|
||||
|
||||
def test_a_former_seasons_coach_no_longer_manages_the_team(self):
|
||||
# StaffAssignment is per-season: authority expires with it.
|
||||
user, member = self.make_user_member("coach@example.com")
|
||||
StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position)
|
||||
|
||||
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
||||
self.assertEqual(list(teams_staffed_by(user, self.club)), [])
|
||||
self.assertFalse(roles_in_club(user, self.club))
|
||||
|
||||
def test_a_former_seasons_coach_cannot_edit_a_current_event(self):
|
||||
user, member = self.make_user_member("coach@example.com")
|
||||
StaffAssignment.objects.create(team=self.team, member=member, season=self.make_past_season(), position=self.coach_position)
|
||||
event = self.make_event()
|
||||
event.teams.add(self.team)
|
||||
|
||||
self.assertFalse(can_edit_event(user, event))
|
||||
|
||||
# --- members_visible_to ---
|
||||
def test_admin_sees_all_club_members(self):
|
||||
user, member = self.make_user_member("admin@example.com")
|
||||
self.grant(member, ClubRole.Roles.ADMIN)
|
||||
other = Member.objects.create(first_name="Other", last_name="Member")
|
||||
ClubMembership.objects.create(club=self.club, member=member, season=self.season)
|
||||
ClubMembership.objects.create(club=self.club, member=other, season=self.season)
|
||||
|
||||
self.assertEqual(set(members_visible_to(user, self.club)), {member, other})
|
||||
|
||||
def test_coach_sees_self_and_managed_roster(self):
|
||||
user, member = self.make_user_member("coach@example.com")
|
||||
self.make_coach(member)
|
||||
player = Member.objects.create(first_name="Player", last_name="One")
|
||||
TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward)
|
||||
unrelated = Member.objects.create(first_name="Un", last_name="Related")
|
||||
|
||||
visible = set(members_visible_to(user, self.club))
|
||||
|
||||
self.assertEqual(visible, {member, player})
|
||||
self.assertNotIn(unrelated, visible)
|
||||
|
||||
def test_parent_sees_self_and_children(self):
|
||||
user, parent = self.make_user_member("parent@example.com")
|
||||
child = Member.objects.create(first_name="Kid", last_name="Doe")
|
||||
family = Family.objects.create(name="Doe")
|
||||
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
|
||||
self.assertEqual(set(members_visible_to(user, self.club)), {parent, child})
|
||||
|
||||
def test_user_without_a_member_sees_nobody(self):
|
||||
user = get_user_model().objects.create_user(email="ghost@example.com", password="pw")
|
||||
|
||||
self.assertEqual(list(members_visible_to(user, self.club)), [])
|
||||
|
||||
def test_non_management_staff_sees_the_roster_but_holds_no_authority(self):
|
||||
# A physio can see the team they work with, but manages nothing.
|
||||
user, member = self.make_user_member("physio@example.com")
|
||||
self.make_support_staff(member)
|
||||
player = Member.objects.create(first_name="Player", last_name="One")
|
||||
TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=self.forward)
|
||||
|
||||
self.assertEqual(set(members_visible_to(user, self.club)), {member, player})
|
||||
self.assertEqual(list(teams_managed_by(user, self.club)), [])
|
||||
self.assertEqual(list(teams_staffed_by(user, self.club)), [self.team])
|
||||
|
||||
def test_manager_also_sees_the_teams_other_staff(self):
|
||||
user, manager = self.make_user_member("coach@example.com")
|
||||
self.make_coach(manager)
|
||||
_, physio = self.make_user_member("physio@example.com")
|
||||
self.make_support_staff(physio)
|
||||
|
||||
self.assertIn(physio, set(members_visible_to(user, self.club)))
|
||||
|
||||
def test_admin_sees_members_without_a_club_membership(self):
|
||||
user, admin = self.make_user_member("admin@example.com")
|
||||
self.grant(admin, ClubRole.Roles.ADMIN)
|
||||
_, coach = self.make_user_member("coach@example.com")
|
||||
self.make_coach(coach) # staff, but no ClubMembership
|
||||
|
||||
visible = set(members_visible_to(user, self.club))
|
||||
|
||||
self.assertIn(coach, visible)
|
||||
self.assertIn(admin, visible) # the admin sees themselves via their ClubRole
|
||||
|
||||
def test_roster_visibility_is_scoped_to_the_current_season(self):
|
||||
user, member = self.make_user_member("coach@example.com")
|
||||
self.make_coach(member)
|
||||
old_season = Season.objects.create(
|
||||
club=self.club,
|
||||
start_date=self.season.start_date - datetime.timedelta(days=400),
|
||||
end_date=self.season.start_date - datetime.timedelta(days=1),
|
||||
)
|
||||
former_player = Member.objects.create(first_name="Former", last_name="Player")
|
||||
TeamMembership.objects.create(team=self.team, member=former_player, season=old_season, position=self.forward)
|
||||
|
||||
self.assertNotIn(former_player, set(members_visible_to(user, self.club)))
|
||||
|
||||
# --- can_edit_event ---
|
||||
def test_admin_can_edit_event(self):
|
||||
user, member = self.make_user_member("admin@example.com")
|
||||
self.grant(member, ClubRole.Roles.ADMIN)
|
||||
|
||||
self.assertTrue(can_edit_event(user, self.make_event()))
|
||||
|
||||
def test_editor_can_edit_event(self):
|
||||
user, member = self.make_user_member("editor@example.com")
|
||||
self.grant(member, ClubRole.Roles.EDITOR)
|
||||
|
||||
self.assertTrue(can_edit_event(user, self.make_event()))
|
||||
|
||||
def test_owner_can_edit_their_event(self):
|
||||
user, member = self.make_user_member("owner@example.com")
|
||||
|
||||
self.assertTrue(can_edit_event(user, self.make_event(created_by=member)))
|
||||
|
||||
def test_coach_can_edit_their_teams_event(self):
|
||||
user, member = self.make_user_member("coach@example.com")
|
||||
self.make_coach(member)
|
||||
event = self.make_event()
|
||||
event.teams.add(self.team)
|
||||
|
||||
self.assertTrue(can_edit_event(user, event))
|
||||
|
||||
def test_coach_cannot_edit_another_teams_event(self):
|
||||
user, member = self.make_user_member("coach@example.com")
|
||||
self.make_coach(member)
|
||||
event = self.make_event()
|
||||
event.teams.add(self.second_team)
|
||||
|
||||
self.assertFalse(can_edit_event(user, event))
|
||||
|
||||
def test_plain_member_cannot_edit_event(self):
|
||||
user, _ = self.make_user_member("plain@example.com")
|
||||
|
||||
self.assertFalse(can_edit_event(user, self.make_event()))
|
||||
|
||||
def test_non_management_staff_cannot_edit_their_teams_event(self):
|
||||
user, member = self.make_user_member("physio@example.com")
|
||||
self.make_support_staff(member)
|
||||
event = self.make_event()
|
||||
event.teams.add(self.team)
|
||||
|
||||
self.assertFalse(can_edit_event(user, event))
|
||||
|
||||
def test_non_management_staff_on_a_managed_team_cannot_edit_its_event(self):
|
||||
# Same escalation shape as teams_managed_by: a manager exists on the team,
|
||||
# but the physio must not inherit edit rights from them.
|
||||
_, manager = self.make_user_member("coach@example.com")
|
||||
self.make_coach(manager)
|
||||
physio_user, physio = self.make_user_member("physio@example.com")
|
||||
self.make_support_staff(physio)
|
||||
event = self.make_event()
|
||||
event.teams.add(self.team)
|
||||
|
||||
self.assertFalse(can_edit_event(physio_user, event))
|
||||
|
||||
# --- can_manage_shop ---
|
||||
def test_only_admin_can_manage_shop(self):
|
||||
admin_user, admin_member = self.make_user_member("admin@example.com")
|
||||
self.grant(admin_member, ClubRole.Roles.ADMIN)
|
||||
editor_user, editor_member = self.make_user_member("editor@example.com")
|
||||
self.grant(editor_member, ClubRole.Roles.EDITOR)
|
||||
|
||||
self.assertTrue(can_manage_shop(admin_user, self.club))
|
||||
self.assertFalse(can_manage_shop(editor_user, self.club))
|
||||
|
||||
|
||||
class ClubRoleStatusSyncTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
today = timezone.localdate()
|
||||
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + datetime.timedelta(days=300))
|
||||
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
||||
|
||||
def roles(self):
|
||||
return ClubRole.objects.filter(club=self.club, member=self.member)
|
||||
|
||||
def make_membership(self, status=ClubMembership.StatusChoices.ACTIVE):
|
||||
return ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=status)
|
||||
|
||||
def test_active_membership_grants_member_role(self):
|
||||
self.make_membership()
|
||||
|
||||
self.assertEqual(self.roles().get().role, ClubRole.Roles.MEMBER)
|
||||
|
||||
def test_pending_membership_grants_no_role(self):
|
||||
self.make_membership(status=ClubMembership.StatusChoices.PENDING)
|
||||
|
||||
self.assertFalse(self.roles().exists())
|
||||
|
||||
def test_deactivating_membership_withdraws_member_role(self):
|
||||
membership = self.make_membership()
|
||||
self.assertTrue(self.roles().exists())
|
||||
|
||||
membership.status = ClubMembership.StatusChoices.LAPSED
|
||||
membership.save()
|
||||
|
||||
self.assertFalse(self.roles().exists())
|
||||
|
||||
def test_deleting_membership_withdraws_member_role(self):
|
||||
membership = self.make_membership()
|
||||
|
||||
membership.delete()
|
||||
|
||||
self.assertFalse(self.roles().exists())
|
||||
|
||||
def test_elevated_role_is_never_downgraded_or_removed(self):
|
||||
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN)
|
||||
|
||||
membership = self.make_membership()
|
||||
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
||||
|
||||
membership.status = ClubMembership.StatusChoices.CANCELLED
|
||||
membership.save()
|
||||
|
||||
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
||||
|
||||
def test_editor_role_survives_a_lapsed_membership(self):
|
||||
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.EDITOR)
|
||||
membership = self.make_membership()
|
||||
|
||||
membership.status = ClubMembership.StatusChoices.LAPSED
|
||||
membership.save()
|
||||
|
||||
self.assertEqual(self.roles().get().role, ClubRole.Roles.EDITOR)
|
||||
|
||||
def test_elevated_role_survives_membership_deletion(self):
|
||||
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN)
|
||||
membership = self.make_membership()
|
||||
|
||||
membership.delete()
|
||||
|
||||
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
||||
|
||||
def test_elevated_role_survives_a_season_rollover(self):
|
||||
# Last season's membership lapses and the new season's is still pending:
|
||||
# the admin must not lose their role in the gap.
|
||||
ClubRole.objects.create(club=self.club, member=self.member, role=ClubRole.Roles.ADMIN)
|
||||
last_season = self.make_membership()
|
||||
next_season = Season.objects.create(
|
||||
club=self.club,
|
||||
start_date=self.season.end_date + datetime.timedelta(days=1),
|
||||
end_date=self.season.end_date + datetime.timedelta(days=300),
|
||||
)
|
||||
|
||||
last_season.status = ClubMembership.StatusChoices.LAPSED
|
||||
last_season.save()
|
||||
ClubMembership.objects.create(club=self.club, member=self.member, season=next_season, status=ClubMembership.StatusChoices.PENDING)
|
||||
|
||||
self.assertEqual(self.roles().get().role, ClubRole.Roles.ADMIN)
|
||||
|
||||
def test_elevated_access_and_login_survive_a_lapsed_membership(self):
|
||||
# The whole point: a lapsed membership must not lock an admin out.
|
||||
user = get_user_model().objects.create_user(email="admin@example.com", password="pw")
|
||||
admin = Member.objects.create(user=user, first_name="Ada", last_name="Min")
|
||||
ClubRole.objects.create(club=self.club, member=admin, role=ClubRole.Roles.ADMIN)
|
||||
membership = ClubMembership.objects.create(club=self.club, member=admin, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
|
||||
membership.status = ClubMembership.StatusChoices.CANCELLED
|
||||
membership.save()
|
||||
|
||||
self.assertTrue(user.is_active) # can still log in
|
||||
self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club))
|
||||
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
|
||||
self.assertTrue(can_manage_shop(user, self.club))
|
||||
|
||||
Reference in New Issue
Block a user