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:
@@ -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,)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "accounts"
|
|
||||||
@@ -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__"
|
|
||||||
@@ -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)
|
|
||||||
@@ -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), [])
|
|
||||||
3
authentication/admin.py
Normal file
3
authentication/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
authentication/apps.py
Normal file
5
authentication/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationConfig(AppConfig):
|
||||||
|
name = 'authentication'
|
||||||
@@ -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.db.models.deletion
|
||||||
import django.utils.timezone
|
|
||||||
import phonenumber_field.modelfields
|
import phonenumber_field.modelfields
|
||||||
|
import uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -20,11 +20,11 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Family',
|
name='Family',
|
||||||
fields=[
|
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)),
|
||||||
('name', models.CharField(help_text='e.g. "The Smiths"', max_length=150)),
|
('name', models.CharField(max_length=255)),
|
||||||
('address', models.CharField(blank=True, max_length=255)),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
'verbose_name': 'family',
|
||||||
'verbose_name_plural': 'families',
|
'verbose_name_plural': 'families',
|
||||||
'ordering': ['name'],
|
'ordering': ['name'],
|
||||||
},
|
},
|
||||||
@@ -32,41 +32,63 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='User',
|
name='User',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
('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')),
|
('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)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into the admin site.')),
|
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||||
|
('is_staff', models.BooleanField(default=False)),
|
||||||
('is_active', models.BooleanField(default=True)),
|
('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')),
|
('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')),
|
('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={
|
options={
|
||||||
'abstract': False,
|
'verbose_name': 'user',
|
||||||
|
'verbose_name_plural': 'users',
|
||||||
|
'ordering': ['email'],
|
||||||
},
|
},
|
||||||
managers=[
|
managers=[
|
||||||
('objects', accounts.managers.UserManager()),
|
('objects', authentication.managers.UserManager()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Member',
|
name='Member',
|
||||||
fields=[
|
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)),
|
('first_name', models.CharField(max_length=150)),
|
||||||
('last_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)),
|
('email', models.EmailField(blank=True, max_length=254)),
|
||||||
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None)),
|
('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, region=None)),
|
('emergency_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, 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')),
|
|
||||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL)),
|
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
'verbose_name': 'member',
|
||||||
|
'verbose_name_plural': 'members',
|
||||||
'ordering': ['last_name', 'first_name'],
|
'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')},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
128
authentication/models.py
Normal file
128
authentication/models.py
Normal 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
234
authentication/tests.py
Normal 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
3
authentication/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
club/__init__.py
Normal file
0
club/__init__.py
Normal file
3
club/admin.py
Normal file
3
club/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
club/apps.py
Normal file
5
club/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ClubConfig(AppConfig):
|
||||||
|
name = 'club'
|
||||||
0
club/migrations/__init__.py
Normal file
0
club/migrations/__init__.py
Normal file
20
club/models.py
Normal file
20
club/models.py
Normal file
@@ -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): ...
|
||||||
3
club/tests.py
Normal file
3
club/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
club/views.py
Normal file
3
club/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
21
clubmanager/base.py
Normal file
21
clubmanager/base.py
Normal file
@@ -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
|
||||||
@@ -44,11 +44,10 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"phonenumber_field",
|
"phonenumber_field",
|
||||||
"accounts",
|
"club.apps.ClubConfig",
|
||||||
|
"authentication.apps.AuthenticationConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
@@ -61,6 +60,8 @@ MIDDLEWARE = [
|
|||||||
|
|
||||||
ROOT_URLCONF = "clubmanager.urls"
|
ROOT_URLCONF = "clubmanager.urls"
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = "authentication.User"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
|||||||
Reference in New Issue
Block a user