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:
@@ -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=
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"]}],
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +27,22 @@ 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 season is not None:
|
|
||||||
team_ids = list(event.teams.values_list("id", flat=True))
|
if event.club_wide:
|
||||||
if team_ids:
|
member_ids = set()
|
||||||
member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True))
|
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.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))
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -261,12 +261,12 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
|
|||||||
# family that has an explicit name of "Smith" or that includes anyone
|
# family that has an explicit name of "Smith" or that includes anyone
|
||||||
# surnamed Smith, not just a member literally named Smith themself.
|
# surnamed Smith, not just a member literally named Smith themself.
|
||||||
memberships = (
|
memberships = (
|
||||||
memberships.filter(member__first_name__icontains=search)
|
memberships.filter(member__first_name__icontains=search)
|
||||||
| memberships.filter(member__last_name__icontains=search)
|
| memberships.filter(member__last_name__icontains=search)
|
||||||
| memberships.filter(member__email__icontains=search)
|
| memberships.filter(member__email__icontains=search)
|
||||||
| memberships.filter(member__user__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__name__icontains=search)
|
||||||
| memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search)
|
| memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search)
|
||||||
)
|
)
|
||||||
|
|
||||||
return memberships.distinct()
|
return memberships.distinct()
|
||||||
@@ -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
92
rosterchief/mail.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user