From 819700ad0cb8cd74321de115a2c132d9226dad98 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 13 Jul 2026 14:43:15 +0200 Subject: [PATCH] feat(club): ClubRole, RBAC access service and role sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- club/admin.py | 10 +- club/apps.py | 3 + club/migrations/0009_clubrole.py | 31 +++ club/models.py | 26 +- club/services/__init__.py | 10 + club/services/access.py | 145 +++++++++++ club/signals.py | 39 +++ club/tests.py | 415 ++++++++++++++++++++++++++++++- 8 files changed, 674 insertions(+), 5 deletions(-) create mode 100644 club/migrations/0009_clubrole.py create mode 100644 club/signals.py diff --git a/club/admin.py b/club/admin.py index ea9063e..0a0f3bd 100644 --- a/club/admin.py +++ b/club/admin.py @@ -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"] diff --git a/club/apps.py b/club/apps.py index 8e8a26a..48e3b96 100644 --- a/club/apps.py +++ b/club/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class ClubConfig(AppConfig): name = "club" + + def ready(self): + from . import signals # noqa: F401 diff --git a/club/migrations/0009_clubrole.py b/club/migrations/0009_clubrole.py new file mode 100644 index 0000000..1c1d660 --- /dev/null +++ b/club/migrations/0009_clubrole.py @@ -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')], + }, + ), + ] diff --git a/club/models.py b/club/models.py index 0a20cb5..17c9b30 100644 --- a/club/models.py +++ b/club/models.py @@ -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}" diff --git a/club/services/__init__.py b/club/services/__init__.py index e69de29..0c594c5 100644 --- a/club/services/__init__.py +++ b/club/services/__init__.py @@ -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", +] diff --git a/club/services/access.py b/club/services/access.py index e69de29..145ed81 100644 --- a/club/services/access.py +++ b/club/services/access.py @@ -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) diff --git a/club/signals.py b/club/signals.py new file mode 100644 index 0000000..6a142d6 --- /dev/null +++ b/club/signals.py @@ -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() diff --git a/club/tests.py b/club/tests.py index 8186d95..7e1890f 100644 --- a/club/tests.py +++ b/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))