Let eligible referees self-serve sign-up for games needing one
Creating (or re-teaming) a home game for a club-managed team now auto- invites every eligible referee (teams.RefereeProfile) via a new RefereeSignup model, notifying them the same way news/events already do. They see it as its own distinct row on the mobile Calendar (own accent colour) and can accept or decline right there -- accepting routes through the existing capacity-checked assign_referee (assigned_by=None marks it self-service), so it lands as a real EventReferee row with no separate sync step. The desktop referee-management screen and event detail page both surface pending invites and flag self-signed-up referees distinctly from admin assignments. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -73,6 +73,14 @@
|
||||
--color-ice: #14b8e8;
|
||||
--color-ice-ink: #04212c;
|
||||
|
||||
/* Referee sign-up accent -- deliberately distinct from game/training/other's
|
||||
club-red/info-blue/warn-amber (mobile/_calendar_row.html) so a "needs a
|
||||
referee" row stands out from the member's own RSVP rows at a glance. */
|
||||
--color-referee: #7c4dff;
|
||||
--color-referee-bg: #f1ecff;
|
||||
--color-referee-border: #d9caff;
|
||||
--color-referee-text: #5b2fd6;
|
||||
|
||||
--color-row-sel: #fff7f8;
|
||||
--color-row-focus: #f4f9ff;
|
||||
--color-row-warn: #fffdf6;
|
||||
@@ -393,6 +401,12 @@
|
||||
border: 1px solid #dde1e7;
|
||||
}
|
||||
|
||||
.pill-referee {
|
||||
background: var(--color-referee-bg);
|
||||
color: var(--color-referee-text);
|
||||
border: 1px solid var(--color-referee-border);
|
||||
}
|
||||
|
||||
/* --- Person switcher chips (Home's own -- see mobile/templates/mobile/home.html) --- */
|
||||
|
||||
.person-chip {
|
||||
|
||||
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
|
||||
|
||||
|
||||
@admin.register(Opponent)
|
||||
@@ -138,3 +138,11 @@ class EventRefereeAdmin(admin.ModelAdmin):
|
||||
@admin.display(description=_("total payable"))
|
||||
def total_payable(self, obj):
|
||||
return obj.total_payable
|
||||
|
||||
|
||||
@admin.register(RefereeSignup)
|
||||
class RefereeSignupAdmin(admin.ModelAdmin):
|
||||
list_display = ["event", "member", "status", "responded_at"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["event__title", "member__first_name", "member__last_name"]
|
||||
raw_id_fields = ["event", "member"]
|
||||
|
||||
34
events/migrations/0026_refereesignup.py
Normal file
34
events/migrations/0026_refereesignup.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-22 14:32
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0025_remove_lineupunit_lineup_lineupselection_and_more'),
|
||||
('members', '0006_parentclaim_submitted_by_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RefereeSignup',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('invited', 'invited'), ('accepted', 'accepted'), ('declined', 'declined')], default='invited', max_length=20, verbose_name='status')),
|
||||
('responded_at', models.DateTimeField(blank=True, null=True, verbose_name='responded at')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referee_signups', to='events.event', verbose_name='event')),
|
||||
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referee_signups', to='members.member', verbose_name='member')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'referee sign-up',
|
||||
'verbose_name_plural': 'referee sign-ups',
|
||||
'ordering': ['event', 'member__last_name', 'member__first_name'],
|
||||
'constraints': [models.UniqueConstraint(fields=('event', 'member'), name='unique_referee_signup_per_event_per_member')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -304,6 +304,42 @@ class EventReferee(UUIDModel):
|
||||
return (self.fee or Decimal("0")) + self.km_total
|
||||
|
||||
|
||||
class RefereeSignup(UUIDModel):
|
||||
"""One eligible referee's invite/response for one home game -- the
|
||||
self-service counterpart to admin assignment (EventReferee). Created
|
||||
automatically (events.services.referees.sync_referee_invites, wired from
|
||||
events/signals.py) the moment a home game needs a club-arranged referee,
|
||||
for every currently-eligible member (teams.RefereeProfile). Accepting
|
||||
(events.services.referees.accept_referee_signup) creates a real
|
||||
EventReferee row via the same capacity-checked assign_referee every admin
|
||||
assignment goes through (assigned_by=None marks it self-service) -- this
|
||||
table only ever tracks the invite/response, never payment/capacity, so
|
||||
the desktop referee-management screen stays the single source of truth
|
||||
for who's actually refereeing.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
INVITED = "invited", _("invited")
|
||||
ACCEPTED = "accepted", _("accepted")
|
||||
DECLINED = "declined", _("declined")
|
||||
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="referee_signups", verbose_name=_("event"))
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="referee_signups", verbose_name=_("member"))
|
||||
status = models.CharField(_("status"), max_length=20, choices=Status.choices, default=Status.INVITED)
|
||||
responded_at = models.DateTimeField(_("responded at"), null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("referee sign-up")
|
||||
verbose_name_plural = _("referee sign-ups")
|
||||
ordering = ["event", "member__last_name", "member__first_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["event", "member"], name="unique_referee_signup_per_event_per_member"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event} - {self.member} ({self.status})"
|
||||
|
||||
|
||||
class Competition(models.Model):
|
||||
"""A competition has a name with a specific URL to fetch data from. These are managed centrally."""
|
||||
|
||||
|
||||
@@ -27,9 +27,10 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee
|
||||
from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee, RefereeSignup
|
||||
from events.services.attendance import effective_members
|
||||
from members.models import Member
|
||||
from notifications.services import notify_members
|
||||
from teams.models import RefereeLevel, Team
|
||||
|
||||
|
||||
@@ -147,3 +148,56 @@ def set_referee_fee(referee, *, fee=None, km=None, km_rate=None):
|
||||
referee.km_rate = km_rate
|
||||
referee.save(update_fields=["fee", "km", "km_rate"])
|
||||
return referee
|
||||
|
||||
|
||||
def sync_referee_invites(event):
|
||||
"""Invites every currently-eligible, not-yet-invited referee to `event` --
|
||||
the referee-facing counterpart to events.sync_event_attendances, wired
|
||||
from the same signal points (events/signals.py's post_save(Event) and
|
||||
the Event.teams m2m). Idempotent: only creates rows for members with no
|
||||
RefereeSignup at all yet for this event, so an unrelated edit (or a
|
||||
second team added later) never re-notifies someone who already
|
||||
responded -- or re-invites someone who already said no."""
|
||||
if not needs_referee_management(event):
|
||||
return []
|
||||
|
||||
already_invited_ids = set(RefereeSignup.objects.filter(event=event).values_list("member_id", flat=True))
|
||||
new_members = [member for member in eligible_referees(event) if member.pk not in already_invited_ids]
|
||||
if not new_members:
|
||||
return []
|
||||
|
||||
RefereeSignup.objects.bulk_create([RefereeSignup(event=event, member=member) for member in new_members])
|
||||
body = _("%(event)s needs a referee -- can you take it?") % {"event": event.title}
|
||||
notify_members(new_members, club=event.club, title=_("Referee needed"), body=body, source=event)
|
||||
return new_members
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def accept_referee_signup(signup):
|
||||
"""Confirms `signup`'s member as an actual referee for the game --
|
||||
routed through the exact same assign_referee every admin assignment
|
||||
uses (assigned_by=None marks it self-service), so capacity is enforced
|
||||
once, in one place, and the desktop referee-management screen sees the
|
||||
new assignment with no separate sync step. Raises RefereeAssignmentError
|
||||
unchanged if the game's already full -- `signup` is left INVITED so the
|
||||
referee can retry if a slot frees up."""
|
||||
assign_referee(signup.event, signup.member, assigned_by=None)
|
||||
signup.status = RefereeSignup.Status.ACCEPTED
|
||||
signup.responded_at = timezone.now()
|
||||
signup.save(update_fields=["status", "responded_at"])
|
||||
return signup
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def decline_referee_signup(signup):
|
||||
"""Declines `signup`. If they'd already accepted, also removes their
|
||||
self-service EventReferee row (assigned_by is None -- never touches an
|
||||
admin-made assignment for the same member/event, though assign_referee's
|
||||
own "already assigned" guard makes that pairing impossible anyway) so
|
||||
the desktop screen stops counting someone who's since said they can't
|
||||
make it after all."""
|
||||
EventReferee.objects.filter(event=signup.event, member=signup.member, assigned_by__isnull=True).delete()
|
||||
signup.status = RefereeSignup.Status.DECLINED
|
||||
signup.responded_at = timezone.now()
|
||||
signup.save(update_fields=["status", "responded_at"])
|
||||
return signup
|
||||
|
||||
@@ -21,6 +21,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from club.models import ClubMembership
|
||||
from events.models import Event, EventSeries
|
||||
from events.services import sync_event_attendances
|
||||
from events.services.referees import sync_referee_invites
|
||||
from members.models import Group, GroupMembership
|
||||
from teams.models import Team, TeamMembership
|
||||
|
||||
@@ -50,6 +51,7 @@ def validate_groups_same_club(sender, instance, action, reverse, pk_set, **kwarg
|
||||
@receiver(post_save, sender=Event)
|
||||
def sync_on_event_save(sender, instance, **kwargs):
|
||||
sync_event_attendances(instance)
|
||||
sync_referee_invites(instance)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Event.teams.through)
|
||||
@@ -68,6 +70,18 @@ def sync_on_audience_change(sender, instance, action, reverse, model, pk_set, **
|
||||
sync_event_attendances(event)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Event.teams.through)
|
||||
def sync_referee_invites_on_teams_change(sender, instance, action, reverse, **kwargs):
|
||||
# Referee eligibility only depends on `teams` (needs_referee_management),
|
||||
# not groups/invited/excluded_members -- a separate, narrower receiver
|
||||
# rather than folding into sync_on_audience_change above, which is about
|
||||
# attendance specifically. reverse (team.scheduled_events.add(...)) isn't
|
||||
# a used path -- see validate_teams_same_club's own comment.
|
||||
if action not in M2M_SYNC_ACTIONS or reverse or not isinstance(instance, Event):
|
||||
return
|
||||
sync_referee_invites(instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=TeamMembership)
|
||||
@receiver(post_delete, sender=TeamMembership)
|
||||
def sync_on_roster_change(sender, instance, **kwargs):
|
||||
|
||||
119
events/tests.py
119
events/tests.py
@@ -19,7 +19,7 @@ from notifications.models import Notification
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
|
||||
|
||||
from .admin import EventAdminForm
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
|
||||
from .services import (
|
||||
cancel_occurrence,
|
||||
detach_occurrence,
|
||||
@@ -36,7 +36,19 @@ from .services import (
|
||||
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
||||
from .services.lineup import notify_dropout, publish_lineup, selected_members_by_position, toggle_selection
|
||||
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
|
||||
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
|
||||
from .services.referees import (
|
||||
RefereeAssignmentError,
|
||||
accept_referee_signup,
|
||||
add_external_referee,
|
||||
assign_referee,
|
||||
conflicting_events,
|
||||
decline_referee_signup,
|
||||
eligible_referees,
|
||||
needs_referee_management,
|
||||
remove_referee,
|
||||
set_referee_fee,
|
||||
sync_referee_invites,
|
||||
)
|
||||
from .tasks import send_deadline_reminders
|
||||
|
||||
|
||||
@@ -1488,6 +1500,109 @@ class RefereeServiceTests(EventsTestBase):
|
||||
self.assertEqual(assignment.total_payable, Decimal("25.00"))
|
||||
|
||||
|
||||
class RefereeSignupServiceTests(EventsTestBase):
|
||||
"""events.services.referees.sync_referee_invites/accept_referee_signup/
|
||||
decline_referee_signup -- the self-service sign-up flow, layered on top
|
||||
of the same assign_referee/eligible_referees the admin flow uses."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
cls.home_ground = Location.objects.create(club=cls.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
|
||||
cls.level.teams.add(cls.team)
|
||||
cls.referee = Member.objects.create(first_name="Ref", last_name="Eree")
|
||||
cls.referee_profile = RefereeProfile.objects.create(member=cls.referee, level=cls.level, valid_until=timezone.localdate() + timedelta(days=30))
|
||||
|
||||
def make_home_game(self, **kwargs):
|
||||
kwargs.setdefault("kind", Event.EventKind.GAME)
|
||||
kwargs.setdefault("location", self.home_ground)
|
||||
event = self.make_event(**kwargs)
|
||||
event.teams.add(self.team)
|
||||
return event
|
||||
|
||||
def test_creating_a_home_game_invites_eligible_referees(self):
|
||||
# sync_referee_invites is wired from events/signals.py's post_save(Event)
|
||||
# -- make_home_game's own .save() (via make_event) already triggers it.
|
||||
game = self.make_home_game()
|
||||
|
||||
self.assertTrue(RefereeSignup.objects.filter(event=game, member=self.referee, status=RefereeSignup.Status.INVITED).exists())
|
||||
|
||||
def test_creating_a_home_game_notifies_eligible_referees(self):
|
||||
self.make_home_game()
|
||||
|
||||
self.assertTrue(Notification.objects.filter(member=self.referee, title="Referee needed").exists())
|
||||
|
||||
def test_sync_does_not_reinvite_someone_who_already_responded(self):
|
||||
game = self.make_home_game()
|
||||
signup = RefereeSignup.objects.get(event=game, member=self.referee)
|
||||
signup.status = RefereeSignup.Status.DECLINED
|
||||
signup.responded_at = timezone.now()
|
||||
signup.save()
|
||||
|
||||
sync_referee_invites(game)
|
||||
|
||||
signup.refresh_from_db()
|
||||
self.assertEqual(signup.status, RefereeSignup.Status.DECLINED)
|
||||
|
||||
def test_sync_is_a_noop_for_an_away_game(self):
|
||||
away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
|
||||
game = self.make_home_game(location=away_ground)
|
||||
|
||||
self.assertFalse(RefereeSignup.objects.filter(event=game).exists())
|
||||
|
||||
def test_accept_creates_a_real_event_referee_assignment(self):
|
||||
game = self.make_home_game()
|
||||
signup = RefereeSignup.objects.get(event=game, member=self.referee)
|
||||
|
||||
accept_referee_signup(signup)
|
||||
|
||||
assignment = EventReferee.objects.get(event=game, member=self.referee)
|
||||
self.assertIsNone(assignment.assigned_by)
|
||||
signup.refresh_from_db()
|
||||
self.assertEqual(signup.status, RefereeSignup.Status.ACCEPTED)
|
||||
self.assertIsNotNone(signup.responded_at)
|
||||
|
||||
def test_accept_raises_when_the_game_is_already_full(self):
|
||||
game = self.make_home_game(max_referees=1)
|
||||
add_external_referee(game, "Guest Referee", assigned_by=None)
|
||||
signup = RefereeSignup.objects.get(event=game, member=self.referee)
|
||||
|
||||
with self.assertRaises(RefereeAssignmentError):
|
||||
accept_referee_signup(signup)
|
||||
|
||||
signup.refresh_from_db()
|
||||
self.assertEqual(signup.status, RefereeSignup.Status.INVITED)
|
||||
|
||||
def test_decline_marks_the_signup_declined(self):
|
||||
game = self.make_home_game()
|
||||
signup = RefereeSignup.objects.get(event=game, member=self.referee)
|
||||
|
||||
decline_referee_signup(signup)
|
||||
|
||||
signup.refresh_from_db()
|
||||
self.assertEqual(signup.status, RefereeSignup.Status.DECLINED)
|
||||
self.assertIsNotNone(signup.responded_at)
|
||||
|
||||
def test_decline_after_accepting_removes_the_self_service_assignment(self):
|
||||
game = self.make_home_game()
|
||||
signup = RefereeSignup.objects.get(event=game, member=self.referee)
|
||||
accept_referee_signup(signup)
|
||||
|
||||
decline_referee_signup(signup)
|
||||
|
||||
self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists())
|
||||
|
||||
def test_decline_never_removes_an_admin_made_assignment_for_the_same_member(self):
|
||||
game = self.make_home_game()
|
||||
signup = RefereeSignup.objects.get(event=game, member=self.referee)
|
||||
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.alice)
|
||||
|
||||
decline_referee_signup(signup)
|
||||
|
||||
self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee, assigned_by=self.alice).exists())
|
||||
|
||||
|
||||
class CalendarGridTests(EventsTestBase):
|
||||
"""events.services.calendar -- date-range math and grid layout behind the
|
||||
Events page's Week/Month/Season views."""
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
Shared by the event detail page and the referee management dashboard --
|
||||
context: event, referees (EventReferee rows -- fee/km/km_rate read straight
|
||||
off the model instance, no separate form object needed), referee_candidates,
|
||||
referees_full, can_manage_referees, and an optional next_url to return to
|
||||
after a POST (defaults to the event detail page when blank).
|
||||
referees_full, pending_signups (RefereeSignup rows still INVITED -- the
|
||||
self-service counterpart, events.services.referees.sync_referee_invites/
|
||||
accept_referee_signup), can_manage_referees, and an optional next_url to
|
||||
return to after a POST (defaults to the event detail page when blank).
|
||||
|
||||
A referee row with no assigned_by (events.services.referees.
|
||||
accept_referee_signup passes assigned_by=None) is one who signed
|
||||
themselves up rather than one an admin picked -- flagged "Self sign-up"
|
||||
instead of "Assigned by ..." so that stays visible here without a
|
||||
separate screen.
|
||||
|
||||
The fee is entered right here, inline, rather than behind a "Fee" button
|
||||
that opened a second (and on the dashboard, nested-inside-a-dialog) modal
|
||||
@@ -33,7 +41,11 @@
|
||||
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
|
||||
{% if can_manage_referees and not referee.fee %}<span class="badge badge-warning badge-xs">{% trans "Fee not set" %}</span>{% endif %}
|
||||
</div>
|
||||
{% if referee.assigned_by %}<span class="text-xs text-muted">{% blocktrans with name=referee.assigned_by %}Assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
|
||||
{% if referee.assigned_by %}
|
||||
<span class="text-xs text-muted">{% blocktrans with name=referee.assigned_by %}Assigned by {{ name }}{% endblocktrans %}</span>
|
||||
{% elif not referee.is_external %}
|
||||
<span class="badge badge-outline badge-xs w-fit">{% trans "Self sign-up" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_manage_referees %}
|
||||
@@ -76,6 +88,13 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if pending_signups %}
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{% blocktrans count counter=pending_signups|length %}Invited, no response yet: {% plural %}Invited, no response yet: {% endblocktrans %}
|
||||
{% for signup in pending_signups %}{{ signup.member }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if can_manage_referees and not referees_full %}
|
||||
<div class="mt-3 flex flex-col gap-2 border-t border-rule pt-3">
|
||||
<span class="font-mono text-[11px] tracking-[.08em] text-dim uppercase">{% trans "Add a referee" %}</span>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
|
||||
</h3>
|
||||
<div class="mb-4 font-mono text-sm text-muted">{{ game.start|date:"j M Y H:i" }}</div>
|
||||
{% include "management/_referee_assignment_panel.html" with event=game referees=game.referee_rows referee_candidates=game.referee_candidates referees_full=game.referees_full can_manage_referees=True next_url=request.get_full_path %}
|
||||
{% include "management/_referee_assignment_panel.html" with event=game referees=game.referee_rows referee_candidates=game.referee_candidates referees_full=game.referees_full pending_signups=game.pending_signups can_manage_referees=True next_url=request.get_full_path %}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
@@ -23,7 +23,7 @@ from billing.services.dues import record_payment, subscribe
|
||||
from club.models import Club, ClubMembership, ClubRole, DuesInvoice, FeePayment, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||
from club.services.invoicing import DuesInvoicePDFError
|
||||
from club.services.onboarding import mark_complete
|
||||
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
|
||||
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
|
||||
from events.services.rbihf_import import RBIHFImportError
|
||||
from events.services.recurrence import detach_occurrence, generate_occurrences
|
||||
from management.bulk_import import TEMPLATE_COLUMNS
|
||||
@@ -6554,6 +6554,39 @@ class RefereeManagementDashboardTests(ManagementTestBase):
|
||||
|
||||
self.assertRedirects(response, reverse("management:event_detail", args=[game.pk]))
|
||||
|
||||
def test_shows_a_pending_self_service_invite(self):
|
||||
# make_game()'s own event.teams.add(...) already triggers
|
||||
# events.services.referees.sync_referee_invites via events/signals.py --
|
||||
# self.referee (eligible for self.team) gets invited automatically.
|
||||
game = self.make_game()
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("referee_management")
|
||||
|
||||
self.assertTrue(RefereeSignup.objects.filter(event=game, member=self.referee, status=RefereeSignup.Status.INVITED).exists())
|
||||
self.assertContains(response, "Invited, no response yet")
|
||||
self.assertContains(response, str(self.referee))
|
||||
|
||||
def test_no_pending_invite_shown_once_the_game_is_full(self):
|
||||
game = self.make_game(max_referees=1)
|
||||
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member)
|
||||
other_referee = Member.objects.create(first_name="Other", last_name="Ref")
|
||||
RefereeSignup.objects.create(event=game, member=other_referee, status=RefereeSignup.Status.INVITED)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("referee_management")
|
||||
|
||||
self.assertNotContains(response, "Invited, no response yet")
|
||||
|
||||
def test_self_signed_up_referee_is_flagged_distinctly_from_an_admin_assignment(self):
|
||||
game = self.make_game()
|
||||
EventReferee.objects.create(event=game, member=self.referee, assigned_by=None)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("referee_management")
|
||||
|
||||
self.assertContains(response, "Self sign-up")
|
||||
|
||||
|
||||
class FeatureGatedSectionsTests(ManagementTestBase):
|
||||
"""The Shop and Forms sections are still stubs (StubListMixin) and, on top
|
||||
|
||||
@@ -35,7 +35,7 @@ from club.services.onboarding import annotate_onboarding_status, approve_all_cle
|
||||
from controlpanel.messages import notify
|
||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||
from controlpanel.services.statistics import club_attention, club_charts, club_statistics, unrostered_members
|
||||
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent
|
||||
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
|
||||
from events.services.attendance import member_attendance_counts, member_attendance_sparkline, player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
|
||||
from events.services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
||||
from events.services.competitions import CompetitionFetchError, fetch_game_info
|
||||
@@ -2501,6 +2501,7 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
referees = []
|
||||
referee_candidates = []
|
||||
referees_full = False
|
||||
pending_signups = []
|
||||
if referee_management_needed:
|
||||
referees = list(event.referees.select_related("member", "assigned_by").order_by("member__last_name", "member__first_name"))
|
||||
referees_full = len(referees) >= event.max_referees
|
||||
@@ -2510,6 +2511,8 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
candidate.has_conflict = bool(conflicts)
|
||||
candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
|
||||
referee_candidates.append(candidate)
|
||||
if can_manage_referees:
|
||||
pending_signups = list(event.referee_signups.filter(status=RefereeSignup.Status.INVITED).select_related("member"))
|
||||
|
||||
return super().get_context_data(
|
||||
can_manage=can_manage,
|
||||
@@ -2521,6 +2524,7 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
referees=referees,
|
||||
referee_candidates=referee_candidates,
|
||||
referees_full=referees_full,
|
||||
pending_signups=pending_signups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -2745,6 +2749,7 @@ class RefereeManagementDashboardView(MemberAdminRequiredMixin, TemplateView):
|
||||
game.referees_full = len(game.referee_rows) >= game.max_referees
|
||||
game.referee_candidates = []
|
||||
game.fees_pending = any(not referee.fee for referee in game.referee_rows)
|
||||
game.pending_signups = list(game.referee_signups.filter(status=RefereeSignup.Status.INVITED).select_related("member")) if not game.referees_full else []
|
||||
if not game.referee_rows:
|
||||
kpi_no_referee += 1
|
||||
elif not game.referees_full:
|
||||
|
||||
50
mobile/templates/mobile/_calendar_referee_row.html
Normal file
50
mobile/templates/mobile/_calendar_referee_row.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% load i18n lucide %}
|
||||
{% comment %}
|
||||
One referee sign-up row on M3's Calendar -- a home game the signed-in
|
||||
account is eligible (and, once responded, confirmed) to referee for.
|
||||
Merged into the same chronological list as the account's own RSVP rows
|
||||
(mobile/_calendar_row.html), but in the referee accent (assets/mobile.
|
||||
css's --color-referee/.pill-referee) so it reads as a different kind of
|
||||
commitment at a glance -- and scoped to self.me only (mobile/views.py's
|
||||
CalendarView), never managed_people, since a referee is an adult acting
|
||||
on their own behalf, not something a parent does for a child.
|
||||
|
||||
Expects ``row`` ({event, referee_signup}) in scope. Accept/Decline are
|
||||
two small forms, not a single multi-button one, so hx-boost="false" can
|
||||
sit on each without a formaction/htmx-boost mismatch (see event_detail.
|
||||
html's own top-of-file comment for that failure mode).
|
||||
{% endcomment %}
|
||||
<div class="flex items-center gap-3 bg-white px-4 py-3">
|
||||
<div class="w-9.5 shrink-0 text-center">
|
||||
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ row.event.start|date:"D" }}</div>
|
||||
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ row.event.start|date:"d" }}</div>
|
||||
</div>
|
||||
<div class="w-[3px] shrink-0 self-stretch rounded-full" style="background: var(--color-referee)"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1 text-sm font-semibold text-ink">
|
||||
{% lucide "flag" size=13 stroke_width=2.4 class="shrink-0 text-referee" %}
|
||||
<span class="truncate">{% trans "Referee" %} · {{ row.event.title }}</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted">
|
||||
{{ row.event.start|date:"H:i" }}
|
||||
{% for team in row.event.teams.all %}· {{ team.name }}{% endfor %}
|
||||
{% if row.event.location %}· {{ row.event.location.name }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if row.referee_signup.status == "accepted" %}
|
||||
<span class="pill pill-referee shrink-0">{% trans "Confirmed" %}</span>
|
||||
{% else %}
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}" hx-boost="false">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="response" value="accept">
|
||||
<button type="submit" class="pill pill-referee">{% trans "I'll ref" %}</button>
|
||||
</form>
|
||||
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}" hx-boost="false">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="response" value="decline">
|
||||
<button type="submit" class="pill pill-neutral">{% trans "Can't" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -7,7 +7,10 @@
|
||||
everything further out grouped by calendar month (see CalendarView's own
|
||||
docstring -- later_months is a list of {month_start, rows}). No person
|
||||
switcher and no club-wide toggle here -- always every event
|
||||
self.managed_people is invited to, full stop. A real, working ?kind=
|
||||
self.managed_people is invited to, plus (merged into the same
|
||||
chronological list, own accent colour) any home game self.me is
|
||||
eligible to referee -- see mobile/_calendar_referee_row.html. A real,
|
||||
working ?kind=
|
||||
filter (All/Games/Practices) stands in for the design mock's "Games only"
|
||||
pill; its List/Month toggle isn't reproduced -- that's a second view
|
||||
mode, not a filter. -mx-4 (on the row groups only, not this filter row)
|
||||
@@ -60,7 +63,7 @@
|
||||
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
|
||||
<div class="flex flex-col gap-px bg-line">
|
||||
{% for row in this_week %}
|
||||
{% include "mobile/_calendar_row.html" %}
|
||||
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +74,7 @@
|
||||
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
|
||||
<div class="flex flex-col gap-px bg-line">
|
||||
{% for row in next_week %}
|
||||
{% include "mobile/_calendar_row.html" %}
|
||||
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +85,7 @@
|
||||
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
|
||||
<div class="flex flex-col gap-px bg-line">
|
||||
{% for row in month.rows %}
|
||||
{% include "mobile/_calendar_row.html" %}
|
||||
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
143
mobile/tests.py
143
mobile/tests.py
@@ -9,11 +9,11 @@ from django.utils import timezone, translation
|
||||
from icalendar import Calendar as ICalCalendar
|
||||
|
||||
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||
from events.models import Attendance, Event, Lineup, LineupSelection
|
||||
from events.models import Attendance, Event, EventReferee, Lineup, LineupSelection, Location, RefereeSignup
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
from news.models import News
|
||||
from notifications.models import Notification
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
|
||||
|
||||
from .models import CalendarFeedToken, PushSubscription
|
||||
from .services.icons import render_fallback_icon
|
||||
@@ -1008,6 +1008,145 @@ class CalendarViewTests(TestCase):
|
||||
self.assertIn(event, self._events_in_context(response))
|
||||
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||
class CalendarRefereeSignupTests(TestCase):
|
||||
"""M3's Calendar merges in the signed-in account's own referee sign-ups
|
||||
(self.me only, never managed_people) -- see mobile/_calendar_referee_row.
|
||||
html and CalendarView's own docstring."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
today = timezone.localdate()
|
||||
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||
cls.user = User.objects.create_user(email="ref@example.com", password="pw-secret-123")
|
||||
cls.member = Member.objects.create(first_name="Ref", last_name="Eree", email="ref@example.com", user=cls.user)
|
||||
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||
|
||||
cls.home_ground = Location.objects.create(club=cls.club, name="Home Rink", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
|
||||
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
|
||||
cls.level.teams.add(cls.team)
|
||||
RefereeProfile.objects.create(member=cls.member, level=cls.level, valid_until=today + datetime.timedelta(days=30))
|
||||
|
||||
cls.game = Event.objects.create(club=cls.club, title="Home game", kind=Event.EventKind.GAME, location=cls.home_ground, start=timezone.now() + datetime.timedelta(days=1))
|
||||
cls.game.teams.add(cls.team) # triggers sync_referee_invites via events/signals.py
|
||||
|
||||
def _get(self, **params):
|
||||
url = reverse("mobile:calendar")
|
||||
if params:
|
||||
url += "?" + "&".join(f"{key}={value}" for key, value in params.items())
|
||||
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_invited_game_shows_up_on_the_calendar(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertContains(response, "Referee")
|
||||
self.assertContains(response, "I'll ref")
|
||||
|
||||
def test_declined_signup_is_not_shown(self):
|
||||
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
|
||||
signup.status = RefereeSignup.Status.DECLINED
|
||||
signup.save()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertNotContains(response, "I'll ref")
|
||||
|
||||
def test_accepted_signup_shows_a_confirmed_pill_with_no_actions(self):
|
||||
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
|
||||
signup.status = RefereeSignup.Status.ACCEPTED
|
||||
signup.save()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertContains(response, "Confirmed")
|
||||
self.assertNotContains(response, "I'll ref")
|
||||
|
||||
def test_training_filter_excludes_referee_rows(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(kind="training")
|
||||
|
||||
self.assertNotContains(response, "I'll ref")
|
||||
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||
class RefereeSignupRespondViewTests(TestCase):
|
||||
"""mobile:referee_signup_respond -- Accept/Decline from the Calendar."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
today = timezone.localdate()
|
||||
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||
cls.user = User.objects.create_user(email="ref@example.com", password="pw-secret-123")
|
||||
cls.member = Member.objects.create(first_name="Ref", last_name="Eree", email="ref@example.com", user=cls.user)
|
||||
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||
|
||||
cls.home_ground = Location.objects.create(club=cls.club, name="Home Rink", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
|
||||
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
|
||||
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
|
||||
cls.level.teams.add(cls.team)
|
||||
RefereeProfile.objects.create(member=cls.member, level=cls.level, valid_until=today + datetime.timedelta(days=30))
|
||||
|
||||
cls.game = Event.objects.create(club=cls.club, title="Home game", kind=Event.EventKind.GAME, location=cls.home_ground, start=timezone.now() + datetime.timedelta(days=1))
|
||||
cls.game.teams.add(cls.team)
|
||||
|
||||
def _post(self, response_value):
|
||||
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
|
||||
return self.client.post(reverse("mobile:referee_signup_respond", kwargs={"signup_id": signup.pk}), {"response": response_value}, HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_requires_login(self):
|
||||
response = self._post("accept")
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_accept_creates_a_real_assignment(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._post("accept")
|
||||
|
||||
self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False)
|
||||
self.assertTrue(EventReferee.objects.filter(event=self.game, member=self.member).exists())
|
||||
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
|
||||
self.assertEqual(signup.status, RefereeSignup.Status.ACCEPTED)
|
||||
|
||||
def test_accept_shows_an_error_when_the_game_is_already_full(self):
|
||||
self.game.max_referees = 0
|
||||
self.game.save(update_fields=["max_referees"])
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._post("accept")
|
||||
|
||||
self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False)
|
||||
self.assertFalse(EventReferee.objects.filter(event=self.game, member=self.member).exists())
|
||||
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
|
||||
self.assertEqual(signup.status, RefereeSignup.Status.INVITED)
|
||||
|
||||
def test_decline_marks_the_signup_declined(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._post("decline")
|
||||
|
||||
self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False)
|
||||
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
|
||||
self.assertEqual(signup.status, RefereeSignup.Status.DECLINED)
|
||||
|
||||
def test_cannot_respond_to_someone_elses_signup(self):
|
||||
stranger_user = User.objects.create_user(email="stranger@example.com", password="pw-secret-123")
|
||||
Member.objects.create(first_name="Not", last_name="You", email="stranger@example.com", user=stranger_user)
|
||||
self.client.force_login(stranger_user)
|
||||
|
||||
response = self._post("accept")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||
class EventDetailScreenTests(TestCase):
|
||||
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section,
|
||||
|
||||
@@ -14,6 +14,7 @@ urlpatterns = [
|
||||
# Member mode (M1-M7).
|
||||
path("", views.HomeView.as_view(), name="home"),
|
||||
path("calendar/", views.CalendarView.as_view(), name="calendar"),
|
||||
path("referee-signups/<uuid:signup_id>/respond/", views.RefereeSignupRespondView.as_view(), name="referee_signup_respond"),
|
||||
path("events/<uuid:pk>/", views.EventDetailView.as_view(), name="event_detail"),
|
||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
|
||||
|
||||
@@ -27,9 +27,10 @@ from club.services.fees import open_dues_rows
|
||||
from club.services.onboarding import checklist_for
|
||||
from club.services.sponsors import active_sponsors
|
||||
from controlpanel.messages import notify
|
||||
from events.models import Attendance, Event, Lineup
|
||||
from events.models import Attendance, Event, Lineup, RefereeSignup
|
||||
from events.services.calendar import week_bounds
|
||||
from events.services.lineup import notify_dropout, selected_members_by_position
|
||||
from events.services.referees import RefereeAssignmentError, accept_referee_signup, decline_referee_signup
|
||||
from members.models import FamilyMembership, Member
|
||||
from members.views import ClubScopedPublicMixin
|
||||
from news.models import News
|
||||
@@ -348,6 +349,31 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
for attendance in attendances
|
||||
]
|
||||
|
||||
# Referee sign-ups are scoped to self.me only, never managed_people --
|
||||
# a referee is an adult with their own account, not something a
|
||||
# parent does on a child's behalf, unlike every RSVP row above.
|
||||
# Merged into the same chronological list (own dict shape, no
|
||||
# pill_class/member) rather than a separate section, so it reads on
|
||||
# the actual day it falls on -- distinct styling is what sets it
|
||||
# apart (mobile/_calendar_referee_row.html), not a different place
|
||||
# on the screen. Declined invites are dropped; accepted ones stay
|
||||
# visible as a confirmed commitment.
|
||||
if self.me is not None and kind_filter != "training":
|
||||
signups = (
|
||||
RefereeSignup.objects.filter(
|
||||
member=self.me,
|
||||
event__club=self.request.club,
|
||||
event__cancelled=False,
|
||||
event__start__gte=now,
|
||||
status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED],
|
||||
)
|
||||
.select_related("event", "event__location", "event__opponent")
|
||||
.prefetch_related("event__teams")
|
||||
)
|
||||
rows += [{"event": signup.event, "referee_signup": signup} for signup in signups]
|
||||
|
||||
rows.sort(key=lambda row: row["event"].start)
|
||||
|
||||
this_week, next_week, later_rows = [], [], []
|
||||
for row in rows:
|
||||
event_date = timezone.localtime(row["event"].start).date()
|
||||
@@ -368,6 +394,33 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
return super().get_context_data(this_week=this_week, next_week=next_week, later_months=later_months, kind_filter=kind_filter if kind_filter in self.KIND_FILTERS else "", **kwargs)
|
||||
|
||||
|
||||
class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View):
|
||||
"""Accept/decline a referee invite from a Calendar row
|
||||
(mobile/_calendar_referee_row.html) -- routes through
|
||||
events.services.referees.accept_referee_signup/decline_referee_signup,
|
||||
so capacity is enforced in the one place the desktop admin flow already
|
||||
enforces it, and the referee-management screen sees the result with no
|
||||
separate sync step."""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member=self.me, event__club=request.club)
|
||||
response = request.POST.get("response")
|
||||
|
||||
if response == "accept":
|
||||
try:
|
||||
accept_referee_signup(signup)
|
||||
notify(request, f"s|{_('Referee sign-up confirmed')}|{_('Thanks for signing up -- see you there.')}")
|
||||
except RefereeAssignmentError as exc:
|
||||
notify(request, f"e|{_('Could not sign you up')}|{exc}")
|
||||
elif response == "decline":
|
||||
decline_referee_signup(signup)
|
||||
notify(request, f"s|{_('Declined')}|{_('No problem -- thanks for letting us know.')}")
|
||||
else:
|
||||
return HttpResponseBadRequest(_("Unknown response."))
|
||||
|
||||
return HttpResponseRedirect(reverse("mobile:calendar"))
|
||||
|
||||
|
||||
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section:
|
||||
"answer for several". A hero header (no event photos in this codebase --
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user