diff --git a/club/migrations/0010_club_archived_at.py b/club/migrations/0010_club_archived_at.py new file mode 100644 index 0000000..a07d5ec --- /dev/null +++ b/club/migrations/0010_club_archived_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-13 13:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0009_clubrole'), + ] + + operations = [ + migrations.AddField( + model_name='club', + name='archived_at', + field=models.DateTimeField(blank=True, help_text='Archived clubs stop resolving on their subdomain, but their data is retained.', null=True, verbose_name='archived at'), + ), + ] diff --git a/club/models.py b/club/models.py index 17c9b30..c61557d 100644 --- a/club/models.py +++ b/club/models.py @@ -15,11 +15,19 @@ class ClubManager(models.Manager): return get_current_club() + def active(self): + return self.filter(archived_at__isnull=True) + + def archived(self): + return self.filter(archived_at__isnull=False) + class Club(UUIDModel): name = models.CharField(_("name"), max_length=255) slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.clubmanager.app).")) + archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained.")) + objects = ClubManager() class Meta: @@ -35,6 +43,26 @@ class Club(UUIDModel): self.slug = unique_slugify(self, self.name) super().save(*args, **kwargs) + @property + def is_archived(self) -> bool: + return self.archived_at is not None + + def archive(self): + """Soft-delete: the club stops resolving, but nothing is destroyed. + + Clubs are never hard-deleted — a club with any data cannot be removed + anyway (ClubMembership PROTECTs its Season), and financial records must + be retained. + """ + if not self.is_archived: + self.archived_at = timezone.now() + self.save(update_fields=["archived_at"]) + + def restore(self): + if self.is_archived: + self.archived_at = None + self.save(update_fields=["archived_at"]) + class Season(ClubScopedModel): start_date = models.DateField(_("start date")) diff --git a/club/services/access.py b/club/services/access.py index 145ed81..495e247 100644 --- a/club/services/access.py +++ b/club/services/access.py @@ -101,9 +101,7 @@ def members_visible_to(user: User, club: Club) -> QuerySet[Member]: 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() + 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: @@ -117,9 +115,7 @@ def members_visible_to(user: User, club: Club) -> QuerySet[Member]: 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) - ) + 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) diff --git a/club/tenancy.py b/club/tenancy.py index a519f29..3df0a62 100644 --- a/club/tenancy.py +++ b/club/tenancy.py @@ -67,7 +67,8 @@ class ClubTenantMiddleware: if not subdomain: return None - return Club.objects.filter(slug=subdomain).first() + # Archived clubs stop resolving: their subdomain behaves as unknown. + return Club.objects.active().filter(slug=subdomain).first() @staticmethod def get_subdomain(request) -> str | None: diff --git a/club/tests.py b/club/tests.py index 403c63b..e39c0ed 100644 --- a/club/tests.py +++ b/club/tests.py @@ -492,6 +492,80 @@ class AdminRegistrationSmokeTests(TestCase): self.assertEqual(self.client.get(url).status_code, 200) +class ClubArchivingTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + + def test_a_new_club_is_active(self): + self.assertFalse(self.club.is_archived) + self.assertIn(self.club, Club.objects.active()) + self.assertNotIn(self.club, Club.objects.archived()) + + def test_archive_and_restore(self): + self.club.archive() + + self.assertTrue(self.club.is_archived) + self.assertIn(self.club, Club.objects.archived()) + self.assertNotIn(self.club, Club.objects.active()) + + self.club.restore() + + self.assertFalse(self.club.is_archived) + self.assertIn(self.club, Club.objects.active()) + + def test_archiving_twice_keeps_the_original_timestamp(self): + self.club.archive() + first = self.club.archived_at + + self.club.archive() + + self.assertEqual(self.club.archived_at, first) + + def test_restoring_an_active_club_is_a_no_op(self): + self.club.restore() + + self.assertFalse(self.club.is_archived) + + def test_archiving_destroys_nothing(self): + season = make_season(self.club) + member = Member.objects.create(first_name="Jane", last_name="Doe") + ClubMembership.objects.create(club=self.club, member=member, season=season) + + self.club.archive() + + self.assertTrue(ClubMembership.objects.filter(club=self.club).exists()) + self.assertTrue(Season.objects.filter(club=self.club).exists()) + + +@override_settings(CLUBMANAGER_BASE_DOMAIN="clubmanager.app", ALLOWED_HOSTS=[".clubmanager.app"]) +class ArchivedClubTenancyTests(TestCase): + """An archived club's subdomain must stop resolving — that is what makes + archiving a real deactivation rather than a cosmetic flag.""" + + def setUp(self): + self.club = Club.objects.create(name="Ajax United", slug="ajax-united") + self.middleware = ClubTenantMiddleware(lambda request: "response") + + def resolve(self): + request = RequestFactory().get("/", HTTP_HOST="ajax-united.clubmanager.app") + self.middleware(request) + return request.club + + def test_active_club_resolves(self): + self.assertEqual(self.resolve(), self.club) + + def test_archived_club_stops_resolving(self): + self.club.archive() + + self.assertIsNone(self.resolve()) + + def test_restored_club_resolves_again(self): + self.club.archive() + self.club.restore() + + self.assertEqual(self.resolve(), self.club) + + class ClubRoleTests(TestCase): def test_str(self): club = Club.objects.create(name="Ajax United", slug="ajax-united")