diff --git a/authentication/management/__init__.py b/authentication/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/management/commands/__init__.py b/authentication/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/management/commands/import_members_csv.py b/authentication/management/commands/import_members_csv.py new file mode 100644 index 0000000..61ad519 --- /dev/null +++ b/authentication/management/commands/import_members_csv.py @@ -0,0 +1,43 @@ +from django.core.management.base import BaseCommand, CommandError + +from authentication.services.member_csv_importer import MemberCsvImporter + + +class Command(BaseCommand): + help = "Import club members from a CSV file." + + def add_arguments(self, parser) -> None: + parser.add_argument( + "csv_file", + type=str, + help="Path to the CSV file to import.", + ) + parser.add_argument( + "--date-format", + default="%Y-%m-%d", + help="Date format for date_of_birth. Default: %%Y-%%m-%%d", + ) + + def handle(self, *args, **options) -> str | None: + importer = MemberCsvImporter(date_format=options["date_format"]) + + try: + result = importer.import_path(options["csv_file"]) + except Exception as exc: + raise CommandError(str(exc)) from exc + + for error in result.errors: + self.stderr.write(self.style.ERROR(f"Row {error.row_number} skipped: {error.message}")) + + self.stdout.write( + self.style.SUCCESS( + "Import complete. " + 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}." + ) + ) diff --git a/authentication/services/__init__.py b/authentication/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/services/member_csv_importer.py b/authentication/services/member_csv_importer.py new file mode 100644 index 0000000..8c810e1 --- /dev/null +++ b/authentication/services/member_csv_importer.py @@ -0,0 +1,180 @@ +import csv +from dataclasses import dataclass, field +from datetime import date, datetime +from pathlib import Path +from typing import IO, Any + +from django.contrib.auth import get_user_model +from django.db import transaction + +from authentication.models import Member +from club.models import Club, ClubMembership + +REQUIRED_COLUMNS = {"first_name", "last_name", "email", "date_of_birth", "create_account", "club_name", "license_number"} +TRUE_VALUES = {"1", "true", "yes", "y", "on"} + +User = get_user_model() + + +@dataclass +class MemberImportRowError: + row_number: int + message: str + + +@dataclass +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 + errors: list[MemberImportRowError] = field(default_factory=list) + + @property + def successful_rows(self): + return self.created_members + self.updated_members + + +@dataclass +class ImportedMemberRowResult: + member_created: bool + user_created: bool + club_created: bool + membership_created: bool + + +class MemberCsvImporter: + def __init__(self, *, date_format="%Y-%m-%d") -> None: + self.date_format = date_format + + def import_path(self, csv_path) -> MemberImportResult: + path = Path(csv_path) + + if not path.exists(): + raise FileNotFoundError(f"CSV file does not exist: {path}") + + with path.open(newline="", encoding="utf-8-sig") as csv_file: + return self.import_file(csv_file) + + def import_file(self, csv_file: IO[str]) -> MemberImportResult: + result = MemberImportResult() + reader = csv.DictReader(csv_file) + + if reader.fieldnames is None: + raise ValueError("CSV file is empty or missing a header row.") + + missing_columns = REQUIRED_COLUMNS - set(reader.fieldnames) + if missing_columns: + raise ValueError("CSV file is missing required columns: " + ", ".join(sorted(missing_columns))) + + for row_number, row in enumerate(reader, start=2): + try: + row_result = self.import_row(row) + except Exception as exc: + result.skipped_rows += 1 + result.errors.append( + MemberImportRowError( + row_number=row_number, + message=str(exc), + ) + ) + continue + + if row_result.member_created: + result.created_members += 1 + else: + result.updated_members += 1 + + 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: + result.updated_memberships += 1 + + return result + + @transaction.atomic + def import_row(self, row) -> ImportedMemberRowResult: + first_name = self.clean_required(row, "first_name") + last_name = self.clean_required(row, "last_name") + email = self.clean_required(row, "email").lower() + date_of_birth = self.parse_date(self.clean_required(row, "date_of_birth")) + create_account = self.as_bool(row.get("create_account", "")) + club_name = self.clean_required(row, "club_name") + license_number = row.get("license_number", "").strip() + + user = None + user_created = False + + if create_account: + user, user_created = self.get_or_create_user(email) + + member, member_created = Member.objects.update_or_create( + email=email, + defaults={ + "first_name": first_name, + "last_name": last_name, + "date_of_birth": date_of_birth, + "user": user, + }, + ) + + if create_account and member.user_id is None: + member.user = user + member.save(update_fields=["user"]) + + club, club_created = Club.objects.get_or_create(name=club_name) + + _, membership_created = ClubMembership.objects.update_or_create( + club=club, + member=member, + defaults={ + "license": license_number, + }, + ) + + return ImportedMemberRowResult( + member_created=member_created, + user_created=user_created, + club_created=club_created, + membership_created=membership_created, + ) + + def get_or_create_user(self, email) -> tuple[User, bool]: + user, created = User.objects.get_or_create( + email=email, + defaults={ + "is_active": True, + }, + ) + + if created: + user.set_unusable_password() + user.save(update_fields=["password"]) + + return user, created + + def clean_required(self, row, field_name) -> Any: + value = row.get(field_name, "").strip() + + if not value: + raise ValueError(f"{field_name} is required.") + + return value + + def parse_date(self, value) -> date: + try: + return datetime.strptime(value, self.date_format).date() + except ValueError as exc: + raise ValueError(f"Invalid date_of_birth '{value}'. Expected format: {self.date_format}.") from exc + + def as_bool(self, value) -> bool: + return value.strip().lower() in TRUE_VALUES diff --git a/authentication/tests.py b/authentication/tests.py index 2cdd8e5..c344312 100644 --- a/authentication/tests.py +++ b/authentication/tests.py @@ -1,11 +1,18 @@ +import tempfile import uuid +from datetime import date +from io import StringIO +from pathlib import Path from django.contrib.auth import get_user_model +from django.core.management import call_command +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 authentication.models import Family, FamilyMembership, Member +from club.models import Club, ClubMembership User = get_user_model() @@ -332,3 +339,233 @@ class AdminSmokeTests(TestCase): {"app_label": "authentication", "model_name": "member", "field_name": "user", "term": "root"}, ) self.assertEqual(response.status_code, 200) + + +class ImportMembersCsvCommandTests(TestCase): + def write_csv(self, content): + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") + temp_file.write(content) + temp_file.close() + self.addCleanup(lambda: Path(temp_file.name).unlink(missing_ok=True)) + return temp_file.name + + def call_import_command(self, csv_path, **options): + stdout = StringIO() + stderr = StringIO() + + call_command( + "import_members_csv", + csv_path, + stdout=stdout, + stderr=stderr, + **options, + ) + + return stdout.getvalue(), stderr.getvalue() + + def test_import_creates_member_club_membership_and_user_when_requested(self): + 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,true,City Swim Club,LIC-001", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + 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) + + member = Member.objects.get(email="jane@example.com") + self.assertEqual(member.first_name, "Jane") + self.assertEqual(member.last_name, "Doe") + self.assertEqual(member.date_of_birth, date(2010, 4, 12)) + self.assertIsNotNone(member.user) + 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) + self.assertEqual(membership.license, "LIC-001") + + def test_import_creates_member_without_user_when_create_account_is_false(self): + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", + "John,Smith,john@example.com,2009-11-03,false,City Swim Club,LIC-002", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + self.assertIn("Members created: 1.", stdout) + self.assertIn("Users created: 0.", stdout) + + member = Member.objects.get(email="john@example.com") + self.assertIsNone(member.user) + 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", + email="jane@example.com", + date_of_birth=date(2010, 1, 1), + ) + ClubMembership.objects.create( + club=club, + member=member, + license="OLD-LIC", + ) + + 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,City Swim Club,LIC-NEW", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + self.assertIn("Members created: 0.", stdout) + self.assertIn("Members updated: 1.", stdout) + self.assertIn("Memberships created: 0.", stdout) + self.assertIn("Memberships updated: 1.", stdout) + + member.refresh_from_db() + self.assertEqual(member.first_name, "Jane") + self.assertEqual(member.last_name, "Doe") + self.assertEqual(member.date_of_birth, date(2010, 4, 12)) + + membership = ClubMembership.objects.get(club=club, member=member) + self.assertEqual(membership.license, "LIC-NEW") + + def test_import_links_existing_user_when_create_account_is_true(self): + user = User.objects.create_user(email="jane@example.com", password="secret123") + + 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,true,City Swim Club,LIC-001", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + self.assertIn("Users created: 0.", stdout) + + member = Member.objects.get(email="jane@example.com") + self.assertEqual(member.user, user) + self.assertTrue(user.check_password("secret123")) + + def test_import_supports_custom_date_format(self): + 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,12/04/2010,false,City Swim Club,LIC-001", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path, date_format="%d/%m/%Y") + + self.assertEqual(stderr, "") + self.assertIn("Members created: 1.", stdout) + + member = Member.objects.get(email="jane@example.com") + self.assertEqual(member.date_of_birth, date(2010, 4, 12)) + + def test_import_skips_invalid_row_and_imports_valid_rows(self): + 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,City Swim Club,LIC-001", + "Broken,Date,broken@example.com,not-a-date,false,City Swim Club,LIC-002", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertIn("Row 3 skipped:", stderr) + self.assertIn("Invalid date_of_birth 'not-a-date'.", stderr) + self.assertIn("Members created: 1.", stdout) + self.assertIn("Rows skipped: 1.", stdout) + + self.assertTrue(Member.objects.filter(email="jane@example.com").exists()) + self.assertFalse(Member.objects.filter(email="broken@example.com").exists()) + + def test_import_fails_for_missing_file(self): + stdout = StringIO() + stderr = StringIO() + + with self.assertRaises(CommandError) as context: + call_command( + "import_members_csv", + "does-not-exist.csv", + stdout=stdout, + stderr=stderr, + ) + + self.assertIn("CSV file does not exist", str(context.exception)) + + def test_import_fails_for_missing_required_columns(self): + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email", + "Jane,Doe,jane@example.com", + ] + ) + ) + + stdout = StringIO() + stderr = StringIO() + + with self.assertRaises(CommandError) as context: + call_command( + "import_members_csv", + csv_path, + stdout=stdout, + stderr=stderr, + ) + + self.assertIn("CSV file is missing required columns:", str(context.exception)) + self.assertIn("club_name", str(context.exception)) + self.assertIn("date_of_birth", str(context.exception)) + self.assertIn("license_number", str(context.exception)) + + def test_import_fails_for_empty_csv_file(self): + csv_path = self.write_csv("") + + stdout = StringIO() + stderr = StringIO() + + with self.assertRaises(CommandError) as context: + call_command( + "import_members_csv", + csv_path, + stdout=stdout, + stderr=stderr, + ) + + self.assertIn("CSV file is empty or missing a header row.", str(context.exception)) diff --git a/club/admin.py b/club/admin.py index 8c38f3f..b526846 100644 --- a/club/admin.py +++ b/club/admin.py @@ -1,3 +1,24 @@ from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import Club, ClubMembership + # Register your models here. +@admin.register(Club) +class ClubAdmin(admin.ModelAdmin): + list_display = ["name"] + search_fields = ["name"] + ordering = ["name"] + + +@admin.register(ClubMembership) +class ClubMembershipAdmin(admin.ModelAdmin): + list_display = ["club__name", "member__last_name", "member__first_name", "license"] + search_fields = ["club__name", "member__last_name", "member__first_name"] + list_filter = ["club"] + raw_id_fields = ["member"] + fieldsets = [ + [None, {"fields": ["club", "member"]}], + [_("Member informaton"), {"fields": ["license"]}], + ] diff --git a/club/migrations/0002_clubmembership_club_clubmembership_license_and_more.py b/club/migrations/0002_clubmembership_club_clubmembership_license_and_more.py new file mode 100644 index 0000000..cb993cf --- /dev/null +++ b/club/migrations/0002_clubmembership_club_clubmembership_license_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-07-05 13:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'), + ('club', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='clubmembership', + name='club', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='members', to='club.club'), + preserve_default=False, + ), + migrations.AddField( + model_name='clubmembership', + name='license', + field=models.CharField(default=1, max_length=250), + preserve_default=False, + ), + migrations.AddField( + model_name='clubmembership', + name='member', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='authentication.member'), + preserve_default=False, + ), + ] diff --git a/club/migrations/0003_alter_clubmembership_options_alter_club_name_and_more.py b/club/migrations/0003_alter_clubmembership_options_alter_club_name_and_more.py new file mode 100644 index 0000000..d909263 --- /dev/null +++ b/club/migrations/0003_alter_clubmembership_options_alter_club_name_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.6 on 2026-07-05 20:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'), + ('club', '0002_clubmembership_club_clubmembership_license_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='clubmembership', + options={'ordering': ['club', 'member__last_name', 'member__first_name'], 'verbose_name': 'club membership', 'verbose_name_plural': 'club memberships'}, + ), + migrations.AlterField( + model_name='club', + name='name', + field=models.CharField(max_length=255, verbose_name='name'), + ), + migrations.AlterField( + model_name='clubmembership', + name='club', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='club.club', verbose_name='club'), + ), + migrations.AlterField( + model_name='clubmembership', + name='license', + field=models.CharField(max_length=250, verbose_name='license'), + ), + migrations.AlterField( + model_name='clubmembership', + name='member', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='authentication.member', verbose_name='member'), + ), + migrations.AlterUniqueTogether( + name='clubmembership', + unique_together={('club', 'member')}, + ), + ] diff --git a/club/migrations/0004_alter_clubmembership_license.py b/club/migrations/0004_alter_clubmembership_license.py new file mode 100644 index 0000000..87ffff5 --- /dev/null +++ b/club/migrations/0004_alter_clubmembership_license.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-07-05 20:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0003_alter_clubmembership_options_alter_club_name_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='clubmembership', + name='license', + field=models.CharField(blank=True, max_length=250, verbose_name='license'), + ), + ] diff --git a/club/models.py b/club/models.py index c7ab82a..da28c33 100644 --- a/club/models.py +++ b/club/models.py @@ -2,11 +2,12 @@ from django.contrib.auth.base_user import AbstractBaseUser from django.db import models from django.utils.translation import gettext_lazy as _ +from authentication.models import Member from clubmanager.base import ClubScopedModel, UUIDModel class Club(UUIDModel): - name = models.CharField(max_length=255) + name = models.CharField(_("name"), max_length=255) class Meta: verbose_name = _("club") @@ -17,4 +18,17 @@ class Club(UUIDModel): return self.name -class ClubMembership(UUIDModel): ... +class ClubMembership(UUIDModel): + member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="member_of", verbose_name=_("member")) + club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="members", verbose_name=_("club")) + + license = models.CharField(_("license"), max_length=250, blank=True) + + class Meta: + verbose_name = _("club membership") + verbose_name_plural = _("club memberships") + ordering = ["club", "member__last_name", "member__first_name"] + unique_together = ("club", "member") + + def __str__(self): + return f"{self.club} - {self.member}" diff --git a/club/tests.py b/club/tests.py index 7ce503c..ea5615c 100644 --- a/club/tests.py +++ b/club/tests.py @@ -1,3 +1,165 @@ +import uuid + +from django.db import IntegrityError from django.test import TestCase +from authentication.models import Member + +from .models import Club, ClubMembership + + +class ClubModelTests(TestCase): + def test_str_returns_name(self): + club = Club.objects.create(name="City Swim Club") + + self.assertEqual(str(club), "City Swim Club") + + def test_pk_is_uuid(self): + club = Club.objects.create(name="City Swim Club") + + self.assertIsInstance(club.pk, uuid.UUID) + + def test_clubs_are_ordered_by_name(self): + Club.objects.create(name="Zulu Club") + Club.objects.create(name="Alpha Club") + Club.objects.create(name="Middle Club") + + self.assertEqual( + list(Club.objects.values_list("name", flat=True)), + ["Alpha Club", "Middle Club", "Zulu Club"], + ) + + def test_verbose_names(self): + self.assertEqual(Club._meta.verbose_name, "club") + self.assertEqual(Club._meta.verbose_name_plural, "clubs") + + +class ClubMembershipModelTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="City Swim Club") + self.member = Member.objects.create( + first_name="Jane", + last_name="Doe", + email="jane@example.com", + ) + + def test_str_returns_club_and_member(self): + membership = ClubMembership.objects.create( + club=self.club, + member=self.member, + license="LIC-001", + ) + + self.assertEqual(str(membership), "City Swim Club - Jane Doe") + + def test_license_is_optional(self): + membership = ClubMembership.objects.create( + club=self.club, + member=self.member, + ) + + self.assertEqual(membership.license, "") + + def test_pk_is_uuid(self): + membership = ClubMembership.objects.create( + club=self.club, + member=self.member, + ) + + self.assertIsInstance(membership.pk, uuid.UUID) + + def test_same_member_can_join_different_clubs(self): + other_club = Club.objects.create(name="Other Swim Club") + + first_membership = ClubMembership.objects.create( + club=self.club, + member=self.member, + license="LIC-001", + ) + second_membership = ClubMembership.objects.create( + club=other_club, + member=self.member, + license="LIC-002", + ) + + self.assertEqual(first_membership.member, self.member) + self.assertEqual(second_membership.member, self.member) + self.assertEqual(self.member.member_of.count(), 2) + + def test_member_is_unique_per_club(self): + ClubMembership.objects.create( + club=self.club, + member=self.member, + license="LIC-001", + ) + + with self.assertRaises(IntegrityError): + ClubMembership.objects.create( + club=self.club, + member=self.member, + license="LIC-002", + ) + + def test_deleting_club_deletes_membership_but_keeps_member(self): + ClubMembership.objects.create( + club=self.club, + member=self.member, + license="LIC-001", + ) + + self.club.delete() + + self.assertFalse(ClubMembership.objects.exists()) + self.assertTrue(Member.objects.filter(pk=self.member.pk).exists()) + + def test_deleting_member_deletes_membership_but_keeps_club(self): + ClubMembership.objects.create( + club=self.club, + member=self.member, + license="LIC-001", + ) + + self.member.delete() + + self.assertFalse(ClubMembership.objects.exists()) + self.assertTrue(Club.objects.filter(pk=self.club.pk).exists()) + + def test_memberships_are_ordered_by_club_then_member_name(self): + alpha_club = Club.objects.create(name="Alpha Club") + zulu_club = Club.objects.create(name="Zulu Club") + + jane = Member.objects.create(first_name="Jane", last_name="Doe") + alice = Member.objects.create(first_name="Alice", last_name="Smith") + bob = Member.objects.create(first_name="Bob", last_name="Smith") + + ClubMembership.objects.create(club=zulu_club, member=bob) + ClubMembership.objects.create(club=alpha_club, member=bob) + ClubMembership.objects.create(club=alpha_club, member=alice) + ClubMembership.objects.create(club=alpha_club, member=jane) + + self.assertEqual( + [(membership.club.name, membership.member.last_name, membership.member.first_name) for membership in ClubMembership.objects.all()], + [ + ("Alpha Club", "Doe", "Jane"), + ("Alpha Club", "Smith", "Alice"), + ("Alpha Club", "Smith", "Bob"), + ("Zulu Club", "Smith", "Bob"), + ], + ) + + def test_reverse_relations(self): + membership = ClubMembership.objects.create( + club=self.club, + member=self.member, + license="LIC-001", + ) + + self.assertEqual(list(self.club.members.all()), [membership]) + self.assertEqual(list(self.member.member_of.all()), [membership]) + + def test_verbose_names(self): + self.assertEqual(ClubMembership._meta.verbose_name, "club membership") + self.assertEqual(ClubMembership._meta.verbose_name_plural, "club memberships") + + # Create your tests here.