From 458d1414708549a4ca1155f0873ba7e2953411e7 Mon Sep 17 00:00:00 2001 From: Tom Wu Date: Tue, 15 Sep 2026 14:03:37 -0400 Subject: [PATCH] Add tenant-scoped solution ICS export Summary: - implement tenant-scoped solution ICS export - add real-JWT scope and privacy coverage - refresh current authorization, roadmap, OpenAPI, and generated-client docs Changed files: - api/routers/solutions.py: render scoped ICS downloads - tests/api/test_scheduling_tenant_boundaries.py: verify auth and tenant isolation - tests/contract/openapi.snapshot.json and mobile/api_client/: synchronize the documented contract - docs/API_AUTHORIZATION.md and docs/ROADMAP.md: replace the stale 501 limitation Validation: - make test-all: 1985 passed, 21 skipped - make mobile-codegen-check: passed - make test-mobile-generated: 692 passed - poetry run black --check api tests: passed - poetry run ruff check api tests: passed - make test-docs: passed Follow-ups: - production calendar-client acceptance remains part of the authorized release track --- api/routers/solutions.py | 60 +++++++++++++++++-- docs/API_AUTHORIZATION.md | 2 +- docs/ROADMAP.md | 5 +- mobile/api_client/doc/SolutionsApi.md | 2 +- .../api_client/lib/src/api/solutions_api.dart | 2 +- .../api_client/test/solutions_api_test.dart | 2 +- .../api/test_scheduling_tenant_boundaries.py | 47 ++++++++++++++- tests/contract/openapi.snapshot.json | 2 +- 8 files changed, 108 insertions(+), 14 deletions(-) diff --git a/api/routers/solutions.py b/api/routers/solutions.py index db3af5d..c5862dc 100644 --- a/api/routers/solutions.py +++ b/api/routers/solutions.py @@ -3,7 +3,7 @@ import json import math from copy import deepcopy -from typing import Any +from typing import Any, cast from fastapi import ( APIRouter, @@ -36,6 +36,7 @@ Notification, Organization, Person, + Resource, Solution, Team, TeamMember, @@ -63,6 +64,7 @@ unpublish_solution_transaction, ) from api.timeutils import utcnow +from api.utils.calendar_utils import generate_ics_from_events from api.utils.pdf_export import generate_schedule_pdf router = APIRouter(prefix="/solutions", tags=["solutions"]) @@ -349,7 +351,7 @@ def export_solution( current_admin: Person = Depends(get_current_admin_user), db: Session = Depends(get_db), ): - """Export a tenant-scoped solution in JSON, CSV, or PDF.""" + """Export a tenant-scoped solution in JSON, CSV, ICS, or PDF.""" solution = _get_admin_solution(solution_id, current_admin, db) if export_format.format not in {"json", "csv", "ics", "pdf"}: @@ -534,9 +536,57 @@ def export_solution( ) elif export_format.format == "ics": - # TODO: ICS export has StringIO bug - needs fixing - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="ICS export not yet implemented" + org = db.query(Organization).filter(Organization.id == solution.org_id).one() + org_config: dict[str, Any] = org.config if isinstance(org.config, dict) else {} + people_by_id: dict[str, Person] = {cast(str, person.id): person for person in people_db} + resource_ids = { + cast(str, event.resource_id) for event in events_db if event.resource_id is not None + } + resources_by_id: dict[str, Resource] = { + cast(str, resource.id): resource + for resource in db.query(Resource) + .filter(Resource.org_id == solution.org_id, Resource.id.in_(resource_ids)) + .all() + } + + calendar_events: list[dict[str, Any]] = [] + for event in sorted(events_db, key=lambda item: (item.start_time, item.id)): + event_id = cast(str, event.id) + resource_id = cast(str | None, event.resource_id) + resource = resources_by_id.get(resource_id) if resource_id is not None else None + calendar_events.append( + { + "id": event_id, + "type": event.type, + "start_time": event.start_time, + "end_time": event.end_time, + "extra_data": event.extra_data or {}, + "resource": {"location": resource.location} if resource else None, + "assignments": [ + { + "person": { + "name": people_by_id[cast(str, row.person_id)].name, + }, + "role": row.role, + } + for row in sorted( + event_assignments[event_id], + key=lambda item: (item.person_id, item.role or ""), + ) + ], + } + ) + + content = generate_ics_from_events( + calendar_events, + calendar_name=f"{org.name} - Solution {solution_id}", + timezone=str(org_config.get("timezone") or "UTC"), + include_assignments=True, + ) + return Response( + content=content, + media_type="text/calendar", + headers={"Content-Disposition": f"attachment; filename=solution_{solution_id}.ics"}, ) elif export_format.format == "pdf": diff --git a/docs/API_AUTHORIZATION.md b/docs/API_AUTHORIZATION.md index bcea7e3..74c8f98 100644 --- a/docs/API_AUTHORIZATION.md +++ b/docs/API_AUTHORIZATION.md @@ -90,7 +90,7 @@ same-origin HTMX success, and a rejected foreign-origin write in Chromium. | `/conflicts` | Admin | Admin check operation | Person, target event, existing assignments, and overlapping events are joined through the admin's organization. | `tests/api/test_scheduling_tenant_boundaries.py`, conflict case | | `/solver` | Admin | Admin | The requested organization must equal the admin's organization; source rows are organization-filtered. | Domain playbooks and multi-tenant API tests | | `/solutions` | Admin, including assignment snapshots, streams, statistics, and comparisons | Admin, including create, export, publish, rollback, and delete | Solutions are selected by ID plus actor organization. Assignment children require same-tenant event and person joins. | `tests/api/test_scheduling_tenant_boundaries.py`, solution and stream cases | -| `/solutions/{id}/export` | Admin | Admin | Validate `org`, `person:{id}`, or `team:{id}` before querying assignments. Filter event, person, assignment, and identity-bearing metric rows before JSON, CSV, or PDF serialization. ICS remains an explicit `501`, never a broader fallback. | `tests/api/test_scheduling_tenant_boundaries.py`, export case | +| `/solutions/{id}/export` | Admin | Admin | Validate `org`, `person:{id}`, or `team:{id}` before querying assignments. Filter event, person, assignment, resource, and identity-bearing metric rows before JSON, CSV, ICS, or PDF serialization. | `tests/api/test_scheduling_tenant_boundaries.py`, export case | The remaining mounted operations are classified individually in the executable policy. Public-token routes are exceptions, not evidence that a router or route diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b6c5c4d..45591d0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -210,8 +210,9 @@ evidence with a local mock. Do not deploy or enable providers from a roadmap alo iOS simulator integration passes, and an Android debug APK builds locally. Physical-device, signed release, store, and authorized environment acceptance remain open. -- ICS solution export remains explicitly unsupported with `501`; calendar ICS - export is separate. Do not represent unsupported solution ICS as shipped. +- Tenant-scoped solution ICS export is implemented for organization, person, + and team scopes. Real-JWT API coverage parses the calendar and verifies that + foreign people, assignments, events, and roles are excluded. These are verified partial milestones, not blanket closure of their tickets. Retire old effort totals until remaining acceptance criteria are re-estimated. diff --git a/mobile/api_client/doc/SolutionsApi.md b/mobile/api_client/doc/SolutionsApi.md index 7a98b82..5a5b855 100644 --- a/mobile/api_client/doc/SolutionsApi.md +++ b/mobile/api_client/doc/SolutionsApi.md @@ -158,7 +158,7 @@ void (empty response body) Export Solution -Export a tenant-scoped solution in JSON, CSV, or PDF. +Export a tenant-scoped solution in JSON, CSV, ICS, or PDF. ### Example ```dart diff --git a/mobile/api_client/lib/src/api/solutions_api.dart b/mobile/api_client/lib/src/api/solutions_api.dart index a55d3bc..d8875f1 100644 --- a/mobile/api_client/lib/src/api/solutions_api.dart +++ b/mobile/api_client/lib/src/api/solutions_api.dart @@ -265,7 +265,7 @@ class SolutionsApi { } /// Export Solution - /// Export a tenant-scoped solution in JSON, CSV, or PDF. + /// Export a tenant-scoped solution in JSON, CSV, ICS, or PDF. /// /// Parameters: /// * [solutionId] diff --git a/mobile/api_client/test/solutions_api_test.dart b/mobile/api_client/test/solutions_api_test.dart index d8f3770..4cf818b 100644 --- a/mobile/api_client/test/solutions_api_test.dart +++ b/mobile/api_client/test/solutions_api_test.dart @@ -36,7 +36,7 @@ void main() { // Export Solution // - // Export a tenant-scoped solution in JSON, CSV, or PDF. + // Export a tenant-scoped solution in JSON, CSV, ICS, or PDF. // //Future exportSolution(int solutionId, ExportFormat exportFormat) async test('test exportSolution', () async { diff --git a/tests/api/test_scheduling_tenant_boundaries.py b/tests/api/test_scheduling_tenant_boundaries.py index d36622a..8375eb2 100644 --- a/tests/api/test_scheduling_tenant_boundaries.py +++ b/tests/api/test_scheduling_tenant_boundaries.py @@ -4,6 +4,7 @@ from io import BytesIO import pytest +from icalendar import Calendar from api.models import ( Assignment, @@ -340,6 +341,15 @@ def test_solution_exports_filter_person_and_team_before_serialization( db.commit() endpoint = f"/api/v1/solutions/{solution.id}/export" + assert client.post(endpoint, json={"format": "ics", "scope": "org"}).status_code == 401 + assert ( + client.post( + endpoint, + json={"format": "ics", "scope": "org"}, + headers=headers["member_a"], + ).status_code + == 403 + ) person_response = client.post( endpoint, json={"format": "json", "scope": f"person:{tenants['member_a']['person_id']}"}, @@ -413,8 +423,41 @@ def _capture_pdf(org_name, events, people, assignments, events_db_map, blocked_d json={"format": "ics", "scope": "team:tenant-a-team"}, headers=headers["admin_a"], ) - assert ics_response.status_code == 501 - assert "Member A" not in ics_response.text + assert ics_response.status_code == 200, ics_response.text + assert ics_response.headers["content-type"].startswith("text/calendar") + assert ( + ics_response.headers["content-disposition"] + == f"attachment; filename=solution_{solution.id}.ics" + ) + calendar = Calendar.from_ical(ics_response.content) + calendar_events = [component for component in calendar.walk() if component.name == "VEVENT"] + assert len(calendar_events) == 1 + assert str(calendar_events[0]["summary"]) == tenants["event_a"]["type"] + assert "Member A" in str(calendar_events[0]["description"]) + assert "guard" in str(calendar_events[0]["description"]) + assert "Peer A" not in ics_response.text + assert "Member B" not in ics_response.text + + person_ics_response = client.post( + endpoint, + json={"format": "ics", "scope": f"person:{tenants['member_a']['person_id']}"}, + headers=headers["admin_a"], + ) + assert person_ics_response.status_code == 200, person_ics_response.text + assert "Member A" in person_ics_response.text + assert "Peer A" not in person_ics_response.text + assert "Member B" not in person_ics_response.text + + org_ics_response = client.post( + endpoint, + json={"format": "ics", "scope": "org"}, + headers=headers["admin_a"], + ) + assert org_ics_response.status_code == 200, org_ics_response.text + assert org_ics_response.text.count("BEGIN:VEVENT") == 1 + assert "Member A" in org_ics_response.text + assert "Peer A" in org_ics_response.text + assert "Member B" not in org_ics_response.text for scope, expected_status in ( (f"person:{tenants['member_b']['person_id']}", 404), diff --git a/tests/contract/openapi.snapshot.json b/tests/contract/openapi.snapshot.json index 35b4bab..18af3f3 100644 --- a/tests/contract/openapi.snapshot.json +++ b/tests/contract/openapi.snapshot.json @@ -12137,7 +12137,7 @@ }, "/api/v1/solutions/{solution_id}/export": { "post": { - "description": "Export a tenant-scoped solution in JSON, CSV, or PDF.", + "description": "Export a tenant-scoped solution in JSON, CSV, ICS, or PDF.", "operationId": "exportSolution", "parameters": [ {