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:
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import ClubMembership, ClubRole, FeePayment, Sponsor
|
||||
from club.services.access import is_club_admin, teams_managed_by
|
||||
from club.services.access import groups_manageable_by, is_club_admin, teams_managed_by
|
||||
from events.models import Competition, Event, EventReferee, EventSeries, Location, Opponent
|
||||
from events.services.rbihf_import import RBIHFImportError, extract_team_id
|
||||
from members.models import Family, FamilyMembership, Group, Member
|
||||
@@ -240,29 +240,47 @@ def _location_label(location) -> str:
|
||||
class EventAudienceFormMixin:
|
||||
"""Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
|
||||
teams restricted to the ones the requester manages (all of them for an
|
||||
admin), and a non-admin must pick at least one -- a team-less/club-wide
|
||||
event (e.g. an AGM) has no team-manager claim to anchor it to, so that's
|
||||
admin-only."""
|
||||
admin), groups restricted to the ones the requester belongs to (all of them
|
||||
for an admin -- Group has no manager/owner concept the way Team does, so
|
||||
membership is the only claim there is), and a non-admin must pick at least
|
||||
one team or group -- a club-wide event (e.g. an AGM) has no such claim to
|
||||
anchor it to, so `club_wide` stays admin-only (the field doesn't even exist
|
||||
on the form otherwise)."""
|
||||
|
||||
def scope_audience_fields(self, club, user):
|
||||
self.club = club
|
||||
self.user = user
|
||||
self.fields["teams"].queryset = Team.objects.filter(club=club) if is_club_admin(user, club) else teams_managed_by(user, club)
|
||||
self.fields["teams"].queryset = teams_managed_by(user, club)
|
||||
self.fields["groups"].queryset = groups_manageable_by(user, club)
|
||||
self.fields["location"].queryset = Location.objects.filter(club=club)
|
||||
self.fields["location"].label_from_instance = _location_label
|
||||
self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
|
||||
members = Member.objects.filter(member_of__club=club).distinct()
|
||||
self.fields["invited_members"].queryset = members
|
||||
self.fields["excluded_members"].queryset = members
|
||||
if not is_club_admin(user, club):
|
||||
del self.fields["club_wide"]
|
||||
|
||||
def clean_teams_requires_one_for_non_admins(self, cleaned):
|
||||
def clean_audience_requires_a_claim_for_non_admins(self, cleaned):
|
||||
if is_club_admin(self.user, self.club):
|
||||
return
|
||||
teams = cleaned.get("teams")
|
||||
if teams is not None and not teams.exists() and not is_club_admin(self.user, self.club):
|
||||
self.add_error("teams", _("Select at least one of your teams, or ask an admin to create a club-wide event."))
|
||||
groups = cleaned.get("groups")
|
||||
if not (teams is not None and teams.exists()) and not (groups is not None and groups.exists()):
|
||||
self.add_error(None, _("Select at least one of your teams or groups, or ask an admin to create a club-wide event."))
|
||||
|
||||
def clean_club_wide_excludes_teams_and_groups(self, cleaned):
|
||||
if not cleaned.get("club_wide"):
|
||||
return
|
||||
teams = cleaned.get("teams")
|
||||
groups = cleaned.get("groups")
|
||||
if (teams is not None and teams.exists()) or (groups is not None and groups.exists()):
|
||||
self.add_error("club_wide", _("A whole-club event can't also list specific teams or groups -- clear them, or turn this off."))
|
||||
|
||||
|
||||
_AUDIENCE_WIDGETS = {
|
||||
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}),
|
||||
"groups": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a group to search...")}),
|
||||
"invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
||||
"excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
||||
"location": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name or city to search...")}),
|
||||
@@ -272,7 +290,27 @@ _AUDIENCE_WIDGETS = {
|
||||
class EventForm(EventAudienceFormMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]
|
||||
fields = [
|
||||
"title",
|
||||
"kind",
|
||||
"teams",
|
||||
"groups",
|
||||
"club_wide",
|
||||
"invited_members",
|
||||
"excluded_members",
|
||||
"location",
|
||||
"opponent",
|
||||
"start",
|
||||
"end",
|
||||
"gathering",
|
||||
"deadline",
|
||||
"competition",
|
||||
"external_game_id",
|
||||
"score_for",
|
||||
"score_against",
|
||||
"is_live",
|
||||
"max_referees",
|
||||
]
|
||||
widgets = {
|
||||
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
"end": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
@@ -308,7 +346,8 @@ class EventForm(EventAudienceFormMixin, forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
self.clean_teams_requires_one_for_non_admins(cleaned)
|
||||
self.clean_audience_requires_a_claim_for_non_admins(cleaned)
|
||||
self.clean_club_wide_excludes_teams_and_groups(cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
@@ -357,7 +396,7 @@ class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = EventSeries
|
||||
fields = ["title", "kind", "dtstart", "until", "teams", "invited_members", "excluded_members", "location", "opponent"]
|
||||
fields = ["title", "kind", "dtstart", "until", "teams", "groups", "club_wide", "invited_members", "excluded_members", "location", "opponent"]
|
||||
widgets = {
|
||||
"dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
"until": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
@@ -390,7 +429,8 @@ class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
self.clean_teams_requires_one_for_non_admins(cleaned)
|
||||
self.clean_audience_requires_a_claim_for_non_admins(cleaned)
|
||||
self.clean_club_wide_excludes_teams_and_groups(cleaned)
|
||||
|
||||
if cleaned.get("advanced_rrule"):
|
||||
return cleaned # the advanced field wins outright; nothing else to check
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
<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 == '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 %}
|
||||
<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_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
|
||||
{% 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><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>
|
||||
<dd>{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{% 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.excluded_members %}
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{% 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.excluded_members %}
|
||||
</div>
|
||||
|
||||
@@ -3484,6 +3484,19 @@ class EventManagementTests(ManagementTestBase):
|
||||
StaffAssignment.objects.create(team=self.own_team, member=staff_member, season=self.season, position=position)
|
||||
return staff_user
|
||||
|
||||
def make_group_member(self, group, email="committee-events@example.com"):
|
||||
# Staff (so they can reach the management app at all -- ClubStaffRequiredMixin
|
||||
# excludes a plain MEMBER-only club member entirely), but NOT a team manager of
|
||||
# anything: a non-management StaffAssignment on an unrelated team, so
|
||||
# teams_managed_by(this user) is empty and the group claim is what's really
|
||||
# being tested, isolated from any team-manager claim.
|
||||
member_user = User.objects.create_user(email=email, password="pw-secret-123")
|
||||
member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group")
|
||||
physio_position = Position.objects.create(club=self.club, name="Committee Physio", short_name="CP", staff_position=True, management_position=False)
|
||||
StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position)
|
||||
GroupMembership.objects.create(group=group, member=member)
|
||||
return member_user
|
||||
|
||||
def event_data(self, **overrides):
|
||||
data = {
|
||||
"title": "Training",
|
||||
@@ -3550,6 +3563,80 @@ class EventManagementTests(ManagementTestBase):
|
||||
self.assertContains(response, 'name="location"')
|
||||
self.assertContains(response, "data-searchable")
|
||||
|
||||
def test_a_group_member_can_create_an_event_for_their_own_group(self):
|
||||
group = Group.objects.create(club=self.club, name="Committee")
|
||||
self.client.force_login(self.make_group_member(group))
|
||||
|
||||
response = self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)]))
|
||||
|
||||
event = Event.objects.get(title="Training")
|
||||
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
|
||||
self.assertIn(group, event.groups.all())
|
||||
|
||||
def test_a_group_member_cannot_pick_a_group_they_do_not_belong_to(self):
|
||||
own_group = Group.objects.create(club=self.club, name="Committee")
|
||||
other_group = Group.objects.create(club=self.club, name="Other Committee")
|
||||
self.client.force_login(self.make_group_member(own_group))
|
||||
|
||||
response = self.club_post("event_create", self.event_data(teams=[], groups=[str(other_group.pk)]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(Event.objects.filter(title="Training").exists())
|
||||
|
||||
def test_a_non_admin_with_neither_team_nor_group_is_rejected(self):
|
||||
self.client.force_login(self.make_group_member(Group.objects.create(club=self.club, name="Committee")))
|
||||
|
||||
response = self.club_post("event_create", self.event_data(teams=[], groups=[]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(Event.objects.filter(title="Training").exists())
|
||||
|
||||
def test_club_wide_is_not_offered_to_a_non_admin(self):
|
||||
self.client.force_login(self.make_coach(self.own_team))
|
||||
|
||||
response = self.club_get("event_create")
|
||||
|
||||
self.assertNotContains(response, 'name="club_wide"')
|
||||
|
||||
def test_a_non_admin_cannot_force_a_club_wide_event(self):
|
||||
# club_wide isn't in the form for a non-admin, so even a forged POST
|
||||
# value must not slip through as a create-time claim.
|
||||
self.client.force_login(self.make_coach(self.own_team))
|
||||
|
||||
response = self.club_post("event_create", self.event_data(teams=[], club_wide="on"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
event = Event.objects.filter(title="Training").first()
|
||||
self.assertIsNone(event)
|
||||
|
||||
def test_an_admin_can_schedule_a_club_wide_event(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_post("event_create", self.event_data(teams=[], club_wide="on"))
|
||||
|
||||
event = Event.objects.get(title="Training")
|
||||
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
|
||||
self.assertTrue(event.club_wide)
|
||||
|
||||
def test_club_wide_cannot_be_combined_with_teams(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_post("event_create", self.event_data(club_wide="on"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(Event.objects.filter(title="Training").exists())
|
||||
|
||||
def test_a_group_member_who_is_not_a_team_manager_can_edit_their_group_event(self):
|
||||
group = Group.objects.create(club=self.club, name="Committee")
|
||||
member_user = self.make_group_member(group)
|
||||
self.client.force_login(member_user)
|
||||
self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)]))
|
||||
event = Event.objects.get(title="Training")
|
||||
|
||||
response = self.club_get("event_update", event.pk)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_the_new_event_forms_competition_dropdown_shows_every_competition_regardless_of_flag(self):
|
||||
# Unlike the Django-admin form, this dropdown isn't filtered by whether the
|
||||
# competition's flag is active for the club -- see management.forms.EventForm
|
||||
@@ -3806,6 +3893,30 @@ class EventSeriesManagementTests(ManagementTestBase):
|
||||
|
||||
self.assertTrue(series.occurrences.exists())
|
||||
|
||||
def test_a_group_member_can_create_a_series_for_their_own_group(self):
|
||||
group = Group.objects.create(club=self.club, name="Committee")
|
||||
member_user = User.objects.create_user(email="committee-series@example.com", password="pw-secret-123")
|
||||
member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group")
|
||||
physio_position = Position.objects.create(club=self.club, name="Series Physio", short_name="SP", staff_position=True, management_position=False)
|
||||
StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position)
|
||||
GroupMembership.objects.create(group=group, member=member)
|
||||
self.client.force_login(member_user)
|
||||
|
||||
response = self.club_post("event_series_create", self.series_data(teams=[], groups=[str(group.pk)]))
|
||||
|
||||
series = EventSeries.objects.get(title="Weekly training")
|
||||
self.assertRedirects(response, reverse("management:event_series_detail", args=[series.pk]))
|
||||
self.assertIn(group, series.groups.all())
|
||||
|
||||
def test_generated_occurrences_copy_groups_and_club_wide(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
series = EventSeries.objects.create(club=self.club, title="AGM", kind=Event.EventKind.MEETING, dtstart=timezone.now() + datetime.timedelta(days=1), rrule="FREQ=WEEKLY;COUNT=1", club_wide=True)
|
||||
|
||||
generate_occurrences(series)
|
||||
|
||||
occurrence = series.occurrences.get()
|
||||
self.assertTrue(occurrence.club_wide)
|
||||
|
||||
def test_editing_a_series_propagates_to_future_occurrences_but_not_a_detached_one(self):
|
||||
self.client.force_login(self.make_coach(self.team))
|
||||
series = self.create_series()
|
||||
|
||||
@@ -26,7 +26,7 @@ from club.mixins import (
|
||||
TeamManagerRequiredMixin,
|
||||
)
|
||||
from club.models import ClubMembership, ClubRole, Season, Sponsor
|
||||
from club.services.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
|
||||
from club.services.access import can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
|
||||
from club.services.fees import mark_as_paid, record_payment, remaining_balance
|
||||
from controlpanel.messages import notify
|
||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||
@@ -261,12 +261,12 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
|
||||
# family that has an explicit name of "Smith" or that includes anyone
|
||||
# surnamed Smith, not just a member literally named Smith themself.
|
||||
memberships = (
|
||||
memberships.filter(member__first_name__icontains=search)
|
||||
| memberships.filter(member__last_name__icontains=search)
|
||||
| memberships.filter(member__email__icontains=search)
|
||||
| memberships.filter(member__user__email__icontains=search)
|
||||
| memberships.filter(member__family_memberships__family__name__icontains=search)
|
||||
| memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search)
|
||||
memberships.filter(member__first_name__icontains=search)
|
||||
| memberships.filter(member__last_name__icontains=search)
|
||||
| memberships.filter(member__email__icontains=search)
|
||||
| memberships.filter(member__user__email__icontains=search)
|
||||
| memberships.filter(member__family_memberships__family__name__icontains=search)
|
||||
| memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search)
|
||||
)
|
||||
|
||||
return memberships.distinct()
|
||||
@@ -348,7 +348,7 @@ class MembershipMarkPaidView(ClubAdminRequiredMixin, View):
|
||||
mark_as_paid(membership, recorded_by=request.user)
|
||||
count += 1
|
||||
|
||||
notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count} }")
|
||||
notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count}}")
|
||||
|
||||
next_url = request.POST.get("next")
|
||||
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
|
||||
@@ -573,9 +573,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View):
|
||||
season = current_season(self.request.club)
|
||||
if season is None:
|
||||
return None
|
||||
return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership(
|
||||
club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate()
|
||||
)
|
||||
return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership(club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate())
|
||||
|
||||
def render_form(self, member, form, membership_form):
|
||||
return render(self.request, self.template_name, {"object": member, "update_view": True, "form": form, "membership_form": membership_form})
|
||||
@@ -596,7 +594,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View):
|
||||
form.save()
|
||||
if membership_form is not None:
|
||||
membership_form.save()
|
||||
notify(request, f"s|{_('Member updated')}|{_('“%(member)s” updated.') % {'member': member} }")
|
||||
notify(request, f"s|{_('Member updated')}|{_('“%(member)s” updated.') % {'member': member}}")
|
||||
return redirect("management:member_detail", pk=member.pk)
|
||||
|
||||
return self.render_form(member, form, membership_form)
|
||||
@@ -701,10 +699,10 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
|
||||
if member.user_id is not None:
|
||||
# Already has one -- the row's button shouldn't have been there at all;
|
||||
# a direct POST replay (e.g. a resubmitted form) is the only way here.
|
||||
notify(self.request, f"w|{_('Already has a login')}|{_('“%(member)s” can already sign in.') % {'member': member} }")
|
||||
notify(self.request, f"w|{_('Already has a login')}|{_('“%(member)s” can already sign in.') % {'member': member}}")
|
||||
else:
|
||||
grant_login(member, form.cleaned_data["email"])
|
||||
notify(self.request, f"s|{_('Login granted')}|{_('“%(member)s” can now sign in.') % {'member': member} }")
|
||||
notify(self.request, f"s|{_('Login granted')}|{_('“%(member)s” can now sign in.') % {'member': member}}")
|
||||
return redirect("management:member_detail", pk=member.pk)
|
||||
|
||||
|
||||
@@ -909,11 +907,7 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
no_shows=no_shows,
|
||||
# None (not an empty queryset) signals "federation-managed" to the
|
||||
# template, distinct from "club-managed, nobody eligible yet".
|
||||
eligible_referees=(
|
||||
Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name")
|
||||
if team.referee_management == Team.RefereeManagement.CLUB
|
||||
else None
|
||||
),
|
||||
eligible_referees=(Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name") if team.referee_management == Team.RefereeManagement.CLUB else None),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -1187,7 +1181,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
|
||||
try:
|
||||
membership.full_clean()
|
||||
membership.save()
|
||||
except (ValidationError, IntegrityError):
|
||||
except ValidationError, IntegrityError:
|
||||
errors.append(_("%(member)s: could not be added as a player -- please check the details and try again.") % {"member": member})
|
||||
else:
|
||||
players_added += 1
|
||||
@@ -1203,7 +1197,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
|
||||
try:
|
||||
assignment.full_clean()
|
||||
assignment.save()
|
||||
except (ValidationError, IntegrityError):
|
||||
except ValidationError, IntegrityError:
|
||||
errors.append(_("%(member)s: could not be assigned as staff -- please check the details and try again.") % {"member": member})
|
||||
else:
|
||||
staff_added += 1
|
||||
@@ -2234,9 +2228,10 @@ class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
|
||||
|
||||
class EventCreateView(ClubStaffRequiredMixin, CreateView):
|
||||
"""Broader than EventManagerRequiredMixin's own gate (no object yet to check
|
||||
teams against): anyone managing at least one team, or an admin. EventForm
|
||||
itself then restricts *which* teams a non-admin can pick and requires at
|
||||
least one, so a team-less/club-wide event stays admin-only."""
|
||||
teams/groups against): anyone managing at least one team, belonging to at
|
||||
least one group, or an admin. EventForm itself then restricts *which*
|
||||
teams/groups a non-admin can pick and requires at least one, so a
|
||||
club-wide event stays admin-only."""
|
||||
|
||||
model = Event
|
||||
form_class = EventForm
|
||||
@@ -2244,7 +2239,7 @@ class EventCreateView(ClubStaffRequiredMixin, CreateView):
|
||||
|
||||
def test_func(self):
|
||||
user, club = self.request.user, self.request.club
|
||||
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
|
||||
return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists()
|
||||
|
||||
def get_form_kwargs(self):
|
||||
# Event.clean() rejects a location/opponent from another club by comparing
|
||||
@@ -2274,6 +2269,9 @@ class EventUpdateView(EventManagerRequiredMixin, UpdateView):
|
||||
def get_teams(self):
|
||||
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all()
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "editing": True}
|
||||
|
||||
@@ -2301,6 +2299,9 @@ class EventDeleteView(EventManagerRequiredMixin, View):
|
||||
def get_teams(self):
|
||||
return self.get_event().teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return self.get_event().groups.all()
|
||||
|
||||
def post(self, request, pk):
|
||||
event = self.get_event()
|
||||
title = str(event)
|
||||
@@ -2324,6 +2325,9 @@ class EventDetachView(EventManagerRequiredMixin, View):
|
||||
def get_teams(self):
|
||||
return self.get_event().teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return self.get_event().groups.all()
|
||||
|
||||
def post(self, request, pk):
|
||||
event = self.get_event()
|
||||
detach_occurrence(event)
|
||||
@@ -2345,6 +2349,9 @@ class EventFetchGameInfoView(EventManagerRequiredMixin, View):
|
||||
def get_teams(self):
|
||||
return self.get_event().teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return self.get_event().groups.all()
|
||||
|
||||
def post(self, request, pk):
|
||||
event = self.get_event()
|
||||
try:
|
||||
@@ -2460,7 +2467,7 @@ class EventSeriesCreateView(ClubStaffRequiredMixin, CreateView):
|
||||
|
||||
def test_func(self):
|
||||
user, club = self.request.user, self.request.club
|
||||
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
|
||||
return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists()
|
||||
|
||||
def get_form_kwargs(self):
|
||||
# Same reasoning as EventCreateView: EventSeries.clean() needs a real
|
||||
@@ -2492,6 +2499,9 @@ class EventSeriesUpdateView(EventManagerRequiredMixin, UpdateView):
|
||||
def get_teams(self):
|
||||
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all()
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user}
|
||||
|
||||
@@ -2526,6 +2536,9 @@ class EventSeriesDeleteView(EventManagerRequiredMixin, View):
|
||||
def get_teams(self):
|
||||
return self.get_series().teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return self.get_series().groups.all()
|
||||
|
||||
def post(self, request, pk):
|
||||
series = self.get_series()
|
||||
title = str(series)
|
||||
@@ -2545,6 +2558,9 @@ class EventSeriesStopView(EventManagerRequiredMixin, View):
|
||||
def get_teams(self):
|
||||
return self.get_series().teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return self.get_series().groups.all()
|
||||
|
||||
def post(self, request, pk):
|
||||
series = self.get_series()
|
||||
series.until = timezone.now()
|
||||
@@ -2560,6 +2576,9 @@ class EventSeriesGenerateView(EventManagerRequiredMixin, View):
|
||||
def get_teams(self):
|
||||
return self.get_series().teams.all()
|
||||
|
||||
def get_groups(self):
|
||||
return self.get_series().groups.all()
|
||||
|
||||
def post(self, request, pk):
|
||||
series = self.get_series()
|
||||
created = generate_occurrences(series)
|
||||
|
||||
Reference in New Issue
Block a user