diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f88e61 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +ClubManager is a **single-club** sport club management app + public website, built on **Django 6.0** (Python 3.14+). It is deliberately *not* multi-tenant — there is no `club_id` tenancy; the app manages one club. + +The repo is currently an early **skeleton**: a stock `django-admin startproject` layout with only Django's built-in apps installed. None of the domain apps exist yet — see "Planned architecture" below for the intended shape (encoded in `pyproject.toml`, not yet on disk). Verify against the actual tree before assuming a module exists. + +## Commands + +Dependencies and the virtualenv are managed with **uv** (`pyproject.toml` at repo root, `uv.lock` committed). Run Django/tools through `uv run` so the project venv is used. + +```bash +uv sync # install deps (incl. dev group) into .venv +uv run python manage.py runserver # dev server +uv run python manage.py migrate # apply migrations +uv run python manage.py makemigrations +uv run python manage.py createsuperuser +uv run python manage.py shell + +uv run python manage.py test # run all tests (Django test runner) +uv run python manage.py test # one app +uv run python manage.py test .tests. # one TestCase +uv run python manage.py test .tests.. # one test + +uv run ruff check . # lint +uv run ruff check --fix . # lint + autofix +uv run ruff format . # format +``` + +## Configuration + +Settings live in a single `clubmanager/settings.py` and read from the environment via **python-decouple** (`config(...)`), with a local `.env` file for dev. Key vars: `DJANGO_SECRET_KEY` (required), `DJANGO_DEBUG`, `DJANGO_ALLOWED_HOSTS`, `DJANGO_CSRF_TRUSTED_ORIGINS`, `DJANGO_DATABASE_URL`, `DJANGO_TIME_ZONE`. + +The database is configured through a single `DJANGO_DATABASE_URL` (parsed by **dj-database-url**), defaulting to `sqlite:///db.sqlite3` for dev; production is intended to point at PostgreSQL via that URL. Don't hardcode DB settings — go through the env var. + +## Planned architecture + +`pyproject.toml`'s isort `known-first-party` list is the intended app decomposition — treat it as the roadmap when adding domain code: +`accounts`, `club`, `members`, `teams`, `events`, `news`, `pages`, `home`, `search`. + +Domain notes (drive modeling decisions): +- **Season** is the central organizing concept. Team rosters, events, and attendance are season-scoped — model them with a FK to a season, not as global state. +- A **Member** can play on one or more **Teams**, each with a position + jersey number, always tied to a specific season. +- Three access tiers, implemented via Django groups/permissions: public site / members + parents / coaches + team managers. + +## Conventions + +- Ruff config anticipates a Wagtail-style codebase (`DJ` Django rules; `RUF012`/`RUF005` ignored for framework idioms; `line-length = 250`). Migrations are excluded from linting — don't hand-edit them to satisfy ruff. +- Settings files are exempt from `F403/F405/E501` (star imports allowed) under `clubmanager/settings/*` — note the config expects a settings *package*, though the current code is a single `settings.py`. If you split settings, match that path. diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..a31abb8 --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,61 @@ +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 + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + add_form = UserCreationForm + form = UserChangeForm + model = User + + list_display = ("email", "is_staff", "is_superuser", "is_active") + list_filter = ("is_staff", "is_superuser", "is_active", "groups") + search_fields = ("email",) + ordering = ("email",) + + fieldsets = ( + (None, {"fields": ("email", "password")}), + ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}), + ("Important dates", {"fields": ("last_login", "date_joined")}), + ) + add_fieldsets = ( + (None, { + "classes": ("wide",), + "fields": ("email", "usable_password", "password1", "password2"), + }), + ) + + +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") + 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",) + search_fields = ("first_name", "last_name", "email", "license_number") + autocomplete_fields = ("user", "family") + inlines = (GuardianshipInline,) + + +@admin.register(Family) +class FamilyAdmin(admin.ModelAdmin): + list_display = ("name", "address") + search_fields = ("name",) + inlines = (MemberInline,) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..0cb51e6 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..6bada4f --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,16 @@ +from django.contrib.auth.forms import BaseUserCreationForm +from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm + +from .models import User + + +class UserCreationForm(BaseUserCreationForm): + class Meta(BaseUserCreationForm.Meta): + model = User + fields = ("email",) + + +class UserChangeForm(BaseUserChangeForm): + class Meta(BaseUserChangeForm.Meta): + model = User + fields = "__all__" diff --git a/accounts/managers.py b/accounts/managers.py new file mode 100644 index 0000000..9f49068 --- /dev/null +++ b/accounts/managers.py @@ -0,0 +1,32 @@ +from django.contrib.auth.base_user import BaseUserManager + + +class UserManager(BaseUserManager): + """Manager for the email-based custom User model.""" + + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + if not email: + raise ValueError("Users must have an email address.") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..1b18bf5 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# Generated by Django 6.0.6 on 2026-07-01 22:04 + +import accounts.managers +import django.db.models.deletion +import django.utils.timezone +import phonenumber_field.modelfields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Family', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='e.g. "The Smiths"', max_length=150)), + ('address', models.CharField(blank=True, max_length=255)), + ], + options={ + 'verbose_name_plural': 'families', + '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=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(max_length=254, 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=django.utils.timezone.now)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', accounts.managers.UserManager()), + ], + ), + migrations.CreateModel( + name='Member', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=150)), + ('last_name', models.CharField(max_length=150)), + ('email', models.EmailField(blank=True, max_length=254)), + ('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None)), + ('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()), + ('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/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..7580b4e --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,140 @@ +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()})" diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..e0d85dc --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,87 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase + +from .models import Family, Guardianship, Member, User + + +class UserManagerTests(TestCase): + def test_create_user_with_email(self): + user = User.objects.create_user(email="Parent@Example.com", password="pw") + self.assertEqual(user.email, "Parent@example.com") # domain normalized + self.assertTrue(user.check_password("pw")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_superuser(self): + admin = User.objects.create_superuser(email="admin@example.com", password="pw") + self.assertTrue(admin.is_staff) + self.assertTrue(admin.is_superuser) + + def test_create_user_requires_email(self): + with self.assertRaises(ValueError): + User.objects.create_user(email="", password="pw") + + def test_create_superuser_rejects_non_superuser_flag(self): + with self.assertRaises(ValueError): + User.objects.create_superuser(email="a@b.com", password="pw", is_superuser=False) + + +class MemberTests(TestCase): + def test_member_without_login_or_optional_fields(self): + member = Member.objects.create( + first_name="Kid", + last_name="Smith", + date_of_birth="2015-05-01", + ) + self.assertIsNone(member.user) + self.assertEqual(member.email, "") + self.assertEqual(member.phone, "") + self.assertEqual(member.license_number, "") + + def test_valid_phone_accepted(self): + member = Member( + first_name="Ann", + last_name="Smith", + date_of_birth="1980-01-01", + phone="+32470123456", + ) + member.full_clean() # should not raise + + def test_invalid_phone_rejected(self): + member = Member( + first_name="Ann", + last_name="Smith", + date_of_birth="1980-01-01", + phone="not-a-number", + ) + with self.assertRaises(ValidationError): + member.full_clean() + + +class FamilyAndGuardianshipTests(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 + ) + self.child = Member.objects.create( + first_name="Kid", last_name="Smith", date_of_birth="2015-05-01", family=self.family + ) + + 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_no_self_guardianship(self): + with transaction.atomic(), self.assertRaises(IntegrityError): + Guardianship.objects.create(guardian=self.parent, child=self.parent) + + 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) diff --git a/clubmanager/settings.py b/clubmanager/settings.py index fb77a37..418b78f 100644 --- a/clubmanager/settings.py +++ b/clubmanager/settings.py @@ -43,8 +43,12 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "phonenumber_field", + "accounts", ] +AUTH_USER_MODEL = "accounts.User" + MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -123,3 +127,9 @@ STATICFILES_DIRS = [BASE_DIR / "static"] MEDIA_URL = "media/" MEDIA_ROOT = BASE_DIR / "media" + + +# Phone numbers (django-phonenumber-field) + +PHONENUMBER_DEFAULT_REGION = "BE" +PHONENUMBER_DB_FORMAT = "E164" diff --git a/pyproject.toml b/pyproject.toml index f8070a7..42d664f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ requires-python = ">=3.14" dependencies = [ "dj-database-url>=3.1.2", "django>=6.0.6", + "django-phonenumber-field[phonenumbers]>=8.4.0", "python-decouple>=3.8", ] diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock index a17813d..30d8bbc 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,7 @@ source = { virtual = "." } dependencies = [ { name = "dj-database-url" }, { name = "django" }, + { name = "django-phonenumber-field", extra = ["phonenumbers"] }, { name = "python-decouple" }, ] @@ -30,6 +31,7 @@ dev = [ requires-dist = [ { name = "dj-database-url", specifier = ">=3.1.2" }, { name = "django", specifier = ">=6.0.6" }, + { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, { name = "python-decouple", specifier = ">=3.8" }, ] @@ -62,6 +64,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, ] +[[package]] +name = "django-phonenumber-field" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/bf/8aa60c9834773b955dff1ddea842e361e8daaf6b0945d5bfc29fc66d53ab/django_phonenumber_field-8.4.0.tar.gz", hash = "sha256:2b83e843dac35eec6a69880a166487235b737a71a1e38c9a52e5ad67d6996083", size = 45512, upload-time = "2025-11-24T15:09:51.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/a3/f6b85a9246e22cf719752ad83f5e95aa9ba12419b2f9eff70f20d30e55df/django_phonenumber_field-8.4.0-py3-none-any.whl", hash = "sha256:7a1cb3a6456edb54d879f11ffa0acb227ded08c93b587035d0f28093f0e46511", size = 69528, upload-time = "2025-11-24T15:09:45.479Z" }, +] + +[package.optional-dependencies] +phonenumbers = [ + { name = "phonenumbers" }, +] + +[[package]] +name = "phonenumbers" +version = "9.0.33" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/37/dfc4cf24169f1a7169ebaedaf896c818f0add8603409d1e748e3085ccdc0/phonenumbers-9.0.33.tar.gz", hash = "sha256:9ab8a02b940b90c64f3866c0b25a30e567ddf7bb9836a3e11efdb0478f65fc1c", size = 2306756, upload-time = "2026-06-22T10:23:33.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/29/f7e30e3dbd3c7e3d9c4a55006112c04ee62b4765a31f21bcc28c253ac3f1/phonenumbers-9.0.33-py2.py3-none-any.whl", hash = "sha256:ba1d0da52711d5fdda6b2b673b2621fe80774fc5d1b2e5a6ef783396b0343186", size = 2595422, upload-time = "2026-06-22T10:23:29.925Z" }, +] + [[package]] name = "python-decouple" version = "3.8"