Add group/club-wide event audiences and a Resend email backend

Events can now target members.Group audiences alongside teams, or go
club_wide (every ACTIVE ClubMembership member for the event's season)
instead of specific teams/groups -- the two are mutually exclusive,
enforced in EventForm/EventSeriesForm.clean() since an M2M can't be
validated via a DB CheckConstraint or Event.clean() (no PK yet). Attendance
sync (events/signals.py) now reacts to GroupMembership and ClubMembership
changes the same way it already did for TeamMembership. Authorization:
club.services.access.groups_manageable_by mirrors teams_managed_by (all
groups for an ADMIN, else only the ones the user belongs to -- Group has no
manager/owner concept); a non-admin needs at least one managed team or
belonged-to group to create/edit an event, club_wide stays admin-only, and
EventManagerRequiredMixin gained a get_groups() hook so a non-admin who
creates a group-only event isn't immediately locked out of managing it.

Also adds rosterchief.mail.ResendEmailBackend, an HTTP-API-based Django
email backend for Resend (resend.com) using the existing `requests`
dependency -- no new SDK. Opt in via DJANGO_EMAIL_BACKEND and RESEND_API_KEY;
every Django-sent email (allauth's password reset included) follows
whichever EMAIL_BACKEND is configured, so this covers all of them for free.
Resend's own SMTP relay remains a valid code-free alternative, documented
alongside it in .env.production.example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:02:26 +02:00
parent e737de9140
commit 581cc81ba7
22 changed files with 720 additions and 70 deletions

View File

@@ -48,3 +48,14 @@ DJANGO_EMAIL_USE_TLS=True
DJANGO_DEFAULT_FROM_EMAIL=RosterChief <noreply@rosterchief.app> DJANGO_DEFAULT_FROM_EMAIL=RosterChief <noreply@rosterchief.app>
# Where a club is told to reply with a billing question. # Where a club is told to reply with a billing question.
ROSTERCHIEF_BILLING_CONTACT_EMAIL=billing@rosterchief.app 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=<your Resend API key>
# 2. Resend's HTTP API via rosterchief.mail.ResendEmailBackend (see that module):
# DJANGO_EMAIL_BACKEND=rosterchief.mail.ResendEmailBackend
# RESEND_API_KEY=<your Resend API key>
RESEND_API_KEY=

View File

@@ -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 a missing check-in. See `events/services/attendance.py::record_check_in` and
`management/views.py::TeamDetailView`'s attendance panel. `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 **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 `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 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). `stripe`) + webhook endpoint that creates/confirms `Payment`s (§5.7).
- **Excel export** for form reporting beyond CSV: `openpyxl`. - **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`), *Conventions cross-reference:* `rosterchief/base.py` (`UUIDModel`, `ClubScopedModel`),

View File

@@ -2,7 +2,9 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import Http404 from django.http import Http404
from waffle import flag_is_active 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): class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
@@ -74,20 +76,31 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
class EventManagerRequiredMixin(ClubStaffRequiredMixin): class EventManagerRequiredMixin(ClubStaffRequiredMixin):
"""Admin, or a manager of at least one of this event's/series' *current* """Admin, a manager of at least one of this event's/series' *current*
teams. ``self.get_teams()`` must return the Team queryset/iterable the teams, or a member of at least one of its groups. ``self.get_teams()``
view acts on (e.g. ``self.get_object().teams.all()``) before ``test_func`` must return the Team queryset/iterable the view acts on (e.g.
runs. Events/series aren't single-team like a roster entry -- ``teams`` is ``self.get_object().teams.all()``) before ``test_func`` runs; override
M2M, so authority is "manages at least one", not "manages the one".""" ``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): def get_teams(self):
raise NotImplementedError("Subclasses must return the Teams this view acts on.") raise NotImplementedError("Subclasses must return the Teams this view acts on.")
def get_groups(self):
return Group.objects.none()
def test_func(self): def test_func(self):
user, club = self.request.user, self.request.club user, club = self.request.user, self.request.club
if is_club_admin(user, club): if is_club_admin(user, club):
return True 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): class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):

View File

@@ -22,7 +22,7 @@ from django.utils import timezone
from authentication.models import User from authentication.models import User
from club.models import Club, ClubRole, Season from club.models import Club, ClubRole, Season
from events.models import Event from events.models import Event
from members.models import FamilyMembership, Member from members.models import FamilyMembership, Group, Member
from teams.models import StaffAssignment, Team from teams.models import StaffAssignment, Team
#: Derived (never stored) roles. #: Derived (never stored) roles.
@@ -92,6 +92,15 @@ def teams_managed_by(user: User, club: Club) -> QuerySet[Team]:
).distinct() ).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]: def teams_staffed_by(user: User, club: Club) -> QuerySet[Team]:
"""Teams the user is on the staff of this season, management or not. """Teams the user is on the staff of this season, management or not.

View File

@@ -36,12 +36,12 @@ class EventSeriesAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"] list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"]
list_filter = ["kind", "club"] list_filter = ["kind", "club"]
search_fields = ["title"] search_fields = ["title"]
autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"] autocomplete_fields = ["location", "opponent", "teams", "groups", "invited_members", "excluded_members"]
fieldsets = [ fieldsets = [
[None, {"fields": ["title", "kind"]}], [None, {"fields": ["title", "kind"]}],
[_("Recurrence"), {"fields": ["rrule", "dtstart", "until", "excluded_dates", "generated_until"]}], [_("Recurrence"), {"fields": ["rrule", "dtstart", "until", "excluded_dates", "generated_until"]}],
[_("Timing"), {"fields": ["duration", "gathering_offset", "deadline_offset"]}], [_("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"]}], [_("Where"), {"fields": ["location", "opponent"]}],
] ]
@@ -56,7 +56,7 @@ class EventAdminForm(forms.ModelForm):
class Meta: class Meta:
model = Event model = Event
fields = [ 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", "start", "end", "gathering", "deadline", "location", "opponent",
"competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees", "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"] list_filter = ["kind", "club", "teams", "detached", "cancelled"]
search_fields = ["title"] search_fields = ["title"]
date_hierarchy = "start" 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] inlines = [AttendanceInline, EventRefereeInline]
fieldsets = [ fieldsets = [
[None, {"fields": ["title", "kind", "season"]}], [None, {"fields": ["title", "kind", "season"]}],
[_("Series"), {"fields": ["series", "detached", "cancelled"]}], [_("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"]}], [_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
[_("Where"), {"fields": ["location", "opponent"]}], [_("Where"), {"fields": ["location", "opponent"]}],
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]}], [_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]}],

View File

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

View File

@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField from django_countries.fields import CountryField
from club.models import Club, Season 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 rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Team from teams.models import Team
@@ -69,6 +69,8 @@ class Event(ClubScopedModel):
cancelled = models.BooleanField(_("cancelled"), default=False) cancelled = models.BooleanField(_("cancelled"), default=False)
teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams")) 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")) 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")) 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.")) 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")) 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")) 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")) 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")) 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")) excluded_members = models.ManyToManyField(Member, related_name="excluded_from_event_series", blank=True, verbose_name=_("excluded members"))

View File

@@ -1,15 +1,18 @@
"""Keep an event's attendance rows in sync with its effective audience. """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`` 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 (for the event's season) and the current members of its ``groups``, plus any
``excluded_members``. Attendance rows are reconciled against that set, but only individually ``invited_members``, minus any ``excluded_members`` -- or, for a
for events that are still in the future — history is never rewritten. ``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.db.models import Count, Q
from django.utils import timezone from django.utils import timezone
from club.models import Season from club.models import ClubMembership, Season
from events.models import Attendance, Event from events.models import Attendance, Event
from members.models import Member from members.models import Member
from teams.models import TeamMembership from teams.models import TeamMembership
@@ -24,14 +27,23 @@ def resolve_season(event):
def effective_members(event): def effective_members(event):
"""Return the ``Member`` queryset invited to ``event``.""" """Return the ``Member`` queryset invited to ``event``."""
member_ids: set = set()
season = resolve_season(event) season = resolve_season(event)
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: if season is not None:
team_ids = list(event.teams.values_list("id", flat=True)) team_ids = list(event.teams.values_list("id", flat=True))
if team_ids: if team_ids:
member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True)) 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.update(event.invited_members.values_list("id", flat=True))
member_ids.difference_update(event.excluded_members.values_list("id", flat=True)) member_ids.difference_update(event.excluded_members.values_list("id", flat=True))

View File

@@ -52,12 +52,14 @@ def apply_template(series, event):
event.title = series.title event.title = series.title
event.location = series.location event.location = series.location
event.opponent = series.opponent 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.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.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.deadline = event.start - series.deadline_offset if series.deadline_offset is not None else None
event.save() event.save()
event.teams.set(series.teams.all()) event.teams.set(series.teams.all())
event.groups.set(series.groups.all())
event.invited_members.set(series.invited_members.all()) event.invited_members.set(series.invited_members.all())
event.excluded_members.set(series.excluded_members.all()) event.excluded_members.set(series.excluded_members.all())

View File

@@ -1,11 +1,15 @@
"""Signal wiring that keeps attendance in sync with the event audience. """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`` / * editing an event (its ``start``, or its ``teams`` / ``groups`` /
``excluded_members``) re-syncs that event; ``invited_members`` / ``excluded_members``) re-syncs that event;
* adding or removing a member from a team roster re-syncs that team's future * 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 from django.core.exceptions import ValidationError
@@ -14,8 +18,10 @@ from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership
from events.models import Event, EventSeries from events.models import Event, EventSeries
from events.services import sync_event_attendances from events.services import sync_event_attendances
from members.models import Group, GroupMembership
from teams.models import Team, TeamMembership from teams.models import Team, TeamMembership
M2M_SYNC_ACTIONS = {"post_add", "post_remove", "post_clear"} 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.")) 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) @receiver(post_save, sender=Event)
def sync_on_event_save(sender, instance, **kwargs): def sync_on_event_save(sender, instance, **kwargs):
sync_event_attendances(instance) sync_event_attendances(instance)
@receiver(m2m_changed, sender=Event.teams.through) @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.invited_members.through)
@receiver(m2m_changed, sender=Event.excluded_members.through) @receiver(m2m_changed, sender=Event.excluded_members.through)
def sync_on_audience_change(sender, instance, action, reverse, model, pk_set, **kwargs): 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() now = timezone.now()
for event in Event.objects.filter(teams=instance.team_id, start__gte=now).distinct(): for event in Event.objects.filter(teams=instance.team_id, start__gte=now).distinct():
sync_event_attendances(event) 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)

View File

@@ -9,8 +9,8 @@ from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from waffle import get_waffle_flag_model from waffle import get_waffle_flag_model
from club.models import Club, Season from club.models import Club, ClubMembership, Season
from members.models import Member from members.models import Group, GroupMembership, Member
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
from .admin import EventAdminForm from .admin import EventAdminForm
@@ -202,6 +202,55 @@ class EffectiveMembersTests(EventsTestBase):
self.assertEqual(event.attendances.count(), 0) 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): class AttendanceSyncTests(EventsTestBase):
def test_setting_teams_creates_attendance_for_roster(self): 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)) 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): def test_roster_change_leaves_past_events_untouched(self):
past = self.make_event(start=timezone.now() - timedelta(days=1)) past = self.make_event(start=timezone.now() - timedelta(days=1))
past.teams.add(self.team) past.teams.add(self.team)

View File

@@ -7,7 +7,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment, Sponsor 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.models import Competition, Event, EventReferee, EventSeries, Location, Opponent
from events.services.rbihf_import import RBIHFImportError, extract_team_id from events.services.rbihf_import import RBIHFImportError, extract_team_id
from members.models import Family, FamilyMembership, Group, Member from members.models import Family, FamilyMembership, Group, Member
@@ -240,29 +240,47 @@ def _location_label(location) -> str:
class EventAudienceFormMixin: class EventAudienceFormMixin:
"""Shared club/user-scoped audience fields for EventForm and EventSeriesForm: """Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
teams restricted to the ones the requester manages (all of them for an 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 admin), groups restricted to the ones the requester belongs to (all of them
event (e.g. an AGM) has no team-manager claim to anchor it to, so that's for an admin -- Group has no manager/owner concept the way Team does, so
admin-only.""" 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): def scope_audience_fields(self, club, user):
self.club = club self.club = club
self.user = user 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"].queryset = Location.objects.filter(club=club)
self.fields["location"].label_from_instance = _location_label self.fields["location"].label_from_instance = _location_label
self.fields["opponent"].queryset = Opponent.objects.filter(club=club) self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
members = Member.objects.filter(member_of__club=club).distinct() members = Member.objects.filter(member_of__club=club).distinct()
self.fields["invited_members"].queryset = members self.fields["invited_members"].queryset = members
self.fields["excluded_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") teams = cleaned.get("teams")
if teams is not None and not teams.exists() and not is_club_admin(self.user, self.club): groups = cleaned.get("groups")
self.add_error("teams", _("Select at least one of your teams, or ask an admin to create a club-wide event.")) 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 = { _AUDIENCE_WIDGETS = {
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}), "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...")}), "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...")}), "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...")}), "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 EventForm(EventAudienceFormMixin, forms.ModelForm):
class Meta: class Meta:
model = Event 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 = { widgets = {
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}), "start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"end": 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): def clean(self):
cleaned = super().clean() 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 return cleaned
@@ -357,7 +396,7 @@ class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
class Meta: class Meta:
model = EventSeries 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 = { widgets = {
"dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}), "dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"until": 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): def clean(self):
cleaned = super().clean() 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"): if cleaned.get("advanced_rrule"):
return cleaned # the advanced field wins outright; nothing else to check return cleaned # the advanced field wins outright; nothing else to check

View File

@@ -25,11 +25,11 @@
<li class="menu-title">{% trans "Teams" %}</li> <li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li> <li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li> <li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>
{% if is_club_admin %} {% if is_club_admin %}
<li><a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
<li><a class="{% if nav == 'referee_level_list' %}menu-active{% endif %}" href="{% url 'management:referee_level_list' %}">{% lucide "badge-check" size=16 %} {% trans "Referee levels" %}</a></li> <li><a class="{% if nav == 'referee_level_list' %}menu-active{% endif %}" href="{% url 'management:referee_level_list' %}">{% lucide "badge-check" size=16 %} {% trans "Referee levels" %}</a></li>
<li><a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
{% endif %} {% endif %}
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>
<li class="menu-title">{% trans "News" %}</li> <li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li> <li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>

View File

@@ -65,6 +65,16 @@
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt> <dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
<dd>{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd> <dd>{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div> </div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Groups" %}</dt>
<dd>{% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
{% if event.club_wide %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Audience" %}</dt>
<dd><span class="badge badge-primary">{% trans "Whole club" %}</span></dd>
</div>
{% endif %}
</dl> </dl>
</div> </div>
</div> </div>

View File

@@ -24,6 +24,12 @@
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3> <h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.teams %} {% form_field form.teams %}
{% form_field form.groups %}
{% if "club_wide" in form.fields %}
{% form_field form.club_wide %}
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{% form_field form.invited_members %} {% form_field form.invited_members %}
{% form_field form.excluded_members %} {% form_field form.excluded_members %}
</div> </div>

View File

@@ -41,6 +41,12 @@
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3> <h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.teams %} {% form_field form.teams %}
{% form_field form.groups %}
{% if "club_wide" in form.fields %}
{% form_field form.club_wide %}
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{% form_field form.invited_members %} {% form_field form.invited_members %}
{% form_field form.excluded_members %} {% form_field form.excluded_members %}
</div> </div>

View File

@@ -3484,6 +3484,19 @@ class EventManagementTests(ManagementTestBase):
StaffAssignment.objects.create(team=self.own_team, member=staff_member, season=self.season, position=position) StaffAssignment.objects.create(team=self.own_team, member=staff_member, season=self.season, position=position)
return staff_user 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): def event_data(self, **overrides):
data = { data = {
"title": "Training", "title": "Training",
@@ -3550,6 +3563,80 @@ class EventManagementTests(ManagementTestBase):
self.assertContains(response, 'name="location"') self.assertContains(response, 'name="location"')
self.assertContains(response, "data-searchable") 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): 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 # 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 # competition's flag is active for the club -- see management.forms.EventForm
@@ -3806,6 +3893,30 @@ class EventSeriesManagementTests(ManagementTestBase):
self.assertTrue(series.occurrences.exists()) 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): def test_editing_a_series_propagates_to_future_occurrences_but_not_a_detached_one(self):
self.client.force_login(self.make_coach(self.team)) self.client.force_login(self.make_coach(self.team))
series = self.create_series() series = self.create_series()

View File

@@ -26,7 +26,7 @@ from club.mixins import (
TeamManagerRequiredMixin, TeamManagerRequiredMixin,
) )
from club.models import ClubMembership, ClubRole, Season, Sponsor 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 club.services.fees import mark_as_paid, record_payment, remaining_balance
from controlpanel.messages import notify from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin from controlpanel.mixins import RedirectOnInvalidMixin
@@ -348,7 +348,7 @@ class MembershipMarkPaidView(ClubAdminRequiredMixin, View):
mark_as_paid(membership, recorded_by=request.user) mark_as_paid(membership, recorded_by=request.user)
count += 1 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") 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()): 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) season = current_season(self.request.club)
if season is None: if season is None:
return None return None
return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership( 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())
club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate()
)
def render_form(self, member, form, membership_form): 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}) 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() form.save()
if membership_form is not None: if membership_form is not None:
membership_form.save() 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 redirect("management:member_detail", pk=member.pk)
return self.render_form(member, form, membership_form) return self.render_form(member, form, membership_form)
@@ -701,10 +699,10 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
if member.user_id is not None: if member.user_id is not None:
# Already has one -- the row's button shouldn't have been there at all; # 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. # 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: else:
grant_login(member, form.cleaned_data["email"]) 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) return redirect("management:member_detail", pk=member.pk)
@@ -909,11 +907,7 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
no_shows=no_shows, no_shows=no_shows,
# None (not an empty queryset) signals "federation-managed" to the # None (not an empty queryset) signals "federation-managed" to the
# template, distinct from "club-managed, nobody eligible yet". # template, distinct from "club-managed, nobody eligible yet".
eligible_referees=( 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),
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, **kwargs,
) )
@@ -1187,7 +1181,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
try: try:
membership.full_clean() membership.full_clean()
membership.save() 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}) errors.append(_("%(member)s: could not be added as a player -- please check the details and try again.") % {"member": member})
else: else:
players_added += 1 players_added += 1
@@ -1203,7 +1197,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
try: try:
assignment.full_clean() assignment.full_clean()
assignment.save() 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}) errors.append(_("%(member)s: could not be assigned as staff -- please check the details and try again.") % {"member": member})
else: else:
staff_added += 1 staff_added += 1
@@ -2234,9 +2228,10 @@ class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
class EventCreateView(ClubStaffRequiredMixin, CreateView): class EventCreateView(ClubStaffRequiredMixin, CreateView):
"""Broader than EventManagerRequiredMixin's own gate (no object yet to check """Broader than EventManagerRequiredMixin's own gate (no object yet to check
teams against): anyone managing at least one team, or an admin. EventForm teams/groups against): anyone managing at least one team, belonging to at
itself then restricts *which* teams a non-admin can pick and requires at least one group, or an admin. EventForm itself then restricts *which*
least one, so a team-less/club-wide event stays admin-only.""" teams/groups a non-admin can pick and requires at least one, so a
club-wide event stays admin-only."""
model = Event model = Event
form_class = EventForm form_class = EventForm
@@ -2244,7 +2239,7 @@ class EventCreateView(ClubStaffRequiredMixin, CreateView):
def test_func(self): def test_func(self):
user, club = self.request.user, self.request.club 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): def get_form_kwargs(self):
# Event.clean() rejects a location/opponent from another club by comparing # Event.clean() rejects a location/opponent from another club by comparing
@@ -2274,6 +2269,9 @@ class EventUpdateView(EventManagerRequiredMixin, UpdateView):
def get_teams(self): def get_teams(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all() 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): def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "editing": True} 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): def get_teams(self):
return self.get_event().teams.all() return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk): def post(self, request, pk):
event = self.get_event() event = self.get_event()
title = str(event) title = str(event)
@@ -2324,6 +2325,9 @@ class EventDetachView(EventManagerRequiredMixin, View):
def get_teams(self): def get_teams(self):
return self.get_event().teams.all() return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk): def post(self, request, pk):
event = self.get_event() event = self.get_event()
detach_occurrence(event) detach_occurrence(event)
@@ -2345,6 +2349,9 @@ class EventFetchGameInfoView(EventManagerRequiredMixin, View):
def get_teams(self): def get_teams(self):
return self.get_event().teams.all() return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk): def post(self, request, pk):
event = self.get_event() event = self.get_event()
try: try:
@@ -2460,7 +2467,7 @@ class EventSeriesCreateView(ClubStaffRequiredMixin, CreateView):
def test_func(self): def test_func(self):
user, club = self.request.user, self.request.club 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): def get_form_kwargs(self):
# Same reasoning as EventCreateView: EventSeries.clean() needs a real # Same reasoning as EventCreateView: EventSeries.clean() needs a real
@@ -2492,6 +2499,9 @@ class EventSeriesUpdateView(EventManagerRequiredMixin, UpdateView):
def get_teams(self): def get_teams(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all() 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): def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user} 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): def get_teams(self):
return self.get_series().teams.all() return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk): def post(self, request, pk):
series = self.get_series() series = self.get_series()
title = str(series) title = str(series)
@@ -2545,6 +2558,9 @@ class EventSeriesStopView(EventManagerRequiredMixin, View):
def get_teams(self): def get_teams(self):
return self.get_series().teams.all() return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk): def post(self, request, pk):
series = self.get_series() series = self.get_series()
series.until = timezone.now() series.until = timezone.now()
@@ -2560,6 +2576,9 @@ class EventSeriesGenerateView(EventManagerRequiredMixin, View):
def get_teams(self): def get_teams(self):
return self.get_series().teams.all() return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk): def post(self, request, pk):
series = self.get_series() series = self.get_series()
created = generate_occurrences(series) created = generate_occurrences(series)

92
rosterchief/mail.py Normal file
View File

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

View File

@@ -335,6 +335,12 @@ LOGGING = {
# DEFAULT rather than the dev-only branch -- a deployment that forgets to configure mail # 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 # 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. # 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_BACKEND = config("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend")
EMAIL_HOST = config("DJANGO_EMAIL_HOST", default="") EMAIL_HOST = config("DJANGO_EMAIL_HOST", default="")
EMAIL_PORT = config("DJANGO_EMAIL_PORT", default=587, cast=int) 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_USE_SSL = config("DJANGO_EMAIL_USE_SSL", default=False, cast=bool)
EMAIL_TIMEOUT = config("DJANGO_EMAIL_TIMEOUT", default=10, cast=int) 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 <noreply@rosterchief.app>") DEFAULT_FROM_EMAIL = config("DJANGO_DEFAULT_FROM_EMAIL", default="RosterChief <noreply@rosterchief.app>")
SERVER_EMAIL = config("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) SERVER_EMAIL = config("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)

View File

@@ -1,11 +1,14 @@
import importlib import importlib
from unittest import mock from unittest import mock
import requests
from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.db.utils import OperationalError from django.db.utils import OperationalError
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from django.urls import Resolver404, clear_url_caches, resolve, reverse from django.urls import Resolver404, clear_url_caches, resolve, reverse
from . import urls from . import urls
from .mail import ResendEmailBackend
class BrowserReloadUrlTests(SimpleTestCase): class BrowserReloadUrlTests(SimpleTestCase):
@@ -86,3 +89,106 @@ class HealthCheckTests(SimpleTestCase):
# container is unhealthy forever — which is exactly how the first deploy failed. # container is unhealthy forever — which is exactly how the first deploy failed.
for host in ("127.0.0.1", "localhost"): for host in ("127.0.0.1", "localhost"):
self.assertEqual(self.client.get(reverse("healthz"), HTTP_HOST=host).status_code, 200, host) 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("<p>HTML body.</p>", "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"], "<p>HTML body.</p>")
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()

View File

@@ -29,6 +29,7 @@ class Team(ClubScopedModel):
constraints = [ constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_team_name_per_club"), models.UniqueConstraint(fields=["club", "name"], name="unique_team_name_per_club"),
] ]
ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name