From aa2c6329b9430d5e101e33b1451604594fb97f46 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Thu, 2 Jul 2026 00:18:35 +0200 Subject: [PATCH] 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 --- accounts/admin.py | 18 ++------ accounts/migrations/0001_initial.py | 29 +----------- accounts/models.py | 70 +++++++++++------------------ accounts/tests.py | 54 +++++++++++++++------- 4 files changed, 70 insertions(+), 101 deletions(-) diff --git a/accounts/admin.py b/accounts/admin.py index a31abb8..16c00b3 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -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) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 1b18bf5..0b39cf3 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -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'), - ), ] diff --git a/accounts/models.py b/accounts/models.py index 7580b4e..8b7e88e 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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) diff --git a/accounts/tests.py b/accounts/tests.py index e0d85dc..46a01d4 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -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), [])