Refactor accounts app into authentication and club apps

- Split the accounts app into new authentication and club apps for better separation of concerns.
- Migrate the custom User, Member, and Family models to the authentication app.
- Introduce Club and ClubMembership models in the club app.
- Refactor Family model to use UUID as the primary key and consolidate family-role relationships into a new FamilyMembership model.
- Update tests, managers, and migrations to align with the new structure.
This commit is contained in:
2026-07-02 09:40:06 +02:00
parent aa2c6329b9
commit 8f71bb74c0
23 changed files with 473 additions and 326 deletions

View File

3
authentication/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
authentication/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
name = 'authentication'

View File

@@ -0,0 +1,32 @@
from django.contrib.auth.base_user import BaseUserManager
class UserManager(BaseUserManager):
"""Manager for the email-based custom User model."""
use_in_migrations = True
def _create_user(self, email, password, **extra_fields):
if not email:
raise ValueError("Users must have an email address.")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True.")
return self._create_user(email, password, **extra_fields)

View File

@@ -0,0 +1,94 @@
# Generated by Django 6.0.6 on 2026-07-02 07:33
import authentication.managers
import django.db.models.deletion
import phonenumber_field.modelfields
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='Family',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
],
options={
'verbose_name': 'family',
'verbose_name_plural': 'families',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'ordering': ['email'],
},
managers=[
('objects', authentication.managers.UserManager()),
],
),
migrations.CreateModel(
name='Member',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('first_name', models.CharField(max_length=150)),
('last_name', models.CharField(max_length=150)),
('date_of_birth', models.DateField(blank=True, null=True)),
('email', models.EmailField(blank=True, max_length=254)),
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
('emergency_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'member',
'verbose_name_plural': 'members',
'ordering': ['last_name', 'first_name'],
},
),
migrations.CreateModel(
name='FamilyMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255)),
('family', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='authentication.family')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='authentication.member')),
],
options={
'verbose_name': 'family membership',
'verbose_name_plural': 'family memberships',
'ordering': ['family', 'role', 'member__last_name', 'member__first_name'],
},
),
migrations.AddIndex(
model_name='member',
index=models.Index(fields=['last_name', 'first_name'], name='authenticat_last_na_0a0eca_idx'),
),
migrations.AlterUniqueTogether(
name='familymembership',
unique_together={('family', 'member')},
),
]

View File

128
authentication/models.py Normal file
View File

@@ -0,0 +1,128 @@
import uuid
from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin
from django.db import models
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from clubmanager.base import UUIDModel
from .managers import UserManager
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(unique=True, db_index=True)
is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
objects = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")
ordering = ["email"]
def __str__(self):
return self.get_full_name()
def get_full_name(self):
member = getattr(self, "member", None)
if member is not None:
return member.get_full_name()
return self.email
def get_short_name(self):
member = getattr(self, "member", None)
if member is not None:
return member.get_short_name()
return self.email
class Family(UUIDModel):
name = models.CharField(max_length=255)
class Meta:
verbose_name = _("family")
verbose_name_plural = _("families")
ordering = ["name"]
def __str__(self):
return self.name
@property
def guardians(self):
return Member.objects.filter(
family_memberships__family=self,
family_memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
)
@property
def children(self):
return Member.objects.filter(
family_memberships__family=self,
family_memberships__role=FamilyMembership.FamilyRole.CHILD,
)
class Member(UUIDModel):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="member", null=True, blank=True)
first_name = models.CharField(max_length=150)
last_name = models.CharField(max_length=150)
date_of_birth = models.DateField(null=True, blank=True)
email = models.EmailField(blank=True)
phone = PhoneNumberField(null=True, blank=True)
emergency_phone = PhoneNumberField(null=True, blank=True)
class Meta:
verbose_name = _("member")
verbose_name_plural = _("members")
ordering = ["last_name", "first_name"]
indexes = [models.Index(fields=["last_name", "first_name"])]
def __str__(self):
return f"{self.first_name} {self.last_name}"
def get_full_name(self):
return f"{self.first_name} {self.last_name}".strip()
def get_short_name(self):
return self.first_name
@property
def guardians(self):
return Member.objects.filter(
family_memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
family_memberships__family__memberships__member=self,
family_memberships__family__memberships__role=FamilyMembership.FamilyRole.CHILD,
).distinct()
class FamilyMembership(models.Model):
class FamilyRole(models.TextChoices):
PARENT = "parent", _("parent")
CHILD = "child", _("child")
GUARDIAN = "guardian", _("guardian")
OTHER = "other", _("other")
family = models.ForeignKey(Family, on_delete=models.CASCADE, related_name="memberships")
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="family_memberships")
role = models.CharField(max_length=255, choices=FamilyRole.choices, default=FamilyRole.PARENT)
class Meta:
verbose_name = _("family membership")
verbose_name_plural = _("family memberships")
ordering = ["family", "role", "member__last_name", "member__first_name"]
unique_together = ("family", "member")
def __str__(self):
return f"{self.family} - {self.member} ({self.get_role_display()})"

234
authentication/tests.py Normal file
View File

@@ -0,0 +1,234 @@
import uuid
from django.contrib.auth import get_user_model
from django.db import IntegrityError
from django.db.models import SET_NULL
from django.test import TestCase
from authentication.models import Family, FamilyMembership, Member
User = get_user_model()
class UserManagerTests(TestCase):
def test_create_user_defaults(self):
user = User.objects.create_user(email="alice@example.com", password="secret123")
self.assertEqual(user.email, "alice@example.com")
self.assertTrue(user.check_password("secret123"))
self.assertFalse(user.is_staff)
self.assertFalse(user.is_superuser)
self.assertTrue(user.is_active)
def test_create_user_requires_email(self):
with self.assertRaises(ValueError):
User.objects.create_user(email="", password="secret123")
def test_create_user_normalizes_email_domain(self):
# BaseUserManager lowercases the domain part of the address.
user = User.objects.create_user(email="Bob@Example.COM", password="secret123")
self.assertEqual(user.email, "Bob@example.com")
def test_create_user_password_is_hashed(self):
user = User.objects.create_user(email="carol@example.com", password="secret123")
self.assertNotEqual(user.password, "secret123")
def test_create_user_without_password_is_unusable(self):
user = User.objects.create_user(email="dave@example.com")
self.assertFalse(user.has_usable_password())
def test_create_superuser_defaults(self):
admin = User.objects.create_superuser(email="admin@example.com", password="secret123")
self.assertTrue(admin.is_staff)
self.assertTrue(admin.is_superuser)
self.assertTrue(admin.is_active)
def test_create_superuser_rejects_non_staff(self):
with self.assertRaises(ValueError):
User.objects.create_superuser(email="admin@example.com", password="x", is_staff=False)
def test_create_superuser_rejects_non_superuser(self):
with self.assertRaises(ValueError):
User.objects.create_superuser(email="admin@example.com", password="x", is_superuser=False)
class UserModelTests(TestCase):
def test_email_is_username_field(self):
self.assertEqual(User.USERNAME_FIELD, "email")
self.assertEqual(User.REQUIRED_FIELDS, [])
def test_email_is_unique(self):
User.objects.create_user(email="dup@example.com", password="x")
with self.assertRaises(IntegrityError):
User.objects.create_user(email="dup@example.com", password="y")
def test_pk_is_uuid(self):
user = User.objects.create_user(email="uuid@example.com", password="x")
self.assertIsInstance(user.pk, uuid.UUID)
def test_str_and_names_fall_back_to_email_without_member(self):
user = User.objects.create_user(email="lonely@example.com", password="x")
self.assertEqual(str(user), "lonely@example.com")
self.assertEqual(user.get_full_name(), "lonely@example.com")
self.assertEqual(user.get_short_name(), "lonely@example.com")
def test_str_and_names_use_linked_member(self):
user = User.objects.create_user(email="linked@example.com", password="x")
Member.objects.create(user=user, first_name="Jane", last_name="Doe")
# Re-fetch so the reverse OneToOne relation is resolved from the DB.
user = User.objects.get(pk=user.pk)
self.assertEqual(str(user), "Jane Doe")
self.assertEqual(user.get_full_name(), "Jane Doe")
self.assertEqual(user.get_short_name(), "Jane")
class MemberModelTests(TestCase):
def test_str_and_name_helpers(self):
member = Member.objects.create(first_name="John", last_name="Smith")
self.assertEqual(str(member), "John Smith")
self.assertEqual(member.get_full_name(), "John Smith")
self.assertEqual(member.get_short_name(), "John")
def test_get_full_name_strips_when_partial(self):
member = Member.objects.create(first_name="Cher", last_name="")
self.assertEqual(member.get_full_name(), "Cher")
def test_member_can_exist_without_user(self):
member = Member.objects.create(first_name="No", last_name="Login")
self.assertIsNone(member.user)
def test_deleting_user_nulls_member_but_keeps_it(self):
user = User.objects.create_user(email="temp@example.com", password="x")
member = Member.objects.create(user=user, first_name="Keep", last_name="Me")
# OneToOneField uses on_delete=SET_NULL.
field = Member._meta.get_field("user")
self.assertIs(field.remote_field.on_delete, SET_NULL)
user.delete()
member.refresh_from_db()
self.assertIsNone(member.user)
self.assertTrue(Member.objects.filter(pk=member.pk).exists())
def test_user_member_is_one_to_one(self):
user = User.objects.create_user(email="once@example.com", password="x")
Member.objects.create(user=user, first_name="First", last_name="Member")
with self.assertRaises(IntegrityError):
Member.objects.create(user=user, first_name="Second", last_name="Member")
class FamilyModelTests(TestCase):
def setUp(self):
self.family = Family.objects.create(name="The Smiths")
self.parent = Member.objects.create(first_name="Pat", last_name="Smith")
self.guardian = Member.objects.create(first_name="Gale", last_name="Smith")
self.child = Member.objects.create(first_name="Kim", last_name="Smith")
self.other = Member.objects.create(first_name="Ola", last_name="Smith")
FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=self.family, member=self.guardian, role=FamilyMembership.FamilyRole.GUARDIAN)
FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=self.family, member=self.other, role=FamilyMembership.FamilyRole.OTHER)
def test_str(self):
self.assertEqual(str(self.family), "The Smiths")
def test_guardians_include_parents_and_guardians_only(self):
guardians = set(self.family.guardians)
self.assertEqual(guardians, {self.parent, self.guardian})
def test_children_include_children_only(self):
children = list(self.family.children)
self.assertEqual(children, [self.child])
def test_guardians_are_scoped_to_the_family(self):
other_family = Family.objects.create(name="The Joneses")
outsider = Member.objects.create(first_name="Out", last_name="Sider")
FamilyMembership.objects.create(family=other_family, member=outsider, role=FamilyMembership.FamilyRole.PARENT)
self.assertNotIn(outsider, set(self.family.guardians))
class MemberGuardiansTests(TestCase):
def test_guardians_of_a_child_are_family_parents_and_guardians(self):
family = Family.objects.create(name="The Does")
mum = Member.objects.create(first_name="Mary", last_name="Doe")
legal = Member.objects.create(first_name="Lee", last_name="Doe")
kid = Member.objects.create(first_name="Kit", last_name="Doe")
FamilyMembership.objects.create(family=family, member=mum, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family, member=legal, role=FamilyMembership.FamilyRole.GUARDIAN)
FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD)
self.assertEqual(set(kid.guardians), {mum, legal})
def test_guardians_empty_for_a_parent(self):
family = Family.objects.create(name="The Roes")
parent = Member.objects.create(first_name="Ray", last_name="Roe")
kid = Member.objects.create(first_name="Ren", last_name="Roe")
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD)
self.assertEqual(list(parent.guardians), [])
def test_guardians_do_not_leak_across_families(self):
family_a = Family.objects.create(name="Family A")
family_b = Family.objects.create(name="Family B")
parent_a = Member.objects.create(first_name="Ann", last_name="A")
parent_b = Member.objects.create(first_name="Ben", last_name="B")
kid = Member.objects.create(first_name="Cody", last_name="A")
FamilyMembership.objects.create(family=family_a, member=parent_a, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family_a, member=kid, role=FamilyMembership.FamilyRole.CHILD)
# parent_b belongs to a different family and must not appear as kid's guardian.
FamilyMembership.objects.create(family=family_b, member=parent_b, role=FamilyMembership.FamilyRole.PARENT)
self.assertEqual(set(kid.guardians), {parent_a})
class FamilyMembershipModelTests(TestCase):
def test_default_role_is_parent(self):
family = Family.objects.create(name="Fam")
member = Member.objects.create(first_name="D", last_name="Efault")
membership = FamilyMembership.objects.create(family=family, member=member)
self.assertEqual(membership.role, FamilyMembership.FamilyRole.PARENT)
def test_member_unique_per_family(self):
family = Family.objects.create(name="Fam")
member = Member.objects.create(first_name="Solo", last_name="Once")
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.PARENT)
with self.assertRaises(IntegrityError):
FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD)
def test_same_member_can_join_multiple_families(self):
member = Member.objects.create(first_name="Multi", last_name="Fam")
family_a = Family.objects.create(name="A")
family_b = Family.objects.create(name="B")
FamilyMembership.objects.create(family=family_a, member=member, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=family_b, member=member, role=FamilyMembership.FamilyRole.PARENT)
self.assertEqual(member.family_memberships.count(), 2)
def test_deleting_family_cascades_to_memberships(self):
family = Family.objects.create(name="Doomed")
member = Member.objects.create(first_name="Cas", last_name="Cade")
FamilyMembership.objects.create(family=family, member=member)
family.delete()
self.assertFalse(FamilyMembership.objects.exists())
# The member itself survives; only the membership is removed.
self.assertTrue(Member.objects.filter(pk=member.pk).exists())

3
authentication/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.