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
64 changes: 64 additions & 0 deletions tests/e2e/test_bulk_notify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Overnight B7 e2e — admin bulk-notifies the published schedule.

publish → admin clicks "Notify assignees" → the assignee's inbox shows
the reminder (alongside the auto publish notification).
"""

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_notify_published_schedule_reaches_inbox(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", "Robin 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]", "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')")

page.goto(f"{base}/a/solver")
page.fill("#from_date", "2026-05-19")
page.fill("#to_date", "2026-06-30")
page.click("button:has-text('Run solver')")
page.wait_for_selector("#solver-result:has-text('Review solution')")
page.click("a:has-text('Review solution')")
page.wait_for_url("**/a/solution/**")
page.wait_for_selector("#publish-state")
page.click("button:has-text('Publish this solution')")
page.wait_for_selector("#publish-state:has-text('Unpublish')")

# Explicit bulk reminder.
page.click("button:has-text('Notify assignees')")
page.wait_for_selector("#notify-result:has-text('Reminder sent to 1 assignee(s)')")

# The volunteer's inbox carries both the auto publish notice and the reminder.
vol_page.goto(f"{base}/v/inbox")
vol_page.wait_for_selector("#inbox-list:has-text('Reminder')")
vol_page.wait_for_selector("#inbox-list:has-text('New assignment')")
vol_page.wait_for_selector("#inbox-list:has-text('unread')")

no_js_errors(page)
106 changes: 106 additions & 0 deletions tests/web/test_bulk_notify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Overnight B7 — bulk-notify the published schedule → inbox."""

from __future__ import annotations

from datetime import datetime, timedelta

from api.models import Assignment, EmailPreference, Event, Notification, Solution
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 _published_solution(db, org, *, people, eid="bn_ev", published=True):
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),
)
)
sol = Solution(
org_id=org,
solve_ms=12.0,
hard_violations=0,
soft_score=1.0,
health_score=90.0,
metrics={},
is_published=published,
published_at=datetime.utcnow() if published else None,
)
db.add(sol)
db.commit()
db.refresh(sol)
for pid in people:
seed_person(db, person_id=pid, org_id=org, email=f"{pid}@bn.test", roles=["volunteer"])
db.add(
Assignment(
event_id=eid,
person_id=pid,
role="usher",
status="confirmed",
solution_id=sol.id,
)
)
db.commit()
return sol


def test_notify_creates_reminder_per_assignee(client, db):
tok = _admin(client, db, org="bn_o1", email="bn1@web.test")
sol = _published_solution(db, "bn_o1", people=["bn_a", "bn_b"])
r = client.post(f"/a/solution/{sol.id}/notify", cookies={SESSION_COOKIE: tok})
assert r.status_code == 200
assert "Reminder sent to 2 assignee(s)" in r.text
n = (
db.query(Notification)
.filter(Notification.org_id == "bn_o1", Notification.type == "reminder")
.all()
)
assert {x.recipient_id for x in n} == {"bn_a", "bn_b"}


def test_notify_honors_email_preferences(client, db):
tok = _admin(client, db, org="bn_o2", email="bn2@web.test")
sol = _published_solution(db, "bn_o2", people=["bn_c", "bn_d"])
# bn_c opted out of reminders.
db.add(
EmailPreference(
person_id="bn_c",
org_id="bn_o2",
enabled_types=["assignment"],
unsubscribe_token="bn-c-tok",
)
)
db.commit()
r = client.post(f"/a/solution/{sol.id}/notify", cookies={SESSION_COOKIE: tok})
assert "Reminder sent to 1 assignee(s)" in r.text
recips = {
x.recipient_id
for x in db.query(Notification)
.filter(Notification.org_id == "bn_o2", Notification.type == "reminder")
.all()
}
assert recips == {"bn_d"}


def test_notify_requires_published(client, db):
tok = _admin(client, db, org="bn_o3", email="bn3@web.test")
sol = _published_solution(db, "bn_o3", people=["bn_e"], published=False)
r = client.post(f"/a/solution/{sol.id}/notify", cookies={SESSION_COOKIE: tok})
assert r.status_code == 400
assert "Publish the solution before notifying" in r.text
assert db.query(Notification).filter(Notification.type == "reminder").count() == 0


def test_notify_unknown_solution_404(client, db):
tok = _admin(client, db, org="bn_o4", email="bn4@web.test")
assert client.post("/a/solution/99999/notify", cookies={SESSION_COOKIE: tok}).status_code == 404
78 changes: 76 additions & 2 deletions web/routers/partials.py
Original file line number Diff line number Diff line change
Expand Up @@ -1497,10 +1497,56 @@ def _emit_publish_notifications(db: Session, person: Person, sid: int) -> None:
db.rollback()


def _publish_state(request: Request, person: Person, db: Session, sid: int, *, error=None):
def _emit_reminder_notifications(db: Session, person: Person, sid: int) -> int:
"""Create a `reminder` inbox Notification for each distinct assignee
in the (published) solution, honoring per-person EmailPreference —
recipients who removed `reminder` from enabled_types are skipped.
Org-scoped on purpose (strict tenancy guard); DB-only, no email
dependency. Returns how many notifications were created."""
rows = (
db.query(Assignment.person_id, Assignment.event_id)
.join(Event, Assignment.event_id == Event.id)
.filter(Event.org_id == person.org_id, Assignment.solution_id == sid)
.all()
)
first_event: dict[str, str] = {}
for pid, eid in rows:
first_event.setdefault(pid, eid)
if not first_event:
return 0
prefs = {
p.person_id: (p.enabled_types or [])
for p in db.query(EmailPreference)
.filter(EmailPreference.org_id == person.org_id)
.all()
}
created = 0
for pid, eid in first_event.items():
if pid in prefs and "reminder" not in prefs[pid]:
continue
db.add(
Notification(
org_id=person.org_id,
recipient_id=pid,
type="reminder",
status="pending",
event_id=eid,
template_data={"solution_id": sid},
)
)
created += 1
if created:
db.commit()
return created


def _publish_state(
request: Request, person: Person, db: Session, sid: int, *, error=None, notified=None
):
"""Re-render #publish-state from the solution's current state
(is_published + rollback eligibility), always carrying solution_id
so the swapped fragment's buttons keep working."""
so the swapped fragment's buttons keep working. `notified` is the
count from a just-run notify action (None = not just notified)."""
from web.app import templates

review = _solution_review(db, person, sid)
Expand All @@ -1517,6 +1563,7 @@ def _publish_state(request: Request, person: Person, db: Session, sid: int, *, e
"published": review["is_published"],
"can_rollback": review["can_rollback"],
"error": error,
"notified": notified,
},
status_code=400 if error else 200,
)
Expand All @@ -1543,6 +1590,33 @@ def solution_publish(
return _publish_state(request, person, db, solution_id)


@router.post("/a/solution/{solution_id}/notify", response_class=HTMLResponse)
def solution_notify(
request: Request,
solution_id: int,
person: Person = Depends(get_session_admin),
db: Session = Depends(get_db),
):
"""Send a reminder to everyone on the published solution (inbox +
sandbox email where enabled), honoring each person's preferences."""
if _solution_owned(db, person, solution_id) is None:
return HTMLResponse(
'<div id="publish-state" class="empty">Solution not found.</div>',
status_code=404,
)
review = _solution_review(db, person, solution_id)
if review is None or not review["is_published"]:
return _publish_state(
request,
person,
db,
solution_id,
error="Publish the solution before notifying assignees.",
)
count = _emit_reminder_notifications(db, person, solution_id)
return _publish_state(request, person, db, solution_id, notified=count)


@router.post("/a/solution/{solution_id}/unpublish", response_class=HTMLResponse)
def solution_unpublish(
request: Request,
Expand Down
14 changes: 14 additions & 0 deletions web/templates/partials/publish_state.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@
<div class="form-notice" role="status">
Published — volunteers now see these assignments on their schedule.
</div>
{% if notified is not none %}
<div class="spacer-12"></div>
<div class="form-notice" role="status" id="notify-result">
{% if notified %}Reminder sent to {{ notified }} assignee(s).
{% else %}No assignees to notify.{% endif %}
</div>
{% endif %}
<div class="spacer-12"></div>
<button class="btn btn-secondary" type="button"
hx-post="/a/solution/{{ solution_id }}/notify"
hx-target="#publish-state" hx-swap="outerHTML"
hx-confirm="Send a reminder to everyone on this published schedule?">
Notify assignees
</button>
<div class="spacer-12"></div>
<button class="btn btn-destructive" type="button"
hx-post="/a/solution/{{ solution_id }}/unpublish"
Expand Down
Loading