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 .forms import UserChangeForm, UserCreationForm
from .models import Family, Guardianship, Member, User
from .models import Family, Member, 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):
model = Member
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
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
list_display = ("last_name", "first_name", "date_of_birth", "license_number", "family", "user")
list_filter = ("family",)
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")
inlines = (GuardianshipInline,)
@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 django.db.models.deletion
@@ -29,13 +29,6 @@ class Migration(migrations.Migration):
'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=[
@@ -68,30 +61,12 @@ class Migration(migrations.Migration):
('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')),
('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

@@ -59,7 +59,12 @@ class Family(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(
settings.AUTH_USER_MODEL,
@@ -72,6 +77,7 @@ class Member(models.Model):
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)
@@ -85,12 +91,9 @@ class Member(models.Model):
blank=True,
related_name="members",
)
guardians = models.ManyToManyField(
"self",
through="Guardianship",
through_fields=("child", "guardian"),
symmetrical=False,
related_name="dependents",
is_guardian = models.BooleanField(
default=False,
help_text="Whether this member is a parent/guardian in their family.",
)
class Meta:
@@ -99,42 +102,21 @@ class Member(models.Model):
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 "")
class Guardianship(models.Model):
"""Directional guardian -> child relationship between two members."""
@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)
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()})"
@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)

View File

@@ -1,8 +1,7 @@
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
from .models import Family, Guardianship, Member, User
from .models import Family, Member, User
class UserManagerTests(TestCase):
@@ -58,30 +57,53 @@ class MemberTests(TestCase):
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, "")
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):
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
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
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_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_child_guardians_are_family_guardians(self):
self.assertIn(self.parent, self.child.guardians)
self.assertNotIn(self.child, self.child.guardians)
def test_no_self_guardianship(self):
with transaction.atomic(), self.assertRaises(IntegrityError):
Guardianship.objects.create(guardian=self.parent, child=self.parent)
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_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)
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), [])