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:
2026-07-13 14:43:15 +02:00
parent 78fdcdf138
commit 819700ad0c
8 changed files with 674 additions and 5 deletions

View File

@@ -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"]

View File

@@ -3,3 +3,6 @@ from django.apps import AppConfig
class ClubConfig(AppConfig):
name = "club"
def ready(self):
from . import signals # noqa: F401

View 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')],
},
),
]

View File

@@ -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}"

View File

@@ -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",
]

View File

@@ -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
View 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()

View File

@@ -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))