feat: season-scope club memberships and convert to UniqueConstraint

ClubMembership is now tied to a Season (plus status / fee_status / sign-up
dates) and unique per (club, member, season). The CSV importer attaches
each membership to the club's current season (Season.get_current), skipping
the row with a clear error when none exists; the now-unreachable
"clubs created" bookkeeping is removed.

Register SeasonAdmin and rebuild ClubMembershipAdmin for the new fields, and
add an admin smoke test that asserts every model in authentication/club/
members/teams is registered and its changelist + add pages load.

Convert every unique_together to a Meta UniqueConstraint (Season,
ClubMembership, FamilyMembership) per Django's guidance. Regenerate
migrations. club, members, and importer stay at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 16:47:39 +02:00
parent 3914765f90
commit dd2e4b2169
11 changed files with 316 additions and 108 deletions

View File

@@ -35,7 +35,6 @@ class Command(BaseCommand):
f"Members created: {result.created_members}. "
f"Members updated: {result.updated_members}. "
f"Users created: {result.created_users}. "
f"Clubs created: {result.created_clubs}. "
f"Memberships created: {result.created_memberships}. "
f"Memberships updated: {result.updated_memberships}. "
f"Rows skipped: {result.skipped_rows}."

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0.6 on 2026-07-12 14:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0001_initial'),
]
operations = [
migrations.AlterUniqueTogether(
name='familymembership',
unique_together=set(),
),
migrations.AddConstraint(
model_name='familymembership',
constraint=models.UniqueConstraint(fields=('family', 'member'), name='unique_member_per_family'),
),
]

View File

@@ -93,7 +93,9 @@ class FamilyMembership(models.Model):
verbose_name = _("family membership")
verbose_name_plural = _("family memberships")
ordering = ["family", "role", "member__last_name", "member__first_name"]
unique_together = ("family", "member")
constraints = [
models.UniqueConstraint(fields=["family", "member"], name="unique_member_per_family"),
]
def __str__(self):
return f"{self.family} - {self.member} ({self.get_role_display()})"

View File

@@ -7,7 +7,8 @@ from typing import IO, Any
from django.contrib.auth import get_user_model
from django.db import transaction
from club.models import Club, ClubMembership
from club.models import Club, ClubMembership, Season
from club.tenancy import reset_current_club, set_current_club
from members.models import Member
REQUIRED_COLUMNS = {"first_name", "last_name", "email", "date_of_birth", "create_account", "club_name", "license_number"}
@@ -27,7 +28,6 @@ class MemberImportResult:
created_members: int = 0
updated_members: int = 0
created_users: int = 0
created_clubs: int = 0
created_memberships: int = 0
updated_memberships: int = 0
skipped_rows: int = 0
@@ -42,7 +42,6 @@ class MemberImportResult:
class ImportedMemberRowResult:
member_created: bool
user_created: bool
club_created: bool
membership_created: bool
@@ -91,9 +90,6 @@ class MemberCsvImporter:
if row_result.user_created:
result.created_users += 1
if row_result.club_created:
result.created_clubs += 1
if row_result.membership_created:
result.created_memberships += 1
else:
@@ -127,11 +123,13 @@ class MemberCsvImporter:
},
)
club, club_created = Club.objects.get_or_create(name=club_name)
club, _ = Club.objects.get_or_create(name=club_name)
season = self.get_current_season(club)
_, membership_created = ClubMembership.objects.update_or_create(
club=club,
member=member,
season=season,
defaults={
"license": license_number,
},
@@ -140,10 +138,23 @@ class MemberCsvImporter:
return ImportedMemberRowResult(
member_created=member_created,
user_created=user_created,
club_created=club_created,
membership_created=membership_created,
)
def get_current_season(self, club) -> Season:
# Season.get_current() is tenant-scoped, so bind the row's club as the
# active tenant for the lookup.
token = set_current_club(club)
try:
season = Season.get_current()
finally:
reset_current_club(token)
if season is None:
raise ValueError(f"No current season for club '{club.name}'.")
return season
def get_or_create_user(self, email) -> tuple[User, bool]:
user, created = User.objects.get_or_create(
email=email,

View File

@@ -1,5 +1,5 @@
import tempfile
from datetime import date
from datetime import date, timedelta
from io import StringIO
from pathlib import Path
@@ -9,9 +9,10 @@ from django.core.management.base import CommandError
from django.db import IntegrityError
from django.db.models import SET_NULL
from django.test import TestCase
from django.utils import timezone
from authentication.models import User
from club.models import Club, ClubMembership
from club.models import Club, ClubMembership, Season
from members.admin import FamilyAdmin
from members.models import Family, FamilyMembership, Member
from members.services import MemberImportResult
@@ -292,6 +293,17 @@ class AdminSmokeTests(TestCase):
class ImportMembersCsvCommandTests(TestCase):
def setUp(self):
# Memberships are season-scoped: the importer attaches each one to the
# club's current season, so the target club needs one covering today.
self.club = Club.objects.create(name="City Swim Club")
today = timezone.localdate()
self.season = Season.objects.create(
club=self.club,
start_date=today - timedelta(days=90),
end_date=today + timedelta(days=275),
)
def write_csv(self, content):
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8")
temp_file.write(content)
@@ -329,7 +341,6 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertIn("Import complete.", stdout)
self.assertIn("Members created: 1.", stdout)
self.assertIn("Users created: 1.", stdout)
self.assertIn("Clubs created: 1.", stdout)
self.assertIn("Memberships created: 1.", stdout)
self.assertIn("Rows skipped: 0.", stdout)
@@ -341,9 +352,9 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertEqual(member.user.email, "jane@example.com")
self.assertFalse(member.user.has_usable_password())
club = Club.objects.get(name="City Swim Club")
membership = ClubMembership.objects.get(club=club, member=member)
membership = ClubMembership.objects.get(club=self.club, member=member)
self.assertEqual(membership.license, "LIC-001")
self.assertEqual(membership.season, self.season)
def test_import_creates_member_without_user_when_create_account_is_false(self):
csv_path = self.write_csv(
@@ -366,7 +377,6 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertFalse(User.objects.filter(email="john@example.com").exists())
def test_import_updates_existing_member_and_membership(self):
club = Club.objects.create(name="City Swim Club")
member = Member.objects.create(
first_name="Old",
last_name="Name",
@@ -374,7 +384,8 @@ class ImportMembersCsvCommandTests(TestCase):
date_of_birth=date(2010, 1, 1),
)
ClubMembership.objects.create(
club=club,
club=self.club,
season=self.season,
member=member,
license="OLD-LIC",
)
@@ -401,7 +412,7 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertEqual(member.last_name, "Doe")
self.assertEqual(member.date_of_birth, date(2010, 4, 12))
membership = ClubMembership.objects.get(club=club, member=member)
membership = ClubMembership.objects.get(club=self.club, member=member)
self.assertEqual(membership.license, "LIC-NEW")
def test_import_links_existing_user_when_create_account_is_true(self):
@@ -539,6 +550,26 @@ class ImportMembersCsvCommandTests(TestCase):
self.assertIn("Rows skipped: 1.", stdout)
self.assertFalse(Member.objects.filter(email="nofirst@example.com").exists())
def test_import_skips_row_when_club_has_no_current_season(self):
# "New Club" has no season, so the row is skipped and — because the row
# is atomic — the member and club creation roll back too.
csv_path = self.write_csv(
"\n".join(
[
"first_name,last_name,email,date_of_birth,create_account,club_name,license_number",
"Jane,Doe,jane@example.com,2010-04-12,false,New Club,LIC-001",
]
)
)
stdout, stderr = self.call_import_command(csv_path)
self.assertIn("Row 2 skipped:", stderr)
self.assertIn("No current season for club 'New Club'.", stderr)
self.assertIn("Rows skipped: 1.", stdout)
self.assertFalse(Member.objects.filter(email="jane@example.com").exists())
self.assertFalse(Club.objects.filter(name="New Club").exists())
class MemberImportResultTests(TestCase):
def test_successful_rows_sums_created_and_updated(self):

View File

@@ -1,2 +1 @@
# Create your views here.