Build M2 Event detail ("answer for several") for the mobile app
Full event-detail screen: hero header, Face-off/Meet/Where facts (the design mock's Kit/dressing-room details have no backing Event field, so they're simply omitted), a per-managed-person RSVP card so a parent with several kids on the same event can answer for each independently, and a club-visible squad-response aggregate (counts only, never who answered what). Extends the existing quick-RSVP POST (built for M1's Home hero) to also accept "maybe" for M2's three-way buttons, and adds an optional next=event_detail redirect target so answering here doesn't bounce back to Home -- M1's and M3's existing forms are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
126
mobile/templates/mobile/event_detail.html
Normal file
126
mobile/templates/mobile/event_detail.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{% extends "mobile/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
M2 -- design_handoff_rosterchief_platform/README.md's M2 section, "answer
|
||||||
|
for several". See EventDetailView's own docstring (mobile/views.py) for
|
||||||
|
the judgment calls: no event photos, no Kit/dressing-room fields (the
|
||||||
|
Event model has none), squad response is counts-only (never who answered
|
||||||
|
what).
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="-mx-4 -mt-4 flex h-[220px] flex-col justify-end bg-ink p-4 text-white" style="background: linear-gradient(180deg, rgba(11,18,32,.35) 0%, rgba(11,18,32,.55) 60%, rgba(11,18,32,.92) 100%), var(--color-navy)">
|
||||||
|
<a class="mb-auto flex h-11 w-11 items-center justify-center rounded-full bg-white/15" href="{% url "mobile:home" %}" aria-label="{% trans "Back" %}">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||||
|
</a>
|
||||||
|
<span class="mb-2 inline-block w-fit rounded bg-club px-2 py-1 font-display text-xs font-extrabold tracking-wide text-white uppercase">
|
||||||
|
{% if event.kind == "game" %}{% if event.is_home_game %}{% trans "Home game" %}{% else %}{% trans "Away game" %}{% endif %}{% else %}{{ event.get_kind_display }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<h1 class="font-display text-4xl leading-[.98] font-extrabold uppercase">{{ event.title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-card flex flex-col gap-2.5 p-4">
|
||||||
|
<div class="flex gap-3.5">
|
||||||
|
<span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Face-off" %}</span>
|
||||||
|
<span class="text-[15px] font-semibold text-ink">{{ event.start|date:"D d M \a\t H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
{% if event.gathering %}
|
||||||
|
<div class="flex gap-3.5">
|
||||||
|
<span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Meet" %}</span>
|
||||||
|
<span class="text-[15px] font-semibold text-ink">{{ event.gathering|date:"H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if event.location %}
|
||||||
|
<div class="flex gap-3.5">
|
||||||
|
<span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Where" %}</span>
|
||||||
|
<span class="text-[15px] font-semibold text-ink">
|
||||||
|
{{ event.location.name }}<br>
|
||||||
|
<span class="text-[13px] font-normal text-muted">{{ event.location.address }}, {{ event.location.zip_code }} {{ event.location.city }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if your_answers %}
|
||||||
|
<div class="m-card p-4">
|
||||||
|
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Your answers" %}</span>
|
||||||
|
<div class="mt-3 flex flex-col gap-3">
|
||||||
|
{% for answer in your_answers %}
|
||||||
|
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2.5">
|
||||||
|
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-steel font-display text-sm font-extrabold text-white">{{ answer.member.first_name|slice:":1" }}{{ answer.member.last_name|slice:":1" }}</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-[15px] font-semibold text-ink">{{ answer.member.get_full_name }}</div>
|
||||||
|
{% if answer.membership %}
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{{ answer.membership.team.short_name }}
|
||||||
|
{% if answer.membership.jersey_number %}· #{{ answer.membership.jersey_number }}{% endif %}
|
||||||
|
{% if answer.membership.position %}· {{ answer.membership.position.name }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if answer.attendance.status == "no_response" %}
|
||||||
|
<span class="pill pill-warn shrink-0">{% trans "No reply" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
{% trans "In" as label_in %}
|
||||||
|
{% trans "Maybe" as label_maybe %}
|
||||||
|
{% trans "Out" as label_out %}
|
||||||
|
{% with status=answer.attendance.status %}
|
||||||
|
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
|
||||||
|
<input type="hidden" name="status" value="present">
|
||||||
|
<input type="hidden" name="next" value="event_detail">
|
||||||
|
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "present" %}btn-positive{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_in }}</button>
|
||||||
|
</form>
|
||||||
|
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
|
||||||
|
<input type="hidden" name="status" value="maybe">
|
||||||
|
<input type="hidden" name="next" value="event_detail">
|
||||||
|
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "maybe" %}bg-steel text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_maybe }}</button>
|
||||||
|
</form>
|
||||||
|
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
|
||||||
|
<input type="hidden" name="status" value="absent">
|
||||||
|
<input type="hidden" name="next" value="event_detail">
|
||||||
|
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "absent" %}bg-club text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_out }}</button>
|
||||||
|
</form>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if squad_summary %}
|
||||||
|
<div class="m-card p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Squad response" %}</span>
|
||||||
|
<span class="text-[13px] text-muted">{% blocktrans with responded=squad_summary.responded total=squad_summary.total %}{{ responded }} of {{ total }}{% endblocktrans %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2.5 flex h-2 overflow-hidden rounded-full bg-rule">
|
||||||
|
<div class="bg-ok" style="width: {{ squad_summary.in_pct }}%"></div>
|
||||||
|
<div class="bg-club" style="width: {{ squad_summary.out_pct }}%"></div>
|
||||||
|
<div class="bg-line" style="width: {{ squad_summary.no_reply_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex gap-3.5 text-[13px] text-muted">
|
||||||
|
<span><strong class="text-ink">{{ squad_summary.in_count }}</strong> {% trans "in" %}</span>
|
||||||
|
<span><strong class="text-ink">{{ squad_summary.out_count }}</strong> {% trans "out" %}</span>
|
||||||
|
<span><strong class="text-ink">{{ squad_summary.no_reply_count }}</strong> {% trans "no reply" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not your_answers and not squad_summary %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
118
mobile/tests.py
118
mobile/tests.py
@@ -10,6 +10,7 @@ from club.models import Club, ClubMembership, DuesInvoice, Season
|
|||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from news.models import News
|
from news.models import News
|
||||||
|
from teams.models import Position, Team, TeamMembership
|
||||||
|
|
||||||
from .models import PushSubscription
|
from .models import PushSubscription
|
||||||
from .services.icons import render_fallback_icon
|
from .services.icons import render_fallback_icon
|
||||||
@@ -306,13 +307,28 @@ class EventDetailRsvpTests(TestCase):
|
|||||||
self.attendance.refresh_from_db()
|
self.attendance.refresh_from_db()
|
||||||
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
|
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
|
||||||
|
def test_posting_maybe_updates_attendance(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self._post(self.event, {"status": "maybe"})
|
||||||
|
|
||||||
|
self.attendance.refresh_from_db()
|
||||||
|
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE)
|
||||||
|
|
||||||
def test_rejects_an_unknown_status_value(self):
|
def test_rejects_an_unknown_status_value(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
response = self._post(self.event, {"status": "maybe"})
|
response = self._post(self.event, {"status": "excused"})
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_next_event_detail_redirects_back_to_the_event_instead_of_home(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._post(self.event, {"status": "present", "next": "event_detail"})
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:event_detail", kwargs={"pk": self.event.pk}), fetch_redirect_response=False)
|
||||||
|
|
||||||
def test_cannot_rsvp_for_an_event_from_another_club(self):
|
def test_cannot_rsvp_for_an_event_from_another_club(self):
|
||||||
other_club = Club.objects.create(name="Other Club", slug="other-club", secondary_color="#e4002b")
|
other_club = Club.objects.create(name="Other Club", slug="other-club", secondary_color="#e4002b")
|
||||||
other_event = Event.objects.create(club=other_club, title="Not ours", start=timezone.now() + datetime.timedelta(days=3))
|
other_event = Event.objects.create(club=other_club, title="Not ours", start=timezone.now() + datetime.timedelta(days=3))
|
||||||
@@ -416,3 +432,103 @@ class CalendarViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "No one to show yet")
|
self.assertContains(response, "No one to show yet")
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
"answer for several" (the GET side; POST is covered by
|
||||||
|
EventDetailRsvpTests above)."""
|
||||||
|
|
||||||
|
@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="parent@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
|
||||||
|
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||||
|
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
|
||||||
|
cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="F")
|
||||||
|
cls.event = Event.objects.create(club=cls.club, title="Home game", start=timezone.now() + datetime.timedelta(days=7))
|
||||||
|
cls.event.teams.add(cls.team)
|
||||||
|
|
||||||
|
def _get(self, event=None):
|
||||||
|
event = event or self.event
|
||||||
|
return self.client.get(reverse("mobile:event_detail", kwargs={"pk": event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_renders_event_details(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Home game")
|
||||||
|
|
||||||
|
def test_your_answers_only_lists_managed_people_invited_to_this_event(self):
|
||||||
|
# TeamMembership -> Event.teams (both already set up in setUpTestData) auto-creates
|
||||||
|
# a NO_RESPONSE Attendance row via events/signals.py -- no need to create one by hand.
|
||||||
|
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.position, jersey_number=17)
|
||||||
|
|
||||||
|
family = Family.objects.create(name="Bakker")
|
||||||
|
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
||||||
|
not_invited_child = Member.objects.create(first_name="Noor", last_name="Bakker")
|
||||||
|
FamilyMembership.objects.create(family=family, member=not_invited_child, role=FamilyMembership.FamilyRole.CHILD)
|
||||||
|
ClubMembership.objects.create(club=self.club, member=not_invited_child, season=self.season)
|
||||||
|
# Noor is a managed person but has no Attendance row for this event -- not invited.
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
answered_members = {answer["member"] for answer in response.context["your_answers"]}
|
||||||
|
self.assertEqual(answered_members, {self.member})
|
||||||
|
self.assertContains(response, "#17")
|
||||||
|
self.assertContains(response, "No reply")
|
||||||
|
|
||||||
|
def test_your_answers_is_empty_when_nobody_managed_is_invited(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(list(response.context["your_answers"]), [])
|
||||||
|
self.assertContains(response, "No one you manage is invited")
|
||||||
|
|
||||||
|
def test_squad_response_counts_are_correct(self):
|
||||||
|
in_member = Member.objects.create(first_name="A", last_name="In")
|
||||||
|
out_member = Member.objects.create(first_name="B", last_name="Out")
|
||||||
|
no_reply_member = Member.objects.create(first_name="C", last_name="Silent")
|
||||||
|
Attendance.objects.create(event=self.event, member=in_member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
|
Attendance.objects.create(event=self.event, member=out_member, status=Attendance.AttendanceStatus.ABSENT)
|
||||||
|
Attendance.objects.create(event=self.event, member=no_reply_member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
summary = response.context["squad_summary"]
|
||||||
|
self.assertEqual(summary["in_count"], 1)
|
||||||
|
self.assertEqual(summary["out_count"], 1)
|
||||||
|
self.assertEqual(summary["no_reply_count"], 1)
|
||||||
|
self.assertEqual(summary["total"], 3)
|
||||||
|
|
||||||
|
def test_squad_response_is_absent_for_an_event_with_no_teams(self):
|
||||||
|
club_wide_event = Event.objects.create(club=self.club, title="Club BBQ", start=timezone.now() + datetime.timedelta(days=3), club_wide=True)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get(club_wide_event)
|
||||||
|
|
||||||
|
self.assertIsNone(response.context["squad_summary"])
|
||||||
|
|
||||||
|
def test_404_for_an_event_from_another_club(self):
|
||||||
|
other_club = Club.objects.create(name="Other Club", slug="other-club", secondary_color="#e4002b")
|
||||||
|
other_event = Event.objects.create(club=other_club, title="Not ours", start=timezone.now() + datetime.timedelta(days=3))
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get(other_event)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db.models import Q
|
from django.db.models import Count, Q
|
||||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
|
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
@@ -284,17 +284,78 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
return super().get_context_data(scope_all=scope_all, this_week=this_week, next_week=next_week, **kwargs)
|
return super().get_context_data(scope_all=scope_all, this_week=this_week, next_week=next_week, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class EventDetailView(_PlaceholderScreen):
|
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
"""GET is still the M2 placeholder (a later screen owns the full detail
|
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section:
|
||||||
page); POST is M1's quick In/Out RSVP action, reused by any future screen
|
"answer for several". A hero header (no event photos in this codebase --
|
||||||
that posts the same {status, member_id} shape at this URL."""
|
a solid dark block instead, like M1's hero card), an event-detail card
|
||||||
|
(Face-off/Meet/Where -- the design mock's extra "Kit" row and dressing-room
|
||||||
|
detail have no backing Event field, so they're simply not shown), a
|
||||||
|
per-managed-person RSVP card scoped to ``self.managed_people`` who
|
||||||
|
actually have an ``Attendance`` row for this event (i.e. are invited --
|
||||||
|
not every managed person necessarily is), and a club/team-visible
|
||||||
|
squad-response aggregate (counts only, never who-answered-what) shown
|
||||||
|
only when the event has an actual team roster to aggregate.
|
||||||
|
|
||||||
|
POST is still M1's quick In/Out RSVP action (now also accepting "maybe"
|
||||||
|
for M2's three-way buttons), reused by any screen that posts the same
|
||||||
|
{status, member_id} shape at this URL. An optional ``next=event_detail``
|
||||||
|
field redirects back here instead of Home -- M2's own forms send it so a
|
||||||
|
parent answering for several people in a row sees each update land
|
||||||
|
without bouncing away; M1's/M3's existing forms don't send it, so they
|
||||||
|
keep redirecting to Home unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/event_detail.html"
|
||||||
screen_title = _("Event")
|
screen_title = _("Event")
|
||||||
active_tab = "calendar"
|
active_tab = "calendar"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
event = get_object_or_404(Event.objects.select_related("location", "opponent"), pk=self.kwargs["pk"], club=self.request.club)
|
||||||
|
season = event.season or current_season(self.request.club)
|
||||||
|
|
||||||
|
your_answers = []
|
||||||
|
if self.managed_people:
|
||||||
|
managed_ids = [person.pk for person in self.managed_people]
|
||||||
|
attendances_by_member = {attendance.member_id: attendance for attendance in Attendance.objects.filter(event=event, member_id__in=managed_ids).select_related("member")}
|
||||||
|
|
||||||
|
memberships_by_member = {}
|
||||||
|
if season is not None:
|
||||||
|
memberships_by_member = {
|
||||||
|
membership.member_id: membership
|
||||||
|
for membership in TeamMembership.objects.filter(member_id__in=managed_ids, team__in=event.teams.all(), season=season).select_related("team", "position")
|
||||||
|
}
|
||||||
|
|
||||||
|
for person in self.managed_people:
|
||||||
|
attendance = attendances_by_member.get(person.pk)
|
||||||
|
if attendance is None:
|
||||||
|
continue
|
||||||
|
your_answers.append({"member": person, "attendance": attendance, "membership": memberships_by_member.get(person.pk)})
|
||||||
|
|
||||||
|
squad_summary = None
|
||||||
|
if event.teams.exists():
|
||||||
|
counts = Attendance.objects.filter(event=event).aggregate(
|
||||||
|
in_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED])),
|
||||||
|
out_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.NOT_SELECTED])),
|
||||||
|
no_reply_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.MAYBE, Attendance.AttendanceStatus.NO_RESPONSE])),
|
||||||
|
)
|
||||||
|
total = counts["in_count"] + counts["out_count"] + counts["no_reply_count"]
|
||||||
|
if total:
|
||||||
|
squad_summary = {
|
||||||
|
"total": total,
|
||||||
|
"responded": counts["in_count"] + counts["out_count"],
|
||||||
|
"in_count": counts["in_count"],
|
||||||
|
"out_count": counts["out_count"],
|
||||||
|
"no_reply_count": counts["no_reply_count"],
|
||||||
|
"in_pct": round(100 * counts["in_count"] / total),
|
||||||
|
"out_pct": round(100 * counts["out_count"] / total),
|
||||||
|
"no_reply_pct": round(100 * counts["no_reply_count"] / total),
|
||||||
|
}
|
||||||
|
|
||||||
|
return super().get_context_data(screen_title=event.title, event=event, your_answers=your_answers, squad_summary=squad_summary, **kwargs)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
status = request.POST.get("status")
|
status = request.POST.get("status")
|
||||||
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT):
|
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE):
|
||||||
return HttpResponseBadRequest(_("Unknown RSVP status."))
|
return HttpResponseBadRequest(_("Unknown RSVP status."))
|
||||||
|
|
||||||
member_id = request.POST.get("member_id") or (str(self.scope_person.pk) if self.scope_person else None)
|
member_id = request.POST.get("member_id") or (str(self.scope_person.pk) if self.scope_person else None)
|
||||||
@@ -304,6 +365,9 @@ class EventDetailView(_PlaceholderScreen):
|
|||||||
|
|
||||||
event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club)
|
event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club)
|
||||||
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status})
|
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status})
|
||||||
|
|
||||||
|
if request.POST.get("next") == "event_detail":
|
||||||
|
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))
|
||||||
return HttpResponseRedirect(reverse("mobile:home"))
|
return HttpResponseRedirect(reverse("mobile:home"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3339,6 +3339,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.-mx-4 {
|
||||||
|
margin-inline: calc(var(--spacing) * -4);
|
||||||
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -3441,6 +3444,9 @@
|
|||||||
.-mt-0\.5 {
|
.-mt-0\.5 {
|
||||||
margin-top: calc(var(--spacing) * -0.5);
|
margin-top: calc(var(--spacing) * -0.5);
|
||||||
}
|
}
|
||||||
|
.-mt-4 {
|
||||||
|
margin-top: calc(var(--spacing) * -4);
|
||||||
|
}
|
||||||
.mt-0 {
|
.mt-0 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@@ -3456,6 +3462,9 @@
|
|||||||
.mt-2 {
|
.mt-2 {
|
||||||
margin-top: calc(var(--spacing) * 2);
|
margin-top: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.mt-2\.5 {
|
||||||
|
margin-top: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.mt-3 {
|
.mt-3 {
|
||||||
margin-top: calc(var(--spacing) * 3);
|
margin-top: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
@@ -3507,6 +3516,9 @@
|
|||||||
.mb-10 {
|
.mb-10 {
|
||||||
margin-bottom: calc(var(--spacing) * 10);
|
margin-bottom: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
|
.mb-auto {
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
.ml-1 {
|
.ml-1 {
|
||||||
margin-left: var(--spacing);
|
margin-left: var(--spacing);
|
||||||
}
|
}
|
||||||
@@ -3947,6 +3959,9 @@
|
|||||||
.h-\[190px\] {
|
.h-\[190px\] {
|
||||||
height: 190px;
|
height: 190px;
|
||||||
}
|
}
|
||||||
|
.h-\[220px\] {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
.h-\[560px\] {
|
.h-\[560px\] {
|
||||||
height: 560px;
|
height: 560px;
|
||||||
}
|
}
|
||||||
@@ -4928,6 +4943,10 @@
|
|||||||
--tw-leading: .96;
|
--tw-leading: .96;
|
||||||
line-height: .96;
|
line-height: .96;
|
||||||
}
|
}
|
||||||
|
.leading-\[\.98\] {
|
||||||
|
--tw-leading: .98;
|
||||||
|
line-height: .98;
|
||||||
|
}
|
||||||
.leading-\[1\.05\] {
|
.leading-\[1\.05\] {
|
||||||
--tw-leading: 1.05;
|
--tw-leading: 1.05;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user