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:
20
events/migrations/0008_event_created_by.py
Normal file
20
events/migrations/0008_event_created_by.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user