Add accounts app: custom User, Member, families & guardianship

Introduce the foundational accounts app:
- Custom email-as-username User (AbstractBaseUser + PermissionsMixin) set
  as AUTH_USER_MODEL, decoupled from membership so children can be members
  without a login.
- Member model holding personal/roster data (names, contact email, phone +
  emergency phone via django-phonenumber-field, license number, DOB) with an
  optional link to a User.
- Family household grouping and directional Guardianship (guardian -> child)
  with uniqueness and no-self-guardian constraints.
- Custom UserAdmin plus Member/Family admin with inlines and autocomplete.
- Settings: register apps, AUTH_USER_MODEL, phonenumber defaults (BE/E164).
- Add CLAUDE.md and a tracked static/ directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 00:08:51 +02:00
parent 537c023258
commit 2ead824c5d
14 changed files with 530 additions and 0 deletions

0
accounts/__init__.py Normal file
View File

61
accounts/admin.py Normal file
View File

@@ -0,0 +1,61 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .forms import UserChangeForm, UserCreationForm
from .models import Family, Guardianship, 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 GuardianshipInline(admin.TabularInline):
"""Guardians of a member (edit from the child's page)."""
model = Guardianship
fk_name = "child"
extra = 0
autocomplete_fields = ("guardian",)
class MemberInline(admin.TabularInline):
model = Member
extra = 0
fields = ("first_name", "last_name", "date_of_birth", "license_number")
show_change_link = True
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
list_display = ("last_name", "first_name", "date_of_birth", "license_number", "family", "user")
list_filter = ("family",)
search_fields = ("first_name", "last_name", "email", "license_number")
autocomplete_fields = ("user", "family")
inlines = (GuardianshipInline,)
@admin.register(Family)
class FamilyAdmin(admin.ModelAdmin):
list_display = ("name", "address")
search_fields = ("name",)
inlines = (MemberInline,)

6
accounts/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"

16
accounts/forms.py Normal file
View File

@@ -0,0 +1,16 @@
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__"

32
accounts/managers.py Normal file
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,97 @@
# Generated by Django 6.0.6 on 2026-07-01 22:04
import accounts.managers
import django.db.models.deletion
import django.utils.timezone
import phonenumber_field.modelfields
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.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)),
],
options={
'verbose_name_plural': 'families',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Guardianship',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('relationship', models.CharField(choices=[('parent', 'Parent'), ('guardian', 'Guardian'), ('other', 'Other')], default='parent', max_length=20)),
],
),
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.')),
('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,
},
managers=[
('objects', accounts.managers.UserManager()),
],
),
migrations.CreateModel(
name='Member',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=150)),
('last_name', models.CharField(max_length=150)),
('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()),
('family', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='accounts.family')),
('guardians', models.ManyToManyField(related_name='dependents', through='accounts.Guardianship', through_fields=('child', 'guardian'), to='accounts.member')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['last_name', 'first_name'],
},
),
migrations.AddField(
model_name='guardianship',
name='child',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='child_links', to='accounts.member'),
),
migrations.AddField(
model_name='guardianship',
name='guardian',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='guardian_links', to='accounts.member'),
),
migrations.AddConstraint(
model_name='guardianship',
constraint=models.UniqueConstraint(fields=('guardian', 'child'), name='unique_guardianship'),
),
migrations.AddConstraint(
model_name='guardianship',
constraint=models.CheckConstraint(condition=models.Q(('guardian', models.F('child')), _negated=True), name='guardian_not_self'),
),
]

View File

140
accounts/models.py Normal file
View File

@@ -0,0 +1,140 @@
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`)."""
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.
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",
)
guardians = models.ManyToManyField(
"self",
through="Guardianship",
through_fields=("child", "guardian"),
symmetrical=False,
related_name="dependents",
)
class Meta:
ordering = ["last_name", "first_name"]
def __str__(self):
return f"{self.first_name} {self.last_name}"
class Guardianship(models.Model):
"""Directional guardian -> child relationship between two members."""
class Relationship(models.TextChoices):
PARENT = "parent", "Parent"
GUARDIAN = "guardian", "Guardian"
OTHER = "other", "Other"
guardian = models.ForeignKey(
Member,
on_delete=models.CASCADE,
related_name="guardian_links",
)
child = models.ForeignKey(
Member,
on_delete=models.CASCADE,
related_name="child_links",
)
relationship = models.CharField(
max_length=20,
choices=Relationship.choices,
default=Relationship.PARENT,
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["guardian", "child"],
name="unique_guardianship",
),
models.CheckConstraint(
condition=~models.Q(guardian=models.F("child")),
name="guardian_not_self",
),
]
def __str__(self):
return f"{self.guardian}{self.child} ({self.get_relationship_display()})"

87
accounts/tests.py Normal file
View File

@@ -0,0 +1,87 @@
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
from .models import Family, Guardianship, 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()
class FamilyAndGuardianshipTests(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
)
self.child = Member.objects.create(
first_name="Kid", last_name="Smith", date_of_birth="2015-05-01", family=self.family
)
def test_family_groups_members(self):
self.assertEqual(self.family.members.count(), 2)
def test_guardianship_relations_resolve(self):
Guardianship.objects.create(guardian=self.parent, child=self.child)
self.assertIn(self.parent, self.child.guardians.all())
self.assertIn(self.child, self.parent.dependents.all())
def test_no_self_guardianship(self):
with transaction.atomic(), self.assertRaises(IntegrityError):
Guardianship.objects.create(guardian=self.parent, child=self.parent)
def test_guardianship_is_unique(self):
Guardianship.objects.create(guardian=self.parent, child=self.child)
with transaction.atomic(), self.assertRaises(IntegrityError):
Guardianship.objects.create(guardian=self.parent, child=self.child)