From 94885ad67f52127f700f53e064a164bad2eaa5a6 Mon Sep 17 00:00:00 2001 From: Tom Wu Date: Tue, 19 May 2026 12:30:34 -0400 Subject: [PATCH] =?UTF-8?q?B4=20=E2=80=94=20event=20roster=20fill=20view?= =?UTF-8?q?=20(needed=20vs=20filled,=20inline=20assign)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-role coverage breakdown to the per-event roster: for each role in extra_data.role_counts, show filled/needed and the gap, with an inline person picker (reusing the existing assign endpoint, role pre-bound) so an admin can close coverage gaps without the solver. The coverage block lives in the HTMX-swapped partial so it updates on every add/remove. Tests: tests/web/test_event_roster_fill.py (3) and committed e2e tests/e2e/test_event_roster_fill.py (admin fills a gap inline, the volunteer then sees the shift). Closes #206 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/test_event_roster_fill.py | 53 +++++++++++++++ tests/web/test_event_roster_fill.py | 68 +++++++++++++++++++ web/routers/pages.py | 15 ++++ web/templates/partials/event_assignments.html | 33 +++++++++ 4 files changed, 169 insertions(+) create mode 100644 tests/e2e/test_event_roster_fill.py create mode 100644 tests/web/test_event_roster_fill.py diff --git a/tests/e2e/test_event_roster_fill.py b/tests/e2e/test_event_roster_fill.py new file mode 100644 index 00000000..6e0c6fc8 --- /dev/null +++ b/tests/e2e/test_event_roster_fill.py @@ -0,0 +1,53 @@ +"""Overnight B4 e2e — admin fills a roster gap inline; volunteer sees it.""" + +from __future__ import annotations + +import pytest + +from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin + +pytestmark = pytest.mark.e2e + + +def test_admin_fills_roster_gap(live_server, new_context, page, db_path): + base = live_server + vol_email = f"vol+{rid()}@hope.e2e" + + signup_admin(page, base) + + page.goto(f"{base}/a/people") + page.click("button:has-text('Invite person')") + page.fill("#inv_name", "Pat Vol") + page.fill("#inv_email", vol_email) + page.select_option("#inv_role", "volunteer") + page.click("button:has-text('Send invite')") + page.wait_for_selector("#invite-result:has-text('Invitation sent')") + vol_page = accept_invitation(new_context(), base, invite_token(db_path, vol_email)) + + page.goto(f"{base}/a/events") + page.click("button:has-text('New event')") + page.wait_for_selector("#ev_type", state="visible") + page.fill("#ev_type", "Sunday 10am Service") + page.fill("#ev_date", "2026-06-07") + page.fill("#ev_start", "10:00") + page.fill("#ev_end", "11:30") + page.fill("input[name=role_name]", "usher") + page.fill("input[name=role_count]", "1") + page.click("button:has-text('Create event')") + page.wait_for_selector("#events-list:has-text('Sunday 10am Service')") + + # Open the roster — coverage shows the usher gap. + page.click("a:has-text('Manage')") + page.wait_for_selector("#role-coverage[data-staffed='no']") + page.wait_for_selector(".cov-row[data-role='usher'][data-gap='1']") + + # Inline-fill the gap with the volunteer. + page.select_option(".cov-row[data-role='usher'] select[name=person_id]", label="Pat Vol") + page.click(".cov-row[data-role='usher'] button:has-text('Fill')") + page.wait_for_selector("#role-coverage[data-staffed='yes']") + + # Volunteer now sees the shift. + vol_page.goto(f"{base}/v/schedule") + vol_page.wait_for_selector("text=Sunday 10am Service") + + no_js_errors(page) diff --git a/tests/web/test_event_roster_fill.py b/tests/web/test_event_roster_fill.py new file mode 100644 index 00000000..912bb0a3 --- /dev/null +++ b/tests/web/test_event_roster_fill.py @@ -0,0 +1,68 @@ +"""Overnight B4 — event roster fill view (needed vs filled + inline fill).""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from api.models import Assignment, Event +from tests.web.conftest import seed_person +from web.deps import SESSION_COOKIE + + +def _admin(client, db, *, org, email): + seed_person(db, person_id=f"{org}_adm", org_id=org, email=email, roles=["admin"]) + r = client.post("/auth/login", data={"email": email, "password": "WebPass123!"}) + return r.cookies[SESSION_COOKIE] + + +def _event(db, org, *, eid="rev1", role_counts=None): + start = datetime(2026, 6, 7, 10, 0, 0) + db.add( + Event( + id=eid, + org_id=org, + type="Sunday Service", + start_time=start, + end_time=start + timedelta(hours=1), + extra_data={"role_counts": role_counts} if role_counts else None, + ) + ) + db.commit() + + +def test_roster_shows_gap(client, db): + tok = _admin(client, db, org="rf_o1", email="rf1@web.test") + _event(db, "rf_o1", role_counts={"usher": 2}) + r = client.get("/a/events/rev1/assignments", cookies={SESSION_COOKIE: tok}) + assert r.status_code == 200 + assert 'id="role-coverage"' in r.text + assert 'data-staffed="no"' in r.text + assert "0/2 filled" in r.text and "2 needed" in r.text + + +def test_fill_gap_closes_coverage(client, db): + tok = _admin(client, db, org="rf_o2", email="rf2@web.test") + _event(db, "rf_o2", role_counts={"usher": 1}) + seed_person(db, person_id="rf_v", org_id="rf_o2", email="v@rf.test", roles=["volunteer"]) + r = client.post( + "/a/events/rev1/assignments/add", + data={"person_id": "rf_v", "role": "usher"}, + cookies={SESSION_COOKIE: tok}, + ) + assert r.status_code == 200 + assert 'data-staffed="yes"' in r.text + assert "1/1 filled" in r.text and "covered" in r.text + a = ( + db.query(Assignment) + .filter(Assignment.event_id == "rev1", Assignment.person_id == "rf_v") + .first() + ) + assert a is not None and a.role == "usher" + + +def test_no_role_counts_hides_coverage(client, db): + tok = _admin(client, db, org="rf_o3", email="rf3@web.test") + _event(db, "rf_o3") # no role_counts + r = client.get("/a/events/rev1/assignments", cookies={SESSION_COOKIE: tok}) + assert r.status_code == 200 + assert 'id="role-coverage"' not in r.text diff --git a/web/routers/pages.py b/web/routers/pages.py index 2302f82a..7d896d41 100644 --- a/web/routers/pages.py +++ b/web/routers/pages.py @@ -1099,12 +1099,27 @@ def _event_assignments(db: Session, org_id: str, event_id: str) -> dict | None: ) assigned_ids = {p.id for _, p in rows} people = db.query(Person).filter(Person.org_id == org_id).order_by(Person.name.asc()).all() + rc = (ev.extra_data or {}).get("role_counts") or {} + filled_by_role: dict[str, int] = {} + for a, _ in rows: + filled_by_role[a.role or ""] = filled_by_role.get(a.role or "", 0) + 1 + coverage = [ + { + "role": r, + "needed": n, + "filled": filled_by_role.get(r, 0), + "gap": max(0, n - filled_by_role.get(r, 0)), + } + for r, n in rc.items() + ] return { "event": { "id": ev.id, "type": ev.type, "date_label": ev.start_time.strftime("%a %d %b %Y") if ev.start_time else "", }, + "coverage": coverage, + "fully_staffed": bool(coverage) and all(c["gap"] == 0 for c in coverage), "assignees": [ { "assignment_id": a.id, diff --git a/web/templates/partials/event_assignments.html b/web/templates/partials/event_assignments.html index d8d9c97e..91571af8 100644 --- a/web/templates/partials/event_assignments.html +++ b/web/templates/partials/event_assignments.html @@ -13,6 +13,39 @@ {% endif %} + {% if data.coverage %} +
Role coverage
+
+ {% for c in data.coverage %} +
+
+
{{ c.role or 'unspecified' }}
+
+ {{ c.filled }}/{{ c.needed }} filled + {% if c.gap > 0 %}· {{ c.gap }} needed{% else %}· covered{% endif %} +
+
+ {% if c.gap > 0 and data.candidates %} +
+ + + +
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} +
Assignees ({{ data.assignees|length }})