diff --git a/accounts/admin.py b/accounts/admin.py deleted file mode 100644 index 16c00b3..0000000 --- a/accounts/admin.py +++ /dev/null @@ -1,51 +0,0 @@ -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin - -from .forms import UserChangeForm, UserCreationForm -from .models import Family, Member, User - - -@admin.register(User) -class UserAdmin(BaseUserAdmin): - add_form = UserCreationForm - form = UserChangeForm - model = User - - list_display = ("email", "is_staff", "is_superuser", "is_active") - list_filter = ("is_staff", "is_superuser", "is_active", "groups") - search_fields = ("email",) - ordering = ("email",) - - fieldsets = ( - (None, {"fields": ("email", "password")}), - ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}), - ("Important dates", {"fields": ("last_login", "date_joined")}), - ) - add_fieldsets = ( - (None, { - "classes": ("wide",), - "fields": ("email", "usable_password", "password1", "password2"), - }), - ) - - -class MemberInline(admin.TabularInline): - model = Member - extra = 0 - fields = ("first_name", "last_name", "date_of_birth", "is_guardian", "license_number") - show_change_link = True - - -@admin.register(Member) -class MemberAdmin(admin.ModelAdmin): - list_display = ("last_name", "first_name", "date_of_birth", "is_guardian", "license_number", "family", "user") - list_filter = ("is_guardian", "family") - search_fields = ("first_name", "last_name", "email", "license_number") - autocomplete_fields = ("user", "family") - - -@admin.register(Family) -class FamilyAdmin(admin.ModelAdmin): - list_display = ("name", "address") - search_fields = ("name",) - inlines = (MemberInline,) diff --git a/accounts/apps.py b/accounts/apps.py deleted file mode 100644 index 0cb51e6..0000000 --- a/accounts/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AccountsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "accounts" diff --git a/accounts/forms.py b/accounts/forms.py deleted file mode 100644 index 6bada4f..0000000 --- a/accounts/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.contrib.auth.forms import BaseUserCreationForm -from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm - -from .models import User - - -class UserCreationForm(BaseUserCreationForm): - class Meta(BaseUserCreationForm.Meta): - model = User - fields = ("email",) - - -class UserChangeForm(BaseUserChangeForm): - class Meta(BaseUserChangeForm.Meta): - model = User - fields = "__all__" diff --git a/accounts/models.py b/accounts/models.py deleted file mode 100644 index 8b7e88e..0000000 --- a/accounts/models.py +++ /dev/null @@ -1,122 +0,0 @@ -from django.conf import settings -from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin -from django.db import models -from django.utils import timezone -from phonenumber_field.modelfields import PhoneNumberField - -from .managers import UserManager - - -class User(AbstractBaseUser, PermissionsMixin): - """Login identity. Only people who actually sign in get a User. - - Personal/roster data lives on :class:`Member`; this model carries just the - authentication identity. ``PermissionsMixin`` provides ``is_superuser``, - ``groups`` and ``user_permissions`` (the basis for the access tiers). - """ - - email = models.EmailField(unique=True) - is_staff = models.BooleanField( - default=False, - help_text="Whether the user can log into the admin site.", - ) - is_active = models.BooleanField(default=True) - date_joined = models.DateTimeField(default=timezone.now) - - objects = UserManager() - - USERNAME_FIELD = "email" - REQUIRED_FIELDS = [] - - def __str__(self): - return self.get_full_name() - - def get_full_name(self): - member = getattr(self, "member", None) - if member is not None: - return f"{member.first_name} {member.last_name}".strip() - return self.email - - def get_short_name(self): - member = getattr(self, "member", None) - if member is not None: - return member.first_name - return self.email - - -class Family(models.Model): - """A household grouping members together.""" - - name = models.CharField(max_length=150, help_text='e.g. "The Smiths"') - address = models.CharField(max_length=255, blank=True) - - class Meta: - ordering = ["name"] - verbose_name_plural = "families" - - def __str__(self): - return self.name - - -class Member(models.Model): - """A person in the club. May or may not have a login (:attr:`user`). - - Family relationships are modelled by membership in a :class:`Family`: - guardians (``is_guardian=True``) look after the other members of the same - family (the dependents). - """ - - user = models.OneToOneField( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="member", - ) - - first_name = models.CharField(max_length=150) - last_name = models.CharField(max_length=150) - # Contact email — optional. Children may have none; a login email lives on User. - # Use `contact_email` to read it with a fallback to the linked user's login email. - email = models.EmailField(blank=True) - phone = PhoneNumberField(blank=True) - emergency_phone = PhoneNumberField(blank=True) - license_number = models.CharField(max_length=50, blank=True) - date_of_birth = models.DateField() - - family = models.ForeignKey( - Family, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="members", - ) - is_guardian = models.BooleanField( - default=False, - help_text="Whether this member is a parent/guardian in their family.", - ) - - class Meta: - ordering = ["last_name", "first_name"] - - def __str__(self): - return f"{self.first_name} {self.last_name}" - - @property - def contact_email(self): - """Best email to reach this member: own contact email, else login email.""" - return self.email or (self.user.email if self.user_id else "") - - @property - def guardians(self): - """Members of my family who look after me (only if I'm a dependent).""" - if self.is_guardian or self.family_id is None: - return Member.objects.none() - return self.family.members.filter(is_guardian=True) - - @property - def dependents(self): - """Members of my family I look after (only if I'm a guardian).""" - if not self.is_guardian or self.family_id is None: - return Member.objects.none() - return self.family.members.filter(is_guardian=False) diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 46a01d4..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,109 +0,0 @@ -from django.core.exceptions import ValidationError -from django.test import TestCase - -from .models import Family, Member, User - - -class UserManagerTests(TestCase): - def test_create_user_with_email(self): - user = User.objects.create_user(email="Parent@Example.com", password="pw") - self.assertEqual(user.email, "Parent@example.com") # domain normalized - self.assertTrue(user.check_password("pw")) - self.assertFalse(user.is_staff) - self.assertFalse(user.is_superuser) - - def test_create_superuser(self): - admin = User.objects.create_superuser(email="admin@example.com", password="pw") - self.assertTrue(admin.is_staff) - self.assertTrue(admin.is_superuser) - - def test_create_user_requires_email(self): - with self.assertRaises(ValueError): - User.objects.create_user(email="", password="pw") - - def test_create_superuser_rejects_non_superuser_flag(self): - with self.assertRaises(ValueError): - User.objects.create_superuser(email="a@b.com", password="pw", is_superuser=False) - - -class MemberTests(TestCase): - def test_member_without_login_or_optional_fields(self): - member = Member.objects.create( - first_name="Kid", - last_name="Smith", - date_of_birth="2015-05-01", - ) - self.assertIsNone(member.user) - self.assertEqual(member.email, "") - self.assertEqual(member.phone, "") - self.assertEqual(member.license_number, "") - - def test_valid_phone_accepted(self): - member = Member( - first_name="Ann", - last_name="Smith", - date_of_birth="1980-01-01", - phone="+32470123456", - ) - member.full_clean() # should not raise - - def test_invalid_phone_rejected(self): - member = Member( - first_name="Ann", - last_name="Smith", - date_of_birth="1980-01-01", - phone="not-a-number", - ) - with self.assertRaises(ValidationError): - member.full_clean() - - def test_contact_email_prefers_own_then_login(self): - # No own email, no user -> empty. - member = Member.objects.create( - first_name="Kid", last_name="Smith", date_of_birth="2015-05-01" - ) - self.assertEqual(member.contact_email, "") - - # Linked user, no own email -> falls back to login email. - member.user = User.objects.create_user(email="login@example.com", password="pw") - member.save() - self.assertEqual(member.contact_email, "login@example.com") - - # Own contact email wins over login email. - member.email = "contact@example.com" - self.assertEqual(member.contact_email, "contact@example.com") - - -class FamilyTests(TestCase): - def setUp(self): - self.family = Family.objects.create(name="The Smiths") - self.parent = Member.objects.create( - first_name="Ann", last_name="Smith", date_of_birth="1980-01-01", - family=self.family, is_guardian=True, - ) - self.child = Member.objects.create( - first_name="Kid", last_name="Smith", date_of_birth="2015-05-01", - family=self.family, is_guardian=False, - ) - - def test_family_groups_members(self): - self.assertEqual(self.family.members.count(), 2) - - def test_child_guardians_are_family_guardians(self): - self.assertIn(self.parent, self.child.guardians) - self.assertNotIn(self.child, self.child.guardians) - - def test_guardian_dependents_are_family_non_guardians(self): - self.assertIn(self.child, self.parent.dependents) - self.assertNotIn(self.parent, self.parent.dependents) - - def test_guardian_has_no_guardians_and_child_has_no_dependents(self): - self.assertEqual(list(self.parent.guardians), []) - self.assertEqual(list(self.child.dependents), []) - - def test_member_without_family_has_no_relations(self): - loner = Member.objects.create( - first_name="Solo", last_name="Jones", date_of_birth="1990-01-01" - ) - self.assertEqual(list(loner.guardians), []) - self.assertEqual(list(loner.dependents), []) diff --git a/accounts/__init__.py b/authentication/__init__.py similarity index 100% rename from accounts/__init__.py rename to authentication/__init__.py diff --git a/authentication/admin.py b/authentication/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/authentication/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/authentication/apps.py b/authentication/apps.py new file mode 100644 index 0000000..9635c9d --- /dev/null +++ b/authentication/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + name = 'authentication' diff --git a/accounts/managers.py b/authentication/managers.py similarity index 100% rename from accounts/managers.py rename to authentication/managers.py diff --git a/accounts/migrations/0001_initial.py b/authentication/migrations/0001_initial.py similarity index 51% rename from accounts/migrations/0001_initial.py rename to authentication/migrations/0001_initial.py index 0b39cf3..5e98af2 100644 --- a/accounts/migrations/0001_initial.py +++ b/authentication/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# Generated by Django 6.0.6 on 2026-07-01 22:16 +# Generated by Django 6.0.6 on 2026-07-02 07:33 -import accounts.managers +import authentication.managers import django.db.models.deletion -import django.utils.timezone import phonenumber_field.modelfields +import uuid from django.conf import settings from django.db import migrations, models @@ -20,11 +20,11 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Family', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='e.g. "The Smiths"', max_length=150)), - ('address', models.CharField(blank=True, max_length=255)), + ('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'], }, @@ -32,41 +32,63 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), - ('email', models.EmailField(max_length=254, unique=True)), - ('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into the admin site.')), + ('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)), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now)), ('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={ - 'abstract': False, + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'ordering': ['email'], }, managers=[ - ('objects', accounts.managers.UserManager()), + ('objects', authentication.managers.UserManager()), ], ), migrations.CreateModel( name='Member', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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, region=None)), - ('emergency_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None)), - ('license_number', models.CharField(blank=True, max_length=50)), - ('date_of_birth', models.DateField()), - ('is_guardian', models.BooleanField(default=False, help_text='Whether this member is a parent/guardian in their family.')), - ('family', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='accounts.family')), + ('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')}, + ), ] diff --git a/accounts/migrations/__init__.py b/authentication/migrations/__init__.py similarity index 100% rename from accounts/migrations/__init__.py rename to authentication/migrations/__init__.py diff --git a/authentication/models.py b/authentication/models.py new file mode 100644 index 0000000..29c9fab --- /dev/null +++ b/authentication/models.py @@ -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()})" diff --git a/authentication/tests.py b/authentication/tests.py new file mode 100644 index 0000000..431dd9f --- /dev/null +++ b/authentication/tests.py @@ -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()) diff --git a/authentication/views.py b/authentication/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/authentication/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/club/__init__.py b/club/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/admin.py b/club/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/club/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/club/apps.py b/club/apps.py new file mode 100644 index 0000000..67bff61 --- /dev/null +++ b/club/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ClubConfig(AppConfig): + name = 'club' diff --git a/club/migrations/__init__.py b/club/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/models.py b/club/models.py new file mode 100644 index 0000000..c7ab82a --- /dev/null +++ b/club/models.py @@ -0,0 +1,20 @@ +from django.contrib.auth.base_user import AbstractBaseUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from clubmanager.base import ClubScopedModel, UUIDModel + + +class Club(UUIDModel): + name = models.CharField(max_length=255) + + class Meta: + verbose_name = _("club") + verbose_name_plural = _("clubs") + ordering = ["name"] + + def __str__(self): + return self.name + + +class ClubMembership(UUIDModel): ... diff --git a/club/tests.py b/club/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/club/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/club/views.py b/club/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/club/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/clubmanager/base.py b/clubmanager/base.py new file mode 100644 index 0000000..8b1346a --- /dev/null +++ b/clubmanager/base.py @@ -0,0 +1,21 @@ +import uuid + +from django.db import models + + +class UUIDModel(models.Model): + """Abstract base class giving every model a UUID primary key""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + +class ClubScopedModel(UUIDModel): + """Abstract base for entities owned by a single club (tenant root).""" + + club = models.ForeignKey("club.Club", on_delete=models.CASCADE, related_name="%(class)ss") + + class Meta: + abstract = True diff --git a/clubmanager/settings.py b/clubmanager/settings.py index 418b78f..d55bd6c 100644 --- a/clubmanager/settings.py +++ b/clubmanager/settings.py @@ -44,11 +44,10 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "phonenumber_field", - "accounts", + "club.apps.ClubConfig", + "authentication.apps.AuthenticationConfig", ] -AUTH_USER_MODEL = "accounts.User" - MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -61,6 +60,8 @@ MIDDLEWARE = [ ROOT_URLCONF = "clubmanager.urls" +AUTH_USER_MODEL = "authentication.User" + TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates",