Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend
Large uncommitted body of work accumulated across sessions on this branch -- committing as a checkpoint so it's tracked and future worktree-isolated agents see the real codebase instead of a stale ancestor commit. Covers the management app's dedicated Tailwind theme and templates, the club onboarding requirement/signup workflow (club/services/onboarding.py, requirement/status models, sign-up dashboard), fee/status auto-activation decoupling, referee management, and the new events calendar grid service layer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -7,12 +7,20 @@ individually ``invited_members``, minus any ``excluded_members`` -- or, for a
|
||||
event's season instead of teams/groups (the two are mutually exclusive, see
|
||||
EventForm/EventSeriesForm). Attendance rows are reconciled against that set,
|
||||
but only for events that are still in the future — history is never rewritten.
|
||||
|
||||
A member provisionally rostered by management.views.SignupPlaceInTeamView
|
||||
(on a team, but still PENDING -- their sign-up isn't fully processed yet) is
|
||||
still subtracted back out here if an open onboarding requirement blocks this
|
||||
event's kind -- see club.services.onboarding.blocked_member_ids_for_event.
|
||||
Explicitly ``invited_members`` bypasses that: a named, individual invite is a
|
||||
deliberate staff decision that should win regardless.
|
||||
"""
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import ClubMembership, Season
|
||||
from club.services.onboarding import blocked_member_ids_for_event
|
||||
from events.models import Attendance, Event
|
||||
from members.models import Member
|
||||
from teams.models import TeamMembership
|
||||
@@ -44,7 +52,12 @@ def effective_members(event):
|
||||
if group_ids:
|
||||
member_ids.update(Member.objects.filter(group_memberships__group_id__in=group_ids).values_list("id", flat=True))
|
||||
|
||||
member_ids.update(event.invited_members.values_list("id", flat=True))
|
||||
invited_ids = set(event.invited_members.values_list("id", flat=True))
|
||||
|
||||
if season is not None:
|
||||
member_ids.difference_update(blocked_member_ids_for_event(event.club, season, event.kind) - invited_ids)
|
||||
|
||||
member_ids.update(invited_ids)
|
||||
member_ids.difference_update(event.excluded_members.values_list("id", flat=True))
|
||||
|
||||
return Member.objects.filter(id__in=member_ids)
|
||||
|
||||
220
events/services/calendar.py
Normal file
220
events/services/calendar.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Date-range math and grid layout for the Events page's Week/Month/Season calendar
|
||||
views (management/views.py's EventListView, template event_list.html). Kept separate
|
||||
from the view itself since none of this touches the request/queryset-scoping layer --
|
||||
it only turns "an anchor date + a list of already-visible events" into the shapes each
|
||||
template needs.
|
||||
|
||||
Three grids, one per granularity:
|
||||
- week_grid: a 7-day x hour-gutter layout with absolutely-positioned blocks (top/height
|
||||
as a percentage of the visible hour span), including simple side-by-side column
|
||||
layout for events that overlap in time on the same day.
|
||||
- month_grid: a standard 6-row calendar grid, each day carrying its own event list
|
||||
(title + kind, no time-of-day positioning -- there's no room for it at that scale).
|
||||
- season_grid: one month_grid per month spanning the season, but day cells carry only
|
||||
a count (a full title list is illegible at that scale) -- see D5-alike "Season" view.
|
||||
"""
|
||||
|
||||
import calendar as _calendar
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
#: The week grid's default visible hours -- expanded automatically (see week_grid)
|
||||
#: if an event falls outside it, so nothing is ever clipped out of view.
|
||||
DEFAULT_DAY_START_HOUR = 8
|
||||
DEFAULT_DAY_END_HOUR = 22
|
||||
|
||||
#: Floor on a block's rendered height, in percent of the visible hour span -- a very
|
||||
#: short event (a 15-minute weigh-in) would otherwise render as a sliver too thin to
|
||||
#: click or read.
|
||||
MIN_BLOCK_HEIGHT_PCT = 4.0
|
||||
|
||||
|
||||
def week_bounds(anchor: datetime.date) -> tuple[datetime.date, datetime.date]:
|
||||
"""Monday..Sunday of the week containing ``anchor``."""
|
||||
start = anchor - datetime.timedelta(days=anchor.weekday())
|
||||
return start, start + datetime.timedelta(days=6)
|
||||
|
||||
|
||||
def month_bounds(anchor: datetime.date) -> tuple[datetime.date, datetime.date]:
|
||||
"""First..last day of the month containing ``anchor``."""
|
||||
last_day = _calendar.monthrange(anchor.year, anchor.month)[1]
|
||||
return anchor.replace(day=1), anchor.replace(day=last_day)
|
||||
|
||||
|
||||
def add_months(anchor: datetime.date, months: int) -> datetime.date:
|
||||
"""``anchor`` shifted by whole months, clamped to day 1 -- only ever used to
|
||||
step between month-starts (month_bounds/season month list), so the
|
||||
day-of-month is never meaningful to preserve."""
|
||||
month_index = anchor.month - 1 + months
|
||||
year = anchor.year + month_index // 12
|
||||
month = month_index % 12 + 1
|
||||
return datetime.date(year, month, 1)
|
||||
|
||||
|
||||
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
|
||||
documents for non-GAME kinds -- this is a display concern, so it doesn't
|
||||
touch the stored field)."""
|
||||
start = timezone.localtime(event.start)
|
||||
end = timezone.localtime(event.end) if event.end else start + datetime.timedelta(hours=1)
|
||||
if end <= start:
|
||||
end = start + datetime.timedelta(hours=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _assign_columns(blocks: list[dict]) -> None:
|
||||
"""Side-by-side layout for same-day events that overlap in time -- sets
|
||||
``left_pct``/``width_pct`` on each block dict in place. Greedy interval
|
||||
scheduling: sort by start, hand each event the lowest-numbered column
|
||||
whose previous occupant has already ended, and once a run of mutually
|
||||
overlapping events (a "cluster") is fully placed, every block in it shares
|
||||
that cluster's column count as its width divisor -- otherwise an event
|
||||
that only overlaps one neighbour would render at 1/3 width just because
|
||||
the *neighbour* also overlaps something else further along."""
|
||||
blocks.sort(key=lambda b: (b["start"], b["end"]))
|
||||
columns: list[datetime.datetime] = [] # end time currently occupying each column
|
||||
cluster: list[dict] = []
|
||||
cluster_end = None
|
||||
|
||||
def flush(cluster_blocks):
|
||||
if not cluster_blocks:
|
||||
return
|
||||
width = max(b["column"] for b in cluster_blocks) + 1
|
||||
for b in cluster_blocks:
|
||||
b["width_pct"] = round(100 / width, 2)
|
||||
b["left_pct"] = round(b["column"] * 100 / width, 2)
|
||||
|
||||
for block in blocks:
|
||||
if cluster_end is not None and block["start"] >= cluster_end:
|
||||
flush(cluster)
|
||||
cluster, columns, cluster_end = [], [], None
|
||||
|
||||
placed = False
|
||||
for index, occupied_until in enumerate(columns):
|
||||
if block["start"] >= occupied_until:
|
||||
columns[index] = block["end"]
|
||||
block["column"] = index
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
columns.append(block["end"])
|
||||
block["column"] = len(columns) - 1
|
||||
|
||||
cluster.append(block)
|
||||
cluster_end = max(cluster_end, block["end"]) if cluster_end else block["end"]
|
||||
|
||||
flush(cluster)
|
||||
|
||||
|
||||
def week_grid(events, week_start: datetime.date) -> dict:
|
||||
"""``events`` (already club/visibility/season-scoped) laid out across the
|
||||
Monday..Sunday week starting ``week_start``. Returns the hour gutter
|
||||
bounds plus, per day, a list of blocks each carrying top/height/left/width
|
||||
percentages for absolute positioning against a single shared-height grid."""
|
||||
week_end = week_start + datetime.timedelta(days=6)
|
||||
day_start_hour, day_end_hour = DEFAULT_DAY_START_HOUR, DEFAULT_DAY_END_HOUR
|
||||
by_day: dict[datetime.date, list] = {week_start + datetime.timedelta(days=i): [] for i in range(7)}
|
||||
|
||||
spans = []
|
||||
for event in events:
|
||||
start, end = _local_span(event)
|
||||
if not (week_start <= start.date() <= week_end):
|
||||
continue
|
||||
spans.append((event, start, end))
|
||||
day_start_hour = min(day_start_hour, start.hour)
|
||||
end_hour_frac = end.hour + end.minute / 60 + (1 if end.second or end.microsecond else 0)
|
||||
day_end_hour = max(day_end_hour, int(end_hour_frac) + (1 if end_hour_frac % 1 else 0))
|
||||
|
||||
span_hours = max(day_end_hour - day_start_hour, 1)
|
||||
for event, start, end in spans:
|
||||
start_frac = max(0.0, (start.hour + start.minute / 60) - day_start_hour)
|
||||
end_frac = min(float(span_hours), (end.hour + end.minute / 60) - day_start_hour)
|
||||
by_day[start.date()].append(
|
||||
{
|
||||
"event": event,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"top_pct": round(100 * start_frac / span_hours, 2),
|
||||
"height_pct": max(round(100 * (end_frac - start_frac) / span_hours, 2), MIN_BLOCK_HEIGHT_PCT),
|
||||
}
|
||||
)
|
||||
|
||||
days = []
|
||||
for i in range(7):
|
||||
day = week_start + datetime.timedelta(days=i)
|
||||
blocks = by_day[day]
|
||||
_assign_columns(blocks)
|
||||
days.append({"date": day, "is_today": day == timezone.localdate(), "blocks": blocks})
|
||||
|
||||
return {
|
||||
"week_start": week_start,
|
||||
"week_end": week_end,
|
||||
"days": days,
|
||||
"hours": list(range(day_start_hour, day_end_hour + 1)),
|
||||
"day_start_hour": day_start_hour,
|
||||
"day_end_hour": day_end_hour,
|
||||
}
|
||||
|
||||
|
||||
def month_grid(events, anchor: datetime.date) -> dict:
|
||||
"""A standard calendar grid (always full weeks, so always a multiple of 7
|
||||
cells) for the month containing ``anchor``, each day carrying the events
|
||||
that start on it. Cells outside the month stay in the grid (so the week
|
||||
rows line up) but are flagged ``in_month=False`` for the template to dim."""
|
||||
month_start, month_end = month_bounds(anchor)
|
||||
grid_start = month_start - datetime.timedelta(days=month_start.weekday())
|
||||
weeks_needed = -(-((month_end - grid_start).days + 1) // 7) # ceil div
|
||||
grid_end = grid_start + datetime.timedelta(days=weeks_needed * 7 - 1)
|
||||
|
||||
by_day: dict[datetime.date, list] = {}
|
||||
for event in events:
|
||||
start, _end = _local_span(event)
|
||||
day = start.date()
|
||||
if grid_start <= day <= grid_end:
|
||||
by_day.setdefault(day, []).append(event)
|
||||
|
||||
today = timezone.localdate()
|
||||
weeks = []
|
||||
day = grid_start
|
||||
while day <= grid_end:
|
||||
week = []
|
||||
for _ in range(7):
|
||||
week.append(
|
||||
{
|
||||
"date": day,
|
||||
"in_month": day.month == anchor.month,
|
||||
"is_today": day == today,
|
||||
"events": by_day.get(day, []),
|
||||
}
|
||||
)
|
||||
day += datetime.timedelta(days=1)
|
||||
weeks.append(week)
|
||||
|
||||
return {"month_start": month_start, "month_end": month_end, "weeks": weeks}
|
||||
|
||||
|
||||
def season_grid(events, season) -> list[dict]:
|
||||
"""One compact month_grid per month spanning ``season``, day cells
|
||||
trimmed to just a count (see module docstring) -- events are split up
|
||||
front by month so each month's grid only scans its own slice, not the
|
||||
whole season's list."""
|
||||
events_by_month: dict[tuple[int, int], list] = {}
|
||||
for event in events:
|
||||
start, _end = _local_span(event)
|
||||
events_by_month.setdefault((start.year, start.month), []).append(event)
|
||||
|
||||
months = []
|
||||
cursor = season.start_date.replace(day=1)
|
||||
end_month = season.end_date.replace(day=1)
|
||||
while cursor <= end_month:
|
||||
grid = month_grid(events_by_month.get((cursor.year, cursor.month), []), cursor)
|
||||
for week in grid["weeks"]:
|
||||
for cell in week:
|
||||
cell["count"] = len(cell["events"])
|
||||
del cell["events"]
|
||||
months.append({"label": cursor, "weeks": grid["weeks"]})
|
||||
cursor = add_months(cursor, 1)
|
||||
|
||||
return months
|
||||
28
events/tasks.py
Normal file
28
events/tasks.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Celery task behind the `extend-event-series` beat schedule entry (see
|
||||
rosterchief/settings.CELERY_BEAT_SCHEDULE and features/jobs.py).
|
||||
|
||||
Mirrors `manage.py extend_event_series` exactly -- that command still exists, unchanged, for
|
||||
manual use from a shell (see events/management/commands/extend_event_series.py).
|
||||
"""
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from events.models import EventSeries
|
||||
from events.services import generate_occurrences, horizon
|
||||
from features.models import Maintenance
|
||||
|
||||
|
||||
@shared_task(name="events.tasks.extend_event_series")
|
||||
def extend_event_series():
|
||||
if Maintenance.is_on():
|
||||
# Loud, not silent: a job that quietly skips itself while the platform is closed is
|
||||
# how a rolling horizon quietly runs dry. Raising here is what turns it into a
|
||||
# Failure on the control panel's Jobs tab instead of nothing happening at all.
|
||||
raise RuntimeError("Platform is in maintenance mode; this job stood down.")
|
||||
|
||||
until = horizon()
|
||||
total = 0
|
||||
for series in EventSeries.objects.all():
|
||||
total += len(generate_occurrences(series, until))
|
||||
|
||||
return f"Generated {total} occurrence(s) across {EventSeries.objects.count()} series."
|
||||
183
events/tests.py
183
events/tests.py
@@ -1,7 +1,8 @@
|
||||
from datetime import timedelta
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from io import StringIO
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management import call_command
|
||||
from django.db import IntegrityError
|
||||
@@ -9,7 +10,8 @@ from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from waffle import get_waffle_flag_model
|
||||
|
||||
from club.models import Club, ClubMembership, Season
|
||||
from club.models import Club, ClubMembership, OnboardingRequirement, Season
|
||||
from club.services.onboarding import mark_bypassed, mark_complete
|
||||
from members.models import Group, GroupMembership, Member
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
||||
|
||||
@@ -28,6 +30,7 @@ from .services import (
|
||||
team_attendance_rate,
|
||||
team_no_shows,
|
||||
)
|
||||
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
|
||||
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
|
||||
|
||||
@@ -257,6 +260,66 @@ class EffectiveMembersTests(EventsTestBase):
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {dave.id})
|
||||
|
||||
# --- onboarding-requirement gating (club.services.onboarding.blocked_member_ids_for_event) ---
|
||||
def make_membership(self, member, **kwargs):
|
||||
kwargs.setdefault("status", ClubMembership.StatusChoices.ACTIVE)
|
||||
return ClubMembership.objects.create(club=self.club, member=member, season=self.season, **kwargs)
|
||||
|
||||
def test_an_open_blocking_requirement_excludes_the_member_for_that_kind(self):
|
||||
self.make_membership(self.alice)
|
||||
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
||||
event = self.make_event(kind=Event.EventKind.GAME)
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.bob.id})
|
||||
|
||||
def test_the_same_open_requirement_does_not_block_an_unlisted_kind(self):
|
||||
self.make_membership(self.alice)
|
||||
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
||||
event = self.make_event(kind=Event.EventKind.TRAINING)
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
||||
|
||||
def test_a_completed_blocking_requirement_stops_excluding_the_member(self):
|
||||
membership = self.make_membership(self.alice)
|
||||
staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
|
||||
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
||||
mark_complete(membership, requirement, user=staff)
|
||||
event = self.make_event(kind=Event.EventKind.GAME)
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
||||
|
||||
def test_a_bypassed_blocking_requirement_also_stops_excluding_the_member(self):
|
||||
membership = self.make_membership(self.alice)
|
||||
staff = get_user_model().objects.create_user(email="staff@example.com", password="pw-secret-123")
|
||||
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
||||
mark_bypassed(membership, requirement, user=staff, note="waived")
|
||||
event = self.make_event(kind=Event.EventKind.GAME)
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
||||
|
||||
def test_an_explicit_invite_overrides_the_block(self):
|
||||
self.make_membership(self.alice)
|
||||
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
||||
event = self.make_event(kind=Event.EventKind.GAME)
|
||||
event.teams.set([self.team])
|
||||
event.invited_members.set([self.alice])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
||||
|
||||
def test_a_member_with_no_club_membership_at_all_is_unaffected_either_way(self):
|
||||
# Alice/Bob have a TeamMembership but no ClubMembership in the base fixture --
|
||||
# blocked_member_ids_for_event has nothing to look up for them, and they were
|
||||
# never excluded by it to begin with (this is really a "doesn't crash" check).
|
||||
OnboardingRequirement.objects.create(club=self.club, name="Medical certificate", blocked_event_kinds=["game"])
|
||||
event = self.make_event(kind=Event.EventKind.GAME)
|
||||
event.teams.set([self.team])
|
||||
|
||||
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id})
|
||||
|
||||
|
||||
class AttendanceSyncTests(EventsTestBase):
|
||||
def test_setting_teams_creates_attendance_for_roster(self):
|
||||
@@ -1289,3 +1352,119 @@ class RefereeServiceTests(EventsTestBase):
|
||||
assignment.refresh_from_db()
|
||||
self.assertEqual(assignment.km_total, Decimal("0"))
|
||||
self.assertEqual(assignment.total_payable, Decimal("25.00"))
|
||||
|
||||
|
||||
class CalendarGridTests(EventsTestBase):
|
||||
"""events.services.calendar -- date-range math and grid layout behind the
|
||||
Events page's Week/Month/Season views."""
|
||||
|
||||
def at(self, day, hour, minute=0):
|
||||
return timezone.make_aware(datetime.combine(day, time(hour, minute)))
|
||||
|
||||
def test_week_bounds_returns_monday_to_sunday(self):
|
||||
start, end = week_bounds(date(2026, 8, 19)) # a Wednesday
|
||||
|
||||
self.assertEqual(start, date(2026, 8, 17))
|
||||
self.assertEqual(end, date(2026, 8, 23))
|
||||
|
||||
def test_month_bounds_returns_first_and_last_day(self):
|
||||
start, end = month_bounds(date(2026, 2, 10))
|
||||
|
||||
self.assertEqual(start, date(2026, 2, 1))
|
||||
self.assertEqual(end, date(2026, 2, 28))
|
||||
|
||||
def test_add_months_rolls_over_the_year(self):
|
||||
self.assertEqual(add_months(date(2026, 11, 15), 2), date(2027, 1, 1))
|
||||
|
||||
def test_week_grid_places_an_event_within_the_default_hours(self):
|
||||
monday = date(2026, 8, 17)
|
||||
event = self.make_event(start=self.at(monday, 10), end=self.at(monday, 11, 30))
|
||||
|
||||
grid = week_grid([event], monday)
|
||||
|
||||
self.assertEqual(grid["day_start_hour"], 8)
|
||||
self.assertEqual(grid["day_end_hour"], 22)
|
||||
span = 22 - 8
|
||||
block = grid["days"][0]["blocks"][0]
|
||||
self.assertAlmostEqual(block["top_pct"], 100 * (10 - 8) / span, places=2)
|
||||
self.assertAlmostEqual(block["height_pct"], 100 * 1.5 / span, places=2)
|
||||
|
||||
def test_week_grid_expands_the_hours_for_an_early_or_late_event(self):
|
||||
monday = date(2026, 8, 17)
|
||||
event = self.make_event(start=self.at(monday, 6), end=self.at(monday, 23))
|
||||
|
||||
grid = week_grid([event], monday)
|
||||
|
||||
self.assertEqual(grid["day_start_hour"], 6)
|
||||
self.assertEqual(grid["day_end_hour"], 23)
|
||||
|
||||
def test_week_grid_excludes_events_outside_the_week(self):
|
||||
monday = date(2026, 8, 17)
|
||||
event = self.make_event(start=self.at(monday + timedelta(days=7), 10))
|
||||
|
||||
grid = week_grid([event], monday)
|
||||
|
||||
self.assertFalse(any(day["blocks"] for day in grid["days"]))
|
||||
|
||||
def test_week_grid_splits_overlapping_events_into_columns(self):
|
||||
monday = date(2026, 8, 17)
|
||||
first = self.make_event(title="Training A", start=self.at(monday, 10), end=self.at(monday, 11))
|
||||
second = self.make_event(title="Training B", start=self.at(monday, 10, 30), end=self.at(monday, 11, 30))
|
||||
|
||||
grid = week_grid([first, second], monday)
|
||||
|
||||
blocks = grid["days"][0]["blocks"]
|
||||
self.assertEqual(len(blocks), 2)
|
||||
self.assertEqual({block["width_pct"] for block in blocks}, {50.0})
|
||||
self.assertEqual({block["left_pct"] for block in blocks}, {0.0, 50.0})
|
||||
|
||||
def test_week_grid_gives_non_overlapping_events_full_width(self):
|
||||
monday = date(2026, 8, 17)
|
||||
first = self.make_event(title="Morning", start=self.at(monday, 9), end=self.at(monday, 10))
|
||||
second = self.make_event(title="Evening", start=self.at(monday, 18), end=self.at(monday, 19))
|
||||
|
||||
grid = week_grid([first, second], monday)
|
||||
|
||||
blocks = grid["days"][0]["blocks"]
|
||||
self.assertTrue(all(block["width_pct"] == 100.0 for block in blocks))
|
||||
|
||||
def test_month_grid_always_spans_full_weeks_of_seven_days(self):
|
||||
grid = month_grid([], date(2026, 2, 1)) # Feb 2026 starts on a Sunday
|
||||
|
||||
self.assertTrue(all(len(week) == 7 for week in grid["weeks"]))
|
||||
self.assertEqual(grid["weeks"][0][0]["date"].weekday(), 0) # Monday
|
||||
|
||||
def test_month_grid_flags_days_outside_the_month(self):
|
||||
grid = month_grid([], date(2026, 2, 1))
|
||||
|
||||
self.assertFalse(grid["weeks"][0][0]["in_month"]) # January spillover
|
||||
in_month_cell = next(cell for week in grid["weeks"] for cell in week if cell["date"] == date(2026, 2, 1))
|
||||
self.assertTrue(in_month_cell["in_month"])
|
||||
|
||||
def test_month_grid_buckets_events_under_their_start_day(self):
|
||||
event = self.make_event(start=self.at(date(2026, 2, 12), 14))
|
||||
|
||||
grid = month_grid([event], date(2026, 2, 1))
|
||||
|
||||
cell = next(cell for week in grid["weeks"] for cell in week if cell["date"] == date(2026, 2, 12))
|
||||
self.assertEqual(cell["events"], [event])
|
||||
|
||||
def test_season_grid_spans_every_month_of_the_season(self):
|
||||
season = Season.objects.create(club=self.club, start_date=date(2026, 8, 1), end_date=date(2027, 7, 31))
|
||||
|
||||
months = season_grid([], season)
|
||||
|
||||
self.assertEqual(len(months), 12)
|
||||
self.assertEqual(months[0]["label"], date(2026, 8, 1))
|
||||
self.assertEqual(months[-1]["label"], date(2027, 7, 1))
|
||||
|
||||
def test_season_grid_cells_carry_a_count_not_the_event_list(self):
|
||||
season = Season.objects.create(club=self.club, start_date=date(2026, 8, 1), end_date=date(2027, 7, 31))
|
||||
event = self.make_event(start=self.at(date(2026, 9, 5), 10))
|
||||
|
||||
months = season_grid([event], season)
|
||||
|
||||
september = next(month for month in months if month["label"] == date(2026, 9, 1))
|
||||
cell = next(cell for week in september["weeks"] for cell in week if cell["date"] == date(2026, 9, 5))
|
||||
self.assertEqual(cell["count"], 1)
|
||||
self.assertNotIn("events", cell)
|
||||
|
||||
Reference in New Issue
Block a user