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
72 changes: 72 additions & 0 deletions tests/e2e/test_swap_marketplace.py
Original file line number Diff line number Diff line change
@@ -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)
98 changes: 98 additions & 0 deletions tests/web/test_swap_marketplace.py
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions web/routers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
70 changes: 70 additions & 0 deletions web/routers/partials.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
from web.routers.pages import (
NOTIF_TYPES,
RRULE_PRESETS,
_claimable_swaps,
_constraints,
_email_prefs,
_event_assignments,
Expand Down Expand Up @@ -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 ───────────────────────────────────────────


Expand Down
27 changes: 27 additions & 0 deletions web/templates/partials/swaps_open_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{# Swap requests another volunteer can cover. #swaps-open-list. #}
<div id="swaps-open-list">
{% if error %}
<div class="form-error" role="alert">{{ error }}</div>
<div class="spacer-12"></div>
{% endif %}

{% if not swaps %}
<div class="empty">No swap requests to cover right now.</div>
{% else %}
{% for s in swaps %}
<div class="card" style="margin-bottom:12px">
<div class="row-title">{{ s.event_type }}</div>
<div class="row-sub">{{ s.when }}</div>
<div style="margin-top:6px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
{% if s.role %}<span class="role-chip">{{ s.role }}</span>{% endif %}
<span class="row-sub">requested by {{ s.requested_by }}</span>
</div>
<div class="spacer-12"></div>
<form hx-post="/v/swaps/{{ s.assignment_id }}/claim"
hx-target="#swaps-open-list" hx-swap="outerHTML" style="margin:0">
<button class="btn btn-go" type="submit">Cover this shift</button>
</form>
</div>
{% endfor %}
{% endif %}
</div>
2 changes: 2 additions & 0 deletions web/templates/volunteer/open.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<div class="page-title">Open shifts</div>
<div class="scroll">
{% include "partials/open_list.html" %}
<div class="spacer-18"></div>
<a class="btn btn-secondary" href="/v/swaps">Cover a teammate's swap →</a>
</div>
{% set tabs = [
('/v/schedule', '▦', 'Schedule', 'schedule'),
Expand Down
20 changes: 20 additions & 0 deletions web/templates/volunteer/swaps_open.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Cover a swap · SignUpFlow{% endblock %}
{% block body %}
<div class="nav">
<a class="nav-back" href="/v/open">‹ Open shifts</a>
<form method="post" action="/auth/logout" style="margin:0">
<button class="nav-action" type="submit">Sign out</button>
</form>
</div>
<div class="page-title">Cover a swap</div>
<div class="scroll">
{% include "partials/swaps_open_list.html" %}
</div>
{% set tabs = [
('/v/schedule', '▦', 'Schedule', 'schedule'),
('/v/availability', '◷', 'Availability', 'availability'),
('/v/profile', '◍', 'Profile', 'profile'),
] %}
{% include "_nav.html" %}
{% endblock %}
Loading