From 22d971d48c4edced8752f8158e375db54e78723a Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 13 Jul 2026 14:42:17 +0200 Subject: [PATCH] feat(events): event owner and cross-club validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Event.created_by (the owner, used later by the access service to let an event's creator edit it). Validate that an event's season/location/opponent — and an EventSeries' location/opponent — belong to the event's club. The teams M2M cannot be checked in clean() (M2M rows are written after save), so an m2m_changed pre_add receiver rejects teams from another club. Co-Authored-By: Claude Opus 4.8 --- events/migrations/0008_event_created_by.py | 20 +++++++++ events/models.py | 9 +++- events/signals.py | 17 +++++++- events/tests.py | 48 ++++++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 events/migrations/0008_event_created_by.py diff --git a/events/migrations/0008_event_created_by.py b/events/migrations/0008_event_created_by.py new file mode 100644 index 0000000..a71a4d7 --- /dev/null +++ b/events/migrations/0008_event_created_by.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.6 on 2026-07-12 21:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0007_event_series_until'), + ('members', '0002_alter_familymembership_unique_together_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to='members.member', verbose_name='created by'), + ), + ] diff --git a/events/models.py b/events/models.py index 69e0c68..c8579c4 100644 --- a/events/models.py +++ b/events/models.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from club.models import Season -from clubmanager.base import ClubScopedModel, UUIDModel +from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope from members.models import Member from teams.models import Team @@ -64,6 +64,7 @@ class Event(ClubScopedModel): location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("location")) opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("opponent")) + created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="created_events", null=True, blank=True, verbose_name=_("created by")) class Meta: verbose_name = _("event") @@ -73,6 +74,9 @@ class Event(ClubScopedModel): def __str__(self): return self.title + def clean(self): + validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent")) + class EventSeries(ClubScopedModel): """A recurring event definition that materialises concrete Event rows.""" @@ -103,6 +107,9 @@ class EventSeries(ClubScopedModel): def __str__(self): return self.title + def clean(self): + validate_club_scope(self, self.club_id, same_club_fields=("location", "opponent")) + class Attendance(UUIDModel): class AttendanceStatus(models.TextChoices): diff --git a/events/signals.py b/events/signals.py index 86a3293..ab3b015 100644 --- a/events/signals.py +++ b/events/signals.py @@ -8,17 +8,30 @@ Registered from ``EventsConfig.ready``. Two triggers: events. """ +from django.core.exceptions import ValidationError from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver from django.utils import timezone +from django.utils.translation import gettext_lazy as _ -from events.models import Event +from events.models import Event, EventSeries from events.services import sync_event_attendances -from teams.models import TeamMembership +from teams.models import Team, TeamMembership M2M_SYNC_ACTIONS = {"post_add", "post_remove", "post_clear"} +@receiver(m2m_changed, sender=Event.teams.through) +@receiver(m2m_changed, sender=EventSeries.teams.through) +def validate_teams_same_club(sender, instance, action, reverse, pk_set, **kwargs): + # Reject teams from another club before they're attached (forward adds only; + # the reverse direction — team.scheduled_events.add(...) — is not a used path). + if action != "pre_add" or reverse: + return + if Team.objects.filter(pk__in=pk_set).exclude(club_id=instance.club_id).exists(): + raise ValidationError(_("Teams must belong to the same club as the event.")) + + @receiver(post_save, sender=Event) def sync_on_event_save(sender, instance, **kwargs): sync_event_attendances(instance) diff --git a/events/tests.py b/events/tests.py index 8af833a..fcd4c4e 100644 --- a/events/tests.py +++ b/events/tests.py @@ -1,6 +1,7 @@ from datetime import timedelta from io import StringIO +from django.core.exceptions import ValidationError from django.core.management import call_command from django.db import IntegrityError from django.test import TestCase @@ -353,3 +354,50 @@ class ExtendSeriesCommandTests(RecurrenceTestBase): self.assertEqual(series.occurrences.count(), 4) self.assertIn("Done.", out.getvalue()) + + +class EventClubScopeTests(EventsTestBase): + def setUp(self): + super().setUp() + self.other = Club.objects.create(name="Rival FC", slug="rival-fc") + today = timezone.localdate() + self.other_season = Season.objects.create(club=self.other, start_date=today - timedelta(days=30), end_date=today + timedelta(days=300)) + self.other_location = Location.objects.create(club=self.other, name="Arena", address="1 St", city="Town", zip_code="1000", country="BE") + self.other_opponent = Opponent.objects.create(club=self.other, name="Rivals") + self.other_team = Team.objects.create(club=self.other, name="First", short_name="1") + + def test_event_rejects_cross_club_season(self): + event = Event(club=self.club, title="Match", start=self.future, season=self.other_season) + with self.assertRaises(ValidationError) as ctx: + event.full_clean() + self.assertIn("season", ctx.exception.error_dict) + + def test_event_rejects_cross_club_location(self): + event = Event(club=self.club, title="Match", start=self.future, location=self.other_location) + with self.assertRaises(ValidationError) as ctx: + event.full_clean() + self.assertIn("location", ctx.exception.error_dict) + + def test_event_accepts_same_club_fields(self): + Event(club=self.club, title="Match", start=self.future, season=self.season).full_clean() + + def test_event_rejects_cross_club_team(self): + event = Event.objects.create(club=self.club, title="Match", start=self.future, season=self.season) + with self.assertRaises(ValidationError): + event.teams.add(self.other_team) + + def test_event_accepts_same_club_team(self): + event = Event.objects.create(club=self.club, title="Match", start=self.future, season=self.season) + event.teams.add(self.team) + self.assertIn(self.team, event.teams.all()) + + def test_series_rejects_cross_club_opponent(self): + series = EventSeries(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future, opponent=self.other_opponent) + with self.assertRaises(ValidationError) as ctx: + series.full_clean() + self.assertIn("opponent", ctx.exception.error_dict) + + def test_series_rejects_cross_club_team(self): + series = EventSeries.objects.create(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future) + with self.assertRaises(ValidationError): + series.teams.add(self.other_team)