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>
141 lines
4.0 KiB
Python
141 lines
4.0 KiB
Python
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()})"
|