Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions tests/e2e/test_event_roster_fill.py
Original file line number Diff line number Diff line change
@@ -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)
68 changes: 68 additions & 0 deletions tests/web/test_event_roster_fill.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions web/routers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions web/templates/partials/event_assignments.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,39 @@
{% endif %}
</div>

{% if data.coverage %}
<div class="mono-label" style="margin-top:18px">Role coverage</div>
<div class="group" id="role-coverage"
data-staffed="{{ 'yes' if data.fully_staffed else 'no' }}">
{% for c in data.coverage %}
<div class="row cov-row" data-role="{{ c.role }}" data-gap="{{ c.gap }}">
<div class="row-main">
<div class="row-title">{{ c.role or 'unspecified' }}</div>
<div class="row-sub">
{{ c.filled }}/{{ c.needed }} filled
{% if c.gap > 0 %}· <strong>{{ c.gap }} needed</strong>{% else %}· covered{% endif %}
</div>
</div>
{% if c.gap > 0 and data.candidates %}
<form hx-post="/a/events/{{ data.event.id }}/assignments/add"
hx-target="#event-assignments" hx-swap="outerHTML"
style="margin:0;display:flex;gap:8px;align-items:center">
<input type="hidden" name="role" value="{{ c.role }}">
<select name="person_id" required aria-label="Fill {{ c.role }}"
style="border:0;background:transparent;font-family:inherit;font-size:14px;color:var(--ink-1)">
{% for cand in data.candidates %}
<option value="{{ cand.id }}">{{ cand.name }}</option>
{% endfor %}
</select>
<button class="btn btn-primary" style="width:auto;padding:6px 12px"
type="submit">Fill</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}

<div class="mono-label" style="margin-top:18px">
Assignees ({{ data.assignees|length }})
</div>
Expand Down
Loading