diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d10135e..1bdfa00 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -125,6 +125,36 @@ class ClubTenantMiddleware: # resolves subdomain -> Club, ... # request.club = club; set_current_club(club) ``` +**Considered — `django.contrib.sites` for resolution (rejected as the mechanism).** +Django's Sites framework is the obvious "does the batteries-included answer fit?" candidate, +so it was evaluated explicitly: + +*What it offers.* A `Site(domain, name)` model, `get_current_site(request)` (Host-header → +`Site`, with a per-process `SITE_CACHE`), `CurrentSiteMiddleware` (sets `request.site`), and +`CurrentSiteManager` (auto-filters models that hold an FK to `Site`). Ecosystem code +(`flatpages`, `redirects`, `sitemaps`, `allauth`) is Site-aware for free. + +*Why it does **not** fit as our tenancy mechanism:* +- **Wrong scoping key.** `CurrentSiteManager` filters on a `site` FK; our tenant key is the + `club` FK on `ClubScopedModel`. Adopting Sites' manager would mean putting a *second* FK on + every model, or ignoring the manager — either way it buys us nothing over `.for_club()`. +- **A parallel identity table.** `Site` duplicates identity that already lives on `Club` + (`slug`, domain, name). Two tables to keep in sync, two sources of truth for "which tenant". +- **`SITE_ID` is a single global.** The framework's happy path is *one process = one site* + (`SITE_ID`). Multi-tenant host resolution requires leaving `SITE_ID` unset and relying on + `get_current_site`'s exact-domain match — workable, but the setting is a standing foot-gun + (any library that reads `SITE_ID` silently binds to the wrong tenant), and shells, tasks, + and tests have no Host header, so they still need our contextvar (`require_current_club()`). +- **Host-only.** Sites cannot express the path-prefix option (`/c//`); resolution is + purely `domain`-based, foreclosing that alternative. + +*Verdict.* Keep **`Club` (with `slug` + optional `domain`) as the single tenant root** and +resolve it in `ClubTenantMiddleware` — the resolution logic (Host → `Club`) is a few lines and +avoids the sync/`SITE_ID` hazards. **Optional bridge:** if a Site-aware third party is later +adopted (e.g. `allauth`, `sitemaps`), add a thin `Club.site = OneToOneField(Site)` kept in sync +from `Club.save()`, so the ecosystem gets its `Site` while `Club` stays authoritative — do +this only when such a dependency actually lands, not preemptively. + **Scoping manager.** `ClubScopedModel` gets a tenant-aware manager so day-to-day queries can't accidentally cross tenants: @@ -489,10 +519,16 @@ membership product writes back to the season-scoped `ClubMembership` (§5.1). Product(ClubScopedModel) # -> carries `club` name, slug, description (blank) kind CharField (TextChoices: membership | event_fee | merchandise | donation) - price DecimalField(max_digits=8, decimal_places=2) + price DecimalField(max_digits=8, decimal_places=2) # list price season FK Season (PROTECT, null) # set for membership/event products is_active BooleanField + # early-bird / prompt-payment discount (§5.7.1) — per-product toggle + deadline + early_bird_enabled BooleanField (default=False) + early_bird_deadline DateField (null) # discount valid through this date (inclusive) + early_bird_disc_type CharField (TextChoices DiscountType: PERCENT | AMOUNT, blank) + early_bird_disc_value DecimalField(max_digits=8, decimal_places=2, null) # 0–100 if PERCENT, else € off unit Meta: unique_together (club, slug) + CheckConstraint: early_bird_enabled ⇒ deadline, disc_type, disc_value all set # membership products fulfil into a ClubMembership for the chosen season + beneficiary Cart(ClubScopedModel) # -> carries `club`; one open cart per (club, user) @@ -508,12 +544,17 @@ CartItem(UUIDModel) # club implied by cart unit_price DecimalField # snapshot of price at add-to-cart time Meta: unique_together (cart, product, beneficiary) -Order(ClubScopedModel) # -> carries `club`; immutable, created at checkout +Order(ClubScopedModel) # -> carries `club`; created `pending` at checkout, + # frozen at finalize() (§5.7.1 lifecycle) number CharField # human ref, allocated PER CLUB, e.g. "ORD-2026-00042" purchaser FK Member (PROTECT, related_name="orders") - status CharField (TextChoices: pending | paid | partially_paid | cancelled | refunded) - total DecimalField + status CharField (TextChoices: pending | finalized | paid | partially_paid | cancelled | refunded) + subtotal DecimalField # Σ OrderLine.line_total (after per-line early-bird) + # order-level discounts are SELECTED from a club catalogue, not typed — see AppliedDiscount + # below + OrderDiscountType (§5.7.1); applied by a treasurer while status=pending. + total DecimalField # subtotal - Σ applied discounts; the amount invoiced created_at DateTimeField + finalized_at DateTimeField (null) Meta: unique_together (club, number); ordering = ["-created_at"] OrderLine(UUIDModel) # club implied by order @@ -521,8 +562,10 @@ OrderLine(UUIDModel) # club implied by order product FK Product (PROTECT) beneficiary FK Member (PROTECT, null) quantity PositiveSmallIntegerField - unit_price DecimalField # snapshot - line_total DecimalField + list_price DecimalField # catalogue unit price at checkout (snapshot) + unit_price DecimalField # price actually charged after per-line early-bird (snapshot) + discount_label CharField (blank) # e.g. "Early bird (−15%)" — shown on invoice; blank = none + line_total DecimalField # unit_price * quantity fulfilled_at DateTimeField (null) # when this line's ClubMembership was activated Payment(UUIDModel) # club implied by order; an order may have several (partial) @@ -533,6 +576,26 @@ Payment(UUIDModel) # club implied by order; an order may have reference CharField (blank) # bank/gateway reference paid_at DateTimeField (null) +OrderDiscountType(ClubScopedModel) # -> carries `club`; club-defined catalogue of presets + name CharField # "Sibling discount", "Volunteer", "Hardship" + slug SlugField + disc_type CharField (TextChoices DiscountType: PERCENT | AMOUNT) + value DecimalField(max_digits=8, decimal_places=2) # 0–100 if PERCENT, else € off subtotal + description CharField (blank) # optional note shown to the treasurer + is_active BooleanField (default=True) # soft-retire; keeps historical AppliedDiscounts valid + Meta: unique_together (club, slug); ordering = ["name"] + +AppliedDiscount(UUIDModel) # through: Order <-> OrderDiscountType; club implied by order + order FK Order (CASCADE, related_name="discounts") + discount_type FK OrderDiscountType (PROTECT, related_name="applications") + # snapshot at apply time — the preset may be edited/retired later without altering past orders + label CharField # snapshot of name (shown on invoice) + disc_type CharField (PERCENT | AMOUNT) # snapshot + value DecimalField # snapshot (or a treasurer override, if allowed) + applied_by FK User (SET_NULL, null, related_name="+") + applied_at DateTimeField + Meta: unique_together (order, discount_type) # a preset toggles on/off once per order + Invoice(ClubScopedModel) # -> carries `club` number CharField # sequential PER CLUB per year, e.g. "INV-2026-00042" order OneToOneField Order (PROTECT, related_name="invoice") @@ -566,6 +629,55 @@ Flow & design notes: - **Money = `DecimalField`**, never float. Snapshot prices onto cart items / order lines / invoices so historical records stay correct when `Product.price` changes. +#### 5.7.1 Discounts + +Two independent discount mechanisms, applied at different layers and computed by a single +**pricing service** (`shop/services/pricing.py`) so the rules live in one place and never +in views/templates. A shared `DiscountType` enum (`PERCENT` / `AMOUNT`) is reused by both. + +**A. Early-bird / prompt-payment discount — per `Product`, automatic.** +A club toggles `early_bird_enabled` on a product, sets an `early_bird_deadline`, and a +`PERCENT` or `AMOUNT` value (§5.7 `Product`). Semantics: *buy in time and the unit price +drops.* +- **Anchor = checkout date (recommended).** The discount is evaluated **once, at checkout**, + comparing the order's `created_at` date against the deadline, and the result is frozen into + `OrderLine.unit_price` (+ a human `discount_label`, with `list_price` preserved for + transparency). This keeps the order/invoice total firm — an invoice can't have a + conditional amount. To still reward *paying* early, set the membership `Invoice.due_date` + to the deadline; late non-payment is a dunning concern, not a repricing one. +- **Alternative (payment-date anchor)** — the discount only sticks if a confirmed `Payment` + lands by the deadline, else the line reprices to `list_price`. This makes the total mutable + until the deadline and complicates invoicing; it's the literal reading of "paid before + date" but is deferred unless a club needs it (see open question, §7). +- Only applies when `today <= early_bird_deadline`; otherwise the line charges `list_price`. + A `CheckConstraint` guarantees an enabled product has a deadline + type + value. + +**B. Manual order-level discount — admin-applied, before finalize.** +A `TREASURER`/`BOARD` (§3.2) applies an ad-hoc discount to a whole order — e.g. a multi-kid +/ sibling discount — as a `PERCENT` or `AMOUNT` off the `subtotal`, with a required +`manual_disc_reason` and audit stamp (`manual_disc_by` / `manual_disc_at`). It sits on the +`Order`, not on a product. +- **Lifecycle refinement.** The manual discount forces the order to be *editable before it + freezes*: checkout now creates the order as **`pending`**; a treasurer may set/clear the + manual discount while `pending`; `finalize()` then locks the order, computes the final + `total`, allocates the `Invoice.number`, and issues the PDF. **After `finalize` the order + and its discount are immutable** — a correction means a credit/refund, not an edit. The + access service gates this via `can_manage_shop(user, club)`. + +**Computation & rounding (both kinds).** +`total = subtotal − order_discount`, where `subtotal = Σ line_total` and each `line_total` +already reflects the early-bird price. Order of application: **line-level early-bird first, +then the order-level manual discount** on the resulting subtotal. Percentages compute on the +base they apply to (unit price / subtotal), round **`ROUND_HALF_UP` to 2 decimals**, and are +**clamped to `[0, base]`** so no line or order can go negative. Every discounted document +(order summary, invoice) shows list price, discount, and net so members see how the number +was reached. + +**Extension point.** Both are deliberately field-level, not a discount-row model — enough for +the two required cases. Coupon codes, stacked promotions, or per-member entitlements would +warrant a first-class `Discount`/`Coupon` model + an M2M to orders; add it only when that need +is real. + --- ## 6. Entity-relationship overview @@ -624,6 +736,13 @@ Legend: `───<` one-to-many, `>───<` many-to-many via a through model rows + a single access service (§3). Django's own perms only for platform admin. 7. ✅ **`formbuilder` storage** — **normalized `Answer` is canonical**; no denormalized JSON snapshot (§5.6). +8. ✅ **Tenant resolution ≠ `django.contrib.sites`** — Sites evaluated and rejected as the + mechanism; `Club` stays the single tenant root, resolution in `ClubTenantMiddleware` + (§2.4). Sites optional only as a later bridge for Site-aware third parties. +9. ✅ **Shop discounts** — two field-level mechanisms (§5.7.1): a per-`Product` early-bird + discount (toggle + deadline + PERCENT/AMOUNT, frozen at checkout) and a manual order-level + discount a treasurer applies to a `pending` order before `finalize()`. Adds an + `Order.pending → finalized` step; no discount-row model yet. Infrastructure/config for the above (media storage, dependencies + exact setup) is specified in **§8**. @@ -632,7 +751,9 @@ specified in **§8**. - **Tenant resolution mechanism** — subdomain (recommended) vs. path-prefix `/c//`. Affects DNS/TLS, `ALLOWED_HOSTS`, cookies, and local dev (§8). Pick before building - `ClubTenantMiddleware`. + `ClubTenantMiddleware`. **`django.contrib.sites` was evaluated and rejected as the + mechanism** (§2.4) — `Club` stays the single tenant root; Sites is optional only as a + bridge for Site-aware third parties. - **Auto-scoping vs. explicit scoping** — should the tenant manager filter *automatically* from context, or stay explicit (`.for_club()` / `.current()`)? Doc currently recommends **explicit** (§2.4). @@ -640,6 +761,10 @@ specified in **§8**. context in one session? The model allows it; confirm the UX (club switcher) is in scope. - **Payment gateway** — which provider (Mollie / Stripe / none-yet)? Only needed when online payments go live (§8). +- **Early-bird anchor** — is the discount earned by *ordering* before the deadline + (checkout-date anchor, recommended, frozen total) or by *paying* before it (payment-date + anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal + "paid before date" semantics (§5.7.1). --- diff --git a/ARCHITECTURE.pdf b/ARCHITECTURE.pdf index 983912b..17347a0 100644 Binary files a/ARCHITECTURE.pdf and b/ARCHITECTURE.pdf differ diff --git a/authentication/admin.py b/authentication/admin.py index d663cc6..28b106b 100644 --- a/authentication/admin.py +++ b/authentication/admin.py @@ -2,8 +2,10 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.utils.translation import gettext_lazy as _ +from members.models import Member + from .forms import UserChangeForm, UserCreationForm -from .models import Family, FamilyMembership, Member, User +from .models import User class MemberInline(admin.StackedInline): @@ -40,60 +42,3 @@ class UserAdmin(BaseUserAdmin): @admin.display(description=_("name")) def full_name(self, obj): return obj.get_full_name() - - -class MemberFamilyInline(admin.TabularInline): - """Family memberships shown on the Member page.""" - - model = FamilyMembership - extra = 1 - autocomplete_fields = ("family",) - - -@admin.register(Member) -class MemberAdmin(admin.ModelAdmin): - list_display = ("last_name", "first_name", "contact_email", "phone_display", "emergency_phone_display", "user") - list_select_related = ("user",) - search_fields = ("first_name", "last_name", "email") - autocomplete_fields = ("user",) - inlines = [MemberFamilyInline] - fields = ("user", "first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone") - - @admin.display(description=_("email"), ordering="email") - def contact_email(self, obj): - return obj.contact_email - - @admin.display(description=_("phone"), ordering="phone") - def phone_display(self, obj): - return obj.phone.as_international if obj.phone else "" - - @admin.display(description=_("emergency phone"), ordering="emergency_phone") - def emergency_phone_display(self, obj): - return obj.emergency_phone.as_international if obj.emergency_phone else "" - - -class FamilyMemberInline(admin.TabularInline): - """Members shown on the Family page.""" - - model = FamilyMembership - extra = 1 - autocomplete_fields = ("member",) - - -@admin.register(Family) -class FamilyAdmin(admin.ModelAdmin): - list_display = ("__str__", "member_count") - search_fields = ("name", "memberships__member__first_name", "memberships__member__last_name") - inlines = [FamilyMemberInline] - - @admin.display(description=_("members")) - def member_count(self, obj): - return obj.memberships.count() - - -@admin.register(FamilyMembership) -class FamilyMembershipAdmin(admin.ModelAdmin): - list_display = ("family", "member", "role") - list_filter = ("role",) - autocomplete_fields = ("family", "member") - search_fields = ("family__name", "member__first_name", "member__last_name") diff --git a/authentication/apps.py b/authentication/apps.py index 9635c9d..372ba81 100644 --- a/authentication/apps.py +++ b/authentication/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class AuthenticationConfig(AppConfig): - name = 'authentication' + name = "authentication" diff --git a/authentication/migrations/0004_remove_familymembership_family_and_more.py b/authentication/migrations/0004_remove_familymembership_family_and_more.py new file mode 100644 index 0000000..886aa84 --- /dev/null +++ b/authentication/migrations/0004_remove_familymembership_family_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-07-11 22:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'), + ('club', '0005_alter_clubmembership_member'), + ] + + operations = [ + migrations.DeleteModel( + name='FamilyMembership', + ), + migrations.DeleteModel( + name='Family', + ), + migrations.DeleteModel( + name='Member', + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 1ac4fd5..f4d8c83 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -1,13 +1,9 @@ import uuid -from django.conf import settings from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import PermissionsMixin from django.db import models from django.utils.translation import gettext_lazy as _ -from phonenumber_field.modelfields import PhoneNumberField - -from clubmanager.base import UUIDModel from .managers import UserManager @@ -43,96 +39,3 @@ class User(AbstractBaseUser, PermissionsMixin): if member is not None: return member.get_short_name() return self.email - - -class Family(UUIDModel): - name = models.CharField(_("name"), max_length=255, blank=True) - - class Meta: - verbose_name = _("family") - verbose_name_plural = _("families") - ordering = ["name"] - - def __str__(self): - if self.name: - return self.name - surnames = sorted({last_name for last_name in self.memberships.values_list("member__last_name", flat=True) if last_name}) - if surnames: - return _("%(surnames)s family") % {"surnames": " / ".join(surnames)} - return _("Family %(id)s") % {"id": str(self.pk)[:8]} - - @property - def guardians(self): - return Member.objects.filter( - family_memberships__family=self, - family_memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN], - ) - - @property - def children(self): - return Member.objects.filter( - family_memberships__family=self, - family_memberships__role=FamilyMembership.FamilyRole.CHILD, - ) - - -class Member(UUIDModel): - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="member", null=True, blank=True, verbose_name=_("user")) - - first_name = models.CharField(_("first name"), max_length=150) - last_name = models.CharField(_("last name"), max_length=150) - - date_of_birth = models.DateField(_("date of birth"), null=True, blank=True) - - email = models.EmailField(_("email"), blank=True) - phone = PhoneNumberField(_("phone number"), null=True, blank=True) - emergency_phone = PhoneNumberField(_("emergency phone number"), null=True, blank=True) - - class Meta: - verbose_name = _("member") - verbose_name_plural = _("members") - ordering = ["last_name", "first_name"] - indexes = [models.Index(fields=["last_name", "first_name"])] - - def __str__(self): - return f"{self.first_name} {self.last_name}" - - def get_full_name(self): - return f"{self.first_name} {self.last_name}".strip() - - def get_short_name(self): - return self.first_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 "") - - @property - def guardians(self): - return Member.objects.filter( - family_memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN], - family_memberships__family__memberships__member=self, - family_memberships__family__memberships__role=FamilyMembership.FamilyRole.CHILD, - ).distinct() - - -class FamilyMembership(models.Model): - class FamilyRole(models.TextChoices): - PARENT = "parent", _("parent") - CHILD = "child", _("child") - GUARDIAN = "guardian", _("guardian") - OTHER = "other", _("other") - - family = models.ForeignKey(Family, on_delete=models.CASCADE, related_name="memberships", verbose_name=_("family")) - member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="family_memberships", verbose_name=_("member")) - role = models.CharField(_("role"), max_length=255, choices=FamilyRole.choices, default=FamilyRole.PARENT) - - class Meta: - verbose_name = _("family membership") - verbose_name_plural = _("family memberships") - ordering = ["family", "role", "member__last_name", "member__first_name"] - unique_together = ("family", "member") - - def __str__(self): - return f"{self.family} - {self.member} ({self.get_role_display()})" diff --git a/authentication/tests.py b/authentication/tests.py index c344312..3b267cb 100644 --- a/authentication/tests.py +++ b/authentication/tests.py @@ -1,18 +1,10 @@ -import tempfile import uuid -from datetime import date -from io import StringIO -from pathlib import Path from django.contrib.auth import get_user_model -from django.core.management import call_command -from django.core.management.base import CommandError from django.db import IntegrityError -from django.db.models import SET_NULL from django.test import TestCase -from authentication.models import Family, FamilyMembership, Member -from club.models import Club, ClubMembership +from members.models import Member User = get_user_model() @@ -94,478 +86,3 @@ class UserModelTests(TestCase): self.assertEqual(str(user), "Jane Doe") self.assertEqual(user.get_full_name(), "Jane Doe") self.assertEqual(user.get_short_name(), "Jane") - - -class MemberModelTests(TestCase): - def test_str_and_name_helpers(self): - member = Member.objects.create(first_name="John", last_name="Smith") - - self.assertEqual(str(member), "John Smith") - self.assertEqual(member.get_full_name(), "John Smith") - self.assertEqual(member.get_short_name(), "John") - - def test_get_full_name_strips_when_partial(self): - member = Member.objects.create(first_name="Cher", last_name="") - self.assertEqual(member.get_full_name(), "Cher") - - def test_contact_email_prefers_own_email(self): - user = User.objects.create_user(email="login@example.com", password="x") - member = Member.objects.create(user=user, first_name="Own", last_name="Email", email="own@example.com") - self.assertEqual(member.contact_email, "own@example.com") - - def test_contact_email_falls_back_to_login_email(self): - user = User.objects.create_user(email="login@example.com", password="x") - member = Member.objects.create(user=user, first_name="No", last_name="Email") - self.assertEqual(member.contact_email, "login@example.com") - - def test_contact_email_empty_without_email_or_user(self): - member = Member.objects.create(first_name="Zero", last_name="Contact") - self.assertEqual(member.contact_email, "") - - def test_member_can_exist_without_user(self): - member = Member.objects.create(first_name="No", last_name="Login") - self.assertIsNone(member.user) - - def test_deleting_user_nulls_member_but_keeps_it(self): - user = User.objects.create_user(email="temp@example.com", password="x") - member = Member.objects.create(user=user, first_name="Keep", last_name="Me") - - # OneToOneField uses on_delete=SET_NULL. - field = Member._meta.get_field("user") - self.assertIs(field.remote_field.on_delete, SET_NULL) - - user.delete() - member.refresh_from_db() - self.assertIsNone(member.user) - self.assertTrue(Member.objects.filter(pk=member.pk).exists()) - - def test_user_member_is_one_to_one(self): - user = User.objects.create_user(email="once@example.com", password="x") - Member.objects.create(user=user, first_name="First", last_name="Member") - - with self.assertRaises(IntegrityError): - Member.objects.create(user=user, first_name="Second", last_name="Member") - - -class FamilyNameOptionalTests(TestCase): - def test_family_can_be_created_without_a_name(self): - family = Family.objects.create() - self.assertEqual(family.name, "") - - def test_str_uses_name_when_present(self): - self.assertEqual(str(Family.objects.create(name="The Smiths")), "The Smiths") - - def test_str_falls_back_to_member_surnames(self): - family = Family.objects.create() - smith = Member.objects.create(first_name="Pat", last_name="Smith") - jones = Member.objects.create(first_name="Kim", last_name="Jones") - FamilyMembership.objects.create(family=family, member=smith, role=FamilyMembership.FamilyRole.PARENT) - FamilyMembership.objects.create(family=family, member=jones, role=FamilyMembership.FamilyRole.CHILD) - - # Distinct surnames, alphabetically ordered. - self.assertEqual(str(family), "Jones / Smith family") - - def test_str_falls_back_to_short_id_when_empty(self): - family = Family.objects.create() - self.assertEqual(str(family), f"Family {str(family.pk)[:8]}") - - -class FamilyModelTests(TestCase): - def setUp(self): - self.family = Family.objects.create(name="The Smiths") - self.parent = Member.objects.create(first_name="Pat", last_name="Smith") - self.guardian = Member.objects.create(first_name="Gale", last_name="Smith") - self.child = Member.objects.create(first_name="Kim", last_name="Smith") - self.other = Member.objects.create(first_name="Ola", last_name="Smith") - - FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT) - FamilyMembership.objects.create(family=self.family, member=self.guardian, role=FamilyMembership.FamilyRole.GUARDIAN) - FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) - FamilyMembership.objects.create(family=self.family, member=self.other, role=FamilyMembership.FamilyRole.OTHER) - - def test_str(self): - self.assertEqual(str(self.family), "The Smiths") - - def test_guardians_include_parents_and_guardians_only(self): - guardians = set(self.family.guardians) - self.assertEqual(guardians, {self.parent, self.guardian}) - - def test_children_include_children_only(self): - children = list(self.family.children) - self.assertEqual(children, [self.child]) - - def test_guardians_are_scoped_to_the_family(self): - other_family = Family.objects.create(name="The Joneses") - outsider = Member.objects.create(first_name="Out", last_name="Sider") - FamilyMembership.objects.create(family=other_family, member=outsider, role=FamilyMembership.FamilyRole.PARENT) - - self.assertNotIn(outsider, set(self.family.guardians)) - - -class MemberGuardiansTests(TestCase): - def test_guardians_of_a_child_are_family_parents_and_guardians(self): - family = Family.objects.create(name="The Does") - mum = Member.objects.create(first_name="Mary", last_name="Doe") - legal = Member.objects.create(first_name="Lee", last_name="Doe") - kid = Member.objects.create(first_name="Kit", last_name="Doe") - - FamilyMembership.objects.create(family=family, member=mum, role=FamilyMembership.FamilyRole.PARENT) - FamilyMembership.objects.create(family=family, member=legal, role=FamilyMembership.FamilyRole.GUARDIAN) - FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD) - - self.assertEqual(set(kid.guardians), {mum, legal}) - - def test_guardians_empty_for_a_parent(self): - family = Family.objects.create(name="The Roes") - parent = Member.objects.create(first_name="Ray", last_name="Roe") - kid = Member.objects.create(first_name="Ren", last_name="Roe") - - FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT) - FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD) - - self.assertEqual(list(parent.guardians), []) - - def test_guardians_do_not_leak_across_families(self): - family_a = Family.objects.create(name="Family A") - family_b = Family.objects.create(name="Family B") - parent_a = Member.objects.create(first_name="Ann", last_name="A") - parent_b = Member.objects.create(first_name="Ben", last_name="B") - kid = Member.objects.create(first_name="Cody", last_name="A") - - FamilyMembership.objects.create(family=family_a, member=parent_a, role=FamilyMembership.FamilyRole.PARENT) - FamilyMembership.objects.create(family=family_a, member=kid, role=FamilyMembership.FamilyRole.CHILD) - # parent_b belongs to a different family and must not appear as kid's guardian. - FamilyMembership.objects.create(family=family_b, member=parent_b, role=FamilyMembership.FamilyRole.PARENT) - - self.assertEqual(set(kid.guardians), {parent_a}) - - -class FamilyMembershipModelTests(TestCase): - def test_default_role_is_parent(self): - family = Family.objects.create(name="Fam") - member = Member.objects.create(first_name="D", last_name="Efault") - membership = FamilyMembership.objects.create(family=family, member=member) - - self.assertEqual(membership.role, FamilyMembership.FamilyRole.PARENT) - - def test_member_unique_per_family(self): - family = Family.objects.create(name="Fam") - member = Member.objects.create(first_name="Solo", last_name="Once") - FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.PARENT) - - with self.assertRaises(IntegrityError): - FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD) - - def test_same_member_can_join_multiple_families(self): - member = Member.objects.create(first_name="Multi", last_name="Fam") - family_a = Family.objects.create(name="A") - family_b = Family.objects.create(name="B") - - FamilyMembership.objects.create(family=family_a, member=member, role=FamilyMembership.FamilyRole.CHILD) - FamilyMembership.objects.create(family=family_b, member=member, role=FamilyMembership.FamilyRole.PARENT) - - self.assertEqual(member.family_memberships.count(), 2) - - def test_deleting_family_cascades_to_memberships(self): - family = Family.objects.create(name="Doomed") - member = Member.objects.create(first_name="Cas", last_name="Cade") - FamilyMembership.objects.create(family=family, member=member) - - family.delete() - - self.assertFalse(FamilyMembership.objects.exists()) - # The member itself survives; only the membership is removed. - self.assertTrue(Member.objects.filter(pk=member.pk).exists()) - - -class AdminSmokeTests(TestCase): - """Exercise the admin config end-to-end to catch misregistration - (bad search_fields, autocomplete targets, fieldsets, custom forms).""" - - def setUp(self): - self.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") - self.client.force_login(self.admin) - - def test_changelists_load(self): - for model in ("user", "member", "family", "familymembership"): - with self.subTest(model=model): - response = self.client.get(f"/admin/authentication/{model}/") - self.assertEqual(response.status_code, 200) - - def test_user_add_page_loads(self): - response = self.client.get("/admin/authentication/user/add/") - self.assertEqual(response.status_code, 200) - - def test_member_changelist_shows_grouped_numbers_and_fallback_email(self): - user = User.objects.create_user(email="fallback@example.com", password="pw") - Member.objects.create( - user=user, - first_name="Grouped", - last_name="Numbers", - phone="+32470123456", - emergency_phone="+3221234567", - ) - response = self.client.get("/admin/authentication/member/") - content = response.content.decode() - - # Numbers rendered in grouped international format, not raw E.164. - self.assertIn("+32 470 12 34 56", content) - self.assertIn("+32 2 123 45 67", content) - # Email column falls back to the linked login email. - self.assertIn("fallback@example.com", content) - - def test_create_user_through_admin_hashes_password(self): - response = self.client.post( - "/admin/authentication/user/add/", - { - "email": "new@example.com", - "password1": "a-good-password-42", - "password2": "a-good-password-42", - # Empty MemberInline management form — no profile created. - "member-TOTAL_FORMS": "0", - "member-INITIAL_FORMS": "0", - "member-MIN_NUM_FORMS": "0", - "member-MAX_NUM_FORMS": "1", - }, - ) - self.assertEqual(response.status_code, 302) - created = User.objects.get(email="new@example.com") - self.assertTrue(created.check_password("a-good-password-42")) - - def test_autocomplete_endpoints_respond(self): - # Member.user autocomplete resolves against UserAdmin.search_fields. - response = self.client.get( - "/admin/autocomplete/", - {"app_label": "authentication", "model_name": "member", "field_name": "user", "term": "root"}, - ) - self.assertEqual(response.status_code, 200) - - -class ImportMembersCsvCommandTests(TestCase): - def write_csv(self, content): - temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") - temp_file.write(content) - temp_file.close() - self.addCleanup(lambda: Path(temp_file.name).unlink(missing_ok=True)) - return temp_file.name - - def call_import_command(self, csv_path, **options): - stdout = StringIO() - stderr = StringIO() - - call_command( - "import_members_csv", - csv_path, - stdout=stdout, - stderr=stderr, - **options, - ) - - return stdout.getvalue(), stderr.getvalue() - - def test_import_creates_member_club_membership_and_user_when_requested(self): - csv_path = self.write_csv( - "\n".join( - [ - "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", - "Jane,Doe,jane@example.com,2010-04-12,true,City Swim Club,LIC-001", - ] - ) - ) - - stdout, stderr = self.call_import_command(csv_path) - - self.assertEqual(stderr, "") - self.assertIn("Import complete.", stdout) - self.assertIn("Members created: 1.", stdout) - self.assertIn("Users created: 1.", stdout) - self.assertIn("Clubs created: 1.", stdout) - self.assertIn("Memberships created: 1.", stdout) - self.assertIn("Rows skipped: 0.", stdout) - - member = Member.objects.get(email="jane@example.com") - self.assertEqual(member.first_name, "Jane") - self.assertEqual(member.last_name, "Doe") - self.assertEqual(member.date_of_birth, date(2010, 4, 12)) - self.assertIsNotNone(member.user) - self.assertEqual(member.user.email, "jane@example.com") - self.assertFalse(member.user.has_usable_password()) - - club = Club.objects.get(name="City Swim Club") - membership = ClubMembership.objects.get(club=club, member=member) - self.assertEqual(membership.license, "LIC-001") - - def test_import_creates_member_without_user_when_create_account_is_false(self): - csv_path = self.write_csv( - "\n".join( - [ - "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", - "John,Smith,john@example.com,2009-11-03,false,City Swim Club,LIC-002", - ] - ) - ) - - stdout, stderr = self.call_import_command(csv_path) - - self.assertEqual(stderr, "") - self.assertIn("Members created: 1.", stdout) - self.assertIn("Users created: 0.", stdout) - - member = Member.objects.get(email="john@example.com") - self.assertIsNone(member.user) - self.assertFalse(User.objects.filter(email="john@example.com").exists()) - - def test_import_updates_existing_member_and_membership(self): - club = Club.objects.create(name="City Swim Club") - member = Member.objects.create( - first_name="Old", - last_name="Name", - email="jane@example.com", - date_of_birth=date(2010, 1, 1), - ) - ClubMembership.objects.create( - club=club, - member=member, - license="OLD-LIC", - ) - - csv_path = self.write_csv( - "\n".join( - [ - "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", - "Jane,Doe,jane@example.com,2010-04-12,false,City Swim Club,LIC-NEW", - ] - ) - ) - - stdout, stderr = self.call_import_command(csv_path) - - self.assertEqual(stderr, "") - self.assertIn("Members created: 0.", stdout) - self.assertIn("Members updated: 1.", stdout) - self.assertIn("Memberships created: 0.", stdout) - self.assertIn("Memberships updated: 1.", stdout) - - member.refresh_from_db() - self.assertEqual(member.first_name, "Jane") - self.assertEqual(member.last_name, "Doe") - self.assertEqual(member.date_of_birth, date(2010, 4, 12)) - - membership = ClubMembership.objects.get(club=club, member=member) - self.assertEqual(membership.license, "LIC-NEW") - - def test_import_links_existing_user_when_create_account_is_true(self): - user = User.objects.create_user(email="jane@example.com", password="secret123") - - csv_path = self.write_csv( - "\n".join( - [ - "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", - "Jane,Doe,jane@example.com,2010-04-12,true,City Swim Club,LIC-001", - ] - ) - ) - - stdout, stderr = self.call_import_command(csv_path) - - self.assertEqual(stderr, "") - self.assertIn("Users created: 0.", stdout) - - member = Member.objects.get(email="jane@example.com") - self.assertEqual(member.user, user) - self.assertTrue(user.check_password("secret123")) - - def test_import_supports_custom_date_format(self): - csv_path = self.write_csv( - "\n".join( - [ - "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", - "Jane,Doe,jane@example.com,12/04/2010,false,City Swim Club,LIC-001", - ] - ) - ) - - stdout, stderr = self.call_import_command(csv_path, date_format="%d/%m/%Y") - - self.assertEqual(stderr, "") - self.assertIn("Members created: 1.", stdout) - - member = Member.objects.get(email="jane@example.com") - self.assertEqual(member.date_of_birth, date(2010, 4, 12)) - - def test_import_skips_invalid_row_and_imports_valid_rows(self): - csv_path = self.write_csv( - "\n".join( - [ - "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", - "Jane,Doe,jane@example.com,2010-04-12,false,City Swim Club,LIC-001", - "Broken,Date,broken@example.com,not-a-date,false,City Swim Club,LIC-002", - ] - ) - ) - - stdout, stderr = self.call_import_command(csv_path) - - self.assertIn("Row 3 skipped:", stderr) - self.assertIn("Invalid date_of_birth 'not-a-date'.", stderr) - self.assertIn("Members created: 1.", stdout) - self.assertIn("Rows skipped: 1.", stdout) - - self.assertTrue(Member.objects.filter(email="jane@example.com").exists()) - self.assertFalse(Member.objects.filter(email="broken@example.com").exists()) - - def test_import_fails_for_missing_file(self): - stdout = StringIO() - stderr = StringIO() - - with self.assertRaises(CommandError) as context: - call_command( - "import_members_csv", - "does-not-exist.csv", - stdout=stdout, - stderr=stderr, - ) - - self.assertIn("CSV file does not exist", str(context.exception)) - - def test_import_fails_for_missing_required_columns(self): - csv_path = self.write_csv( - "\n".join( - [ - "first_name,last_name,email", - "Jane,Doe,jane@example.com", - ] - ) - ) - - stdout = StringIO() - stderr = StringIO() - - with self.assertRaises(CommandError) as context: - call_command( - "import_members_csv", - csv_path, - stdout=stdout, - stderr=stderr, - ) - - self.assertIn("CSV file is missing required columns:", str(context.exception)) - self.assertIn("club_name", str(context.exception)) - self.assertIn("date_of_birth", str(context.exception)) - self.assertIn("license_number", str(context.exception)) - - def test_import_fails_for_empty_csv_file(self): - csv_path = self.write_csv("") - - stdout = StringIO() - stderr = StringIO() - - with self.assertRaises(CommandError) as context: - call_command( - "import_members_csv", - csv_path, - stdout=stdout, - stderr=stderr, - ) - - self.assertIn("CSV file is empty or missing a header row.", str(context.exception)) diff --git a/club/apps.py b/club/apps.py index 67bff61..8e8a26a 100644 --- a/club/apps.py +++ b/club/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ClubConfig(AppConfig): - name = 'club' + name = "club" diff --git a/club/migrations/0005_alter_clubmembership_member.py b/club/migrations/0005_alter_clubmembership_member.py new file mode 100644 index 0000000..34d2657 --- /dev/null +++ b/club/migrations/0005_alter_clubmembership_member.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.6 on 2026-07-11 22:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('club', '0004_alter_clubmembership_license'), + ('members', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='clubmembership', + name='member', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_of', to='members.member', verbose_name='member'), + ), + ] diff --git a/club/models.py b/club/models.py index da28c33..ef17947 100644 --- a/club/models.py +++ b/club/models.py @@ -1,9 +1,8 @@ -from django.contrib.auth.base_user import AbstractBaseUser from django.db import models from django.utils.translation import gettext_lazy as _ -from authentication.models import Member -from clubmanager.base import ClubScopedModel, UUIDModel +from clubmanager.base import UUIDModel +from members.models import Member class Club(UUIDModel): diff --git a/club/tests.py b/club/tests.py index ea5615c..6ae2922 100644 --- a/club/tests.py +++ b/club/tests.py @@ -3,7 +3,7 @@ import uuid from django.db import IntegrityError from django.test import TestCase -from authentication.models import Member +from members.models import Member from .models import Club, ClubMembership diff --git a/clubmanager/asgi.py b/clubmanager/asgi.py index 0d569a9..eb47138 100644 --- a/clubmanager/asgi.py +++ b/clubmanager/asgi.py @@ -11,6 +11,6 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'clubmanager.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clubmanager.settings") application = get_asgi_application() diff --git a/clubmanager/settings.py b/clubmanager/settings.py index d55bd6c..c60240b 100644 --- a/clubmanager/settings.py +++ b/clubmanager/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = [ "phonenumber_field", "club.apps.ClubConfig", "authentication.apps.AuthenticationConfig", + "members.apps.MembersConfig", ] MIDDLEWARE = [ diff --git a/clubmanager/urls.py b/clubmanager/urls.py index 1491602..7946d6d 100644 --- a/clubmanager/urls.py +++ b/clubmanager/urls.py @@ -14,9 +14,10 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path urlpatterns = [ - path('admin/', admin.site.urls), + path("admin/", admin.site.urls), ] diff --git a/clubmanager/wsgi.py b/clubmanager/wsgi.py index 45ad08d..ceeeca2 100644 --- a/clubmanager/wsgi.py +++ b/clubmanager/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'clubmanager.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clubmanager.settings") application = get_wsgi_application() diff --git a/manage.py b/manage.py index e3be89a..e08fcbb 100755 --- a/manage.py +++ b/manage.py @@ -1,22 +1,19 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'clubmanager.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "clubmanager.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc + raise ImportError("Couldn't import Django. Are you sure it's installed and available on your PYTHONPATH environment variable? Did you forget to activate a virtual environment?") from exc execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/authentication/management/__init__.py b/members/__init__.py similarity index 100% rename from authentication/management/__init__.py rename to members/__init__.py diff --git a/members/admin.py b/members/admin.py new file mode 100644 index 0000000..f76e698 --- /dev/null +++ b/members/admin.py @@ -0,0 +1,62 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import Family, FamilyMembership, Member + + +# Register your models here. +class MemberFamilyInline(admin.TabularInline): + """Family memberships shown on the Member page.""" + + model = FamilyMembership + extra = 1 + autocomplete_fields = ("family",) + + +@admin.register(Member) +class MemberAdmin(admin.ModelAdmin): + list_display = ("last_name", "first_name", "contact_email", "phone_display", "emergency_phone_display", "user") + list_select_related = ("user",) + search_fields = ("first_name", "last_name", "email") + autocomplete_fields = ("user",) + inlines = [MemberFamilyInline] + fields = ("user", "first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone") + + @admin.display(description=_("email"), ordering="email") + def contact_email(self, obj): + return obj.contact_email + + @admin.display(description=_("phone"), ordering="phone") + def phone_display(self, obj): + return obj.phone.as_international if obj.phone else "" + + @admin.display(description=_("emergency phone"), ordering="emergency_phone") + def emergency_phone_display(self, obj): + return obj.emergency_phone.as_international if obj.emergency_phone else "" + + +class FamilyMemberInline(admin.TabularInline): + """Members shown on the Family page.""" + + model = FamilyMembership + extra = 1 + autocomplete_fields = ("member",) + + +@admin.register(Family) +class FamilyAdmin(admin.ModelAdmin): + list_display = ("__str__", "member_count") + search_fields = ("name", "memberships__member__first_name", "memberships__member__last_name") + inlines = [FamilyMemberInline] + + @admin.display(description=_("members")) + def member_count(self, obj): + return obj.memberships.count() + + +@admin.register(FamilyMembership) +class FamilyMembershipAdmin(admin.ModelAdmin): + list_display = ("family", "member", "role") + list_filter = ("role",) + autocomplete_fields = ("family", "member") + search_fields = ("family__name", "member__first_name", "member__last_name") diff --git a/members/apps.py b/members/apps.py new file mode 100644 index 0000000..85a1306 --- /dev/null +++ b/members/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MembersConfig(AppConfig): + name = "members" diff --git a/authentication/management/commands/__init__.py b/members/management/__init__.py similarity index 100% rename from authentication/management/commands/__init__.py rename to members/management/__init__.py diff --git a/authentication/services/__init__.py b/members/management/commands/__init__.py similarity index 100% rename from authentication/services/__init__.py rename to members/management/commands/__init__.py diff --git a/authentication/management/commands/import_members_csv.py b/members/management/commands/import_members_csv.py similarity index 95% rename from authentication/management/commands/import_members_csv.py rename to members/management/commands/import_members_csv.py index 61ad519..35de581 100644 --- a/authentication/management/commands/import_members_csv.py +++ b/members/management/commands/import_members_csv.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand, CommandError -from authentication.services.member_csv_importer import MemberCsvImporter +from members.services import MemberCsvImporter class Command(BaseCommand): diff --git a/members/migrations/0001_initial.py b/members/migrations/0001_initial.py new file mode 100644 index 0000000..316c63d --- /dev/null +++ b/members/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 6.0.6 on 2026-07-11 22:09 + +import django.db.models.deletion +import phonenumber_field.modelfields +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Family', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, max_length=255, verbose_name='name')), + ], + options={ + 'verbose_name': 'family', + 'verbose_name_plural': 'families', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Member', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('first_name', models.CharField(max_length=150, verbose_name='first name')), + ('last_name', models.CharField(max_length=150, verbose_name='last name')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='date of birth')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email')), + ('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='phone number')), + ('emergency_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='emergency phone number')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'member', + 'verbose_name_plural': 'members', + 'ordering': ['last_name', 'first_name'], + }, + ), + migrations.CreateModel( + name='FamilyMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255, verbose_name='role')), + ('family', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='members.family', verbose_name='family')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='members.member', verbose_name='member')), + ], + options={ + 'verbose_name': 'family membership', + 'verbose_name_plural': 'family memberships', + 'ordering': ['family', 'role', 'member__last_name', 'member__first_name'], + }, + ), + migrations.AddIndex( + model_name='member', + index=models.Index(fields=['last_name', 'first_name'], name='members_mem_last_na_92a910_idx'), + ), + migrations.AlterUniqueTogether( + name='familymembership', + unique_together={('family', 'member')}, + ), + ] diff --git a/members/migrations/__init__.py b/members/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/members/models.py b/members/models.py new file mode 100644 index 0000000..7b2b535 --- /dev/null +++ b/members/models.py @@ -0,0 +1,99 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ +from phonenumber_field.modelfields import PhoneNumberField + +from clubmanager.base import UUIDModel + + +class Family(UUIDModel): + name = models.CharField(_("name"), max_length=255, blank=True) + + class Meta: + verbose_name = _("family") + verbose_name_plural = _("families") + ordering = ["name"] + + def __str__(self): + if self.name: + return self.name + surnames = sorted({last_name for last_name in self.memberships.values_list("member__last_name", flat=True) if last_name}) + if surnames: + return _("%(surnames)s family") % {"surnames": " / ".join(surnames)} + return _("Family %(id)s") % {"id": str(self.pk)[:8]} + + @property + def guardians(self): + return Member.objects.filter( + family_memberships__family=self, + family_memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN], + ) + + @property + def children(self): + return Member.objects.filter( + family_memberships__family=self, + family_memberships__role=FamilyMembership.FamilyRole.CHILD, + ) + + +class Member(UUIDModel): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="member", null=True, blank=True, verbose_name=_("user")) + + first_name = models.CharField(_("first name"), max_length=150) + last_name = models.CharField(_("last name"), max_length=150) + + date_of_birth = models.DateField(_("date of birth"), null=True, blank=True) + + email = models.EmailField(_("email"), blank=True) + phone = PhoneNumberField(_("phone number"), null=True, blank=True) + emergency_phone = PhoneNumberField(_("emergency phone number"), null=True, blank=True) + + class Meta: + verbose_name = _("member") + verbose_name_plural = _("members") + ordering = ["last_name", "first_name"] + indexes = [models.Index(fields=["last_name", "first_name"])] + + def __str__(self): + return self.get_full_name() + + def get_full_name(self): + return f"{self.first_name} {self.last_name}".strip() + + def get_short_name(self): + return self.first_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 "") + + @property + def guardians(self): + return Member.objects.filter( + family_memberships__role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN], + family_memberships__family__memberships__member=self, + family_memberships__family__memberships__role=FamilyMembership.FamilyRole.CHILD, + ).distinct() + + +class FamilyMembership(models.Model): + class FamilyRole(models.TextChoices): + PARENT = "parent", _("parent") + CHILD = "child", _("child") + GUARDIAN = "guardian", _("guardian") + OTHER = "other", _("other") + + family = models.ForeignKey(Family, on_delete=models.CASCADE, related_name="memberships", verbose_name=_("family")) + member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="family_memberships", verbose_name=_("member")) + role = models.CharField(_("role"), max_length=255, choices=FamilyRole.choices, default=FamilyRole.PARENT) + + class Meta: + verbose_name = _("family membership") + verbose_name_plural = _("family memberships") + ordering = ["family", "role", "member__last_name", "member__first_name"] + unique_together = ("family", "member") + + def __str__(self): + return f"{self.family} - {self.member} ({self.get_role_display()})" diff --git a/members/services/__init__.py b/members/services/__init__.py new file mode 100644 index 0000000..c8abceb --- /dev/null +++ b/members/services/__init__.py @@ -0,0 +1,13 @@ +from members.services.member_csv_importer import ( + ImportedMemberRowResult, + MemberCsvImporter, + MemberImportResult, + MemberImportRowError, +) + +__all__ = [ + "ImportedMemberRowResult", + "MemberCsvImporter", + "MemberImportResult", + "MemberImportRowError", +] diff --git a/authentication/services/member_csv_importer.py b/members/services/member_csv_importer.py similarity index 99% rename from authentication/services/member_csv_importer.py rename to members/services/member_csv_importer.py index 8c810e1..508c289 100644 --- a/authentication/services/member_csv_importer.py +++ b/members/services/member_csv_importer.py @@ -7,8 +7,8 @@ from typing import IO, Any from django.contrib.auth import get_user_model from django.db import transaction -from authentication.models import Member from club.models import Club, ClubMembership +from members.models import Member REQUIRED_COLUMNS = {"first_name", "last_name", "email", "date_of_birth", "create_account", "club_name", "license_number"} TRUE_VALUES = {"1", "true", "yes", "y", "on"} diff --git a/members/tests.py b/members/tests.py new file mode 100644 index 0000000..658cff2 --- /dev/null +++ b/members/tests.py @@ -0,0 +1,495 @@ +import tempfile +from datetime import date +from io import StringIO +from pathlib import Path + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db import IntegrityError +from django.db.models import SET_NULL +from django.test import TestCase + +from authentication.models import User +from club.models import Club, ClubMembership +from members.models import Family, FamilyMembership, Member + + +# Create your tests here. +class MemberModelTests(TestCase): + def test_str_and_name_helpers(self): + member = Member.objects.create(first_name="John", last_name="Smith") + + self.assertEqual(str(member), "John Smith") + self.assertEqual(member.get_full_name(), "John Smith") + self.assertEqual(member.get_short_name(), "John") + + def test_get_full_name_strips_when_partial(self): + member = Member.objects.create(first_name="Cher", last_name="") + self.assertEqual(member.get_full_name(), "Cher") + + def test_contact_email_prefers_own_email(self): + user = User.objects.create_user(email="login@example.com", password="x") + member = Member.objects.create(user=user, first_name="Own", last_name="Email", email="own@example.com") + self.assertEqual(member.contact_email, "own@example.com") + + def test_contact_email_falls_back_to_login_email(self): + user = User.objects.create_user(email="login@example.com", password="x") + member = Member.objects.create(user=user, first_name="No", last_name="Email") + self.assertEqual(member.contact_email, "login@example.com") + + def test_contact_email_empty_without_email_or_user(self): + member = Member.objects.create(first_name="Zero", last_name="Contact") + self.assertEqual(member.contact_email, "") + + def test_member_can_exist_without_user(self): + member = Member.objects.create(first_name="No", last_name="Login") + self.assertIsNone(member.user) + + def test_deleting_user_nulls_member_but_keeps_it(self): + user = User.objects.create_user(email="temp@example.com", password="x") + member = Member.objects.create(user=user, first_name="Keep", last_name="Me") + + # OneToOneField uses on_delete=SET_NULL. + field = Member._meta.get_field("user") + self.assertIs(field.remote_field.on_delete, SET_NULL) + + user.delete() + member.refresh_from_db() + self.assertIsNone(member.user) + self.assertTrue(Member.objects.filter(pk=member.pk).exists()) + + def test_user_member_is_one_to_one(self): + user = User.objects.create_user(email="once@example.com", password="x") + Member.objects.create(user=user, first_name="First", last_name="Member") + + with self.assertRaises(IntegrityError): + Member.objects.create(user=user, first_name="Second", last_name="Member") + + +class FamilyNameOptionalTests(TestCase): + def test_family_can_be_created_without_a_name(self): + family = Family.objects.create() + self.assertEqual(family.name, "") + + def test_str_uses_name_when_present(self): + self.assertEqual(str(Family.objects.create(name="The Smiths")), "The Smiths") + + def test_str_falls_back_to_member_surnames(self): + family = Family.objects.create() + smith = Member.objects.create(first_name="Pat", last_name="Smith") + jones = Member.objects.create(first_name="Kim", last_name="Jones") + FamilyMembership.objects.create(family=family, member=smith, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=family, member=jones, role=FamilyMembership.FamilyRole.CHILD) + + # Distinct surnames, alphabetically ordered. + self.assertEqual(str(family), "Jones / Smith family") + + def test_str_falls_back_to_short_id_when_empty(self): + family = Family.objects.create() + self.assertEqual(str(family), f"Family {str(family.pk)[:8]}") + + +class FamilyModelTests(TestCase): + def setUp(self): + self.family = Family.objects.create(name="The Smiths") + self.parent = Member.objects.create(first_name="Pat", last_name="Smith") + self.guardian = Member.objects.create(first_name="Gale", last_name="Smith") + self.child = Member.objects.create(first_name="Kim", last_name="Smith") + self.other = Member.objects.create(first_name="Ola", last_name="Smith") + + FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=self.family, member=self.guardian, role=FamilyMembership.FamilyRole.GUARDIAN) + FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=self.family, member=self.other, role=FamilyMembership.FamilyRole.OTHER) + + def test_str(self): + self.assertEqual(str(self.family), "The Smiths") + + def test_guardians_include_parents_and_guardians_only(self): + guardians = set(self.family.guardians) + self.assertEqual(guardians, {self.parent, self.guardian}) + + def test_children_include_children_only(self): + children = list(self.family.children) + self.assertEqual(children, [self.child]) + + def test_guardians_are_scoped_to_the_family(self): + other_family = Family.objects.create(name="The Joneses") + outsider = Member.objects.create(first_name="Out", last_name="Sider") + FamilyMembership.objects.create(family=other_family, member=outsider, role=FamilyMembership.FamilyRole.PARENT) + + self.assertNotIn(outsider, set(self.family.guardians)) + + +class MemberGuardiansTests(TestCase): + def test_guardians_of_a_child_are_family_parents_and_guardians(self): + family = Family.objects.create(name="The Does") + mum = Member.objects.create(first_name="Mary", last_name="Doe") + legal = Member.objects.create(first_name="Lee", last_name="Doe") + kid = Member.objects.create(first_name="Kit", last_name="Doe") + + FamilyMembership.objects.create(family=family, member=mum, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=family, member=legal, role=FamilyMembership.FamilyRole.GUARDIAN) + FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD) + + self.assertEqual(set(kid.guardians), {mum, legal}) + + def test_guardians_empty_for_a_parent(self): + family = Family.objects.create(name="The Roes") + parent = Member.objects.create(first_name="Ray", last_name="Roe") + kid = Member.objects.create(first_name="Ren", last_name="Roe") + + FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=family, member=kid, role=FamilyMembership.FamilyRole.CHILD) + + self.assertEqual(list(parent.guardians), []) + + def test_guardians_do_not_leak_across_families(self): + family_a = Family.objects.create(name="Family A") + family_b = Family.objects.create(name="Family B") + parent_a = Member.objects.create(first_name="Ann", last_name="A") + parent_b = Member.objects.create(first_name="Ben", last_name="B") + kid = Member.objects.create(first_name="Cody", last_name="A") + + FamilyMembership.objects.create(family=family_a, member=parent_a, role=FamilyMembership.FamilyRole.PARENT) + FamilyMembership.objects.create(family=family_a, member=kid, role=FamilyMembership.FamilyRole.CHILD) + # parent_b belongs to a different family and must not appear as kid's guardian. + FamilyMembership.objects.create(family=family_b, member=parent_b, role=FamilyMembership.FamilyRole.PARENT) + + self.assertEqual(set(kid.guardians), {parent_a}) + + +class FamilyMembershipModelTests(TestCase): + def test_default_role_is_parent(self): + family = Family.objects.create(name="Fam") + member = Member.objects.create(first_name="D", last_name="Efault") + membership = FamilyMembership.objects.create(family=family, member=member) + + self.assertEqual(membership.role, FamilyMembership.FamilyRole.PARENT) + + def test_member_unique_per_family(self): + family = Family.objects.create(name="Fam") + member = Member.objects.create(first_name="Solo", last_name="Once") + FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.PARENT) + + with self.assertRaises(IntegrityError): + FamilyMembership.objects.create(family=family, member=member, role=FamilyMembership.FamilyRole.CHILD) + + def test_same_member_can_join_multiple_families(self): + member = Member.objects.create(first_name="Multi", last_name="Fam") + family_a = Family.objects.create(name="A") + family_b = Family.objects.create(name="B") + + FamilyMembership.objects.create(family=family_a, member=member, role=FamilyMembership.FamilyRole.CHILD) + FamilyMembership.objects.create(family=family_b, member=member, role=FamilyMembership.FamilyRole.PARENT) + + self.assertEqual(member.family_memberships.count(), 2) + + def test_deleting_family_cascades_to_memberships(self): + family = Family.objects.create(name="Doomed") + member = Member.objects.create(first_name="Cas", last_name="Cade") + FamilyMembership.objects.create(family=family, member=member) + + family.delete() + + self.assertFalse(FamilyMembership.objects.exists()) + # The member itself survives; only the membership is removed. + self.assertTrue(Member.objects.filter(pk=member.pk).exists()) + + +class AdminSmokeTests(TestCase): + """Exercise the admin config end-to-end to catch misregistration + (bad search_fields, autocomplete targets, fieldsets, custom forms).""" + + def setUp(self): + self.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123") + self.client.force_login(self.admin) + + def test_changelists_load(self): + for app_label, model in ( + ("authentication", "user"), + ("members", "member"), + ("members", "family"), + ("members", "familymembership"), + ): + with self.subTest(model=model): + response = self.client.get(f"/admin/{app_label}/{model}/") + self.assertEqual(response.status_code, 200) + + def test_user_add_page_loads(self): + response = self.client.get("/admin/authentication/user/add/") + self.assertEqual(response.status_code, 200) + + def test_member_changelist_shows_grouped_numbers_and_fallback_email(self): + user = User.objects.create_user(email="fallback@example.com", password="pw") + Member.objects.create( + user=user, + first_name="Grouped", + last_name="Numbers", + phone="+32470123456", + emergency_phone="+3221234567", + ) + response = self.client.get("/admin/members/member/") + content = response.content.decode() + + # Numbers rendered in grouped international format, not raw E.164. + self.assertIn("+32 470 12 34 56", content) + self.assertIn("+32 2 123 45 67", content) + # Email column falls back to the linked login email. + self.assertIn("fallback@example.com", content) + + def test_create_user_through_admin_hashes_password(self): + response = self.client.post( + "/admin/authentication/user/add/", + { + "email": "new@example.com", + "password1": "a-good-password-42", + "password2": "a-good-password-42", + # Empty MemberInline management form — no profile created. + "member-TOTAL_FORMS": "0", + "member-INITIAL_FORMS": "0", + "member-MIN_NUM_FORMS": "0", + "member-MAX_NUM_FORMS": "1", + }, + ) + self.assertEqual(response.status_code, 302) + created = User.objects.get(email="new@example.com") + self.assertTrue(created.check_password("a-good-password-42")) + + def test_autocomplete_endpoints_respond(self): + # Member.user autocomplete resolves against UserAdmin.search_fields. + response = self.client.get( + "/admin/autocomplete/", + {"app_label": "members", "model_name": "member", "field_name": "user", "term": "root"}, + ) + self.assertEqual(response.status_code, 200) + + +class ImportMembersCsvCommandTests(TestCase): + def write_csv(self, content): + temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") + temp_file.write(content) + temp_file.close() + self.addCleanup(lambda: Path(temp_file.name).unlink(missing_ok=True)) + return temp_file.name + + def call_import_command(self, csv_path, **options): + stdout = StringIO() + stderr = StringIO() + + call_command( + "import_members_csv", + csv_path, + stdout=stdout, + stderr=stderr, + **options, + ) + + return stdout.getvalue(), stderr.getvalue() + + def test_import_creates_member_club_membership_and_user_when_requested(self): + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", + "Jane,Doe,jane@example.com,2010-04-12,true,City Swim Club,LIC-001", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + self.assertIn("Import complete.", stdout) + self.assertIn("Members created: 1.", stdout) + self.assertIn("Users created: 1.", stdout) + self.assertIn("Clubs created: 1.", stdout) + self.assertIn("Memberships created: 1.", stdout) + self.assertIn("Rows skipped: 0.", stdout) + + member = Member.objects.get(email="jane@example.com") + self.assertEqual(member.first_name, "Jane") + self.assertEqual(member.last_name, "Doe") + self.assertEqual(member.date_of_birth, date(2010, 4, 12)) + self.assertIsNotNone(member.user) + self.assertEqual(member.user.email, "jane@example.com") + self.assertFalse(member.user.has_usable_password()) + + club = Club.objects.get(name="City Swim Club") + membership = ClubMembership.objects.get(club=club, member=member) + self.assertEqual(membership.license, "LIC-001") + + def test_import_creates_member_without_user_when_create_account_is_false(self): + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", + "John,Smith,john@example.com,2009-11-03,false,City Swim Club,LIC-002", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + self.assertIn("Members created: 1.", stdout) + self.assertIn("Users created: 0.", stdout) + + member = Member.objects.get(email="john@example.com") + self.assertIsNone(member.user) + self.assertFalse(User.objects.filter(email="john@example.com").exists()) + + def test_import_updates_existing_member_and_membership(self): + club = Club.objects.create(name="City Swim Club") + member = Member.objects.create( + first_name="Old", + last_name="Name", + email="jane@example.com", + date_of_birth=date(2010, 1, 1), + ) + ClubMembership.objects.create( + club=club, + member=member, + license="OLD-LIC", + ) + + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", + "Jane,Doe,jane@example.com,2010-04-12,false,City Swim Club,LIC-NEW", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + self.assertIn("Members created: 0.", stdout) + self.assertIn("Members updated: 1.", stdout) + self.assertIn("Memberships created: 0.", stdout) + self.assertIn("Memberships updated: 1.", stdout) + + member.refresh_from_db() + self.assertEqual(member.first_name, "Jane") + self.assertEqual(member.last_name, "Doe") + self.assertEqual(member.date_of_birth, date(2010, 4, 12)) + + membership = ClubMembership.objects.get(club=club, member=member) + self.assertEqual(membership.license, "LIC-NEW") + + def test_import_links_existing_user_when_create_account_is_true(self): + user = User.objects.create_user(email="jane@example.com", password="secret123") + + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", + "Jane,Doe,jane@example.com,2010-04-12,true,City Swim Club,LIC-001", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertEqual(stderr, "") + self.assertIn("Users created: 0.", stdout) + + member = Member.objects.get(email="jane@example.com") + self.assertEqual(member.user, user) + self.assertTrue(user.check_password("secret123")) + + def test_import_supports_custom_date_format(self): + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", + "Jane,Doe,jane@example.com,12/04/2010,false,City Swim Club,LIC-001", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path, date_format="%d/%m/%Y") + + self.assertEqual(stderr, "") + self.assertIn("Members created: 1.", stdout) + + member = Member.objects.get(email="jane@example.com") + self.assertEqual(member.date_of_birth, date(2010, 4, 12)) + + def test_import_skips_invalid_row_and_imports_valid_rows(self): + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email,date_of_birth,create_account,club_name,license_number", + "Jane,Doe,jane@example.com,2010-04-12,false,City Swim Club,LIC-001", + "Broken,Date,broken@example.com,not-a-date,false,City Swim Club,LIC-002", + ] + ) + ) + + stdout, stderr = self.call_import_command(csv_path) + + self.assertIn("Row 3 skipped:", stderr) + self.assertIn("Invalid date_of_birth 'not-a-date'.", stderr) + self.assertIn("Members created: 1.", stdout) + self.assertIn("Rows skipped: 1.", stdout) + + self.assertTrue(Member.objects.filter(email="jane@example.com").exists()) + self.assertFalse(Member.objects.filter(email="broken@example.com").exists()) + + def test_import_fails_for_missing_file(self): + stdout = StringIO() + stderr = StringIO() + + with self.assertRaises(CommandError) as context: + call_command( + "import_members_csv", + "does-not-exist.csv", + stdout=stdout, + stderr=stderr, + ) + + self.assertIn("CSV file does not exist", str(context.exception)) + + def test_import_fails_for_missing_required_columns(self): + csv_path = self.write_csv( + "\n".join( + [ + "first_name,last_name,email", + "Jane,Doe,jane@example.com", + ] + ) + ) + + stdout = StringIO() + stderr = StringIO() + + with self.assertRaises(CommandError) as context: + call_command( + "import_members_csv", + csv_path, + stdout=stdout, + stderr=stderr, + ) + + self.assertIn("CSV file is missing required columns:", str(context.exception)) + self.assertIn("club_name", str(context.exception)) + self.assertIn("date_of_birth", str(context.exception)) + self.assertIn("license_number", str(context.exception)) + + def test_import_fails_for_empty_csv_file(self): + csv_path = self.write_csv("") + + stdout = StringIO() + stderr = StringIO() + + with self.assertRaises(CommandError) as context: + call_command( + "import_members_csv", + csv_path, + stdout=stdout, + stderr=stderr, + ) + + self.assertIn("CSV file is empty or missing a header row.", str(context.exception)) diff --git a/members/views.py b/members/views.py new file mode 100644 index 0000000..b8e4ee0 --- /dev/null +++ b/members/views.py @@ -0,0 +1,2 @@ + +# Create your views here.