Coach Schedule: same This week/Next week/month-divider agenda grouping
Extracted the grouping algorithm (previously duplicated between mobile.views. CalendarView and management.views.EventListView's own "List" mode) into a shared events.services.calendar.agenda_groups, and wired it into both -- plus mobile.coach_views.CoachScheduleView, the coach app's own upcoming- events screen, which had no grouping at all before (one flat card). All three now read identically and can't drift apart. Also: give each management event-list row's kind badge a fixed width, so titles start at the same horizontal position regardless of whether the row says "Game" or "Tournament". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ Three grids, one per granularity:
|
||||
|
||||
import calendar as _calendar
|
||||
import datetime
|
||||
import itertools
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -55,6 +56,58 @@ def add_months(anchor: datetime.date, months: int) -> datetime.date:
|
||||
return datetime.date(year, month, 1)
|
||||
|
||||
|
||||
def agenda_groups(items, *, start_of=lambda item: item.start, show_past=False, today=None):
|
||||
"""Group already start-sorted ``items`` the way every agenda-style
|
||||
schedule in this app presents itself: "This week"/"Next week", then
|
||||
everything further out under its own month divider -- shared by
|
||||
mobile.views.CalendarView (the member app's own Calendar), management.
|
||||
views.EventListView (the desktop's "List" view), and mobile.coach_views.
|
||||
CoachScheduleView (the coach app's "Schedule"), so the three stay
|
||||
behaviourally identical rather than three hand-rolled copies of the same
|
||||
grouping drifting apart over time.
|
||||
|
||||
``start_of`` extracts an item's start datetime -- the default assumes a
|
||||
bare ``Event``-like object; pass a lambda for anything else (e.g.
|
||||
CalendarView's own rows, each a dict wrapping one).
|
||||
|
||||
``show_past`` is for a caller already querying in descending order (most
|
||||
recent first) -- "This week"/"Next week" only make sense for what's
|
||||
ahead, so past mode skips that split and groups straight into months
|
||||
instead, newest month first, oldest last.
|
||||
|
||||
Returns ``(this_week, next_week, later_months)`` -- ``later_months`` is
|
||||
``[{"month_start": date, "items": [...]}, ...]``; ``this_week``/
|
||||
``next_week`` are always ``[]`` in show_past mode, with everything
|
||||
carried in ``later_months`` instead.
|
||||
"""
|
||||
if not items:
|
||||
return [], [], []
|
||||
|
||||
def _month_start(item):
|
||||
return timezone.localtime(start_of(item)).date().replace(day=1)
|
||||
|
||||
if show_past:
|
||||
months = [{"month_start": month_start, "items": list(month_items)} for month_start, month_items in itertools.groupby(items, key=_month_start)]
|
||||
return [], [], months
|
||||
|
||||
today = today or timezone.localdate()
|
||||
_this_week_start, this_week_end = week_bounds(today)
|
||||
next_week_end = this_week_end + datetime.timedelta(days=7)
|
||||
|
||||
this_week, next_week, later = [], [], []
|
||||
for item in items:
|
||||
item_date = timezone.localtime(start_of(item)).date()
|
||||
if item_date <= this_week_end:
|
||||
this_week.append(item)
|
||||
elif item_date <= next_week_end:
|
||||
next_week.append(item)
|
||||
else:
|
||||
later.append(item)
|
||||
|
||||
later_months = [{"month_start": month_start, "items": list(month_items)} for month_start, month_items in itertools.groupby(later, key=_month_start)]
|
||||
return this_week, next_week, later_months
|
||||
|
||||
|
||||
def _local_span(event) -> tuple[datetime.datetime, datetime.datetime]:
|
||||
"""An event's start/end in local time, end defaulting to +1h when unset
|
||||
(mirrors the "assumed duration" read-time fallback events.models.Event
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
@@ -35,7 +36,7 @@ from .services import (
|
||||
team_no_shows,
|
||||
)
|
||||
from .services.attendance import member_attendance_counts
|
||||
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
||||
from .services.calendar import add_months, agenda_groups, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
||||
from .services.lineup import cancel_scheduled_publish, notify_dropout, publish_lineup, schedule_lineup_publish, 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 (
|
||||
@@ -1807,6 +1808,53 @@ class CalendarGridTests(EventsTestBase):
|
||||
def test_add_months_rolls_over_the_year(self):
|
||||
self.assertEqual(add_months(date(2026, 11, 15), 2), date(2027, 1, 1))
|
||||
|
||||
def _item(self, day):
|
||||
return SimpleNamespace(start=self.at(day, 12))
|
||||
|
||||
def test_agenda_groups_splits_into_this_week_next_week_and_later(self):
|
||||
# 2026-08-19 is a Wednesday -- this week runs Mon 17..Sun 23, next
|
||||
# week Mon 24..Sun 30, matching test_week_bounds_returns_monday_to_
|
||||
# sunday above.
|
||||
this_week_item = self._item(date(2026, 8, 21))
|
||||
next_week_item = self._item(date(2026, 8, 27))
|
||||
later_item = self._item(date(2026, 9, 15))
|
||||
items = [this_week_item, next_week_item, later_item]
|
||||
|
||||
this_week, next_week, later_months = agenda_groups(items, today=date(2026, 8, 19))
|
||||
|
||||
self.assertEqual(this_week, [this_week_item])
|
||||
self.assertEqual(next_week, [next_week_item])
|
||||
self.assertEqual(len(later_months), 1)
|
||||
self.assertEqual(later_months[0]["month_start"], date(2026, 9, 1))
|
||||
self.assertEqual(later_months[0]["items"], [later_item])
|
||||
|
||||
def test_agenda_groups_later_items_split_across_separate_months(self):
|
||||
september_item = self._item(date(2026, 9, 15))
|
||||
october_item = self._item(date(2026, 10, 15))
|
||||
|
||||
_this_week, _next_week, later_months = agenda_groups([september_item, october_item], today=date(2026, 8, 19))
|
||||
|
||||
self.assertEqual([month["month_start"] for month in later_months], [date(2026, 9, 1), date(2026, 10, 1)])
|
||||
|
||||
def test_agenda_groups_show_past_skips_week_labels(self):
|
||||
past_item = self._item(date(2026, 8, 1))
|
||||
|
||||
this_week, next_week, months = agenda_groups([past_item], show_past=True)
|
||||
|
||||
self.assertEqual(this_week, [])
|
||||
self.assertEqual(next_week, [])
|
||||
self.assertEqual(months, [{"month_start": date(2026, 8, 1), "items": [past_item]}])
|
||||
|
||||
def test_agenda_groups_empty_list(self):
|
||||
self.assertEqual(agenda_groups([]), ([], [], []))
|
||||
|
||||
def test_agenda_groups_start_of_accessor_for_non_event_items(self):
|
||||
row = {"event": self._item(date(2026, 8, 21))}
|
||||
|
||||
this_week, _next_week, _later = agenda_groups([row], start_of=lambda row: row["event"].start, today=date(2026, 8, 19))
|
||||
|
||||
self.assertEqual(this_week, [row])
|
||||
|
||||
def test_week_grid_always_covers_the_full_day(self):
|
||||
monday = date(2026, 8, 17)
|
||||
event = self.make_event(start=self.at(monday, 10), end=self.at(monday, 11, 30))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load i18n lucide ui %}
|
||||
<div class="flex items-center gap-3 px-4 py-3 {% if not forloop.last %}border-b border-line{% endif %}">
|
||||
<span class="badge badge-sm shrink-0 {% if event.kind == "game" %}badge-error{% elif event.kind == "training" %}badge-info{% elif event.kind == "tournament" %}badge-warning{% elif event.kind == "meeting" %}badge-neutral{% elif event.kind == "social" %}border-violet/30 bg-violet/10 text-violet{% else %}badge-outline{% endif %}">
|
||||
<span class="badge badge-sm w-24 shrink-0 justify-center {% if event.kind == "game" %}badge-error{% elif event.kind == "training" %}badge-info{% elif event.kind == "tournament" %}badge-warning{% elif event.kind == "meeting" %}badge-neutral{% elif event.kind == "social" %}border-violet/30 bg-violet/10 text-violet{% else %}badge-outline{% endif %}">
|
||||
{{ event.get_kind_display }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
{% for month in list_months %}
|
||||
<div class="card overflow-hidden">
|
||||
<div class="border-b border-line bg-subhead px-4 py-2 font-display text-xs font-extrabold tracking-[.08em] text-ink uppercase">{{ month.month_start|date:"F Y" }}</div>
|
||||
{% for event in month.events %}{% include "management/_event_list_row.html" %}{% endfor %}
|
||||
{% for event in month.items %}{% include "management/_event_list_row.html" %}{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -5625,7 +5625,7 @@ class EventManagementTests(ManagementTestBase):
|
||||
self.assertEqual([event.title for event in response.context["list_next_week"]], ["Next week event"])
|
||||
later_months = response.context["list_months"]
|
||||
self.assertEqual(len(later_months), 1)
|
||||
self.assertEqual([event.title for event in later_months[0]["events"]], ["Later event"])
|
||||
self.assertEqual([event.title for event in later_months[0]["items"]], ["Later event"])
|
||||
self.assertContains(response, "This week")
|
||||
self.assertContains(response, "Next week")
|
||||
self.assertContains(response, later_event.start.strftime("%B %Y"))
|
||||
@@ -5640,7 +5640,7 @@ class EventManagementTests(ManagementTestBase):
|
||||
self.assertEqual(response.context["list_this_week"], [])
|
||||
self.assertEqual(response.context["list_next_week"], [])
|
||||
self.assertEqual(len(response.context["list_months"]), 1)
|
||||
self.assertEqual([event.title for event in response.context["list_months"][0]["events"]], ["Past event"])
|
||||
self.assertEqual([event.title for event in response.context["list_months"][0]["items"]], ["Past event"])
|
||||
self.assertNotContains(response, "This week")
|
||||
|
||||
def test_the_dashboard_only_shows_upcoming_events_for_managed_teams(self):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import itertools
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -38,7 +37,7 @@ 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, 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.calendar import add_months, agenda_groups, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
||||
from events.services.competitions import CompetitionFetchError, fetch_game_info
|
||||
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
|
||||
from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series
|
||||
@@ -2473,36 +2472,15 @@ class EventListView(ClubStaffRequiredMixin, ListView):
|
||||
return grid, calendar_nav
|
||||
|
||||
def _list_groups(self, events, show_past):
|
||||
"""The same This week/Next week/by-month agenda grouping mobile.views.
|
||||
CalendarView uses (see that view's own docstring for the algorithm) --
|
||||
applied to whichever page of `events` is actually being shown, so
|
||||
pagination and grouping don't fight each other. Past mode (show_past=1,
|
||||
already descending) skips the this/next-week special-casing -- those
|
||||
labels only make sense for what's ahead -- and just groups straight
|
||||
into months, most recent first."""
|
||||
if not events:
|
||||
return [], [], []
|
||||
|
||||
if show_past:
|
||||
months = [{"month_start": month_start, "events": list(month_events)} for month_start, month_events in itertools.groupby(events, key=lambda event: timezone.localtime(event.start).date().replace(day=1))]
|
||||
return [], [], months
|
||||
|
||||
today = timezone.localdate()
|
||||
_this_week_start, this_week_end = week_bounds(today)
|
||||
next_week_end = this_week_end + timedelta(days=7)
|
||||
|
||||
this_week, next_week, later = [], [], []
|
||||
for event in events:
|
||||
event_date = timezone.localtime(event.start).date()
|
||||
if event_date <= this_week_end:
|
||||
this_week.append(event)
|
||||
elif event_date <= next_week_end:
|
||||
next_week.append(event)
|
||||
else:
|
||||
later.append(event)
|
||||
|
||||
later_months = [{"month_start": month_start, "events": list(month_events)} for month_start, month_events in itertools.groupby(later, key=lambda event: timezone.localtime(event.start).date().replace(day=1))]
|
||||
return this_week, next_week, later_months
|
||||
"""The shared This week/Next week/by-month agenda grouping (events.
|
||||
services.calendar.agenda_groups, also behind mobile.views.CalendarView
|
||||
and mobile.coach_views.CoachScheduleView) -- applied to whichever page
|
||||
of `events` is actually being shown, so pagination and grouping don't
|
||||
fight each other. Past mode (show_past=1, already descending) skips
|
||||
the this/next-week special-casing -- those labels only make sense for
|
||||
what's ahead -- and just groups straight into months, most recent
|
||||
first."""
|
||||
return agenda_groups(events, show_past=show_past)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
club, user = self.request.club, self.request.user
|
||||
|
||||
@@ -26,6 +26,7 @@ from controlpanel.messages import notify
|
||||
from events.models import Attendance, Event, EventSeries, Lineup, LineupSelection, Location, Opponent
|
||||
from events.services import generate_occurrences
|
||||
from events.services.attendance import member_attendance_counts, record_check_in
|
||||
from events.services.calendar import agenda_groups
|
||||
from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection
|
||||
from events.tasks import notify_new_event
|
||||
from management.forms import EventForm, EventSeriesForm, LocationForm, NewsForm, OpponentForm
|
||||
@@ -1104,15 +1105,19 @@ class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
straight into the coach-relevant action -- Bench attendance for a
|
||||
practice, the Line-up for a game -- rather than mobile:event_detail (the
|
||||
Member-shell RSVP page a coach browsing their own team's schedule has no
|
||||
use for)."""
|
||||
use for). Grouped into This week/Next week/by-month dividers via events.
|
||||
services.calendar.agenda_groups -- the same shared grouping mobile.views.
|
||||
CalendarView (the member app's own Calendar) and management.views.
|
||||
EventListView's "List" mode use, so all three read the same way."""
|
||||
|
||||
template_name = "mobile/coach/schedule.html"
|
||||
screen_title = _("Schedule")
|
||||
active_tab = "coach_schedule"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
events = []
|
||||
this_week, next_week, later_months = [], [], []
|
||||
if self.active_team is not None:
|
||||
events = list(Event.objects.filter(teams=self.active_team, cancelled=False, start__gte=timezone.now()).order_by("start"))
|
||||
this_week, next_week, later_months = agenda_groups(events)
|
||||
|
||||
return super().get_context_data(events=events, **kwargs)
|
||||
return super().get_context_data(this_week=this_week, next_week=next_week, later_months=later_months, **kwargs)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full-
|
||||
width chronological agenda, grouped under "This week"/"Next week", then
|
||||
everything further out grouped by calendar month (see CalendarView's own
|
||||
docstring -- later_months is a list of {month_start, rows}). No person
|
||||
docstring -- later_months is a list of {month_start, items}). No person
|
||||
switcher and no club-wide toggle here -- always every event
|
||||
self.managed_people is invited to, plus (merged into the same
|
||||
chronological list, each its own accent colour/look):
|
||||
@@ -90,7 +90,7 @@
|
||||
<div>
|
||||
<div class="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 %}
|
||||
{% for row in month.items %}
|
||||
{% if row.blocked_requirements %}{% include "mobile/_calendar_blocked_row.html" %}{% elif row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
13
mobile/templates/mobile/coach/_schedule_row.html
Normal file
13
mobile/templates/mobile/coach/_schedule_row.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% load i18n %}
|
||||
<a class="flex items-center gap-3 p-3.5" href="{% if event.kind == "game" %}{% url "mobile:coach_lineup" event.pk %}{% else %}{% url "mobile:coach_attendance" event.pk %}{% endif %}">
|
||||
<div class="w-9.5 shrink-0 text-center">
|
||||
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ event.start|date:"D" }}</div>
|
||||
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ event.start|date:"d" }}</div>
|
||||
</div>
|
||||
<div class="w-[3px] shrink-0 self-stretch rounded-full {% if event.kind == "game" %}bg-club{% elif event.kind == "training" %}bg-info{% else %}bg-warn{% endif %}"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-ink">{{ event.title }}</div>
|
||||
<div class="truncate text-xs text-muted">{{ event.start|date:"H:i" }}{% if event.location %} · {{ event.location.name }}{% endif %}</div>
|
||||
</div>
|
||||
<span class="pill {% if event.kind == "game" %}pill-danger{% elif event.kind == "training" %}pill-info{% else %}pill-neutral{% endif %} shrink-0">{{ event.get_kind_display }}</span>
|
||||
</a>
|
||||
@@ -3,9 +3,12 @@
|
||||
|
||||
{% comment %}
|
||||
Bottom-tab "Schedule" -- every upcoming event for the active team (not
|
||||
just the next one, that's Today's job). Each row links straight into the
|
||||
coach-relevant action for its kind rather than mobile:event_detail (the
|
||||
Member-shell RSVP page) -- see CoachScheduleView's own docstring.
|
||||
just the next one, that's Today's job), grouped into This week/Next
|
||||
week/by-month dividers -- see CoachScheduleView's own docstring for the
|
||||
shared agenda_groups grouping behind this (also mobile.views.CalendarView
|
||||
and management.views.EventListView's "List" mode). Each row links
|
||||
straight into the coach-relevant action for its kind rather than
|
||||
mobile:event_detail (the Member-shell RSVP page).
|
||||
{% endcomment %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,26 +16,46 @@
|
||||
<div class="m-card p-6 text-center">
|
||||
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
|
||||
</div>
|
||||
{% elif not events %}
|
||||
{% elif not this_week and not next_week and not later_months %}
|
||||
<div class="m-card p-6 text-center">
|
||||
<p class="text-sm text-muted">{% trans "Nothing scheduled." %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="m-card flex flex-col overflow-hidden">
|
||||
{% for event in events %}
|
||||
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||
<a class="flex items-center gap-3 p-3.5" href="{% if event.kind == "game" %}{% url "mobile:coach_lineup" event.pk %}{% else %}{% url "mobile:coach_attendance" event.pk %}{% endif %}">
|
||||
<div class="w-9.5 shrink-0 text-center">
|
||||
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ event.start|date:"D" }}</div>
|
||||
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ event.start|date:"d" }}</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
{% if this_week %}
|
||||
<div>
|
||||
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
|
||||
<div class="m-card flex flex-col overflow-hidden">
|
||||
{% for event in this_week %}
|
||||
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||
{% include "mobile/coach/_schedule_row.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="w-[3px] shrink-0 self-stretch rounded-full {% if event.kind == "game" %}bg-club{% elif event.kind == "training" %}bg-info{% else %}bg-warn{% endif %}"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-ink">{{ event.title }}</div>
|
||||
<div class="truncate text-xs text-muted">{{ event.start|date:"H:i" }}{% if event.location %} · {{ event.location.name }}{% endif %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if next_week %}
|
||||
<div>
|
||||
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
|
||||
<div class="m-card flex flex-col overflow-hidden">
|
||||
{% for event in next_week %}
|
||||
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||
{% include "mobile/coach/_schedule_row.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="pill {% if event.kind == "game" %}pill-danger{% elif event.kind == "training" %}pill-info{% else %}pill-neutral{% endif %} shrink-0">{{ event.get_kind_display }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for month in later_months %}
|
||||
<div>
|
||||
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
|
||||
<div class="m-card flex flex-col overflow-hidden">
|
||||
{% for event in month.items %}
|
||||
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
|
||||
{% include "mobile/coach/_schedule_row.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -12,6 +12,7 @@ from icalendar import Calendar as ICalCalendar
|
||||
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
|
||||
from events.services.attendance import record_check_in
|
||||
from events.services.calendar import week_bounds
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
from news.models import News
|
||||
from notifications.models import Notification
|
||||
@@ -850,7 +851,7 @@ class CalendarViewTests(TestCase):
|
||||
def _events_in_context(self, response):
|
||||
rows = response.context["this_week"] + response.context["next_week"]
|
||||
for month in response.context["later_months"]:
|
||||
rows += month["rows"]
|
||||
rows += month["items"]
|
||||
return {row["event"] for row in rows}
|
||||
|
||||
def add_child(self, first_name="Noor"):
|
||||
@@ -977,7 +978,7 @@ class CalendarViewTests(TestCase):
|
||||
self.assertEqual(len(later_months), 1)
|
||||
month_start = timezone.localtime(far_future.start).date().replace(day=1)
|
||||
self.assertEqual(later_months[0]["month_start"], month_start)
|
||||
self.assertEqual([row["event"] for row in later_months[0]["rows"]], [far_future])
|
||||
self.assertEqual([row["event"] for row in later_months[0]["items"]], [far_future])
|
||||
self.assertContains(response, month_start.strftime("%B %Y"))
|
||||
|
||||
def test_further_out_events_are_split_across_separate_month_groups(self):
|
||||
@@ -3127,7 +3128,9 @@ class CoachScheduleViewTests(TestCase):
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual(response.context["events"], [])
|
||||
self.assertEqual(response.context["this_week"], [])
|
||||
self.assertEqual(response.context["next_week"], [])
|
||||
self.assertEqual(response.context["later_months"], [])
|
||||
|
||||
def test_no_staff_assignment_shows_a_graceful_empty_state(self):
|
||||
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
||||
@@ -3138,6 +3141,23 @@ class CoachScheduleViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Not staffing a team yet")
|
||||
|
||||
def test_events_are_grouped_into_this_week_next_week_and_month_dividers(self):
|
||||
_this_week_start, this_week_end = week_bounds(timezone.localdate())
|
||||
this_week_event = Event.objects.create(club=self.club, title="This week practice", kind=Event.EventKind.TRAINING, start=timezone.make_aware(datetime.datetime.combine(this_week_end, datetime.time(18, 0))))
|
||||
this_week_event.teams.add(self.team)
|
||||
far_future = Event.objects.create(club=self.club, title="Far future practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=60))
|
||||
far_future.teams.add(self.team)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get()
|
||||
|
||||
self.assertEqual([event.title for event in response.context["this_week"]], ["This week practice"])
|
||||
later_months = response.context["later_months"]
|
||||
self.assertEqual(len(later_months), 1)
|
||||
self.assertEqual([event.title for event in later_months[0]["items"]], ["Far future practice"])
|
||||
self.assertContains(response, "This week")
|
||||
self.assertContains(response, far_future.start.strftime("%B %Y"))
|
||||
|
||||
|
||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||
class CoachAttendanceViewTests(TestCase):
|
||||
|
||||
@@ -4,8 +4,6 @@ phase -- see design_handoff_rosterchief_platform/README.md -- and has no
|
||||
routes here yet.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import itertools
|
||||
import json
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
@@ -29,7 +27,7 @@ from club.services.sponsors import active_sponsors
|
||||
from controlpanel.messages import notify
|
||||
from events.models import Attendance, Event, Lineup, RefereeSignup
|
||||
from events.services.attendance import blocked_upcoming_events_for_member
|
||||
from events.services.calendar import week_bounds
|
||||
from events.services.calendar import agenda_groups, 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
|
||||
@@ -284,10 +282,12 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
"""M3 -- README's M3 section: a chronological agenda list (not the desktop
|
||||
week/month grid events.services.calendar was built for) grouped under
|
||||
"This week"/"Next week", then everything further out grouped by calendar
|
||||
month (a "later_months" list of {month_start, rows}, each its own sticky
|
||||
header) -- an unbounded agenda rather than a fixed lookahead window, since
|
||||
a hard cutoff just hid events a member would reasonably expect to still
|
||||
find here. No ?month= paging beyond that grouping -- there's no season
|
||||
month via events.services.calendar.agenda_groups (also behind management.
|
||||
views.EventListView's own "List" mode and mobile.coach_views.
|
||||
CoachScheduleView, so all three stay behaviourally identical) -- an
|
||||
unbounded agenda rather than a fixed lookahead window, since a hard
|
||||
cutoff just hid events a member would reasonably expect to still find
|
||||
here. No ?month= paging beyond that grouping -- there's no season
|
||||
browser here, just "everything upcoming, readably grouped".
|
||||
|
||||
Always scoped to every one of ``self.managed_people`` -- unlike Home,
|
||||
@@ -322,8 +322,6 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
now = timezone.now()
|
||||
today = timezone.localdate()
|
||||
_this_week_start, this_week_end = week_bounds(today)
|
||||
next_week_end = this_week_end + datetime.timedelta(days=7)
|
||||
kind_filter = self.request.GET.get("kind")
|
||||
|
||||
rows = []
|
||||
@@ -386,22 +384,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
|
||||
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()
|
||||
if event_date <= this_week_end:
|
||||
this_week.append(row)
|
||||
elif event_date <= next_week_end:
|
||||
next_week.append(row)
|
||||
else:
|
||||
later_rows.append(row)
|
||||
|
||||
# ``rows`` is already start-ordered, so a plain groupby (no sorting) is
|
||||
# enough to split later_rows into one chronological run per month.
|
||||
later_months = [
|
||||
{"month_start": month_start, "rows": list(month_rows)}
|
||||
for month_start, month_rows in itertools.groupby(later_rows, key=lambda row: timezone.localtime(row["event"].start).date().replace(day=1))
|
||||
]
|
||||
this_week, next_week, later_months = agenda_groups(rows, start_of=lambda row: row["event"].start, today=today)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user