feat(club): archive clubs instead of deleting them
Add Club.archived_at with active()/archived() managers, archive() and restore(). An archived club stops resolving in ClubTenantMiddleware, so its subdomain behaves as unknown — archiving is a real deactivation, not a cosmetic flag — while every row it owns is retained. There is no hard-delete path, deliberately. A club with any data cannot be deleted anyway (ClubMembership PROTECTs its Season, and the shop chain PROTECTs more), and invoices generally must be kept. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
18
club/migrations/0010_club_archived_at.py
Normal file
18
club/migrations/0010_club_archived_at.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,11 +15,19 @@ class ClubManager(models.Manager):
|
|||||||
|
|
||||||
return get_current_club()
|
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):
|
class Club(UUIDModel):
|
||||||
name = models.CharField(_("name"), max_length=255)
|
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)."))
|
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()
|
objects = ClubManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -35,6 +43,26 @@ class Club(UUIDModel):
|
|||||||
self.slug = unique_slugify(self, self.name)
|
self.slug = unique_slugify(self, self.name)
|
||||||
super().save(*args, **kwargs)
|
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):
|
class Season(ClubScopedModel):
|
||||||
start_date = models.DateField(_("start date"))
|
start_date = models.DateField(_("start date"))
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
|
|||||||
staff of every team they're staffed on.
|
staff of every team they're staffed on.
|
||||||
"""
|
"""
|
||||||
if is_club_admin(user, club):
|
if is_club_admin(user, club):
|
||||||
return Member.objects.filter(
|
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()
|
||||||
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()
|
me = Member.objects.filter(user=user).first()
|
||||||
if me is None:
|
if me is None:
|
||||||
@@ -117,9 +115,7 @@ def members_visible_to(user: User, club: Club) -> QuerySet[Member]:
|
|||||||
|
|
||||||
season = current_season(club)
|
season = current_season(club)
|
||||||
teams = teams_staffed_by(user, club)
|
teams = teams_staffed_by(user, club)
|
||||||
roster = Member.objects.filter(
|
roster = Member.objects.filter(Q(team_memberships__team__in=teams, team_memberships__season=season) | Q(staff_assignments__team__in=teams, staff_assignments__season=season))
|
||||||
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))
|
visible = {me.pk} | set(children.values_list("pk", flat=True)) | set(roster.values_list("pk", flat=True))
|
||||||
return Member.objects.filter(pk__in=visible)
|
return Member.objects.filter(pk__in=visible)
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class ClubTenantMiddleware:
|
|||||||
if not subdomain:
|
if not subdomain:
|
||||||
return None
|
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
|
@staticmethod
|
||||||
def get_subdomain(request) -> str | None:
|
def get_subdomain(request) -> str | None:
|
||||||
|
|||||||
@@ -492,6 +492,80 @@ class AdminRegistrationSmokeTests(TestCase):
|
|||||||
self.assertEqual(self.client.get(url).status_code, 200)
|
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):
|
class ClubRoleTests(TestCase):
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
|||||||
Reference in New Issue
Block a user