diff --git a/api/routers/calendar.py b/api/routers/calendar.py index 62285674..785cfeec 100644 --- a/api/routers/calendar.py +++ b/api/routers/calendar.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel +from sqlalchemy import or_ from sqlalchemy.orm import Session from api.database import get_db @@ -10,7 +11,7 @@ get_current_admin_user, get_current_user, ) -from api.models import Assignment, AuditAction, Event, Organization, Person, Resource +from api.models import Assignment, AuditAction, Event, Organization, Person, Resource, Solution from api.utils.audit_logger import log_audit_event from api.utils.calendar_utils import ( generate_https_feed_url, @@ -407,8 +408,19 @@ def calendar_feed(token: str, db: Session = Depends(get_db)): detail="Invalid calendar token", ) - # Get all assignments for this person - assignments = db.query(Assignment).filter(Assignment.person_id == person.id).all() + # Only published assignments belong on a subscribed calendar: those + # tied to a published solution, plus direct (manual / self-serve / + # swap) assignments that have no solution. Draft solver output stays + # out of the volunteer's calendar until it is published. + assignments = ( + db.query(Assignment) + .outerjoin(Solution, Assignment.solution_id == Solution.id) + .filter( + Assignment.person_id == person.id, + or_(Assignment.solution_id.is_(None), Solution.is_published.is_(True)), + ) + .all() + ) # Load event and resource data for each assignment assignment_data = [] diff --git a/tests/e2e/test_calendar_subscription.py b/tests/e2e/test_calendar_subscription.py new file mode 100644 index 00000000..bb9d8eb2 --- /dev/null +++ b/tests/e2e/test_calendar_subscription.py @@ -0,0 +1,76 @@ +"""Overnight B5 e2e — the calendar subscription only carries published work. + +Solve (draft) → the volunteer's ICS feed must NOT contain the shift. +Publish → fetching the same subscribe URL now returns the shift. +""" + +from __future__ import annotations + +import re + +import pytest + +from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin + +pytestmark = pytest.mark.e2e + + +def _feed_url(vol_page, base): + vol_page.goto(f"{base}/v/profile") + vol_page.wait_for_selector("#calendar-section") + m = re.search(r"/api/v1/calendar/feed/([A-Za-z0-9_\-]+)", vol_page.content()) + assert m, "calendar feed token not found on /v/profile" + return f"{base}/api/v1/calendar/feed/{m.group(1)}" + + +def test_subscription_gates_on_publish(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", "Dana 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')") + + # Solve — produces a DRAFT (unpublished) solution + assignment. + 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')") + + feed_url = _feed_url(vol_page, base) + draft = vol_page.request.get(feed_url) + assert draft.status == 200 + assert "Sunday 10am Service" not in draft.text(), "draft leaked into the ICS feed" + + # Publish. + 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')") + + published = vol_page.request.get(feed_url) + assert published.status == 200 + assert "Sunday 10am Service" in published.text(), "published shift missing from ICS feed" + + no_js_errors(vol_page) diff --git a/tests/web/test_calendar_feed_published.py b/tests/web/test_calendar_feed_published.py new file mode 100644 index 00000000..b786e8a8 --- /dev/null +++ b/tests/web/test_calendar_feed_published.py @@ -0,0 +1,79 @@ +"""Overnight B5 — the ICS subscription feed only exposes published work. + +Draft solver output (assignment tied to an unpublished Solution) must +stay out of the volunteer's subscribed calendar; manual / self-serve +assignments (no solution) and published-solution assignments appear. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from api.models import Assignment, Event, Solution +from tests.web.conftest import seed_person + + +def _event(db, *, eid, org, etype): + start = datetime(2026, 6, 7, 10, 0, 0) + db.add( + Event( + id=eid, + org_id=org, + type=etype, + start_time=start, + end_time=start + timedelta(hours=1), + ) + ) + + +def test_feed_gates_on_publish(client, db): + p = seed_person(db, person_id="cf_v", org_id="cf_o", email="cf@v.test", roles=["volunteer"]) + p.calendar_token = "cftok123" + _event(db, eid="cf_pub", org="cf_o", etype="Published Svc") + _event(db, eid="cf_man", org="cf_o", etype="Manual Svc") + sol = Solution( + org_id="cf_o", + hard_violations=0, + soft_score=1.0, + health_score=90.0, + is_published=False, + ) + db.add(sol) + db.commit() + db.refresh(sol) + db.add( + Assignment( + event_id="cf_pub", + person_id="cf_v", + role="usher", + status="confirmed", + solution_id=sol.id, + ) + ) + db.add( + Assignment( + event_id="cf_man", + person_id="cf_v", + role="greeter", + status="confirmed", + solution_id=None, + ) + ) + db.commit() + + r1 = client.get("/api/v1/calendar/feed/cftok123") + assert r1.status_code == 200 + assert "Manual Svc" in r1.text # manual (no solution) → always shown + assert "Published Svc" not in r1.text # draft solution → hidden + + sol.is_published = True + db.commit() + + r2 = client.get("/api/v1/calendar/feed/cftok123") + assert r2.status_code == 200 + assert "Published Svc" in r2.text # published → now shown + assert "Manual Svc" in r2.text + + +def test_invalid_token_still_404(client, db): + assert client.get("/api/v1/calendar/feed/nope-xyz").status_code == 404