From b072473afb299cccb7f535477545945fc3942167 Mon Sep 17 00:00:00 2001 From: Tom Wu Date: Tue, 19 May 2026 12:46:42 -0400 Subject: [PATCH] =?UTF-8?q?B7=20=E2=80=94=20bulk-notify=20the=20published?= =?UTF-8?q?=20schedule=20=E2=86=92=20inbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit admin "Notify assignees" action on a published solution that creates a `reminder` inbox notification for every assignee, honoring per-person EmailPreference (recipients who removed `reminder` from enabled_types are skipped). Org-scoped to satisfy the strict tenancy guard; DB-only, never breaks the publish view. Surfaces a "Reminder sent to N assignee(s)" notice; blocked until published. Tests: tests/web/test_bulk_notify.py (per-assignee create, prefs honored, requires-published, unknown→404) and committed e2e tests/e2e/test_bulk_notify.py (publish → notify → assignee inbox shows the reminder alongside the auto publish notice). Closes #212 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/test_bulk_notify.py | 64 +++++++++++++ tests/web/test_bulk_notify.py | 106 ++++++++++++++++++++++ web/routers/partials.py | 78 +++++++++++++++- web/templates/partials/publish_state.html | 14 +++ 4 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/test_bulk_notify.py create mode 100644 tests/web/test_bulk_notify.py diff --git a/tests/e2e/test_bulk_notify.py b/tests/e2e/test_bulk_notify.py new file mode 100644 index 00000000..2ad90bb2 --- /dev/null +++ b/tests/e2e/test_bulk_notify.py @@ -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) diff --git a/tests/web/test_bulk_notify.py b/tests/web/test_bulk_notify.py new file mode 100644 index 00000000..06e37576 --- /dev/null +++ b/tests/web/test_bulk_notify.py @@ -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 diff --git a/web/routers/partials.py b/web/routers/partials.py index 8636e7d1..c09ec1a1 100644 --- a/web/routers/partials.py +++ b/web/routers/partials.py @@ -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) @@ -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, ) @@ -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( + '
Solution not found.
', + 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, diff --git a/web/templates/partials/publish_state.html b/web/templates/partials/publish_state.html index 5627d17f..c75dbabc 100644 --- a/web/templates/partials/publish_state.html +++ b/web/templates/partials/publish_state.html @@ -6,6 +6,20 @@
Published — volunteers now see these assignments on their schedule.
+ {% if notified is not none %} +
+
+ {% if notified %}Reminder sent to {{ notified }} assignee(s). + {% else %}No assignees to notify.{% endif %} +
+ {% endif %} +
+