Simplify family model and add Member.contact_email

- Replace the Guardianship through-model + self-referential M2M with a simple
  is_guardian flag on Member. Guardians in a family look after the family's
  dependents; guardians/dependents are now derived properties.
- Add Member.contact_email: own contact email, falling back to the linked
  user's login email, so a linked member need not store their email twice.
- Update admin (is_guardian in list/filter/inline; drop guardianship inline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 00:18:35 +02:00
parent 2ead824c5d
commit aa2c6329b9
4 changed files with 70 additions and 101 deletions

View File

@@ -2,7 +2,7 @@ from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .forms import UserChangeForm, UserCreationForm from .forms import UserChangeForm, UserCreationForm
from .models import Family, Guardianship, Member, User from .models import Family, Member, User
@admin.register(User) @admin.register(User)
@@ -29,29 +29,19 @@ class UserAdmin(BaseUserAdmin):
) )
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): class MemberInline(admin.TabularInline):
model = Member model = Member
extra = 0 extra = 0
fields = ("first_name", "last_name", "date_of_birth", "license_number") fields = ("first_name", "last_name", "date_of_birth", "is_guardian", "license_number")
show_change_link = True show_change_link = True
@admin.register(Member) @admin.register(Member)
class MemberAdmin(admin.ModelAdmin): class MemberAdmin(admin.ModelAdmin):
list_display = ("last_name", "first_name", "date_of_birth", "license_number", "family", "user") list_display = ("last_name", "first_name", "date_of_birth", "is_guardian", "license_number", "family", "user")
list_filter = ("family",) list_filter = ("is_guardian", "family")
search_fields = ("first_name", "last_name", "email", "license_number") search_fields = ("first_name", "last_name", "email", "license_number")
autocomplete_fields = ("user", "family") autocomplete_fields = ("user", "family")
inlines = (GuardianshipInline,)
@admin.register(Family) @admin.register(Family)

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-07-01 22:04 # Generated by Django 6.0.6 on 2026-07-01 22:16
import accounts.managers import accounts.managers
import django.db.models.deletion import django.db.models.deletion
@@ -29,13 +29,6 @@ class Migration(migrations.Migration):
'ordering': ['name'], '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( migrations.CreateModel(
name='User', name='User',
fields=[ fields=[
@@ -68,30 +61,12 @@ class Migration(migrations.Migration):
('emergency_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)), ('license_number', models.CharField(blank=True, max_length=50)),
('date_of_birth', models.DateField()), ('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')), ('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)), ('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={
'ordering': ['last_name', 'first_name'], '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

@@ -59,7 +59,12 @@ class Family(models.Model):
class Member(models.Model): class Member(models.Model):
"""A person in the club. May or may not have a login (:attr:`user`).""" """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( user = models.OneToOneField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@@ -72,6 +77,7 @@ class Member(models.Model):
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)
# Contact email — optional. Children may have none; a login email lives on User. # 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) email = models.EmailField(blank=True)
phone = PhoneNumberField(blank=True) phone = PhoneNumberField(blank=True)
emergency_phone = PhoneNumberField(blank=True) emergency_phone = PhoneNumberField(blank=True)
@@ -85,12 +91,9 @@ class Member(models.Model):
blank=True, blank=True,
related_name="members", related_name="members",
) )
guardians = models.ManyToManyField( is_guardian = models.BooleanField(
"self", default=False,
through="Guardianship", help_text="Whether this member is a parent/guardian in their family.",
through_fields=("child", "guardian"),
symmetrical=False,
related_name="dependents",
) )
class Meta: class Meta:
@@ -99,42 +102,21 @@ class Member(models.Model):
def __str__(self): def __str__(self):
return f"{self.first_name} {self.last_name}" 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 "")
class Guardianship(models.Model): @property
"""Directional guardian -> child relationship between two members.""" 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)
class Relationship(models.TextChoices): @property
PARENT = "parent", "Parent" def dependents(self):
GUARDIAN = "guardian", "Guardian" """Members of my family I look after (only if I'm a guardian)."""
OTHER = "other", "Other" if not self.is_guardian or self.family_id is None:
return Member.objects.none()
guardian = models.ForeignKey( return self.family.members.filter(is_guardian=False)
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()})"

View File

@@ -1,8 +1,7 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase from django.test import TestCase
from .models import Family, Guardianship, Member, User from .models import Family, Member, User
class UserManagerTests(TestCase): class UserManagerTests(TestCase):
@@ -58,30 +57,53 @@ class MemberTests(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
member.full_clean() 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, "")
class FamilyAndGuardianshipTests(TestCase): # 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): def setUp(self):
self.family = Family.objects.create(name="The Smiths") self.family = Family.objects.create(name="The Smiths")
self.parent = Member.objects.create( self.parent = Member.objects.create(
first_name="Ann", last_name="Smith", date_of_birth="1980-01-01", family=self.family first_name="Ann", last_name="Smith", date_of_birth="1980-01-01",
family=self.family, is_guardian=True,
) )
self.child = Member.objects.create( self.child = Member.objects.create(
first_name="Kid", last_name="Smith", date_of_birth="2015-05-01", family=self.family first_name="Kid", last_name="Smith", date_of_birth="2015-05-01",
family=self.family, is_guardian=False,
) )
def test_family_groups_members(self): def test_family_groups_members(self):
self.assertEqual(self.family.members.count(), 2) self.assertEqual(self.family.members.count(), 2)
def test_guardianship_relations_resolve(self): def test_child_guardians_are_family_guardians(self):
Guardianship.objects.create(guardian=self.parent, child=self.child) self.assertIn(self.parent, self.child.guardians)
self.assertIn(self.parent, self.child.guardians.all()) self.assertNotIn(self.child, self.child.guardians)
self.assertIn(self.child, self.parent.dependents.all())
def test_no_self_guardianship(self): def test_guardian_dependents_are_family_non_guardians(self):
with transaction.atomic(), self.assertRaises(IntegrityError): self.assertIn(self.child, self.parent.dependents)
Guardianship.objects.create(guardian=self.parent, child=self.parent) self.assertNotIn(self.parent, self.parent.dependents)
def test_guardianship_is_unique(self): def test_guardian_has_no_guardians_and_child_has_no_dependents(self):
Guardianship.objects.create(guardian=self.parent, child=self.child) self.assertEqual(list(self.parent.guardians), [])
with transaction.atomic(), self.assertRaises(IntegrityError): self.assertEqual(list(self.child.dependents), [])
Guardianship.objects.create(guardian=self.parent, child=self.child)
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), [])