diff --git a/.env.production.example b/.env.production.example index b9cbd2d..67c7466 100644 --- a/.env.production.example +++ b/.env.production.example @@ -48,3 +48,14 @@ DJANGO_EMAIL_USE_TLS=True DJANGO_DEFAULT_FROM_EMAIL=RosterChief # Where a club is told to reply with a billing question. ROSTERCHIEF_BILLING_CONTACT_EMAIL=billing@rosterchief.app + +# --- Email via Resend (resend.com), as an alternative to the SMTP block above --- +# Two ways to use Resend; pick one, don't set both: +# 1. Resend's SMTP relay -- no code involved, just point the SMTP settings above at it: +# DJANGO_EMAIL_HOST=smtp.resend.com +# DJANGO_EMAIL_HOST_USER=resend +# DJANGO_EMAIL_HOST_PASSWORD= +# 2. Resend's HTTP API via rosterchief.mail.ResendEmailBackend (see that module): +# DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend +# RESEND_API_KEY= +RESEND_API_KEY= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 68e0130..d875e0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -541,6 +541,34 @@ row today — there's no check-in UI yet, only Django admin); a "no-show" is a missing check-in. See `events/services/attendance.py::record_check_in` and `management/views.py::TeamDetailView`'s attendance panel. +**As built, an `Event`'s (and `EventSeries`') audience is teams + groups + invited/excluded +members, or the whole club** — `teams` (existing) sits alongside `groups` +(`M2M members.Group`, blank) and `club_wide` (`BooleanField`, default `False`). All three +feed `events/services/attendance.py::effective_members`: teams contribute their +season-scoped roster, groups contribute *every current* `GroupMembership` (Group has no +season concept, unlike Team, so this isn't season-scoped the way a team roster is), and +`invited_members`/`excluded_members` still layer on top/subtract as before — **unless** +`club_wide` is set, which replaces teams+groups with every member holding an ACTIVE +`ClubMembership` for the event's season instead (`invited_members`/`excluded_members` still +apply on top of that too). `club_wide` can't be combined with `teams`/`groups` — enforced in +`EventForm`/`EventSeriesForm.clean()` (`clean_club_wide_excludes_teams_and_groups`), not the +DB (an M2M can't be checked from a `CheckConstraint`, and can't be validated in `Event.clean()` +either — M2M state isn't available until the row has a PK). `events/signals.py` re-syncs a +group's future events on `GroupMembership` change, and a club_wide event's future rows on +`ClubMembership` change, the same way a `TeamMembership` change already re-syncs team events. +- **Authorization**: `club.services.access.groups_manageable_by(user, club)` mirrors + `teams_managed_by` — every group for an ADMIN, else only the ones the user is themselves a + `GroupMembership` of (Group has no manager/owner concept the way Team does via + `StaffAssignment`, so membership is the only claim there is). A non-admin must select at + least one team they manage **or** group they belong to when creating/editing an event + (`EventAudienceFormMixin.clean_audience_requires_a_claim_for_non_admins`) — `club_wide` + itself stays admin-only (the field is removed from the form entirely for anyone else, not + just rejected on submit). `club.mixins.EventManagerRequiredMixin` (gates edit/delete/detach/ + etc. on an *existing* event/series) checks the same two claims via `get_teams()`/ + `get_groups()` (the latter defaults to none, so a view that never deals with groups doesn't + need to override it) — without this, a non-admin who created a group-only event via the + broader create-time gate would immediately be locked out of managing what they just made. + **As built, a GAME-kind `Event` defaults its own `end`** — `Event.save()` sets `end = start + events.models.ASSUMED_EVENT_DURATION` (2 hours) whenever a game is saved with no explicit `end`, and never overwrites one that's already set. Other event kinds are @@ -1105,6 +1133,22 @@ Setup: `stripe`) + webhook endpoint that creates/confirms `Payment`s (§5.7). - **Excel export** for form reporting beyond CSV: `openpyxl`. +### 8.5 Email — SMTP or Resend *(built)* + +Every Django-sent email — allauth's password reset, `send_billing_reminders`, anything else +that goes through `django.core.mail` — follows whichever `EMAIL_BACKEND` is configured; no +per-feature wiring. Console backend by default (§ "Email" in `settings.py`), so a deployment +that forgets to configure mail prints to the log instead of raising against `localhost:25`. + +Two ways to use **Resend** (resend.com), no third-party SDK either way: +- **SMTP relay, zero code**: point the stock `django.core.mail.backends.smtp.EmailBackend` at + `smtp.resend.com` with `resend` as the username and the API key as the password. +- **HTTP API**: `rosterchief.mail.ResendEmailBackend` (`DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend`, + `RESEND_API_KEY=…`) posts each message straight to Resend's `/emails` endpoint via `requests` + (already a dependency, so no new one needed for this). Handles plain text, the HTML + alternative on an `EmailMultiAlternatives`, cc/bcc/reply-to, and base64-encoded attachments; + `fail_silently` is honoured the same way Django's own backends honour it. + --- *Conventions cross-reference:* `rosterchief/base.py` (`UUIDModel`, `ClubScopedModel`), diff --git a/club/mixins.py b/club/mixins.py index 6e83a16..ce7b11b 100644 --- a/club/mixins.py +++ b/club/mixins.py @@ -2,7 +2,9 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.http import Http404 from waffle import flag_is_active -from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, is_coach_manager, teams_managed_by +from members.models import Group + +from .services.access import can_add_news, can_edit_news, can_publish_news, groups_manageable_by, has_management_access, is_club_admin, is_coach_manager, teams_managed_by class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): @@ -74,20 +76,31 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin): class EventManagerRequiredMixin(ClubStaffRequiredMixin): - """Admin, or a manager of at least one of this event's/series' *current* - teams. ``self.get_teams()`` must return the Team queryset/iterable the - view acts on (e.g. ``self.get_object().teams.all()``) before ``test_func`` - runs. Events/series aren't single-team like a roster entry -- ``teams`` is - M2M, so authority is "manages at least one", not "manages the one".""" + """Admin, a manager of at least one of this event's/series' *current* + teams, or a member of at least one of its groups. ``self.get_teams()`` + must return the Team queryset/iterable the view acts on (e.g. + ``self.get_object().teams.all()``) before ``test_func`` runs; override + ``get_groups()`` the same way for a view whose object can carry groups + (it defaults to none, so most subclasses only need get_teams()). Events/ + series aren't single-team/-group like a roster entry -- both are M2M, so + authority is "belongs to at least one", not "belongs to the one". A + club_wide event has no equivalent membership claim to check -- it's + admin-only to create in the first place (EventForm), so the plain + is_club_admin check below already covers it.""" def get_teams(self): raise NotImplementedError("Subclasses must return the Teams this view acts on.") + def get_groups(self): + return Group.objects.none() + def test_func(self): user, club = self.request.user, self.request.club if is_club_admin(user, club): return True - return teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists() + if teams_managed_by(user, club).filter(pk__in=self.get_teams().values_list("pk", flat=True)).exists(): + return True + return groups_manageable_by(user, club).filter(pk__in=self.get_groups().values_list("pk", flat=True)).exists() class ManagementPositionRequiredMixin(ClubStaffRequiredMixin): diff --git a/club/services/access.py b/club/services/access.py index c5e68b6..403f0d8 100644 --- a/club/services/access.py +++ b/club/services/access.py @@ -22,7 +22,7 @@ from django.utils import timezone from authentication.models import User from club.models import Club, ClubRole, Season from events.models import Event -from members.models import FamilyMembership, Member +from members.models import FamilyMembership, Group, Member from teams.models import StaffAssignment, Team #: Derived (never stored) roles. @@ -92,6 +92,15 @@ def teams_managed_by(user: User, club: Club) -> QuerySet[Team]: ).distinct() +def groups_manageable_by(user: User, club: Club) -> QuerySet[Group]: + """Groups the user may schedule an event for: all for an ADMIN, else only + the ones they're themselves a member of -- unlike Team, Group has no + manager/owner concept, so membership is the only claim there is to check.""" + if is_club_admin(user, club): + return Group.objects.filter(club=club) + return Group.objects.filter(club=club, memberships__member__user=user).distinct() + + def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]: """Teams the user is on the staff of this season, management or not. diff --git a/events/admin.py b/events/admin.py index 855ab21..3ca06c1 100644 --- a/events/admin.py +++ b/events/admin.py @@ -36,12 +36,12 @@ class EventSeriesAdmin(admin.ModelAdmin): list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"] list_filter = ["kind", "club"] search_fields = ["title"] - autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"] + autocomplete_fields = ["location", "opponent", "teams", "groups", "invited_members", "excluded_members"] fieldsets = [ [None, {"fields": ["title", "kind"]}], [_("Recurrence"), {"fields": ["rrule", "dtstart", "until", "excluded_dates", "generated_until"]}], [_("Timing"), {"fields": ["duration", "gathering_offset", "deadline_offset"]}], - [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], + [_("Audience"), {"fields": ["teams", "groups", "club_wide", "invited_members", "excluded_members"]}], [_("Where"), {"fields": ["location", "opponent"]}], ] @@ -56,7 +56,7 @@ class EventAdminForm(forms.ModelForm): class Meta: model = Event fields = [ - "title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members", + "title", "kind", "season", "series", "detached", "cancelled", "teams", "groups", "club_wide", "invited_members", "excluded_members", "start", "end", "gathering", "deadline", "location", "opponent", "competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees", ] @@ -82,12 +82,12 @@ class EventAdmin(admin.ModelAdmin): list_filter = ["kind", "club", "teams", "detached", "cancelled"] search_fields = ["title"] date_hierarchy = "start" - autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"] + autocomplete_fields = ["season", "series", "location", "opponent", "teams", "groups", "invited_members", "excluded_members"] inlines = [AttendanceInline, EventRefereeInline] fieldsets = [ [None, {"fields": ["title", "kind", "season"]}], [_("Series"), {"fields": ["series", "detached", "cancelled"]}], - [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], + [_("Audience"), {"fields": ["teams", "groups", "club_wide", "invited_members", "excluded_members"]}], [_("When"), {"fields": ["start", "end", "gathering", "deadline"]}], [_("Where"), {"fields": ["location", "opponent"]}], [_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]}], diff --git a/events/migrations/0022_event_club_wide_event_groups_eventseries_club_wide_and_more.py b/events/migrations/0022_event_club_wide_event_groups_eventseries_club_wide_and_more.py new file mode 100644 index 0000000..11b899d --- /dev/null +++ b/events/migrations/0022_event_club_wide_event_groups_eventseries_club_wide_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.6 on 2026-08-11 09:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0021_eventreferee_external_name_eventreferee_fee_and_more'), + ('members', '0004_group_groupmembership_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='club_wide', + field=models.BooleanField(default=False, help_text="Invite every active club member for this event's season, instead of specific teams/groups. Can't be combined with teams or groups.", verbose_name='whole club'), + ), + migrations.AddField( + model_name='event', + name='groups', + field=models.ManyToManyField(blank=True, help_text='Invite every current member of these groups.', related_name='scheduled_events', to='members.group', verbose_name='groups'), + ), + migrations.AddField( + model_name='eventseries', + name='club_wide', + field=models.BooleanField(default=False, help_text="Invite every active club member for each occurrence's season, instead of specific teams/groups. Can't be combined with teams or groups.", verbose_name='whole club'), + ), + migrations.AddField( + model_name='eventseries', + name='groups', + field=models.ManyToManyField(blank=True, help_text='Invite every current member of these groups.', related_name='event_series', to='members.group', verbose_name='groups'), + ), + ] diff --git a/events/models.py b/events/models.py index b18f987..7cedf5a 100644 --- a/events/models.py +++ b/events/models.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField from club.models import Club, Season -from members.models import Member +from members.models import Group, Member from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope from teams.models import Team @@ -69,6 +69,8 @@ class Event(ClubScopedModel): cancelled = models.BooleanField(_("cancelled"), default=False) teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams")) + groups = models.ManyToManyField(Group, related_name="scheduled_events", blank=True, verbose_name=_("groups"), help_text=_("Invite every current member of these groups.")) + club_wide = models.BooleanField(_("whole club"), default=False, help_text=_("Invite every active club member for this event's season, instead of specific teams/groups. Can't be combined with teams or groups.")) invited_members = models.ManyToManyField(Member, related_name="invited_to_events", blank=True, verbose_name=_("invited members")) excluded_members = models.ManyToManyField(Member, related_name="excluded_from_events", blank=True, verbose_name=_("excluded members")) season = models.ForeignKey(Season, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("season"), help_text=_("Season whose team rosters define the audience; derived from the start date when left blank.")) @@ -138,6 +140,8 @@ class EventSeries(ClubScopedModel): location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("location")) opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("opponent")) teams = models.ManyToManyField(Team, related_name="event_series", blank=True, verbose_name=_("teams")) + groups = models.ManyToManyField(Group, related_name="event_series", blank=True, verbose_name=_("groups"), help_text=_("Invite every current member of these groups.")) + club_wide = models.BooleanField(_("whole club"), default=False, help_text=_("Invite every active club member for each occurrence's season, instead of specific teams/groups. Can't be combined with teams or groups.")) invited_members = models.ManyToManyField(Member, related_name="invited_to_event_series", blank=True, verbose_name=_("invited members")) excluded_members = models.ManyToManyField(Member, related_name="excluded_from_event_series", blank=True, verbose_name=_("excluded members")) diff --git a/events/services/attendance.py b/events/services/attendance.py index 03383a8..b527d72 100644 --- a/events/services/attendance.py +++ b/events/services/attendance.py @@ -1,15 +1,18 @@ """Keep an event's attendance rows in sync with its effective audience. The audience of an event is the union of the current rosters of its ``teams`` -(for the event's season) plus any individually ``invited_members``, minus any -``excluded_members``. Attendance rows are reconciled against that set, but only -for events that are still in the future — history is never rewritten. +(for the event's season) and the current members of its ``groups``, plus any +individually ``invited_members``, minus any ``excluded_members`` -- or, for a +``club_wide`` event, every member with an ACTIVE ClubMembership for the +event's season instead of teams/groups (the two are mutually exclusive, see +EventForm/EventSeriesForm). Attendance rows are reconciled against that set, +but only for events that are still in the future — history is never rewritten. """ from django.db.models import Count, Q from django.utils import timezone -from club.models import Season +from club.models import ClubMembership, Season from events.models import Attendance, Event from members.models import Member from teams.models import TeamMembership @@ -24,13 +27,22 @@ def resolve_season(event): def effective_members(event): """Return the ``Member`` queryset invited to ``event``.""" - member_ids: set = set() - season = resolve_season(event) - if season is not None: - team_ids = list(event.teams.values_list("id", flat=True)) - if team_ids: - member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True)) + + if event.club_wide: + member_ids = set() + if season is not None: + member_ids.update(ClubMembership.objects.filter(club=event.club, season=season, status=ClubMembership.StatusChoices.ACTIVE).values_list("member_id", flat=True)) + else: + member_ids = set() + if season is not None: + team_ids = list(event.teams.values_list("id", flat=True)) + if team_ids: + member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True)) + + group_ids = list(event.groups.values_list("id", flat=True)) + if group_ids: + member_ids.update(Member.objects.filter(group_memberships__group_id__in=group_ids).values_list("id", flat=True)) member_ids.update(event.invited_members.values_list("id", flat=True)) member_ids.difference_update(event.excluded_members.values_list("id", flat=True)) diff --git a/events/services/recurrence.py b/events/services/recurrence.py index e54973e..4cb9d85 100644 --- a/events/services/recurrence.py +++ b/events/services/recurrence.py @@ -52,12 +52,14 @@ def apply_template(series, event): event.title = series.title event.location = series.location event.opponent = series.opponent + event.club_wide = series.club_wide event.end = event.start + series.duration if series.duration is not None else None event.gathering = event.start - series.gathering_offset if series.gathering_offset is not None else None event.deadline = event.start - series.deadline_offset if series.deadline_offset is not None else None event.save() event.teams.set(series.teams.all()) + event.groups.set(series.groups.all()) event.invited_members.set(series.invited_members.all()) event.excluded_members.set(series.excluded_members.all()) diff --git a/events/signals.py b/events/signals.py index ab3b015..05e5468 100644 --- a/events/signals.py +++ b/events/signals.py @@ -1,11 +1,15 @@ """Signal wiring that keeps attendance in sync with the event audience. -Registered from ``EventsConfig.ready``. Two triggers: +Registered from ``EventsConfig.ready``. Triggers: -* editing an event (its ``start``, or its ``teams`` / ``invited_members`` / - ``excluded_members``) re-syncs that event; +* editing an event (its ``start``, or its ``teams`` / ``groups`` / + ``invited_members`` / ``excluded_members``) re-syncs that event; * adding or removing a member from a team roster re-syncs that team's future - events. + events; +* adding or removing a member from a group re-syncs that group's future + events; +* a club membership going active/inactive re-syncs every future club_wide + event for that club. """ from django.core.exceptions import ValidationError @@ -14,8 +18,10 @@ from django.dispatch import receiver from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from club.models import ClubMembership from events.models import Event, EventSeries from events.services import sync_event_attendances +from members.models import Group, GroupMembership from teams.models import Team, TeamMembership M2M_SYNC_ACTIONS = {"post_add", "post_remove", "post_clear"} @@ -32,12 +38,22 @@ def validate_teams_same_club(sender, instance, action, reverse, pk_set, **kwargs raise ValidationError(_("Teams must belong to the same club as the event.")) +@receiver(m2m_changed, sender=Event.groups.through) +@receiver(m2m_changed, sender=EventSeries.groups.through) +def validate_groups_same_club(sender, instance, action, reverse, pk_set, **kwargs): + if action != "pre_add" or reverse: + return + if Group.objects.filter(pk__in=pk_set).exclude(club_id=instance.club_id).exists(): + raise ValidationError(_("Groups 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) @receiver(m2m_changed, sender=Event.teams.through) +@receiver(m2m_changed, sender=Event.groups.through) @receiver(m2m_changed, sender=Event.invited_members.through) @receiver(m2m_changed, sender=Event.excluded_members.through) def sync_on_audience_change(sender, instance, action, reverse, model, pk_set, **kwargs): @@ -58,3 +74,19 @@ def sync_on_roster_change(sender, instance, **kwargs): now = timezone.now() for event in Event.objects.filter(teams=instance.team_id, start__gte=now).distinct(): sync_event_attendances(event) + + +@receiver(post_save, sender=GroupMembership) +@receiver(post_delete, sender=GroupMembership) +def sync_on_group_membership_change(sender, instance, **kwargs): + now = timezone.now() + for event in Event.objects.filter(groups=instance.group_id, start__gte=now).distinct(): + sync_event_attendances(event) + + +@receiver(post_save, sender=ClubMembership) +@receiver(post_delete, sender=ClubMembership) +def sync_on_club_membership_change(sender, instance, **kwargs): + now = timezone.now() + for event in Event.objects.filter(club_wide=True, club_id=instance.club_id, start__gte=now).distinct(): + sync_event_attendances(event) diff --git a/events/tests.py b/events/tests.py index 867a8c1..15152ee 100644 --- a/events/tests.py +++ b/events/tests.py @@ -9,8 +9,8 @@ from django.test import TestCase from django.utils import timezone from waffle import get_waffle_flag_model -from club.models import Club, Season -from members.models import Member +from club.models import Club, ClubMembership, Season +from members.models import Group, GroupMembership, Member from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership from .admin import EventAdminForm @@ -202,6 +202,55 @@ class EffectiveMembersTests(EventsTestBase): self.assertEqual(event.attendances.count(), 0) + def test_group_members_are_included(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + group = Group.objects.create(club=self.club, name="Committee") + GroupMembership.objects.create(group=group, member=carol) + event = self.make_event() + event.groups.set([group]) + + self.assertEqual(self.attendee_ids(event), {carol.id}) + + def test_teams_and_groups_combine(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + group = Group.objects.create(club=self.club, name="Committee") + GroupMembership.objects.create(group=group, member=carol) + event = self.make_event() + event.teams.set([self.team]) + event.groups.set([group]) + + self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id, carol.id}) + + def test_club_wide_includes_every_active_member_this_season(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + lapsed = Member.objects.create(first_name="Len", last_name="Lapsed") + ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED) + event = self.make_event(club_wide=True) + + self.assertEqual(self.attendee_ids(event), {carol.id}) + + def test_club_wide_ignores_teams_and_groups(self): + # Teams/groups can be left populated on the row without affecting the + # audience -- validation (EventForm) is what keeps editors from doing + # this in the UI, but effective_members itself just ignores them. + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + event = self.make_event(club_wide=True) + event.teams.set([self.team]) + + self.assertEqual(self.attendee_ids(event), {carol.id}) + + def test_club_wide_still_honours_invited_and_excluded(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + dave = Member.objects.create(first_name="Dave", last_name="Dogwood") + event = self.make_event(club_wide=True) + event.invited_members.set([dave]) + event.excluded_members.set([carol]) + + self.assertEqual(self.attendee_ids(event), {dave.id}) + class AttendanceSyncTests(EventsTestBase): def test_setting_teams_creates_attendance_for_roster(self): @@ -267,6 +316,45 @@ class RosterChangeSyncTests(EventsTestBase): self.assertNotIn(self.alice.id, self.attendee_ids(event)) + def test_adding_a_group_member_syncs_future_events(self): + group = Group.objects.create(club=self.club, name="Committee") + event = self.make_event() + event.groups.set([group]) + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + + GroupMembership.objects.create(group=group, member=carol) + + self.assertIn(carol.id, self.attendee_ids(event)) + + def test_removing_a_group_member_syncs_future_events(self): + group = Group.objects.create(club=self.club, name="Committee") + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + membership = GroupMembership.objects.create(group=group, member=carol) + event = self.make_event() + event.groups.set([group]) + + membership.delete() + + self.assertNotIn(carol.id, self.attendee_ids(event)) + + def test_a_new_active_club_membership_syncs_future_club_wide_events(self): + event = self.make_event(club_wide=True) + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + + ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + self.assertIn(carol.id, self.attendee_ids(event)) + + def test_a_club_membership_lapsing_syncs_future_club_wide_events(self): + carol = Member.objects.create(first_name="Carol", last_name="Cedar") + membership = ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + event = self.make_event(club_wide=True) + + membership.status = ClubMembership.StatusChoices.LAPSED + membership.save() + + self.assertNotIn(carol.id, self.attendee_ids(event)) + def test_roster_change_leaves_past_events_untouched(self): past = self.make_event(start=timezone.now() - timedelta(days=1)) past.teams.add(self.team) diff --git a/management/forms.py b/management/forms.py index 0f33578..53b6b18 100644 --- a/management/forms.py +++ b/management/forms.py @@ -7,7 +7,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from club.models import ClubMembership, ClubRole, FeePayment, Sponsor -from club.services.access import is_club_admin, teams_managed_by +from club.services.access import groups_manageable_by, is_club_admin, teams_managed_by from events.models import Competition, Event, EventReferee, EventSeries, Location, Opponent from events.services.rbihf_import import RBIHFImportError, extract_team_id from members.models import Family, FamilyMembership, Group, Member @@ -240,29 +240,47 @@ def _location_label(location) -> str: class EventAudienceFormMixin: """Shared club/user-scoped audience fields for EventForm and EventSeriesForm: teams restricted to the ones the requester manages (all of them for an - admin), and a non-admin must pick at least one -- a team-less/club-wide - event (e.g. an AGM) has no team-manager claim to anchor it to, so that's - admin-only.""" + admin), groups restricted to the ones the requester belongs to (all of them + for an admin -- Group has no manager/owner concept the way Team does, so + membership is the only claim there is), and a non-admin must pick at least + one team or group -- a club-wide event (e.g. an AGM) has no such claim to + anchor it to, so `club_wide` stays admin-only (the field doesn't even exist + on the form otherwise).""" def scope_audience_fields(self, club, user): self.club = club self.user = user - self.fields["teams"].queryset = Team.objects.filter(club=club) if is_club_admin(user, club) else teams_managed_by(user, club) + self.fields["teams"].queryset = teams_managed_by(user, club) + self.fields["groups"].queryset = groups_manageable_by(user, club) self.fields["location"].queryset = Location.objects.filter(club=club) self.fields["location"].label_from_instance = _location_label self.fields["opponent"].queryset = Opponent.objects.filter(club=club) members = Member.objects.filter(member_of__club=club).distinct() self.fields["invited_members"].queryset = members self.fields["excluded_members"].queryset = members + if not is_club_admin(user, club): + del self.fields["club_wide"] - def clean_teams_requires_one_for_non_admins(self, cleaned): + def clean_audience_requires_a_claim_for_non_admins(self, cleaned): + if is_club_admin(self.user, self.club): + return teams = cleaned.get("teams") - if teams is not None and not teams.exists() and not is_club_admin(self.user, self.club): - self.add_error("teams", _("Select at least one of your teams, or ask an admin to create a club-wide event.")) + groups = cleaned.get("groups") + if not (teams is not None and teams.exists()) and not (groups is not None and groups.exists()): + self.add_error(None, _("Select at least one of your teams or groups, or ask an admin to create a club-wide event.")) + + def clean_club_wide_excludes_teams_and_groups(self, cleaned): + if not cleaned.get("club_wide"): + return + teams = cleaned.get("teams") + groups = cleaned.get("groups") + if (teams is not None and teams.exists()) or (groups is not None and groups.exists()): + self.add_error("club_wide", _("A whole-club event can't also list specific teams or groups -- clear them, or turn this off.")) _AUDIENCE_WIDGETS = { "teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}), + "groups": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a group to search...")}), "invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}), "excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}), "location": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name or city to search...")}), @@ -272,7 +290,27 @@ _AUDIENCE_WIDGETS = { class EventForm(EventAudienceFormMixin, forms.ModelForm): class Meta: model = Event - fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"] + fields = [ + "title", + "kind", + "teams", + "groups", + "club_wide", + "invited_members", + "excluded_members", + "location", + "opponent", + "start", + "end", + "gathering", + "deadline", + "competition", + "external_game_id", + "score_for", + "score_against", + "is_live", + "max_referees", + ] widgets = { "start": forms.DateTimeInput(attrs={"type": "datetime-local"}), "end": forms.DateTimeInput(attrs={"type": "datetime-local"}), @@ -308,7 +346,8 @@ class EventForm(EventAudienceFormMixin, forms.ModelForm): def clean(self): cleaned = super().clean() - self.clean_teams_requires_one_for_non_admins(cleaned) + self.clean_audience_requires_a_claim_for_non_admins(cleaned) + self.clean_club_wide_excludes_teams_and_groups(cleaned) return cleaned @@ -357,7 +396,7 @@ class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm): class Meta: model = EventSeries - fields = ["title", "kind", "dtstart", "until", "teams", "invited_members", "excluded_members", "location", "opponent"] + fields = ["title", "kind", "dtstart", "until", "teams", "groups", "club_wide", "invited_members", "excluded_members", "location", "opponent"] widgets = { "dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}), "until": forms.DateTimeInput(attrs={"type": "datetime-local"}), @@ -390,7 +429,8 @@ class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm): def clean(self): cleaned = super().clean() - self.clean_teams_requires_one_for_non_admins(cleaned) + self.clean_audience_requires_a_claim_for_non_admins(cleaned) + self.clean_club_wide_excludes_teams_and_groups(cleaned) if cleaned.get("advanced_rrule"): return cleaned # the advanced field wins outright; nothing else to check diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html index 83f90aa..650d98e 100644 --- a/management/templates/management/_nav_items.html +++ b/management/templates/management/_nav_items.html @@ -25,11 +25,11 @@
  • {% lucide "shirt" size=16 %} {% trans "Teams" %}
  • {% lucide "tags" size=16 %} {% trans "Positions" %}
  • -
  • {% lucide "flag" size=16 %} {% trans "Referees" %}
  • {% if is_club_admin %} -
  • {% lucide "calendar-check" size=16 %} {% trans "Referee management" %}
  • {% lucide "badge-check" size=16 %} {% trans "Referee levels" %}
  • +
  • {% lucide "calendar-check" size=16 %} {% trans "Referee management" %}
  • {% endif %} +
  • {% lucide "flag" size=16 %} {% trans "Referees" %}
  • {% lucide "newspaper" size=16 %} {% trans "News" %}
  • diff --git a/management/templates/management/event_detail.html b/management/templates/management/event_detail.html index 41d4457..a0cea2c 100644 --- a/management/templates/management/event_detail.html +++ b/management/templates/management/event_detail.html @@ -65,6 +65,16 @@
    {% trans "Teams" %}
    {% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
    +
    +
    {% trans "Groups" %}
    +
    {% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
    +
    + {% if event.club_wide %} +
    +
    {% trans "Audience" %}
    +
    {% trans "Whole club" %}
    +
    + {% endif %} diff --git a/management/templates/management/event_form.html b/management/templates/management/event_form.html index 79ce00a..4edbacc 100644 --- a/management/templates/management/event_form.html +++ b/management/templates/management/event_form.html @@ -24,6 +24,12 @@

    {% trans "Audience" %}

    {% form_field form.teams %} + {% form_field form.groups %} + {% if "club_wide" in form.fields %} + {% form_field form.club_wide %} + {% endif %} +
    +
    {% form_field form.invited_members %} {% form_field form.excluded_members %}
    diff --git a/management/templates/management/event_series_form.html b/management/templates/management/event_series_form.html index 60e9234..2a0b513 100644 --- a/management/templates/management/event_series_form.html +++ b/management/templates/management/event_series_form.html @@ -41,6 +41,12 @@

    {% trans "Audience" %}

    {% form_field form.teams %} + {% form_field form.groups %} + {% if "club_wide" in form.fields %} + {% form_field form.club_wide %} + {% endif %} +
    +
    {% form_field form.invited_members %} {% form_field form.excluded_members %}
    diff --git a/management/tests.py b/management/tests.py index 136018c..dd28c58 100644 --- a/management/tests.py +++ b/management/tests.py @@ -3484,6 +3484,19 @@ class EventManagementTests(ManagementTestBase): StaffAssignment.objects.create(team=self.own_team, member=staff_member, season=self.season, position=position) return staff_user + def make_group_member(self, group, email="committee-events@example.com"): + # Staff (so they can reach the management app at all -- ClubStaffRequiredMixin + # excludes a plain MEMBER-only club member entirely), but NOT a team manager of + # anything: a non-management StaffAssignment on an unrelated team, so + # teams_managed_by(this user) is empty and the group claim is what's really + # being tested, isolated from any team-manager claim. + member_user = User.objects.create_user(email=email, password="pw-secret-123") + member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group") + physio_position = Position.objects.create(club=self.club, name="Committee Physio", short_name="CP", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position) + GroupMembership.objects.create(group=group, member=member) + return member_user + def event_data(self, **overrides): data = { "title": "Training", @@ -3550,6 +3563,80 @@ class EventManagementTests(ManagementTestBase): self.assertContains(response, 'name="location"') self.assertContains(response, "data-searchable") + def test_a_group_member_can_create_an_event_for_their_own_group(self): + group = Group.objects.create(club=self.club, name="Committee") + self.client.force_login(self.make_group_member(group)) + + response = self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)])) + + event = Event.objects.get(title="Training") + self.assertRedirects(response, reverse("management:event_detail", args=[event.pk])) + self.assertIn(group, event.groups.all()) + + def test_a_group_member_cannot_pick_a_group_they_do_not_belong_to(self): + own_group = Group.objects.create(club=self.club, name="Committee") + other_group = Group.objects.create(club=self.club, name="Other Committee") + self.client.force_login(self.make_group_member(own_group)) + + response = self.club_post("event_create", self.event_data(teams=[], groups=[str(other_group.pk)])) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Event.objects.filter(title="Training").exists()) + + def test_a_non_admin_with_neither_team_nor_group_is_rejected(self): + self.client.force_login(self.make_group_member(Group.objects.create(club=self.club, name="Committee"))) + + response = self.club_post("event_create", self.event_data(teams=[], groups=[])) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Event.objects.filter(title="Training").exists()) + + def test_club_wide_is_not_offered_to_a_non_admin(self): + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_get("event_create") + + self.assertNotContains(response, 'name="club_wide"') + + def test_a_non_admin_cannot_force_a_club_wide_event(self): + # club_wide isn't in the form for a non-admin, so even a forged POST + # value must not slip through as a create-time claim. + self.client.force_login(self.make_coach(self.own_team)) + + response = self.club_post("event_create", self.event_data(teams=[], club_wide="on")) + + self.assertEqual(response.status_code, 200) + event = Event.objects.filter(title="Training").first() + self.assertIsNone(event) + + def test_an_admin_can_schedule_a_club_wide_event(self): + self.client.force_login(self.admin_user) + + response = self.club_post("event_create", self.event_data(teams=[], club_wide="on")) + + event = Event.objects.get(title="Training") + self.assertRedirects(response, reverse("management:event_detail", args=[event.pk])) + self.assertTrue(event.club_wide) + + def test_club_wide_cannot_be_combined_with_teams(self): + self.client.force_login(self.admin_user) + + response = self.club_post("event_create", self.event_data(club_wide="on")) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Event.objects.filter(title="Training").exists()) + + def test_a_group_member_who_is_not_a_team_manager_can_edit_their_group_event(self): + group = Group.objects.create(club=self.club, name="Committee") + member_user = self.make_group_member(group) + self.client.force_login(member_user) + self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)])) + event = Event.objects.get(title="Training") + + response = self.club_get("event_update", event.pk) + + self.assertEqual(response.status_code, 200) + def test_the_new_event_forms_competition_dropdown_shows_every_competition_regardless_of_flag(self): # Unlike the Django-admin form, this dropdown isn't filtered by whether the # competition's flag is active for the club -- see management.forms.EventForm @@ -3806,6 +3893,30 @@ class EventSeriesManagementTests(ManagementTestBase): self.assertTrue(series.occurrences.exists()) + def test_a_group_member_can_create_a_series_for_their_own_group(self): + group = Group.objects.create(club=self.club, name="Committee") + member_user = User.objects.create_user(email="committee-series@example.com", password="pw-secret-123") + member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group") + physio_position = Position.objects.create(club=self.club, name="Series Physio", short_name="SP", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position) + GroupMembership.objects.create(group=group, member=member) + self.client.force_login(member_user) + + response = self.club_post("event_series_create", self.series_data(teams=[], groups=[str(group.pk)])) + + series = EventSeries.objects.get(title="Weekly training") + self.assertRedirects(response, reverse("management:event_series_detail", args=[series.pk])) + self.assertIn(group, series.groups.all()) + + def test_generated_occurrences_copy_groups_and_club_wide(self): + self.client.force_login(self.admin_user) + series = EventSeries.objects.create(club=self.club, title="AGM", kind=Event.EventKind.MEETING, dtstart=timezone.now() + datetime.timedelta(days=1), rrule="FREQ=WEEKLY;COUNT=1", club_wide=True) + + generate_occurrences(series) + + occurrence = series.occurrences.get() + self.assertTrue(occurrence.club_wide) + def test_editing_a_series_propagates_to_future_occurrences_but_not_a_detached_one(self): self.client.force_login(self.make_coach(self.team)) series = self.create_series() diff --git a/management/views.py b/management/views.py index e87c694..e31d8bf 100644 --- a/management/views.py +++ b/management/views.py @@ -26,7 +26,7 @@ from club.mixins import ( TeamManagerRequiredMixin, ) from club.models import ClubMembership, ClubRole, Season, Sponsor -from club.services.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by +from club.services.access import can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by from club.services.fees import mark_as_paid, record_payment, remaining_balance from controlpanel.messages import notify from controlpanel.mixins import RedirectOnInvalidMixin @@ -261,12 +261,12 @@ class MembershipListView(ClubAdminRequiredMixin, ListView): # family that has an explicit name of "Smith" or that includes anyone # surnamed Smith, not just a member literally named Smith themself. memberships = ( - memberships.filter(member__first_name__icontains=search) - | memberships.filter(member__last_name__icontains=search) - | memberships.filter(member__email__icontains=search) - | memberships.filter(member__user__email__icontains=search) - | memberships.filter(member__family_memberships__family__name__icontains=search) - | memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search) + memberships.filter(member__first_name__icontains=search) + | memberships.filter(member__last_name__icontains=search) + | memberships.filter(member__email__icontains=search) + | memberships.filter(member__user__email__icontains=search) + | memberships.filter(member__family_memberships__family__name__icontains=search) + | memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search) ) return memberships.distinct() @@ -348,7 +348,7 @@ class MembershipMarkPaidView(ClubAdminRequiredMixin, View): mark_as_paid(membership, recorded_by=request.user) count += 1 - notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count} }") + notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count}}") next_url = request.POST.get("next") if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()): @@ -573,9 +573,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View): season = current_season(self.request.club) if season is None: return None - return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership( - club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate() - ) + return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership(club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate()) def render_form(self, member, form, membership_form): return render(self.request, self.template_name, {"object": member, "update_view": True, "form": form, "membership_form": membership_form}) @@ -596,7 +594,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View): form.save() if membership_form is not None: membership_form.save() - notify(request, f"s|{_('Member updated')}|{_('“%(member)s” updated.') % {'member': member} }") + notify(request, f"s|{_('Member updated')}|{_('“%(member)s” updated.') % {'member': member}}") return redirect("management:member_detail", pk=member.pk) return self.render_form(member, form, membership_form) @@ -701,10 +699,10 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV if member.user_id is not None: # Already has one -- the row's button shouldn't have been there at all; # a direct POST replay (e.g. a resubmitted form) is the only way here. - notify(self.request, f"w|{_('Already has a login')}|{_('“%(member)s” can already sign in.') % {'member': member} }") + notify(self.request, f"w|{_('Already has a login')}|{_('“%(member)s” can already sign in.') % {'member': member}}") else: grant_login(member, form.cleaned_data["email"]) - notify(self.request, f"s|{_('Login granted')}|{_('“%(member)s” can now sign in.') % {'member': member} }") + notify(self.request, f"s|{_('Login granted')}|{_('“%(member)s” can now sign in.') % {'member': member}}") return redirect("management:member_detail", pk=member.pk) @@ -909,11 +907,7 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView): no_shows=no_shows, # None (not an empty queryset) signals "federation-managed" to the # template, distinct from "club-managed, nobody eligible yet". - eligible_referees=( - Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name") - if team.referee_management == Team.RefereeManagement.CLUB - else None - ), + eligible_referees=(Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name") if team.referee_management == Team.RefereeManagement.CLUB else None), **kwargs, ) @@ -1187,7 +1181,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View): try: membership.full_clean() membership.save() - except (ValidationError, IntegrityError): + except ValidationError, IntegrityError: errors.append(_("%(member)s: could not be added as a player -- please check the details and try again.") % {"member": member}) else: players_added += 1 @@ -1203,7 +1197,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View): try: assignment.full_clean() assignment.save() - except (ValidationError, IntegrityError): + except ValidationError, IntegrityError: errors.append(_("%(member)s: could not be assigned as staff -- please check the details and try again.") % {"member": member}) else: staff_added += 1 @@ -2234,9 +2228,10 @@ class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView): class EventCreateView(ClubStaffRequiredMixin, CreateView): """Broader than EventManagerRequiredMixin's own gate (no object yet to check - teams against): anyone managing at least one team, or an admin. EventForm - itself then restricts *which* teams a non-admin can pick and requires at - least one, so a team-less/club-wide event stays admin-only.""" + teams/groups against): anyone managing at least one team, belonging to at + least one group, or an admin. EventForm itself then restricts *which* + teams/groups a non-admin can pick and requires at least one, so a + club-wide event stays admin-only.""" model = Event form_class = EventForm @@ -2244,7 +2239,7 @@ class EventCreateView(ClubStaffRequiredMixin, CreateView): def test_func(self): user, club = self.request.user, self.request.club - return is_club_admin(user, club) or teams_managed_by(user, club).exists() + return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists() def get_form_kwargs(self): # Event.clean() rejects a location/opponent from another club by comparing @@ -2274,6 +2269,9 @@ class EventUpdateView(EventManagerRequiredMixin, UpdateView): def get_teams(self): return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all() + def get_groups(self): + return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all() + def get_form_kwargs(self): return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "editing": True} @@ -2301,6 +2299,9 @@ class EventDeleteView(EventManagerRequiredMixin, View): def get_teams(self): return self.get_event().teams.all() + def get_groups(self): + return self.get_event().groups.all() + def post(self, request, pk): event = self.get_event() title = str(event) @@ -2324,6 +2325,9 @@ class EventDetachView(EventManagerRequiredMixin, View): def get_teams(self): return self.get_event().teams.all() + def get_groups(self): + return self.get_event().groups.all() + def post(self, request, pk): event = self.get_event() detach_occurrence(event) @@ -2345,6 +2349,9 @@ class EventFetchGameInfoView(EventManagerRequiredMixin, View): def get_teams(self): return self.get_event().teams.all() + def get_groups(self): + return self.get_event().groups.all() + def post(self, request, pk): event = self.get_event() try: @@ -2460,7 +2467,7 @@ class EventSeriesCreateView(ClubStaffRequiredMixin, CreateView): def test_func(self): user, club = self.request.user, self.request.club - return is_club_admin(user, club) or teams_managed_by(user, club).exists() + return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists() def get_form_kwargs(self): # Same reasoning as EventCreateView: EventSeries.clean() needs a real @@ -2492,6 +2499,9 @@ class EventSeriesUpdateView(EventManagerRequiredMixin, UpdateView): def get_teams(self): return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all() + def get_groups(self): + return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all() + def get_form_kwargs(self): return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user} @@ -2526,6 +2536,9 @@ class EventSeriesDeleteView(EventManagerRequiredMixin, View): def get_teams(self): return self.get_series().teams.all() + def get_groups(self): + return self.get_series().groups.all() + def post(self, request, pk): series = self.get_series() title = str(series) @@ -2545,6 +2558,9 @@ class EventSeriesStopView(EventManagerRequiredMixin, View): def get_teams(self): return self.get_series().teams.all() + def get_groups(self): + return self.get_series().groups.all() + def post(self, request, pk): series = self.get_series() series.until = timezone.now() @@ -2560,6 +2576,9 @@ class EventSeriesGenerateView(EventManagerRequiredMixin, View): def get_teams(self): return self.get_series().teams.all() + def get_groups(self): + return self.get_series().groups.all() + def post(self, request, pk): series = self.get_series() created = generate_occurrences(series) diff --git a/rosterchief/mail.py b/rosterchief/mail.py new file mode 100644 index 0000000..370314b --- /dev/null +++ b/rosterchief/mail.py @@ -0,0 +1,92 @@ +"""Django email backend that sends through Resend's HTTP API +(https://resend.com/docs/api-reference/emails/send-email) instead of SMTP. + +Opt in with DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend and +RESEND_API_KEY set (see settings.py's Email section) -- every Django-sent +email (allauth's password reset, billing reminders, ...) goes through +django.core.mail's EMAIL_BACKEND setting, so nothing else has to change to +route mail through Resend once this is configured. + +Resend's own SMTP relay is also a valid, code-free alternative (point the +stock django.core.mail.backends.smtp.EmailBackend at it with your API key as +the SMTP password) -- this backend exists for teams who'd rather go through +Resend's HTTP API directly. +""" + +import base64 +from email.mime.base import MIMEBase + +import requests +from django.conf import settings +from django.core.mail.backends.base import BaseEmailBackend + +RESEND_API_URL = "https://api.resend.com/emails" +REQUEST_TIMEOUT = 10 + + +class ResendEmailBackend(BaseEmailBackend): + def send_messages(self, email_messages) -> int: + if not email_messages: + return 0 + + api_key = settings.RESEND_API_KEY + if not api_key: + if self.fail_silently: + return 0 + raise ValueError("RESEND_API_KEY is not set.") + + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + sent = 0 + with requests.Session() as session: + for message in email_messages: + try: + response = session.post(RESEND_API_URL, headers=headers, json=_payload_for(message), timeout=REQUEST_TIMEOUT) + response.raise_for_status() + except requests.RequestException: + if not self.fail_silently: + raise + continue + sent += 1 + + return sent + + +def _payload_for(message) -> dict: + payload = { + "from": message.from_email, + "to": list(message.to), + "subject": message.subject, + "text": message.body, + } + if message.cc: + payload["cc"] = list(message.cc) + if message.bcc: + payload["bcc"] = list(message.bcc) + if message.reply_to: + payload["reply_to"] = list(message.reply_to) + + # EmailMultiAlternatives (what allauth's templated emails use) carries the + # HTML version as an "alternative" to the plain-text body, not a separate field. + html_body = next((content for content, mimetype in getattr(message, "alternatives", []) if mimetype == "text/html"), None) + if html_body: + payload["html"] = html_body + + attachments = _attachments_for(message) + if attachments: + payload["attachments"] = attachments + + return payload + + +def _attachments_for(message) -> list[dict]: + attachments = [] + for attachment in message.attachments: + if isinstance(attachment, MIMEBase): + filename = attachment.get_filename() + content = attachment.get_payload(decode=True) + else: + filename, content, _mimetype = attachment + if isinstance(content, str): + content = content.encode() + attachments.append({"filename": filename, "content": base64.b64encode(content).decode("ascii")}) + return attachments diff --git a/rosterchief/settings.py b/rosterchief/settings.py index 7ebfb4c..4bb7e6f 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -335,6 +335,12 @@ LOGGING = { # DEFAULT rather than the dev-only branch -- a deployment that forgets to configure mail # should print billing reminders to the log, not raise ConnectionRefused against localhost:25 # on a box with no MTA, which is what Django's own default does. +# +# Resend (resend.com) works either way: point DJANGO_EMAIL_BACKEND at Django's own SMTP +# backend with Resend's SMTP relay credentials, or set it to rosterchief.mail.ResendEmailBackend +# to send through Resend's HTTP API instead (see that module) -- set RESEND_API_KEY either way. +# Every Django-sent email (allauth's password reset included, since it goes through +# django.core.mail like everything else) follows whichever backend is configured here. EMAIL_BACKEND = config("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend") EMAIL_HOST = config("DJANGO_EMAIL_HOST", default="") EMAIL_PORT = config("DJANGO_EMAIL_PORT", default=587, cast=int) @@ -344,6 +350,10 @@ EMAIL_USE_TLS = config("DJANGO_EMAIL_USE_TLS", default=True, cast=bool) EMAIL_USE_SSL = config("DJANGO_EMAIL_USE_SSL", default=False, cast=bool) EMAIL_TIMEOUT = config("DJANGO_EMAIL_TIMEOUT", default=10, cast=int) +#: Only read by rosterchief.mail.ResendEmailBackend -- irrelevant for the SMTP backend +#: (which would use EMAIL_HOST_PASSWORD, e.g. Resend's own SMTP relay, instead). +RESEND_API_KEY = config("RESEND_API_KEY", default="") + DEFAULT_FROM_EMAIL = config("DJANGO_DEFAULT_FROM_EMAIL", default="RosterChief ") SERVER_EMAIL = config("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) diff --git a/rosterchief/tests.py b/rosterchief/tests.py index d7912b9..baf11ff 100644 --- a/rosterchief/tests.py +++ b/rosterchief/tests.py @@ -1,11 +1,14 @@ import importlib from unittest import mock +import requests +from django.core.mail import EmailMessage, EmailMultiAlternatives from django.db.utils import OperationalError from django.test import SimpleTestCase, override_settings from django.urls import Resolver404, clear_url_caches, resolve, reverse from . import urls +from .mail import ResendEmailBackend class BrowserReloadUrlTests(SimpleTestCase): @@ -86,3 +89,106 @@ class HealthCheckTests(SimpleTestCase): # container is unhealthy forever — which is exactly how the first deploy failed. for host in ("127.0.0.1", "localhost"): self.assertEqual(self.client.get(reverse("healthz"), HTTP_HOST=host).status_code, 200, host) + + +@override_settings(RESEND_API_KEY="test-key") +class ResendEmailBackendTests(SimpleTestCase): + def ok_response(self): + response = mock.Mock() + response.raise_for_status.return_value = None + return response + + def test_sends_a_plain_text_message(self): + message = EmailMessage("Subject", "Body text.", "from@example.com", ["to@example.com"]) + + with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post: + sent = ResendEmailBackend().send_messages([message]) + + self.assertEqual(sent, 1) + payload = post.call_args.kwargs["json"] + self.assertEqual(payload["from"], "from@example.com") + self.assertEqual(payload["to"], ["to@example.com"]) + self.assertEqual(payload["subject"], "Subject") + self.assertEqual(payload["text"], "Body text.") + self.assertNotIn("html", payload) + + def test_uses_a_bearer_token_from_settings(self): + message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"]) + + with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post: + ResendEmailBackend().send_messages([message]) + + self.assertEqual(post.call_args.kwargs["headers"]["Authorization"], "Bearer test-key") + + def test_includes_the_html_alternative(self): + message = EmailMultiAlternatives("Subject", "Plain body.", "from@example.com", ["to@example.com"]) + message.attach_alternative("

    HTML body.

    ", "text/html") + + with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post: + ResendEmailBackend().send_messages([message]) + + self.assertEqual(post.call_args.kwargs["json"]["html"], "

    HTML body.

    ") + + def test_cc_bcc_and_reply_to_are_included(self): + message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"], cc=["cc@example.com"], bcc=["bcc@example.com"], reply_to=["reply@example.com"]) + + with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post: + ResendEmailBackend().send_messages([message]) + + payload = post.call_args.kwargs["json"] + self.assertEqual(payload["cc"], ["cc@example.com"]) + self.assertEqual(payload["bcc"], ["bcc@example.com"]) + self.assertEqual(payload["reply_to"], ["reply@example.com"]) + + def test_attachments_are_base64_encoded(self): + message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"]) + message.attach("notes.txt", "hello world", "text/plain") + + with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post: + ResendEmailBackend().send_messages([message]) + + [attachment] = post.call_args.kwargs["json"]["attachments"] + self.assertEqual(attachment["filename"], "notes.txt") + self.assertEqual(attachment["content"], "aGVsbG8gd29ybGQ=") + + def test_sends_each_message_in_a_batch(self): + messages = [EmailMessage("A", "Body.", "from@example.com", ["a@example.com"]), EmailMessage("B", "Body.", "from@example.com", ["b@example.com"])] + + with mock.patch("rosterchief.mail.requests.Session.post", return_value=self.ok_response()) as post: + sent = ResendEmailBackend().send_messages(messages) + + self.assertEqual(sent, 2) + self.assertEqual(post.call_count, 2) + + @override_settings(RESEND_API_KEY="") + def test_a_missing_api_key_raises_by_default(self): + with self.assertRaises(ValueError): + ResendEmailBackend().send_messages([EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])]) + + @override_settings(RESEND_API_KEY="") + def test_a_missing_api_key_is_silent_when_fail_silently(self): + sent = ResendEmailBackend(fail_silently=True).send_messages([EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"])]) + + self.assertEqual(sent, 0) + + def test_a_failed_request_raises_by_default(self): + message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"]) + + with mock.patch("rosterchief.mail.requests.Session.post", side_effect=requests.ConnectionError("down")): + with self.assertRaises(requests.ConnectionError): + ResendEmailBackend().send_messages([message]) + + def test_a_failed_request_is_silent_when_fail_silently(self): + message = EmailMessage("Subject", "Body.", "from@example.com", ["to@example.com"]) + + with mock.patch("rosterchief.mail.requests.Session.post", side_effect=requests.ConnectionError("down")): + sent = ResendEmailBackend(fail_silently=True).send_messages([message]) + + self.assertEqual(sent, 0) + + def test_no_messages_is_a_no_op(self): + with mock.patch("rosterchief.mail.requests.Session.post") as post: + sent = ResendEmailBackend().send_messages([]) + + self.assertEqual(sent, 0) + post.assert_not_called() diff --git a/teams/models.py b/teams/models.py index ebc2eb1..575aecd 100644 --- a/teams/models.py +++ b/teams/models.py @@ -29,6 +29,7 @@ class Team(ClubScopedModel): constraints = [ models.UniqueConstraint(fields=["club", "name"], name="unique_team_name_per_club"), ] + ordering = ["name"] def __str__(self): return self.name