Add the management app: club-facing UI + real fee-payment tracking

Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
This commit is contained in:
2026-08-03 17:01:15 +02:00
parent 5bea8a4a6c
commit 062da00bb9
44 changed files with 5095 additions and 11 deletions

122
members/services/family.py Normal file
View File

@@ -0,0 +1,122 @@
"""Registering families: a parent (who needs a login) and a child (who doesn't),
linked to each other -- either together as a new family, or one at a time onto an
existing one.
"""
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from club.models import ClubMembership
from members.models import Family, FamilyMembership, Member
User = get_user_model()
def find_member_by_email(email):
"""The Member behind a login email, if that account exists at all."""
return Member.objects.filter(user__email__iexact=email).first()
def get_or_create_login_member(email, first_name="", last_name=""):
"""Find or create the Member behind ``email``, creating a User (no usable
password -- they set one via the reset-link flow) the first time we see them.
Mirrors controlpanel.services.admins.grant_club_admin.
"""
email = email.lower()
user, user_created = User.objects.get_or_create(email=email, defaults={"is_active": True})
if user_created:
user.set_unusable_password()
user.save(update_fields=["password"])
member, _ = Member.objects.get_or_create(user=user, defaults={"first_name": first_name, "last_name": last_name})
return member
def grant_login(member, email):
"""Give a login-less member (a child old enough to need their own account, most
often) the ability to sign in -- a new User with no usable password, same as
get_or_create_login_member; they set one via the reset-link flow. Unlike that
function, this attaches the login to a specific, already-existing Member rather
than finding-or-creating one -- the caller's form has already checked the email
isn't already in use."""
user = User.objects.create(email=email.lower(), is_active=True)
user.set_unusable_password()
user.save(update_fields=["password"])
member.user = user
member.save(update_fields=["user"])
return user
def _enrol(club, season, member):
"""Sign a member up for the club's current season, if there is one. The
implicit MEMBER role follows automatically (club/signals.py)."""
if season is None:
return None
membership, _ = ClubMembership.objects.get_or_create(
club=club,
member=member,
season=season,
defaults={"status": ClubMembership.StatusChoices.ACTIVE, "signed_up_at": timezone.localdate()},
)
return membership
@transaction.atomic
def register_family(club, season, *, parent_email, parent_first_name, parent_last_name, child_first_name, child_last_name, child_date_of_birth=None):
"""Create a new family in one go: a parent (with a login) and a child
(without one), linked to each other and signed up for ``season``."""
parent = get_or_create_login_member(parent_email, parent_first_name, parent_last_name)
child = Member.objects.create(first_name=child_first_name, last_name=child_last_name, date_of_birth=child_date_of_birth)
family = Family.objects.create()
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
_enrol(club, season, parent)
_enrol(club, season, child)
return family
@transaction.atomic
def add_child_to_family(club, season, family, *, first_name, last_name, date_of_birth=None):
"""A family that needs one more child registered -- a sibling joining, most often."""
child = Member.objects.create(first_name=first_name, last_name=last_name, date_of_birth=date_of_birth)
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
_enrol(club, season, child)
return child
@transaction.atomic
def add_parent_to_family(club, season, family, *, email, first_name="", last_name=""):
"""A family that needs one more parent/guardian registered."""
parent = get_or_create_login_member(email, first_name, last_name)
# get_or_create, not create: re-adding an email already on this family (a typo'd
# re-submit, say) must not trip unique_member_per_family.
FamilyMembership.objects.get_or_create(family=family, member=parent, defaults={"role": FamilyMembership.FamilyRole.PARENT})
_enrol(club, season, parent)
return parent
def attach_to_family(member, *, role, family=None):
"""Link a standalone member into a family -- a new one, or an existing one they
turn out to belong to (e.g. a second parent already registered separately)."""
if family is None:
family = Family.objects.create()
FamilyMembership.objects.get_or_create(family=family, member=member, defaults={"role": role})
return family
@transaction.atomic
def detach_from_family(member, family):
"""Remove member from one specific family -- they may still belong to others.
An empty family (this was its last member) is cleaned up same as before."""
FamilyMembership.objects.filter(member=member, family=family).delete()
if not family.memberships.exists():
family.delete()