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

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