Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .github/workflows/v2-integration-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
- name: Verify the managed v2 roles hold their declared privileges
run: uv run --frozen --extra dev python scripts/reconcile_v2_privileges.py verify
- name: Test v2 metadata publication, resource routes, the countries list, reports and simulations
run: uv run --frozen --extra dev coverage run --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_catalog_publication_time_periods.py tests/integration/test_v2_catalog_publication_pruning.py tests/integration/test_v2_catalog_spm_availability.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_report_reads.py tests/integration/test_v2_report_creation.py tests/integration/test_v2_budget_window_report_reads.py tests/integration/test_v2_budget_window_report_creation.py
run: uv run --frozen --extra dev coverage run --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_catalog_publication_time_periods.py tests/integration/test_v2_catalog_publication_pruning.py tests/integration/test_v2_catalog_spm_availability.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_report_reads.py tests/integration/test_v2_report_creation.py tests/integration/test_v2_budget_window_report_reads.py tests/integration/test_v2_budget_window_report_creation.py tests/integration/test_v2_report_runs.py
- name: Test v2 policy persistence and immediate v1 mirroring
run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py
- name: Test signed-in identity provisioning, user associations, saved detailed-save records and user types
Expand Down
1 change: 1 addition & 0 deletions changelog.d/v2-report-reruns.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`/v2/reports` runs an economy report or a budget window again and lists its runs. `POST /v2/reports/{report_id}/runs?country_id=` (body `{"force": false}`) checks the report's country and the policies its simulations name, as a new report is checked, then has the simulation service create and start the report's new run on its default worker's versions, reusing each side's successful simulation run on those versions unless `force` is true; it answers 201 with the report and its new run, 409 naming the run when one is pending or running, and keeps every earlier run. `GET /v2/reports/{report_id}/runs?country_id=` lists the report's runs, newest first (a budget window's budget-window runs, each with its years), each with its `country_package_version` and `policyengine_version`, beside the versions the Public API runs now, so a caller can mark a report whose newest run is on older versions as out of date. Household reports are not run again or listed yet.
4 changes: 3 additions & 1 deletion docs/engineering/migration-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi
| --- | ---: |
| route group count | 9 |
| workflow count | 18 |
| request count | 97 |
| request count | 99 |
| db entity count | 6 |
| sim flow count | 3 |

Expand Down Expand Up @@ -207,6 +207,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi
| `POST` | `/v2/reports?country_id=us` | 201 | `report` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.kind`, `result.item.status`, `result.item.run`, `result.item.current_versions`, `result.item.result` |
| `GET` | `/v2/reports?country_id=us&report_id={report_id}` | 200 | `report` | none | `status`, `message`, `result.items` |
| `GET` | `/v2/reports/{report_id}?country_id=us` | 200 | `report` | none | `status`, `message`, `result.item.id`, `result.item.status`, `result.item.run`, `result.item.current_versions`, `result.item.result` |
| `POST` | `/v2/reports/{report_id}/runs?country_id=us` | 201 | `report` | none | `status`, `message`, `result.item.id`, `result.item.status`, `result.item.run`, `result.item.current_versions` |
| `GET` | `/v2/reports/{report_id}/runs?country_id=us` | 200 | `report` | none | `status`, `message`, `result.report_id`, `result.kind`, `result.items`, `result.current_versions` |
| `POST` | `/v2/simulations?country_id=us` | 201 | `simulation` | none | `status`, `message`, `result.item.id`, `result.item.kind`, `result.item.status`, `result.item.run` |
| `GET` | `/v2/simulations/{simulation_id}?country_id=us` | 200 | `simulation` | none | `status`, `message`, `result.item.id`, `result.item.status`, `result.item.run`, `result.item.current_versions` |

Expand Down
32 changes: 31 additions & 1 deletion docs/generated/migration_contracts.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"metadata": {
"db_entity_count": 6,
"request_count": 97,
"request_count": 99,
"route_group_count": 9,
"sim_flow_count": 3,
"workflow_count": 18
Expand Down Expand Up @@ -1423,6 +1423,36 @@
"result.item.result"
]
},
{
"expected_status": 201,
"headers": [],
"method": "POST",
"path": "/v2/reports/{report_id}/runs?country_id=us",
"route_group": "report",
"stable_response_fields": [
"status",
"message",
"result.item.id",
"result.item.status",
"result.item.run",
"result.item.current_versions"
]
},
{
"expected_status": 200,
"headers": [],
"method": "GET",
"path": "/v2/reports/{report_id}/runs?country_id=us",
"route_group": "report",
"stable_response_fields": [
"status",
"message",
"result.report_id",
"result.kind",
"result.items",
"result.current_versions"
]
},
{
"expected_status": 201,
"headers": [],
Expand Down
30 changes: 30 additions & 0 deletions policyengine_api/fastapi_routes/resource_service_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
)
from policyengine_api.services.v2.stored_reports.types import (
StoredReportRead,
StoredReportRunsRead,
StoredReportSummaryRead,
)
from policyengine_api.services.v2.stored_simulations.types import SimulationRead
Expand Down Expand Up @@ -222,6 +223,35 @@ def create_report(self, report_input: ReportCreationInput) -> StoredReportRead:
"""
...

def rerun_report(
self, *, country_id: str, report_id: UUID, force: bool
) -> StoredReportRead:
"""Run the country's economy report or budget window again; return it.

It is returned with its new run as its newest. Raise
``StoredReportNotFoundError`` when the country has no such report,
``ReportKindUnsupportedError`` for a kind whose runs the run routes do
not start yet (household reports), the
checks' errors as ``create_report`` does for the policies of the
report's simulations, the simulation service's errors, among them
``SimulationServiceConflictError`` when it will not run the report
again now, and ``RerunReportUnreadableError`` when the report cannot
be read back once its new run was started.
"""
...

def list_report_runs(
self, *, country_id: str, report_id: UUID
) -> StoredReportRunsRead:
"""Return the runs of the country's economy report or budget window.

They are newest first; a budget window's are its budget-window runs,
each with its years. Raise ``StoredReportNotFoundError`` when the
country has no such report, and ``ReportKindUnsupportedError`` for a
kind whose runs the run routes do not list yet (household reports).
"""
...


class V2StoredSimulationResourceService(Protocol):
"""Route-independent simulation operations for one request."""
Expand Down
131 changes: 130 additions & 1 deletion policyengine_api/fastapi_routes/v2/reports/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
reading never calls the simulation service and never starts a run. A report
is an economy report or a budget window, which its ``kind`` names. Creating
a report checks it, has the simulation service create and start it, and
reads it back.
reads it back. Running a report again checks it as a new report is checked,
has the simulation service start its new run, and reads the report back;
listing its runs reads them, newest first.
"""

from __future__ import annotations
Expand Down Expand Up @@ -41,6 +43,15 @@
report_item,
report_summary_item,
)
from policyengine_api.fastapi_routes.v2.reports.run_models import (
RERUN_RESPONSES,
RUN_LIST_RESPONSES,
ReportRerunConflictResponse,
ReportRerunRequest,
ReportRunListResponse,
RerunReportUnreadableResponse,
report_run_list_result,
)
from policyengine_api.fastapi_routes.v2.simulation_resources_support import (
SUBMISSION_ERROR_MAPPINGS,
SUBMISSION_REFUSALS,
Expand All @@ -55,8 +66,10 @@
ReportCollectionQuery,
ReportCreateQuery,
ReportDetailQuery,
ReportRunsQuery,
)
from policyengine_api.services.v2.simulation_service.types import (
SimulationServiceConflictError,
SimulationServiceUnavailableError,
)
from policyengine_api.services.v2.stored_reports.budget_window_results.types import (
Expand All @@ -69,6 +82,7 @@
CreatedReportUnreadableError,
ReportKindUnsupportedError,
ReportStorageError,
RerunReportUnreadableError,
StoredReportNotFoundError,
)

Expand All @@ -93,6 +107,9 @@
ReportDetailQueryDependency = Annotated[
ReportDetailQuery, Depends(query_dependency(ReportDetailQuery))
]
ReportRunsQueryDependency = Annotated[
ReportRunsQuery, Depends(query_dependency(ReportRunsQuery))
]

# Refusals a caller can act on carry their own message and are not logged.
REPORT_REFUSALS: tuple[type[Exception], ...] = (
Expand Down Expand Up @@ -176,6 +193,117 @@ def _create_report(
return ReportDetailResponse(result=ReportDetailResult(item=report_item(report)))


def _rerun_report(
service: V2StoredReportResourceService,
*,
country_id: str,
report_id: UUID,
force: bool,
) -> ReportDetailResponse | JSONResponse:
"""Run the report again and answer it with its new run.

The simulation service's refusal to run it again now answers 409 with
the run it names, and a report that cannot be read back once its new run
started answers 502 with its ``report_id``. Every other failure is
raised, for the route's error mappings to answer.
"""
try:
report = service.rerun_report(
country_id=country_id, report_id=report_id, force=force
)
except SimulationServiceConflictError as error:
conflict = ReportRerunConflictResponse(message=str(error), run_id=error.run_id)
return JSONResponse(status_code=409, content=conflict.model_dump(mode="json"))
except RerunReportUnreadableError as error:
return created_record_unreadable_answer(
"v2_rerun_report_unreadable",
error,
RerunReportUnreadableResponse(
message=(
"The simulation service started a new run of report "
f"{error.report_id}, but the report could not be read back. "
f"Read it with GET /v2/reports/{error.report_id}?country_id="
f"{error.country_id} rather than running it again."
),
report_id=error.report_id,
),
)
return ReportDetailResponse(result=ReportDetailResult(item=report_item(report)))


def _add_report_run_routes(
router: APIRouter, service_factory: Callable[[], V2StoredReportResourceService]
) -> None:
"""Add the routes that run a report again and list its runs."""

@router.post(
"/reports/{report_id}/runs",
response_model=ReportDetailResponse,
status_code=201,
responses=RERUN_RESPONSES,
summary="Run an economy report or budget window again",
description=(
"Checks the report as a new report is checked (the country has "
"economy reports, and each policy its simulations name is stored and "
"sets only parameters the running catalog version has), then has the "
"simulation service create the report's new run, with the manual "
"trigger, on its default worker's PolicyEngine.py version, and start "
"it. Every earlier run is kept. Without force, each side reuses a "
"run of its own simulation that succeeded on those versions; with "
"force, both simulations are calculated again. A budget window's new "
"run is a budget-window run with a report run for each year, and "
"with force each year calculates its simulations again. Answers the "
"report as stored, whose newest run is then the new one, since no "
"other run "
"starts while it is pending or running: a report with a run pending "
"or running answers 409 naming that run. Read the report again to "
"follow the run."
),
)
def rerun_report(
report_id: UUID,
body: ReportRerunRequest,
query: ReportRunsQueryDependency,
_size: Annotated[None, Depends(enforce_report_request_size)],
) -> ReportDetailResponse | JSONResponse:
def rerun() -> ReportDetailResponse | JSONResponse:
return _rerun_report(
service_factory(),
country_id=query.country_id,
report_id=report_id,
force=body.force,
)

return _report_operation(rerun)

@router.get(
"/reports/{report_id}/runs",
response_model=ReportRunListResponse,
responses=RUN_LIST_RESPONSES,
summary="List an economy report's or budget window's runs",
description=(
"Lists every run of the report, newest first, whatever its status "
"and versions, each with the country package and PolicyEngine.py "
"versions it ran on, beside the versions the Public API runs now. A "
"run on other versions than those is out of date. An economy "
"report's runs are its report runs (kind economy); a budget "
"window's are its budget-window runs, each with its years (kind "
"budget_window). Reading starts no run."
),
)
def list_report_runs(
report_id: UUID,
query: ReportRunsQueryDependency,
) -> ReportRunListResponse | JSONResponse:
def read() -> ReportRunListResponse:
runs = service_factory().list_report_runs(
country_id=query.country_id, report_id=report_id
)
return ReportRunListResponse(result=report_run_list_result(runs))

return _report_operation(read)


def build_v2_report_router(dependencies: NativeRouteDependencies) -> APIRouter:
"""Build the report routes without opening a connection."""
router = APIRouter(prefix="/v2", responses=REPORT_ERROR_RESPONSES)
Expand Down Expand Up @@ -274,4 +402,5 @@ def read() -> ReportDetailResponse:

return _report_operation(read)

_add_report_run_routes(router, service_factory)
return router
Loading
Loading