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
60 changes: 55 additions & 5 deletions api/routers/solutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -36,6 +36,7 @@
Notification,
Organization,
Person,
Resource,
Solution,
Team,
TeamMember,
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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"}:
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion docs/API_AUTHORIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion mobile/api_client/doc/SolutionsApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mobile/api_client/lib/src/api/solutions_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion mobile/api_client/test/solutions_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonObject> exportSolution(int solutionId, ExportFormat exportFormat) async
test('test exportSolution', () async {
Expand Down
47 changes: 45 additions & 2 deletions tests/api/test_scheduling_tenant_boundaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from io import BytesIO

import pytest
from icalendar import Calendar

from api.models import (
Assignment,
Expand Down Expand Up @@ -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']}"},
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion tests/contract/openapi.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down