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
18 changes: 15 additions & 3 deletions api/routers/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 = []
Expand Down
76 changes: 76 additions & 0 deletions tests/e2e/test_calendar_subscription.py
Original file line number Diff line number Diff line change
@@ -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)
79 changes: 79 additions & 0 deletions tests/web/test_calendar_feed_published.py
Original file line number Diff line number Diff line change
@@ -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
Loading