diff --git a/tests/e2e/test_swap_marketplace.py b/tests/e2e/test_swap_marketplace.py new file mode 100644 index 00000000..bc09196b --- /dev/null +++ b/tests/e2e/test_swap_marketplace.py @@ -0,0 +1,72 @@ +"""Overnight B2 e2e — one volunteer covers another's swap, end-to-end.""" + +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 _invite(page, base, name, email): + page.goto(f"{base}/a/people") + page.click("button:has-text('Invite person')") + page.fill("#inv_name", name) + page.fill("#inv_email", email) + page.select_option("#inv_role", "volunteer") + page.click("button:has-text('Send invite')") + page.wait_for_selector("#invite-result:has-text('Invitation sent')") + + +def test_cover_a_swap_end_to_end(live_server, new_context, page, db_path): + base = live_server + a_email = f"a+{rid()}@hope.e2e" + b_email = f"b+{rid()}@hope.e2e" + + signup_admin(page, base) + + _invite(page, base, "Vol A", a_email) + a_page = accept_invitation(new_context(), base, invite_token(db_path, a_email)) + _invite(page, base, "Vol B", b_email) + b_page = accept_invitation(new_context(), base, invite_token(db_path, b_email)) + + # Admin creates an event with one open role. + 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]", "volunteer") + 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')") + + # Vol A claims it, then requests a swap. + a_page.goto(f"{base}/v/open") + a_page.wait_for_selector("#open-list:has-text('Sunday 10am Service')") + a_page.click("button:has-text('Claim')") + # Wait for the claim's HTMX swap to commit before navigating away — + # otherwise the assignment may not be persisted yet (CI race). + a_page.wait_for_selector("#open-list:has-text('No open shifts')") + a_page.goto(f"{base}/v/schedule") + a_page.click("a:has-text('Sunday 10am Service')") + a_page.wait_for_selector("#assignment-card") + a_page.click("button:has-text('Request swap')") + a_page.wait_for_selector("#assignment-card .status-text.swap_requested") + + # Vol B covers it. + b_page.goto(f"{base}/v/swaps") + b_page.wait_for_selector("#swaps-open-list:has-text('Sunday 10am Service')") + b_page.click("button:has-text('Cover this shift')") + b_page.wait_for_selector("#swaps-open-list:has-text('No swap requests to cover')") + + # B now has the shift; A no longer does. + b_page.goto(f"{base}/v/schedule") + b_page.wait_for_selector("text=Sunday 10am Service") + a_page.goto(f"{base}/v/schedule") + a_page.wait_for_selector("text=No assignments yet") + + no_js_errors(b_page) diff --git a/tests/web/test_swap_marketplace.py b/tests/web/test_swap_marketplace.py new file mode 100644 index 00000000..fcee5372 --- /dev/null +++ b/tests/web/test_swap_marketplace.py @@ -0,0 +1,98 @@ +"""Overnight B2 — swap-claim marketplace.""" + +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 _login(client, db, *, pid, org, email): + seed_person(db, person_id=pid, org_id=org, email=email, roles=["volunteer"]) + r = client.post("/auth/login", data={"email": email, "password": "WebPass123!"}) + return r.cookies[SESSION_COOKIE] + + +def _swap_setup(db, org, *, eid, requester, days=10): + start = datetime.utcnow() + timedelta(days=days) + db.add( + Event( + id=eid, + org_id=org, + type="Sunday Service", + start_time=start, + end_time=start + timedelta(hours=1), + ) + ) + db.commit() + a = Assignment(event_id=eid, person_id=requester, role="usher", status="swap_requested") + db.add(a) + db.commit() + db.refresh(a) + return a + + +def test_swaps_requires_auth(client): + assert client.get("/v/swaps").status_code == 303 + + +def test_claim_transfers_assignment(client, db): + a_tok = _login(client, db, pid="sm_a", org="sm_o1", email="a@sm.test") + b_tok = _login(client, db, pid="sm_b", org="sm_o1", email="b@sm.test") + asg = _swap_setup(db, "sm_o1", eid="sm_ev", requester="sm_a") + + # Requester does NOT see their own request. + own = client.get("/v/swaps", cookies={SESSION_COOKIE: a_tok}) + assert "Sunday Service" not in own.text + + # Other volunteer sees + covers it. + page = client.get("/v/swaps", cookies={SESSION_COOKIE: b_tok}) + assert "Sunday Service" in page.text and "requested by" in page.text + r = client.post(f"/v/swaps/{asg.id}/claim", cookies={SESSION_COOKIE: b_tok}) + assert r.status_code == 200 + db.refresh(asg) + assert asg.person_id == "sm_b" and asg.status == "confirmed" + assert "No swap requests to cover" in r.text # nothing left for B + + +def test_cannot_claim_if_already_on_event(client, db): + seed_person(db, person_id="sm_a2", org_id="sm_o2", email="a2@sm.test", roles=["volunteer"]) + b_tok = _login(client, db, pid="sm_b2", org="sm_o2", email="b2@sm.test") + asg = _swap_setup(db, "sm_o2", eid="sm_ev2", requester="sm_a2") + # B is already assigned to the same event. + db.add(Assignment(event_id="sm_ev2", person_id="sm_b2", role="greeter", status="confirmed")) + db.commit() + r = client.post(f"/v/swaps/{asg.id}/claim", cookies={SESSION_COOKIE: b_tok}) + assert r.status_code == 400 + assert "already on" in r.text.lower() + db.refresh(asg) + assert asg.person_id == "sm_a2" # untouched + + +def test_cannot_claim_non_swap(client, db): + b_tok = _login(client, db, pid="sm_b3", org="sm_o3", email="b3@sm.test") + seed_person(db, person_id="sm_a3", org_id="sm_o3", email="a3@sm.test", roles=["volunteer"]) + start = datetime.utcnow() + timedelta(days=5) + db.add( + Event( + id="sm_ev3", + org_id="sm_o3", + type="Confirmed Ev", + start_time=start, + end_time=start + timedelta(hours=1), + ) + ) + db.commit() + a = Assignment(event_id="sm_ev3", person_id="sm_a3", role="usher", status="confirmed") + db.add(a) + db.commit() + db.refresh(a) + r = client.post(f"/v/swaps/{a.id}/claim", cookies={SESSION_COOKIE: b_tok}) + assert r.status_code == 400 + + +def test_open_links_to_swaps(client, db): + tok = _login(client, db, pid="sm_v", org="sm_o4", email="v@sm.test") + assert 'href="/v/swaps"' in client.get("/v/open", cookies={SESSION_COOKIE: tok}).text diff --git a/web/routers/pages.py b/web/routers/pages.py index ef40f7bc..2302f82a 100644 --- a/web/routers/pages.py +++ b/web/routers/pages.py @@ -850,6 +850,65 @@ def admin_billing( ) +def _claimable_swaps(db: Session, person: Person) -> list[dict]: + """Swap-requested assignments another volunteer can cover: same org, + future, not the caller's own request, and not an event the caller is + already on. Org-scoped via the Event join.""" + from datetime import datetime + + now = datetime.utcnow() + rows = ( + db.query(Assignment, Event, Person) + .join(Event, Assignment.event_id == Event.id) + .join(Person, Assignment.person_id == Person.id) + .filter( + Event.org_id == person.org_id, + Assignment.status == "swap_requested", + Assignment.person_id != person.id, + Event.start_time >= now, + ) + .order_by(Event.start_time.asc()) + .all() + ) + mine = { + a.event_id + for a in db.query(Assignment) + .join(Event, Assignment.event_id == Event.id) + .filter(Event.org_id == person.org_id, Assignment.person_id == person.id) + .all() + } + return [ + { + "assignment_id": a.id, + "event_type": e.type, + "when": e.start_time.strftime("%a %d %b %Y · %H:%M") if e.start_time else "", + "role": a.role, + "requested_by": p.name, + } + for a, e, p in rows + if e.id not in mine + ] + + +@router.get("/v/swaps", response_class=HTMLResponse) +def volunteer_swaps_open( + request: Request, + person: Person = Depends(get_session_user), + db: Session = Depends(get_db), +): + from web.app import templates + + return templates.TemplateResponse( + request, + "volunteer/swaps_open.html", + { + "person": person, + "active_tab": "schedule", + "swaps": _claimable_swaps(db, person), + }, + ) + + def _swap_requests(db: Session, org_id: str) -> list[dict]: """Assignments the volunteer flagged for swap, org-scoped via the Event join.""" diff --git a/web/routers/partials.py b/web/routers/partials.py index c7053ab1..8636e7d1 100644 --- a/web/routers/partials.py +++ b/web/routers/partials.py @@ -77,6 +77,7 @@ from web.routers.pages import ( NOTIF_TYPES, RRULE_PRESETS, + _claimable_swaps, _constraints, _email_prefs, _event_assignments, @@ -245,6 +246,75 @@ def open_claim( return _open_list(request, person, db) +# ── Volunteer: swap-claim marketplace ──────────────────────────────── + + +def _swaps_open_list(request: Request, person: Person, db: Session, *, error=None): + from web.app import templates + + return templates.TemplateResponse( + request, + "partials/swaps_open_list.html", + {"swaps": _claimable_swaps(db, person), "error": error}, + status_code=400 if error else 200, + ) + + +@router.post("/v/swaps/{assignment_id}/claim", response_class=HTMLResponse) +def swap_claim( + request: Request, + assignment_id: int, + person: Person = Depends(get_session_user), + db: Session = Depends(get_db), +): + """Cover a teammate's swap: transfer the assignment to the claimer. + Re-validated server-side (org, still swap_requested, not own, future, + claimer not already on the event) — org-scoped direct write.""" + from datetime import datetime + + a = ( + db.query(Assignment) + .join(Event, Assignment.event_id == Event.id) + .filter( + Event.org_id == person.org_id, + Assignment.id == assignment_id, + Assignment.status == "swap_requested", + ) + .first() + ) + if a is None: + return _swaps_open_list( + request, person, db, error="That swap is no longer available." + ) + if a.person_id == person.id: + return _swaps_open_list( + request, person, db, error="That's your own swap request." + ) + ev = db.query(Event).filter(Event.id == a.event_id).first() + if ev is None or (ev.start_time and ev.start_time < datetime.utcnow()): + return _swaps_open_list(request, person, db, error="That event has passed.") + already = ( + db.query(Assignment) + .join(Event, Assignment.event_id == Event.id) + .filter( + Event.org_id == person.org_id, + Assignment.event_id == a.event_id, + Assignment.person_id == person.id, + ) + .first() + ) + if already is not None: + return _swaps_open_list( + request, person, db, error="You're already on that event." + ) + + a.person_id = person.id + a.status = "confirmed" + a.decline_reason = None + db.commit() + return _swaps_open_list(request, person, db) + + # ── Availability: time-off ─────────────────────────────────────────── diff --git a/web/templates/partials/swaps_open_list.html b/web/templates/partials/swaps_open_list.html new file mode 100644 index 00000000..2f999966 --- /dev/null +++ b/web/templates/partials/swaps_open_list.html @@ -0,0 +1,27 @@ +{# Swap requests another volunteer can cover. #swaps-open-list. #} +