Build M1 Home for the mobile app
Hero card for the soonest upcoming event with a quick In/Out RSVP, a "needs your answer" list, a season-dues card, and a news teaser -- all scoped to the person switcher's current selection. The RSVP quick action posts to the (still placeholder-GET) event-detail URL that a later screen (M2) will build out fully. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
122
mobile/templates/mobile/home.html
Normal file
122
mobile/templates/mobile/home.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "mobile/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
M1 -- design_handoff_rosterchief_platform/README.md's M1 section. Four
|
||||||
|
independently-optional cards, all scoped to scope_person (mobile/mixins.py):
|
||||||
|
a hero for the soonest upcoming event with a quick In/Out RSVP, a "needs
|
||||||
|
your answer" list, a season-dues card, and a news teaser.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if scope_person %}
|
||||||
|
{% if hero_attendance %}
|
||||||
|
<div class="m-card-dark overflow-hidden">
|
||||||
|
<div class="p-4 pb-0">
|
||||||
|
<p class="font-display text-xs font-extrabold text-info uppercase tracking-wide">{% blocktrans with date=hero_attendance.event.start|date:"D d M" %}Next up · {{ date }}{% endblocktrans %}</p>
|
||||||
|
<p class="mt-1 font-display text-2xl leading-none font-extrabold uppercase">{{ hero_attendance.event.title }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex flex-wrap gap-4 font-mono text-xs text-on-dark">
|
||||||
|
<span>{% blocktrans with time=hero_attendance.event.start|date:"H:i" %}{{ time }} face-off{% endblocktrans %}</span>
|
||||||
|
{% if hero_attendance.event.gathering %}
|
||||||
|
<span>{% blocktrans with time=hero_attendance.event.gathering|date:"H:i" %}Meet {{ time }}{% endblocktrans %}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if hero_attendance.event.location %}<span>{{ hero_attendance.event.location.name }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rsvp_closed %}
|
||||||
|
<p class="mt-3 font-display text-xs font-extrabold text-on-dark-dim uppercase tracking-wide">{% trans "Replies are closed for this event" %}</p>
|
||||||
|
<span class="pill pill-neutral mt-2">{{ hero_attendance.get_status_display }}</span>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-3 font-display text-xs font-extrabold text-on-dark-dim uppercase tracking-wide">{% blocktrans with name=scope_person.first_name %}{{ name }} — are you in?{% endblocktrans %}</p>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="status" value="present">
|
||||||
|
<button class="btn btn-positive w-full" type="submit">{% trans "In" %}</button>
|
||||||
|
</form>
|
||||||
|
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="status" value="absent">
|
||||||
|
<button class="btn w-full bg-steel text-on-dark" type="submit">{% trans "Out" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if needs_answer %}
|
||||||
|
<div class="m-card p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Needs your answer" %}</span>
|
||||||
|
<span class="font-display text-lg font-extrabold text-club">{{ needs_answer|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-col gap-2.5">
|
||||||
|
{% for attendance in needs_answer %}
|
||||||
|
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||||
|
<a class="flex items-center gap-3" href="{% url "mobile:event_detail" attendance.event.pk %}">
|
||||||
|
<div class="w-9.5 shrink-0 text-center">
|
||||||
|
<div class="font-display text-xl leading-none font-extrabold text-ink">{{ attendance.event.start|date:"d" }}</div>
|
||||||
|
<div class="font-mono text-[10px] text-muted uppercase tracking-wide">{{ attendance.event.start|date:"M" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-semibold text-ink">{{ attendance.event.title }}</div>
|
||||||
|
<div class="text-xs text-muted">{{ scope_person.first_name }} · {{ attendance.event.start|date:"H:i" }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="pill pill-warn shrink-0">{% trans "Reply" %}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if dues_membership %}
|
||||||
|
<div class="m-card flex items-center gap-3 p-4">
|
||||||
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
|
||||||
|
<span class="font-display text-lg font-extrabold text-club">€</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-semibold text-ink">{% blocktrans with name=scope_person.first_name %}Season dues — {{ name }}{% endblocktrans %}</div>
|
||||||
|
<div class="text-xs text-muted">
|
||||||
|
{% if dues_invoice %}
|
||||||
|
{% blocktrans with amount=dues_balance|floatformat:2 due=dues_invoice.due_date|date:"d M" %}€ {{ amount }} · due {{ due }}{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans with amount=dues_balance|floatformat:2 %}€ {{ amount }}{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if news_item %}
|
||||||
|
<div>
|
||||||
|
<div class="mb-2.5 flex items-baseline justify-between">
|
||||||
|
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Club news" %}</span>
|
||||||
|
</div>
|
||||||
|
<a class="m-card block overflow-hidden" href="{% url "mobile:news_detail" news_item.slug %}">
|
||||||
|
<div class="flex h-[104px] items-center justify-center bg-line text-xs text-dim">{% trans "News photo" %}</div>
|
||||||
|
<div class="p-3.5">
|
||||||
|
<div class="font-display text-[11px] font-extrabold text-club uppercase tracking-wide">
|
||||||
|
{% if news_team %}{{ news_team.name }}{% else %}{% trans "Club news" %}{% endif %} · {{ news_item.published_at|date:"d M" }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 font-display text-xl leading-tight font-extrabold text-ink uppercase">{{ news_item.title }}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not hero_attendance and not needs_answer and not dues_membership and not news_item %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="text-sm text-muted">{% trans "Nothing to show right now." %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
|
||||||
|
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule and updates will show up here." %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
176
mobile/tests.py
176
mobile/tests.py
@@ -1,11 +1,15 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from club.models import Club, ClubMembership, Season
|
from club.models import Club, ClubMembership, DuesInvoice, Season
|
||||||
|
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 .models import PushSubscription
|
from .models import PushSubscription
|
||||||
from .services.icons import render_fallback_icon
|
from .services.icons import render_fallback_icon
|
||||||
@@ -147,3 +151,173 @@ class RenderFallbackIconTests(TestCase):
|
|||||||
png_bytes = render_fallback_icon(club, size=64)
|
png_bytes = render_fallback_icon(club, size=64)
|
||||||
|
|
||||||
self.assertTrue(png_bytes.startswith(b"\x89PNG"))
|
self.assertTrue(png_bytes.startswith(b"\x89PNG"))
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class HomeViewTests(TestCase):
|
||||||
|
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section."""
|
||||||
|
|
||||||
|
@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.future = timezone.now() + datetime.timedelta(days=7)
|
||||||
|
|
||||||
|
def _get(self, url_name=None, url=None):
|
||||||
|
url = url or reverse(f"mobile:{url_name}")
|
||||||
|
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def make_event(self, **kwargs):
|
||||||
|
kwargs.setdefault("club", self.club)
|
||||||
|
kwargs.setdefault("title", "Training")
|
||||||
|
kwargs.setdefault("start", self.future)
|
||||||
|
return Event.objects.create(**kwargs)
|
||||||
|
|
||||||
|
def test_hero_shows_the_soonest_upcoming_event(self):
|
||||||
|
soon = self.make_event(title="Away · Herentals", start=self.future)
|
||||||
|
later = self.make_event(title="Practice · Ice 3", start=self.future + datetime.timedelta(days=5))
|
||||||
|
Attendance.objects.create(event=soon, member=self.member)
|
||||||
|
Attendance.objects.create(event=later, member=self.member)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["hero_attendance"].event, soon)
|
||||||
|
self.assertContains(response, "Away")
|
||||||
|
|
||||||
|
def test_hero_is_absent_when_no_upcoming_event(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertIsNone(response.context["hero_attendance"])
|
||||||
|
|
||||||
|
def test_needs_your_answer_only_lists_no_response_and_maybe(self):
|
||||||
|
answered = self.make_event(title="Already answered", start=self.future)
|
||||||
|
awaiting = self.make_event(title="Awaiting reply", start=self.future + datetime.timedelta(days=2))
|
||||||
|
maybe = self.make_event(title="Maybe reply", start=self.future + datetime.timedelta(days=4))
|
||||||
|
Attendance.objects.create(event=answered, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
|
Attendance.objects.create(event=awaiting, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
Attendance.objects.create(event=maybe, member=self.member, status=Attendance.AttendanceStatus.MAYBE)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
|
||||||
|
self.assertEqual(needs_answer_events, {awaiting, maybe})
|
||||||
|
|
||||||
|
def test_needs_your_answer_excludes_the_hero_event_even_if_unanswered(self):
|
||||||
|
soon = self.make_event(title="Soonest", start=self.future)
|
||||||
|
Attendance.objects.create(event=soon, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["hero_attendance"].event, soon)
|
||||||
|
self.assertEqual(list(response.context["needs_answer"]), [])
|
||||||
|
|
||||||
|
def test_dues_card_shows_the_outstanding_balance(self):
|
||||||
|
membership = ClubMembership.objects.get(club=self.club, member=self.member, season=self.season)
|
||||||
|
membership.fee_amount = Decimal("420.00")
|
||||||
|
membership.save(update_fields=["fee_amount"])
|
||||||
|
DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("420.00"), due_date=timezone.localdate() + datetime.timedelta(days=10))
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["dues_balance"], Decimal("420.00"))
|
||||||
|
self.assertContains(response, "420")
|
||||||
|
|
||||||
|
def test_dues_card_is_absent_when_nothing_is_owed(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertIsNone(response.context["dues_membership"])
|
||||||
|
|
||||||
|
def test_news_teaser_shows_the_latest_published_item(self):
|
||||||
|
News.objects.create(club=self.club, title="Old news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=5))
|
||||||
|
latest = News.objects.create(club=self.club, title="Signed: New Player", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=1))
|
||||||
|
News.objects.create(club=self.club, title="Still a draft", body="Body.", status=News.Status.DRAFT)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["news_item"], latest)
|
||||||
|
self.assertContains(response, "Signed: New Player")
|
||||||
|
|
||||||
|
def test_empty_account_gets_a_graceful_empty_state(self):
|
||||||
|
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
||||||
|
self.client.force_login(bare_user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.context["scope_person"])
|
||||||
|
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 EventDetailRsvpTests(TestCase):
|
||||||
|
"""M1's quick In/Out RSVP action, posted from the Home hero card."""
|
||||||
|
|
||||||
|
@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.event = Event.objects.create(club=cls.club, title="Training", start=timezone.now() + datetime.timedelta(days=7))
|
||||||
|
cls.attendance = Attendance.objects.create(event=cls.event, member=cls.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
|
||||||
|
def _post(self, event, data):
|
||||||
|
return self.client.post(reverse("mobile:event_detail", kwargs={"pk": event.pk}), data=data, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def test_posting_present_updates_attendance_and_redirects_home(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._post(self.event, {"status": "present"})
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False)
|
||||||
|
self.attendance.refresh_from_db()
|
||||||
|
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.PRESENT)
|
||||||
|
|
||||||
|
def test_posting_absent_creates_attendance_when_none_existed(self):
|
||||||
|
other_event = Event.objects.create(club=self.club, title="Away game", start=timezone.now() + datetime.timedelta(days=8))
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self._post(other_event, {"status": "absent"})
|
||||||
|
|
||||||
|
self.assertEqual(Attendance.objects.get(event=other_event, member=self.member).status, Attendance.AttendanceStatus.ABSENT)
|
||||||
|
|
||||||
|
def test_cannot_rsvp_for_a_member_who_isnt_managed(self):
|
||||||
|
stranger = Member.objects.create(first_name="Someone", last_name="Else")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._post(self.event, {"status": "present", "member_id": str(stranger.pk)})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.attendance.refresh_from_db()
|
||||||
|
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
|
||||||
|
def test_rejects_an_unknown_status_value(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._post(self.event, {"status": "maybe"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
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_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._post(other_event, {"status": "present"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|||||||
107
mobile/views.py
107
mobile/views.py
@@ -12,15 +12,24 @@ before every screen is built out one at a time.
|
|||||||
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.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
|
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from club.models import ClubMembership
|
||||||
|
from club.services.access import current_season
|
||||||
|
from club.services.fees import remaining_balance
|
||||||
|
from events.models import Attendance, Event
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from members.views import ClubScopedPublicMixin
|
from members.views import ClubScopedPublicMixin
|
||||||
|
from news.models import News
|
||||||
|
from teams.models import TeamMembership
|
||||||
|
|
||||||
from .mixins import PersonScopeMixin
|
from .mixins import PersonScopeMixin
|
||||||
from .models import PushSubscription
|
from .models import PushSubscription
|
||||||
@@ -121,10 +130,88 @@ class _PlaceholderScreen(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs)
|
return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class HomeView(_PlaceholderScreen):
|
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
|
||||||
|
hero card for scope_person's soonest upcoming event (with a quick In/Out
|
||||||
|
RSVP -- see EventDetailView.post below), a "needs your answer" list of
|
||||||
|
upcoming events still NO_RESPONSE/MAYBE, a season-dues card when money is
|
||||||
|
owed, and a news teaser. Every card is independently optional -- an
|
||||||
|
empty-state screen is just the four `if`s below all being falsy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/home.html"
|
||||||
screen_title = _("Home")
|
screen_title = _("Home")
|
||||||
active_tab = "home"
|
active_tab = "home"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
scope_person = self.scope_person
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
hero_attendance = None
|
||||||
|
rsvp_closed = False
|
||||||
|
needs_answer = []
|
||||||
|
dues_membership = None
|
||||||
|
dues_balance = None
|
||||||
|
dues_invoice = None
|
||||||
|
news_item = None
|
||||||
|
|
||||||
|
if scope_person is not None:
|
||||||
|
upcoming = Attendance.objects.filter(
|
||||||
|
member=scope_person,
|
||||||
|
event__club=self.request.club,
|
||||||
|
event__cancelled=False,
|
||||||
|
event__start__gte=now,
|
||||||
|
).select_related("event", "event__location")
|
||||||
|
|
||||||
|
hero_attendance = upcoming.order_by("event__start").first()
|
||||||
|
if hero_attendance is not None:
|
||||||
|
deadline = hero_attendance.event.deadline
|
||||||
|
rsvp_closed = deadline is not None and deadline < now
|
||||||
|
|
||||||
|
needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
|
||||||
|
if hero_attendance is not None:
|
||||||
|
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
|
||||||
|
needs_answer = list(needs_answer_qs)
|
||||||
|
|
||||||
|
season = current_season(self.request.club)
|
||||||
|
if season is not None:
|
||||||
|
membership = (
|
||||||
|
ClubMembership.objects.filter(club=self.request.club, member=scope_person, season=season)
|
||||||
|
.exclude(fee_status=ClubMembership.FeeStatus.WAIVED)
|
||||||
|
.select_related("dues_invoice")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if membership is not None:
|
||||||
|
balance = remaining_balance(membership)
|
||||||
|
if balance > 0:
|
||||||
|
dues_membership = membership
|
||||||
|
dues_balance = balance
|
||||||
|
dues_invoice = getattr(membership, "dues_invoice", None)
|
||||||
|
|
||||||
|
team_ids = list(TeamMembership.objects.filter(member=scope_person, season=season).values_list("team_id", flat=True))
|
||||||
|
else:
|
||||||
|
team_ids = []
|
||||||
|
|
||||||
|
news_item = (
|
||||||
|
News.objects.filter(club=self.request.club, status=News.Status.PUBLISHED, published_at__lte=now)
|
||||||
|
.filter(Q(teams__isnull=True) | Q(teams__id__in=team_ids))
|
||||||
|
.order_by("-published_at")
|
||||||
|
.distinct()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().get_context_data(
|
||||||
|
hero_attendance=hero_attendance,
|
||||||
|
rsvp_closed=rsvp_closed,
|
||||||
|
needs_answer=needs_answer,
|
||||||
|
dues_membership=dues_membership,
|
||||||
|
dues_balance=dues_balance,
|
||||||
|
dues_invoice=dues_invoice,
|
||||||
|
news_item=news_item,
|
||||||
|
news_team=news_item.teams.first() if news_item is not None else None,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalendarView(_PlaceholderScreen):
|
class CalendarView(_PlaceholderScreen):
|
||||||
screen_title = _("Calendar")
|
screen_title = _("Calendar")
|
||||||
@@ -132,9 +219,27 @@ class CalendarView(_PlaceholderScreen):
|
|||||||
|
|
||||||
|
|
||||||
class EventDetailView(_PlaceholderScreen):
|
class EventDetailView(_PlaceholderScreen):
|
||||||
|
"""GET is still the M2 placeholder (a later screen owns the full detail
|
||||||
|
page); POST is M1's quick In/Out RSVP action, reused by any future screen
|
||||||
|
that posts the same {status, member_id} shape at this URL."""
|
||||||
|
|
||||||
screen_title = _("Event")
|
screen_title = _("Event")
|
||||||
active_tab = "calendar"
|
active_tab = "calendar"
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
status = request.POST.get("status")
|
||||||
|
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT):
|
||||||
|
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 = next((person for person in self.managed_people if str(person.pk) == member_id), None) if member_id else None
|
||||||
|
if member is None:
|
||||||
|
return HttpResponseBadRequest(_("You can't RSVP for that person."))
|
||||||
|
|
||||||
|
event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club)
|
||||||
|
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status})
|
||||||
|
return HttpResponseRedirect(reverse("mobile:home"))
|
||||||
|
|
||||||
|
|
||||||
class NewsDetailView(_PlaceholderScreen):
|
class NewsDetailView(_PlaceholderScreen):
|
||||||
screen_title = _("News")
|
screen_title = _("News")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
--font-weight-extrabold: 800;
|
--font-weight-extrabold: 800;
|
||||||
--tracking-wide: 0.025em;
|
--tracking-wide: 0.025em;
|
||||||
--tracking-wider: 0.05em;
|
--tracking-wider: 0.05em;
|
||||||
|
--leading-tight: 1.25;
|
||||||
--leading-relaxed: 1.625;
|
--leading-relaxed: 1.625;
|
||||||
--radius-sm: 0.25rem;
|
--radius-sm: 0.25rem;
|
||||||
--radius-md: 0.375rem;
|
--radius-md: 0.375rem;
|
||||||
@@ -2774,6 +2775,9 @@
|
|||||||
.right-0 {
|
.right-0 {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
.right-1 {
|
||||||
|
right: var(--spacing);
|
||||||
|
}
|
||||||
.right-1\.5 {
|
.right-1\.5 {
|
||||||
right: calc(var(--spacing) * 1.5);
|
right: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
@@ -3928,6 +3932,9 @@
|
|||||||
.h-\[68px\] {
|
.h-\[68px\] {
|
||||||
height: 68px;
|
height: 68px;
|
||||||
}
|
}
|
||||||
|
.h-\[104px\] {
|
||||||
|
height: 104px;
|
||||||
|
}
|
||||||
.h-\[180px\] {
|
.h-\[180px\] {
|
||||||
height: 180px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
@@ -3988,6 +3995,12 @@
|
|||||||
.w-8 {
|
.w-8 {
|
||||||
width: calc(var(--spacing) * 8);
|
width: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.w-9 {
|
||||||
|
width: calc(var(--spacing) * 9);
|
||||||
|
}
|
||||||
|
.w-9\.5 {
|
||||||
|
width: calc(var(--spacing) * 9.5);
|
||||||
|
}
|
||||||
.w-10 {
|
.w-10 {
|
||||||
width: calc(var(--spacing) * 10);
|
width: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@@ -4234,6 +4247,9 @@
|
|||||||
.flex-wrap {
|
.flex-wrap {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.items-baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -4750,6 +4766,9 @@
|
|||||||
.pt-4 {
|
.pt-4 {
|
||||||
padding-top: calc(var(--spacing) * 4);
|
padding-top: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.pb-0 {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
.pb-0\.5 {
|
.pb-0\.5 {
|
||||||
padding-bottom: calc(var(--spacing) * 0.5);
|
padding-bottom: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -4914,6 +4933,10 @@
|
|||||||
--tw-leading: var(--leading-relaxed);
|
--tw-leading: var(--leading-relaxed);
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
}
|
}
|
||||||
|
.leading-tight {
|
||||||
|
--tw-leading: var(--leading-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
.font-bold {
|
.font-bold {
|
||||||
--tw-font-weight: var(--font-weight-bold);
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user