feat(events): event owner and cross-club validation

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:42:17 +02:00
parent c0816a1add
commit 22d971d48c
4 changed files with 91 additions and 3 deletions

View File

@@ -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'),
),
]

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)