diff --git a/.github/workflows/v2-integration-check.yml b/.github/workflows/v2-integration-check.yml index baf3e27ae..d5b92ad60 100644 --- a/.github/workflows/v2-integration-check.yml +++ b/.github/workflows/v2-integration-check.yml @@ -81,8 +81,8 @@ jobs: run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_user_identity_persistence.py tests/integration/test_v2_user_type_persistence.py tests/integration/test_v2_saved_detailed_save_records.py tests/integration/test_v2_saved_budget_window_reports.py - name: Test comparison-run persistence, the simulation API's roles and privilege reconciliation as the managed roles run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_stage12_comparison_persistence.py tests/integration/test_v2_simulation_role_privileges.py tests/integration/test_v2_privilege_reconciliation.py - - name: Test v2 household persistence, immediate v1 copying and household calculation reads - run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_household_persistence.py tests/integration/test_v1_household_dual_write.py tests/integration/test_mysql_household_spm_dual_write.py tests/integration/test_v2_household_calculation_reads.py + - name: Test v2 household persistence, immediate v1 copying, household calculation reads and household reports + run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_household_persistence.py tests/integration/test_v1_household_dual_write.py tests/integration/test_mysql_household_spm_dual_write.py tests/integration/test_v2_household_calculation_reads.py tests/integration/test_v2_household_reports.py - name: Test the row counts v1 ORM updates report on MySQL run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_mysql_update_row_counts.py # The real US and UK models, reduced to a few parameter folders: the diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md new file mode 100644 index 000000000..0b042893a --- /dev/null +++ b/changelog.d/v2-household-reports.added.md @@ -0,0 +1 @@ +`/v2/reports` serves household reports: `POST /v2/reports` with `kind` `household` takes a stored household, a baseline policy and an optional reform policy (null for a report of the baseline alone), and has the simulation service create the report and its household simulations (one for each policy), which calculate the household's default year with every variable the household lacks added. It answers 422 for a country without household calculations (a household-only country has them) and for a household with no default year, and 404 for a household the country does not store, before asking the simulation service. Reading or listing a household report answers it with its newest run; a read adds each simulation's calculated household once that run has succeeded, and each side's calculation error once it has failed. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index d9f04b4bd..7e56dd2db 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -200,13 +200,13 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi ### `detailed_save_reports_v2` - Current contract: `typed_v2_resources` -- Future owner: C1 · 8: v2 simulations and economy reports +- Future owner: C1 · 8: v2 simulations and economy reports; C1 · 16: household reports | Method | Path | Status | Route group | Required headers | Stable response fields | | --- | --- | ---: | --- | --- | --- | | `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` | +| `GET` | `/v2/reports/{report_id}?country_id=us` | 200 | `report` | none | `status`, `message`, `result.item.id`, `result.item.kind`, `result.item.status`, `result.item.run`, `result.item.current_versions`, `result.item.result` | | `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` | diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 6eca98bb9..38149a341 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1374,7 +1374,7 @@ }, { "current_contract": "typed_v2_resources", - "future_owner_pr": "C1 \u00b7 8: v2 simulations and economy reports", + "future_owner_pr": "C1 \u00b7 8: v2 simulations and economy reports; C1 \u00b7 16: household reports", "name": "detailed_save_reports_v2", "requests": [ { @@ -1417,6 +1417,7 @@ "status", "message", "result.item.id", + "result.item.kind", "result.item.status", "result.item.run", "result.item.current_versions", diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 8b016dd70..7208bfe98 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -229,6 +229,7 @@ def _default_simulation_submissions() -> SimulationServiceClient: return SimulationSubmissionService( countries=_default_v2_country_service_factory(), + households=_default_v2_household_service_factory(), policies=_default_v2_policy_service_factory(), client_factory=process_simulation_service_client, ) diff --git a/policyengine_api/fastapi_routes/resource_service_protocols.py b/policyengine_api/fastapi_routes/resource_service_protocols.py index 32542319a..e61102a58 100644 --- a/policyengine_api/fastapi_routes/resource_service_protocols.py +++ b/policyengine_api/fastapi_routes/resource_service_protocols.py @@ -193,10 +193,10 @@ class V2StoredReportResourceService(Protocol): def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: """Return the country's report, its newest run and that run's result. - The report is an economy report or a budget window. Raise - ``StoredReportNotFoundError`` when the country has no such report, - ``ReportKindUnsupportedError`` for a household report, and - ``EconomyResultUnreadableError`` or + The report is an economy report, a household report or a budget + window. Raise ``StoredReportNotFoundError`` when the country has no + such report, and ``EconomyResultUnreadableError``, + ``HouseholdResultUnreadableError`` or ``BudgetWindowResultUnreadableError`` when a succeeded run's result cannot be read. """ @@ -205,20 +205,25 @@ def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] ) -> tuple[StoredReportSummaryRead, ...]: - """Return the country's economy reports and budget windows, as named.""" + """Return the country's reports of every kind among those named, in order.""" ... def create_report(self, report_input: ReportCreationInput) -> StoredReportRead: """Create the report through the simulation service; return it as stored. - Raise ``EconomyCalculationsUnsupportedError`` for a country without - economy reports, ``PolicyNotFoundError`` for a policy the country - does not store, ``PolicyParametersMissingError`` for a policy that - sets parameters the running catalog version lacks, - ``SubmissionStorageError`` when the database or its catalog cannot be - read for those checks, the simulation service's errors, and - ``CreatedReportUnreadableError`` when the report it created cannot be - read back. + Raise ``EconomyCalculationsUnsupportedError`` for an economy report in + a country without economy reports, + ``HouseholdCalculationCountryError`` for a household report in a + country without household calculations, ``HouseholdNotFoundError`` + for a household the country does not store, + ``HouseholdCalculationYearError`` for one with no default year, + ``PolicyNotFoundError`` + for a policy the country does not store, + ``PolicyParametersMissingError`` for a policy that sets parameters the + running catalog version lacks, ``SubmissionStorageError`` when the + database or its catalog cannot be read for those checks, the + simulation service's errors, and ``CreatedReportUnreadableError`` when + the report it created cannot be read back. """ ... diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index e48582192..5596e65f8 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -7,6 +7,7 @@ from policyengine_api.services.v2.simulation_service.types import ( BudgetWindowReportCreationInput, EconomyReportCreationInput, + HouseholdReportCreationInput, ) @@ -19,6 +20,16 @@ class EconomyReportCreateRequest(EconomyReportCreationInput): """ +class HouseholdReportCreateRequest(HouseholdReportCreationInput): + """A household report to create; ``country_id`` must match the query's. + + It calculates the stored household ``household_id`` in its default year + under ``baseline`` and ``reform``; each empty object is current law, and a + null or omitted ``reform`` makes a report of the baseline alone. No field + names a year or a PolicyEngine.py version. + """ + + class BudgetWindowReportCreateRequest(BudgetWindowReportCreationInput): """A budget window to create; ``country_id`` must match the query's. @@ -31,6 +42,8 @@ class BudgetWindowReportCreateRequest(BudgetWindowReportCreationInput): # A report to create: ``kind`` says which. ReportCreateRequest = Annotated[ - EconomyReportCreateRequest | BudgetWindowReportCreateRequest, + EconomyReportCreateRequest + | HouseholdReportCreateRequest + | BudgetWindowReportCreateRequest, Field(discriminator="kind"), ] diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 4f8448d2b..71ae5abbf 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -12,6 +12,9 @@ V2ErrorResponse, V2MissingParametersErrorResponse, ) +from policyengine_api.fastapi_routes.v2.household_calculations.response_models import ( + HouseholdCalculationOutputItem, +) from policyengine_api.fastapi_routes.v2.simulation_resources_support import ( error_response, ) @@ -26,6 +29,8 @@ from policyengine_api.services.v2.stored_reports.types import ( BudgetWindowReportRead, BudgetWindowReportSummaryRead, + HouseholdReportRead, + HouseholdReportSummaryRead, ReportRead, ReportSummaryRead, StoredReportRead, @@ -75,11 +80,9 @@ class ReportSimulationItem(StrictReportAPIModel): dynamic_id: UUID | None -class ReportSummaryItem(StrictReportAPIModel): - """An economy report comparing a baseline and a reform simulation. +class ReportDefinitionItem(StrictReportAPIModel): + """What every report item gives: its definition and the current versions. - ``status`` is the newest run's, or ``not_run`` for a report with no run. - ``run`` is the newest run, whatever its status and versions; ``current_versions`` are the versions the Public API runs now, so a newest run on other versions is out of date. ``options`` are the options the report was created with. @@ -87,19 +90,29 @@ class ReportSummaryItem(StrictReportAPIModel): id: UUID country_id: str - kind: Literal["economy"] label: str description: str | None year: int | None - region_code: str | None - dataset_name: str | None baseline_simulation: ReportSimulationItem | None reform_simulation: ReportSimulationItem | None options: dict[str, JsonValue] created_at: datetime + current_versions: PackageVersionsItem + + +class ReportSummaryItem(ReportDefinitionItem): + """An economy report comparing a baseline and a reform simulation. + + Both simulations use the region and dataset named. ``status`` is the + newest run's, or ``not_run`` for a report with no run. ``run`` is the + newest run, whatever its status and versions. + """ + + kind: Literal["economy"] + region_code: str | None + dataset_name: str | None status: Literal["not_run", "pending", "running", "succeeded", "failed"] run: ReportRunItem | None - current_versions: PackageVersionsItem @classmethod def from_summary(cls, read: ReportSummaryRead) -> ReportSummaryItem: @@ -122,6 +135,85 @@ def from_read(cls, read: ReportRead) -> ReportItem: return cls.model_validate(read) +class HouseholdReportSummaryItem(ReportDefinitionItem): + """A household report: one stored household under a baseline and a reform. + + Both simulations calculate the household ``household_id`` in its default + year, ``year``; ``reform_simulation`` is null for a report of the + baseline alone. ``status`` is the newest run's, or ``not_run`` for a + report with no run. ``run`` is the newest run, whatever its status and + versions, naming each simulation's run. + """ + + kind: Literal["household"] + household_id: UUID + status: Literal["not_run", "pending", "running", "succeeded", "failed"] + run: ReportRunItem | None + + @classmethod + def from_summary( + cls, read: HouseholdReportSummaryRead + ) -> HouseholdReportSummaryItem: + """Build the summary from the service's read, without a result.""" + return cls.model_validate(read) + + +class HouseholdReportResultItem(StrictReportAPIModel): + """Each simulation's calculated household. + + Each is the stored household with every value its calculation filled in, + as ``/v2/household-calculations`` answers one. ``reform`` is null for a + report of the baseline alone. + """ + + baseline: HouseholdCalculationOutputItem + reform: HouseholdCalculationOutputItem | None + + +class HouseholdReportSideFailureItem(StrictReportAPIModel): + """The household function's error for one side's calculation. + + ``code`` is its error code, such as ``invalid_inputs``, with + ``input_errors`` naming each input the country model does not recognize + and ``spm_error`` an SPM error. + """ + + code: str + message: str + input_errors: list[dict[str, JsonValue]] + spm_error: dict[str, JsonValue] | None + + +class HouseholdReportFailureItem(StrictReportAPIModel): + """Why each side of a failed run failed, as the household function said. + + A side is null when it succeeded, or failed without the household + function's error, as a run Modal refused does; the run's + ``error_message`` still says why it failed. ``reform`` is also null for + a report of the baseline alone. + """ + + baseline: HouseholdReportSideFailureItem | None + reform: HouseholdReportSideFailureItem | None + + +class HouseholdReportItem(HouseholdReportSummaryItem): + """A household report with its newest run's outcome. + + ``result`` holds the calculated households once the newest run has + succeeded, and ``failure`` each side's error once it has failed; each is + null otherwise. + """ + + result: HouseholdReportResultItem | None + failure: HouseholdReportFailureItem | None + + @classmethod + def from_read(cls, read: HouseholdReportRead) -> HouseholdReportItem: + """Build the item from the service's read, with its result.""" + return cls.model_validate(read) + + class BudgetWindowYearItem(ReportRunItem): """One year of a budget-window run: the report run calculating that year. @@ -214,8 +306,12 @@ def from_read(cls, read: BudgetWindowReportRead) -> BudgetWindowReportItem: return cls.model_validate(read) -def report_item(read: StoredReportRead) -> ReportItem | BudgetWindowReportItem: +def report_item( + read: StoredReportRead, +) -> ReportItem | HouseholdReportItem | BudgetWindowReportItem: """Build the item of the report's kind from the service's read.""" + if read.kind == "household": + return HouseholdReportItem.from_read(read) if read.kind == "budget_window": return BudgetWindowReportItem.from_read(read) return ReportItem.from_read(read) @@ -223,8 +319,10 @@ def report_item(read: StoredReportRead) -> ReportItem | BudgetWindowReportItem: def report_summary_item( read: StoredReportSummaryRead, -) -> ReportSummaryItem | BudgetWindowReportSummaryItem: +) -> ReportSummaryItem | HouseholdReportSummaryItem | BudgetWindowReportSummaryItem: """Build the summary of the report's kind from the service's read.""" + if read.kind == "household": + return HouseholdReportSummaryItem.from_summary(read) if read.kind == "budget_window": return BudgetWindowReportSummaryItem.from_summary(read) return ReportSummaryItem.from_summary(read) @@ -233,7 +331,10 @@ def report_summary_item( class ReportDetailResult(StrictReportAPIModel): """Result holding one report in ``item``; its ``kind`` says which model it is.""" - item: Annotated[ReportItem | BudgetWindowReportItem, Field(discriminator="kind")] + item: Annotated[ + ReportItem | HouseholdReportItem | BudgetWindowReportItem, + Field(discriminator="kind"), + ] class ReportListResult(StrictReportAPIModel): @@ -241,7 +342,9 @@ class ReportListResult(StrictReportAPIModel): items: list[ Annotated[ - ReportSummaryItem | BudgetWindowReportSummaryItem, + ReportSummaryItem + | HouseholdReportSummaryItem + | BudgetWindowReportSummaryItem, Field(discriminator="kind"), ] ] @@ -294,28 +397,31 @@ class UnavailableAfterReportCreatedResponse(V2ErrorResponse): } READ_RESPONSES: dict[int | str, dict[str, Any]] = { 404: error_response("No report has this ID in this country."), - 422: error_response( - "The request does not match the schema, or the report is a household " - "report, which this route does not read yet." - ), + 422: error_response("The request does not match the schema."), 500: error_response( "The newest run succeeded but its stored result cannot be read (for a " + "household report, a simulation run's calculated household; for a " "budget window, a year has no succeeded run or no budget figures), or " "the operation could not be completed." ), } CREATE_RESPONSES: dict[int | str, dict[str, Any]] = { 400: error_response("The body and query name different countries."), - 404: error_response("A policy the report names is not stored in the country."), + 404: error_response( + "A policy the report names, or a household report's household, is not " + "stored in the country." + ), 413: error_response("The request body exceeds 16 KiB."), 422: { "model": V2MissingParametersErrorResponse | V2ErrorResponse, "description": ( "The request does not match the schema (such as a budget window of " "fewer than 1 or more than 75 years, or one ending after 2200); the " - "country has no economy " - "reports (such as a household-only country); a policy the report " - "names sets parameters the running catalog version does not have " + "country has no economy reports (such as a household-only country), " + "for an economy report or budget window, or no household " + "calculations, for a household report; a household " + "report's household has no default year; a policy the report names " + "sets parameters the running catalog version does not have " "(code missing_parameters, which names them; the baseline's policy is " "checked first); or the simulation service cannot run the report: its " "422, or its 400 for a region, dataset or dynamic it does not have. The " diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index 4a1349005..931a176eb 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -4,9 +4,9 @@ ``/v2/user-reports``. Reports, their runs and results are read from the detailed-save tables the simulation service and its Modal workers write; 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. +is an economy report, a household 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. """ from __future__ import annotations @@ -65,9 +65,11 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResultUnreadableError, ) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultUnreadableError, +) from policyengine_api.services.v2.stored_reports.validators import ( CreatedReportUnreadableError, - ReportKindUnsupportedError, ReportStorageError, StoredReportNotFoundError, ) @@ -97,15 +99,17 @@ # Refusals a caller can act on carry their own message and are not logged. REPORT_REFUSALS: tuple[type[Exception], ...] = ( StoredReportNotFoundError, - ReportKindUnsupportedError, *SUBMISSION_REFUSALS, ) REPORT_ERROR_MAPPINGS = ( V2ErrorMapping((StoredReportNotFoundError,), 404), - V2ErrorMapping((ReportKindUnsupportedError,), 422), *SUBMISSION_ERROR_MAPPINGS, V2ErrorMapping( - (EconomyResultUnreadableError, BudgetWindowResultUnreadableError), + ( + EconomyResultUnreadableError, + HouseholdResultUnreadableError, + BudgetWindowResultUnreadableError, + ), 500, "The report's stored result cannot be read", ), @@ -186,18 +190,23 @@ def build_v2_report_router(dependencies: NativeRouteDependencies) -> APIRouter: response_model=ReportDetailResponse, status_code=201, responses=CREATE_RESPONSES, - summary="Create an economy report or a budget window", + summary="Create an economy report, a household report or a budget window", description=( "Checks that the country has economy reports and stores each named " "policy, and that the running catalog version has every parameter " "those policies set, then has the simulation service create the " "report, its two " "simulations and their runs on its default worker's PolicyEngine.py " - "version, and start the run. A budget window (kind budget_window) is " - "checked the same way; the simulation service creates a " - "budget-window run with a report run for each year, and starts its " - "years. Answers the report as stored, with its pending run; read it " - "again to follow the run. A created report " + "version, and start the run. A household report (kind household) " + "is checked the same way, except that the country must have " + "household calculations instead (a household-only country does), " + "and the household it names must be stored in the country, with a " + "default year; its simulations each calculate that household in its " + "default year. A budget window (kind budget_window) is checked as an " + "economy report is; the simulation service creates a budget-window " + "run with a report run for each year, and starts its years. " + "Answers the report as stored, with its " + "pending run; read it again to follow the run. A created report " "that cannot be read back answers 502 with its report_id, and one " "whose run the simulation service failed before answering 503 " "answers 503 with its report_id." @@ -221,11 +230,10 @@ def create() -> ReportDetailResponse | JSONResponse: response_model=ReportListResponse, summary="List chosen reports", description=( - "Lists the country's economy reports and budget windows among the " - "report_id values (at most 25), in the order named and once each, " - "with their newest runs, and each budget window's years, but without " - "results, read in two queries. Unknown reports, other countries' and " - "household reports are left out." + "Lists the country's reports among the report_id values (at most " + "25), in the order named and once each, with their newest runs, and " + "each budget window's years, but without results, read in two " + "queries. Unknown reports and other countries' are left out." ), ) def list_reports( @@ -249,15 +257,17 @@ def read() -> ReportListResponse: responses=READ_RESPONSES, summary="Read a report", description=( - "Returns the economy report or budget window, its newest run " - "whatever its status and versions, the versions the Public API runs " - "now, and, once the newest run has succeeded, its result in today's " - "format. An economy report's run is one report run and its result " - "today's economy result. A budget window's run is a budget-window " - "run with a report run per year, and its result today's " - "budget-window format: each year's budget figures and their totals. " - "A report with no run, such as one migrated from v1, answers its " - "definition with no result; reading starts no run." + "Returns the report, its newest run whatever its status and " + "versions, the versions the Public API runs now, and, once the newest " + "run has succeeded, its result. An economy report's run is one report " + "run and its result today's economy result. A household report's " + "result is each simulation's calculated household, as " + "/v2/household-calculations answers one, and a failed run gives each " + "side's error. A budget window's run is a budget-window run with a " + "report run per year, and its result today's budget-window format: " + "each year's budget figures and their totals. A report with no run, " + "such as one migrated from v1, answers its definition with no result; " + "reading starts no run." ), ) def get_report( diff --git a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py index 971b73cb4..fbb15814e 100644 --- a/policyengine_api/fastapi_routes/v2/simulation_resources_support.py +++ b/policyengine_api/fastapi_routes/v2/simulation_resources_support.py @@ -22,6 +22,11 @@ run_v2_operation, v2_missing_parameters_response, ) +from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationCountryError, + HouseholdCalculationYearError, +) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError from policyengine_api.services.v2.policies.validators import ( PolicyNotFoundError, PolicyParametersMissingError, @@ -47,7 +52,10 @@ # not logged. SUBMISSION_REFUSALS: tuple[type[Exception], ...] = ( PolicyNotFoundError, + HouseholdNotFoundError, EconomyCalculationsUnsupportedError, + HouseholdCalculationCountryError, + HouseholdCalculationYearError, SimulationServiceInvalidRequestError, ) # How the simulation service's answers and the submission checks are answered. @@ -61,9 +69,15 @@ # the record it created and failed is still a 503, and each create route # answers it with the record's ID (``unavailable_after_creation_answer``). SUBMISSION_ERROR_MAPPINGS = ( - V2ErrorMapping((PolicyNotFoundError,), 404), + V2ErrorMapping((PolicyNotFoundError, HouseholdNotFoundError), 404), V2ErrorMapping( - (EconomyCalculationsUnsupportedError, SimulationServiceInvalidRequestError), 422 + ( + EconomyCalculationsUnsupportedError, + HouseholdCalculationCountryError, + HouseholdCalculationYearError, + SimulationServiceInvalidRequestError, + ), + 422, ), V2ErrorMapping( (SimulationServiceInternalError, SimulationServiceAnswerUnreadableError), 502 diff --git a/policyengine_api/libs/simulation_reports.py b/policyengine_api/libs/simulation_reports.py index 7c936e3f3..7b93efe21 100644 --- a/policyengine_api/libs/simulation_reports.py +++ b/policyengine_api/libs/simulation_reports.py @@ -17,9 +17,14 @@ policy and dynamic, ``include_cliffs``, and the SPM settings only when the caller gave them. A budget window goes to the same ``POST /reports`` as ``report_type`` ``budget_window``, naming its first year and -``budget_window_size`` in place of the year, and no ``include_cliffs``. -None names a PolicyEngine.py version: the simulation service runs its -default worker's. The simulation service answers: +``budget_window_size`` in place of the year, and no ``include_cliffs``. A +household report goes there too, as ``report_type`` ``household``, naming +the label and description, the stored household, each simulation's policy +(the reform null for a report of the baseline alone), but no year: the +simulation service calculates the household's default year, adding every +variable the household lacks. None names a PolicyEngine.py version: the +simulation service runs its default worker's. The simulation service +answers: - 201 or 202 with the created record's ID and its first run's ID: for a budget window, its budget-window run's ID (``budget_window_run_id``). One @@ -137,8 +142,9 @@ BudgetWindowReportCreationInput, EconomyReportCreationInput, EconomySimulationCreationInput, + HouseholdPolicyChoice, + HouseholdReportCreationInput, PolicyChoice, - ReportCreationInput, ) SIMULATIONS_PATH = "/simulations" @@ -233,8 +239,10 @@ def simulation_request(simulation: EconomySimulationCreationInput) -> dict[str, } -def _report_definition(report: ReportCreationInput) -> dict[str, Any]: - """Return the request fields both kinds of report send. +def _report_definition( + report: EconomyReportCreationInput | BudgetWindowReportCreationInput, +) -> dict[str, Any]: + """Return the request fields economy reports and budget windows both send. ``spm`` is sent only when the caller gave it. """ @@ -274,6 +282,28 @@ def budget_window_request(report: BudgetWindowReportCreationInput) -> dict[str, } +def _household_policy_choice(choice: HouseholdPolicyChoice) -> dict[str, str | None]: + return {"policy_id": None if choice.policy_id is None else str(choice.policy_id)} + + +def household_report_request(report: HouseholdReportCreationInput) -> dict[str, Any]: + """Return the simulation service's request body for the household report. + + ``reform`` is null for a report of the baseline alone. + """ + return { + "country": report.country_id, + "report_type": report.kind, + "label": report.label, + "description": report.description, + "household_id": str(report.household_id), + "baseline": _household_policy_choice(report.baseline), + "reform": ( + None if report.reform is None else _household_policy_choice(report.reform) + ), + } + + def post_simulation( http_client: httpx.Client, body: dict[str, Any] ) -> SimulationAnswer: @@ -544,6 +574,25 @@ def submit_report(self, report: EconomyReportCreationInput) -> SubmittedReport: report_id=created.report_id, report_run_id=created.report_run_id ) + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + """Create the household report, its simulations and runs; return its IDs. + + It is sent to the report route, so its refusals and failures are a + report's. Raise the errors ``SimulationServiceClient`` names. + """ + created = self._created( + self._send_report, + household_report_request(report), + _CreatedReport, + record="report", + created_name="report", + ) + return SubmittedReport( + report_id=created.report_id, report_run_id=created.report_run_id + ) + def submit_budget_window( self, report: BudgetWindowReportCreationInput ) -> SubmittedBudgetWindow: diff --git a/policyengine_api/services/v2/household_calculations/types.py b/policyengine_api/services/v2/household_calculations/types.py index 99d40e3c6..fbae62f5b 100644 --- a/policyengine_api/services/v2/household_calculations/types.py +++ b/policyengine_api/services/v2/household_calculations/types.py @@ -202,9 +202,12 @@ class HouseholdCalculationRead: error: HouseholdCalculationFailure | None -# The result documents the household functions store in household_jobs.result. -# The simulation service's household contract owns their shape; these models -# and their two conversions are the only code that names its fields. +# The result documents the household functions store in household_jobs.result, +# and in simulation_runs.household_result for a household report's simulations. +# The simulation service's household contract owns their shape; these models, +# their two conversions and the household report failure read +# (stored_reports.household_results.transformations) are the only code that +# names its fields. class StoredDocumentModel(BaseModel): diff --git a/policyengine_api/services/v2/simulation_service/services.py b/policyengine_api/services/v2/simulation_service/services.py index 13900c3a6..f8f6cb527 100644 --- a/policyengine_api/services/v2/simulation_service/services.py +++ b/policyengine_api/services/v2/simulation_service/services.py @@ -1,9 +1,9 @@ """Check simulations and reports, then ask the simulation service to create them. Every submission to the simulation service's ``POST /simulations`` and -``POST /reports`` (economy reports and budget windows) goes through -``SimulationSubmissionService``, so checks that apply to all of them live in -one place. It checks, in order, that the +``POST /reports`` (economy reports, budget windows and household reports) goes +through ``SimulationSubmissionService``, so checks that apply to all of them +live in one place. It checks, in order, that the country's ``countries`` row allows economy calculations (a household-only country does not), that each policy the submission names is stored in the country, through the same service as ``/v2/policies`` (current law names @@ -12,6 +12,13 @@ Only then is the simulation service asked. The dynamics a submission names, its region and its dataset are checked by the simulation service, which reads them in the catalog version its default worker runs. + +A household report is checked the same way, except that its country's row +must allow household calculations instead (a household-only country passes), +and the household it names must be stored in the country, through the same +service as ``/v2/households``, with a default year, which the report +calculates; the household is checked before the policies. These are the +checks ``/v2/household-calculations`` makes, with the same errors. """ from __future__ import annotations @@ -24,6 +31,10 @@ from policyengine_api.data.v2.catalog.catalog_selection_validation import ( MetadataCatalogUnavailableError, ) +from policyengine_api.services.v2.household_calculations.validators import ( + require_default_year, + require_household_country, +) from policyengine_api.services.v2.policies.validators import ( require_policy_parameters_present, ) @@ -36,11 +47,15 @@ from collections.abc import Callable, Iterable, Iterator from uuid import UUID + from policyengine_api.services.v2.household_calculations.types import ( + HouseholdSource, + ) from policyengine_api.services.v2.simulation_service.types import ( BudgetWindowReportCreationInput, CountrySource, EconomyReportCreationInput, EconomySimulationCreationInput, + HouseholdReportCreationInput, PolicySource, SimulationServiceClient, SubmittedBudgetWindow, @@ -94,16 +109,19 @@ def __init__( self, *, countries: CountrySource, + households: HouseholdSource, policies: PolicySource, client_factory: Callable[[], SimulationServiceClient], ) -> None: """Check through these sources; send through ``client_factory``'s client. - The client is built only once a submission passes the checks, so a - refused submission gets its own answer even when the simulation - service is not configured. + ``households`` is read only for a household report. The client is + built only once a submission passes the checks, so a refused + submission gets its own answer even when the simulation service is + not configured. """ self._countries = countries + self._households = households self._policies = policies self._client_factory = client_factory @@ -142,6 +160,35 @@ def submit_report(self, report: EconomyReportCreationInput) -> SubmittedReport: ) return self._client_factory().submit_report(report) + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + """Check the household report, then have the simulation service create it. + + Raise HouseholdCalculationCountryError unless the country's row + allows household calculations, then HouseholdNotFoundError for a + household the country does not store, then + HouseholdCalculationYearError for one with no default year, then the + policy checks' errors + (the baseline's policy first, and only the baseline's for a report of + the baseline alone), SubmissionStorageError when the database or its + catalog cannot be read, and then the simulation service's errors. + """ + country_id = report.country_id + with _storage_failures(): + require_household_country(self._countries.list_countries(), country_id) + household = self._households.get_household( + country_id=country_id, household_id=report.household_id + ) + require_default_year(household.default_year) + policy_ids = [report.baseline.policy_id] + if report.reform is not None: + policy_ids.append(report.reform.policy_id) + require_runnable_policies( + self._policies, country_id=country_id, policy_ids=policy_ids + ) + return self._client_factory().submit_household_report(report) + def submit_budget_window( self, report: BudgetWindowReportCreationInput ) -> SubmittedBudgetWindow: diff --git a/policyengine_api/services/v2/simulation_service/types.py b/policyengine_api/services/v2/simulation_service/types.py index 3603394de..917479664 100644 --- a/policyengine_api/services/v2/simulation_service/types.py +++ b/policyengine_api/services/v2/simulation_service/types.py @@ -234,6 +234,39 @@ class EconomyReportCreationInput(EconomyReportFields): country_id: CountryId +class HouseholdPolicyChoice(StrictSubmissionModel): + """A household simulation's stored policy; none means current law.""" + + policy_id: ResourceId | None = None + + +class HouseholdReportFields(StrictSubmissionModel): + """A household report: one stored household under a baseline and a reform. + + ``household_id`` names a household stored through ``/v2/households`` in + the report's country, and both simulations calculate it in its default + year, the year its values name, as a household calculation does. + ``reform`` is null, or omitted, for a report of the baseline alone. Each + simulation calculates every yearly, monthly and eternal variable the + household lacks, as ``/calculate-full`` does, so the report gives every + value; there is no option to fill in only the values the household asks + for, as ``/v2/household-calculations`` has. + """ + + kind: Literal["household"] + label: ReportLabel + description: ReportDescription | None = None + household_id: ResourceId + baseline: HouseholdPolicyChoice + reform: HouseholdPolicyChoice | None = None + + +class HouseholdReportCreationInput(HouseholdReportFields): + """A household report to create in the country.""" + + country_id: CountryId + + class BudgetWindowReportFields(ReportDefinitionFields): """A budget window: a baseline and a reform simulation in each of several years. @@ -263,8 +296,12 @@ class BudgetWindowReportCreationInput(BudgetWindowReportFields): country_id: CountryId -# A report of either kind to create. -ReportCreationInput = EconomyReportCreationInput | BudgetWindowReportCreationInput +# A report of any kind to create; ``kind`` says which. +ReportCreationInput = ( + EconomyReportCreationInput + | HouseholdReportCreationInput + | BudgetWindowReportCreationInput +) @dataclass(frozen=True) @@ -320,7 +357,9 @@ class SimulationServiceClient(Protocol): ``SimulationSubmissionService``, which checks a submission before sending it through a client, also raises EconomyCalculationsUnsupportedError, - PolicyNotFoundError, PolicyParametersMissingError and + HouseholdCalculationCountryError, HouseholdNotFoundError, + HouseholdCalculationYearError, PolicyNotFoundError, + PolicyParametersMissingError and SubmissionStorageError from its checks. """ @@ -334,6 +373,16 @@ def submit_report(self, report: EconomyReportCreationInput) -> SubmittedReport: """Create the report, its simulations and their runs, and start them.""" ... + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + """Create the household report, its simulations and runs; start them. + + It goes to the same route as ``submit_report``, so it raises the same + errors. + """ + ... + def submit_budget_window( self, report: BudgetWindowReportCreationInput ) -> SubmittedBudgetWindow: diff --git a/policyengine_api/services/v2/simulation_service/validators.py b/policyengine_api/services/v2/simulation_service/validators.py index 1c0c84a29..9a9841736 100644 --- a/policyengine_api/services/v2/simulation_service/validators.py +++ b/policyengine_api/services/v2/simulation_service/validators.py @@ -19,7 +19,7 @@ class EconomyCalculationsUnsupportedError(ValueError): class SubmissionStorageError(RuntimeError): - """A submission's countries, policies or catalog could not be read.""" + """A submission's countries, households, policies or catalog could not be read.""" def require_economy_country(countries: Iterable[CountryRead], country_id: str) -> None: diff --git a/policyengine_api/services/v2/stored_reports/database_connectors/reads_household_results.py b/policyengine_api/services/v2/stored_reports/database_connectors/reads_household_results.py new file mode 100644 index 000000000..1a307836c --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/database_connectors/reads_household_results.py @@ -0,0 +1,34 @@ +"""Database reads of the calculated households a household report run names.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlmodel import col, select + +from policyengine_api.data.v2.models import SimulationRun +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultRows, +) + +if TYPE_CHECKING: + from sqlmodel import Session + + from policyengine_api.data.v2.models import ReportRun + + +def read_household_result_rows( + session: Session, *, report_run: ReportRun +) -> HouseholdResultRows: + """Return the result documents of the report run's simulation runs, in one query. + + Each simulation run the report run names maps to its ``household_result``; + a run that does not exist is absent. + """ + simulation_run_ids = [report_run.baseline_simulation_run_id] + if report_run.reform_simulation_run_id is not None: + simulation_run_ids.append(report_run.reform_simulation_run_id) + statement = select( + col(SimulationRun.id), col(SimulationRun.household_result) + ).where(col(SimulationRun.id).in_(simulation_run_ids)) + return HouseholdResultRows(documents=dict(session.exec(statement).all())) diff --git a/policyengine_api/services/v2/stored_reports/household_results/__init__.py b/policyengine_api/services/v2/stored_reports/household_results/__init__.py new file mode 100644 index 000000000..0b1684816 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/__init__.py @@ -0,0 +1 @@ +"""Household report results read from each simulation run's calculated household.""" diff --git a/policyengine_api/services/v2/stored_reports/household_results/transformations.py b/policyengine_api/services/v2/stored_reports/household_results/transformations.py new file mode 100644 index 000000000..eaa483a20 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/transformations.py @@ -0,0 +1,58 @@ +"""Read a failed household report run's failure documents.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import ValidationError + +from policyengine_api.services.v2.household_calculations.types import ( + StoredExecutionFailure, +) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, + HouseholdReportSideFailure, +) + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import ReportRun + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultRows, + ) + + +def _side_failure( + rows: HouseholdResultRows, simulation_run_id: UUID | None +) -> HouseholdReportSideFailure | None: + """Return the side's failure, or None without a readable failure document.""" + if simulation_run_id is None: + return None + try: + document = StoredExecutionFailure.model_validate( + rows.documents.get(simulation_run_id) + ) + except ValidationError: + return None + error = document.error + return HouseholdReportSideFailure( + code=error.code, + message=error.message, + input_errors=tuple(error.input_errors), + spm_error=error.spm_error, + ) + + +def household_report_failure( + report_run: ReportRun, rows: HouseholdResultRows +) -> HouseholdReportFailure: + """Return why each side of a failed report run failed, where it says. + + A side with no failure document, or one that cannot be read, gives None: + a failure is never made unreadable by its document. + """ + return HouseholdReportFailure( + baseline=_side_failure(rows, report_run.baseline_simulation_run_id), + reform=_side_failure(rows, report_run.reform_simulation_run_id), + ) diff --git a/policyengine_api/services/v2/stored_reports/household_results/types.py b/policyengine_api/services/v2/stored_reports/household_results/types.py new file mode 100644 index 000000000..8ce6187c1 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/types.py @@ -0,0 +1,89 @@ +"""The result of a household report: each simulation's calculated household. + +A household report's simulations each calculate the report's stored +household under one policy. The household function stores each simulation +run's calculation in ``simulation_runs.household_result``, as the same +result document a household calculation stores in ``household_jobs.result`` +(``household_calculations.types.StoredExecutionResult``), or, for a +calculation the household function refused, its failure document +(``StoredExecutionFailure``). Once the report's run has succeeded, the result +is both calculations, read from those documents; once it has failed, each +side's failure, where its document records one. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from uuid import UUID + + from pydantic import JsonValue + + from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, + ) + + +class HouseholdResultUnreadableError(RuntimeError): + """A succeeded household report run's calculated households cannot be read. + + A simulation run it names is missing, or holds no result document of the + household contract's version 1 with a calculated household. + """ + + +@dataclass(frozen=True) +class HouseholdResultRows: + """The result documents of a report run's simulation runs, by run ID. + + A run with no stored document maps to None; a run that does not exist is + absent. + """ + + documents: dict[UUID, JsonValue] + + +@dataclass(frozen=True) +class HouseholdReportResult: + """A household report's result: the baseline's and the reform's calculations. + + Each is the stored household with every value the calculation filled in, + its warnings and its SPM settings and receipt. ``reform`` is None for a + report of the baseline alone. + """ + + baseline: HouseholdCalculationOutput + reform: HouseholdCalculationOutput | None + + +@dataclass(frozen=True) +class HouseholdReportSideFailure: + """The household function's error for one side's calculation. + + It is read from the side's failure document, which always names its + ``code``, such as ``invalid_inputs``; ``input_errors`` names each input + the country model does not recognize, and ``spm_error`` is an SPM error. + """ + + code: str + message: str + input_errors: tuple[dict[str, JsonValue], ...] + spm_error: dict[str, JsonValue] | None + + +@dataclass(frozen=True) +class HouseholdReportFailure: + """Why each side of a failed household report run failed, where it says. + + Each is the household function's error for that side's calculation. A + side is None when it succeeded, has no failure document (a + run failed outside its calculation, such as one Modal refused, records + none) or has one that cannot be read; the report run's error message + still says why it failed. ``reform`` is also None for a report of the + baseline alone. + """ + + baseline: HouseholdReportSideFailure | None + reform: HouseholdReportSideFailure | None diff --git a/policyengine_api/services/v2/stored_reports/household_results/validators.py b/policyengine_api/services/v2/stored_reports/household_results/validators.py new file mode 100644 index 000000000..668634c73 --- /dev/null +++ b/policyengine_api/services/v2/stored_reports/household_results/validators.py @@ -0,0 +1,64 @@ +"""Checks of the calculated households a household report run's simulations stored.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import ValidationError + +from policyengine_api.services.v2.household_calculations.types import ( + StoredExecutionResult, +) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportResult, + HouseholdResultUnreadableError, +) + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import ReportRun + from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, + ) + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdResultRows, + ) + + +def _calculated_household( + rows: HouseholdResultRows, simulation_run_id: UUID +) -> HouseholdCalculationOutput: + """Return one simulation run's calculated household. + + Raise HouseholdResultUnreadableError when the run does not exist or its + document is not a version 1 result document with a calculated household. + """ + if simulation_run_id not in rows.documents: + msg = f"simulation run {simulation_run_id} does not exist" + raise HouseholdResultUnreadableError(msg) + try: + document = StoredExecutionResult.model_validate( + rows.documents[simulation_run_id] + ) + except ValidationError as error: + msg = f"simulation run {simulation_run_id} has no readable household result" + raise HouseholdResultUnreadableError(msg) from error + return document.calculation_output() + + +def require_household_report_result( + report_run: ReportRun, rows: HouseholdResultRows +) -> HouseholdReportResult: + """Return a succeeded report run's calculated households. + + The reform's is read only when the run names a reform simulation run. + Raise HouseholdResultUnreadableError when either cannot be read. + """ + reform_run_id = report_run.reform_simulation_run_id + return HouseholdReportResult( + baseline=_calculated_household(rows, report_run.baseline_simulation_run_id), + reform=None + if reform_run_id is None + else _calculated_household(rows, reform_run_id), + ) diff --git a/policyengine_api/services/v2/stored_reports/services.py b/policyengine_api/services/v2/stored_reports/services.py index 436108d75..13d8a95c8 100644 --- a/policyengine_api/services/v2/stored_reports/services.py +++ b/policyengine_api/services/v2/stored_reports/services.py @@ -1,4 +1,4 @@ -"""Create economy reports through the simulation service, and read them. +"""Create reports through the simulation service, and read them. Reading a report reads it within its country (``reports.country``), with its newest run: the run created last, whatever its status and versions. That @@ -15,15 +15,19 @@ run, read with its years; once that run has succeeded, its result is the budget-window format, rebuilt from each year's budget rows. Reading a window's definition and runs never reads those rows, so only a read of a -succeeded window can find them unreadable. Lists name at most 25 reports and -read them, with their newest runs and any window's years, in two queries, -without results. +succeeded window can find them unreadable. A household report's result is +instead each of its simulation runs' calculated household +(``simulation_runs.household_result``), read once its newest report run has +succeeded, and each side's failure once it has failed. Lists name at most 25 +reports and read them, with their newest runs and any window's years, in two +queries, without results. Creating a report or budget window checks the country and the policies it -names, asks the simulation service to create and start it, then reads it -back from the tables. Once the simulation service has answered with the report's ID, the -report exists: a failure to read it back raises CreatedReportUnreadableError, -which names it, so the caller reads it rather than submitting it again. +names (and a household report's household), asks the simulation service to +create and start it, then reads it back from the tables. Once the simulation +service has answered with the report's ID, the report exists: a failure to +read it back raises CreatedReportUnreadableError, which names it, so the +caller reads it rather than submitting it again. Re-runs and run lists are not here. """ @@ -44,6 +48,9 @@ from policyengine_api.services.v2.stored_reports.database_connectors import ( reads_budget_window_results as window_result_reads, ) +from policyengine_api.services.v2.stored_reports.database_connectors import ( + reads_household_results as household_result_reads, +) from policyengine_api.services.v2.stored_reports.database_connectors import ( reads_results as result_reads, ) @@ -53,9 +60,17 @@ from policyengine_api.services.v2.stored_reports.economy_results import ( transformations as result_transformations, ) +from policyengine_api.services.v2.stored_reports.household_results import ( + transformations as household_result_transformations, +) +from policyengine_api.services.v2.stored_reports.household_results.validators import ( + require_household_report_result, +) from policyengine_api.services.v2.stored_reports.transformations import ( budget_window_read, budget_window_summary_read, + household_report_read, + household_report_summary_read, listed_reports, report_kind, report_read, @@ -64,7 +79,6 @@ from policyengine_api.services.v2.stored_reports.validators import ( CreatedReportUnreadableError, ReportStorageError, - require_readable_report, require_reform_run, require_report, ) @@ -85,6 +99,7 @@ ) from policyengine_api.services.v2.stored_reports.types import ( BudgetWindowReportRead, + HouseholdReportRead, StoredReportRead, StoredReportRows, StoredReportSummaryRead, @@ -105,8 +120,20 @@ def _storage_failures() -> Iterator[None]: raise ReportStorageError(msg) from error +def _summary_read( + stored: StoredReportRows, *, current_versions: PackageVersions +) -> StoredReportSummaryRead: + """Return the report's definition and newest run, as its kind reads them.""" + kind = report_kind(stored.report) + if kind == "household": + return household_report_summary_read(stored, current_versions=current_versions) + if kind == "budget_window": + return budget_window_summary_read(stored, current_versions=current_versions) + return report_summary_read(stored, current_versions=current_versions) + + class V2StoredReportService: - """Read v2 reports and their results, and create economy reports and windows.""" + """Read v2 reports and their results, and create reports of every kind.""" def __init__( self, @@ -129,13 +156,14 @@ def __init__( def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: """Return the country's report, its newest run and that run's result. - The report is an economy report or a budget window. Raise - StoredReportNotFoundError when the country has no such report, - ReportKindUnsupportedError for a household report, - EconomyResultUnreadableError when a succeeded run's rows lack a part - every result has, BudgetWindowResultUnreadableError when a succeeded - window run's years cannot give its result, and ReportStorageError - when the database cannot be read. + The report is an economy report, a household report or a budget + window. Raise StoredReportNotFoundError when the country has no such + report, EconomyResultUnreadableError when a succeeded run's rows lack + a part every result has, HouseholdResultUnreadableError when a + succeeded household report run's calculated households cannot be + read, BudgetWindowResultUnreadableError when a succeeded window run's + years cannot give its result, and ReportStorageError when the + database cannot be read. """ with _storage_failures(), self._database_session.read() as session: stored = require_report( @@ -150,8 +178,11 @@ def get_report(self, *, country_id: str, report_id: UUID) -> StoredReportRead: report_id=report_id, country_id=country_id, ) - if require_readable_report(stored) == "budget_window": + kind = report_kind(stored.report) + if kind == "budget_window": return self._budget_window(session, stored) + if kind == "household": + return self._household_report(session, stored) run = stored.newest_run result = None if run is not None and run.status is ReportRunStatus.SUCCEEDED: @@ -189,14 +220,44 @@ def _budget_window( result=result, ) + def _household_report( + self, session: Session, stored: StoredReportRows + ) -> HouseholdReportRead: + """Return a household report with its newest run's outcome. + + Its calculated households are read once that run has succeeded, and + each side's failure once it has failed. + """ + run = stored.newest_run + result = failure = None + if run is not None and run.status in { + ReportRunStatus.SUCCEEDED, + ReportRunStatus.FAILED, + }: + rows = household_result_reads.read_household_result_rows( + session, report_run=run + ) + if run.status is ReportRunStatus.SUCCEEDED: + result = require_household_report_result(run, rows) + else: + failure = household_result_transformations.household_report_failure( + run, rows + ) + return household_report_read( + stored, + current_versions=self._current_versions(stored.report.country), + result=result, + failure=failure, + ) + def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] ) -> tuple[StoredReportSummaryRead, ...]: """Return the country's reports among ``report_ids``, without results. - They are economy reports and budget windows, in the order named, each - once; unknown reports, other countries' and household reports are - left out. Raise ReportStorageError when the database cannot be read. + They are in the order named, each once; unknown reports and other + countries' are left out. Raise ReportStorageError when the database + cannot be read. """ with _storage_failures(), self._database_session.read() as session: rows = read_report_rows( @@ -204,27 +265,29 @@ def list_reports( ) current_versions = self._current_versions(country_id) return tuple( - budget_window_summary_read(stored, current_versions=current_versions) - if report_kind(stored.report) == "budget_window" - else report_summary_read(stored, current_versions=current_versions) + _summary_read(stored, current_versions=current_versions) for stored in listed_reports(rows, report_ids=report_ids) ) def create_report(self, report_input: ReportCreationInput) -> StoredReportRead: """Create the report through the simulation service and return it as stored. - An economy report and a budget window each go through their own - submission. Raise the submission's errors (see - ``SimulationSubmissionService``), and CreatedReportUnreadableError, - caused by the read's own error, when the report the simulation service - created cannot be read back in the country: not stored there, the - database unavailable, or any other failure of the read. + Each kind of report goes through its own submission. Raise the + submission's errors (see ``SimulationSubmissionService``), and + CreatedReportUnreadableError, caused by the read's own error, when the + report the simulation service created cannot be read back in the + country: not stored there, the database unavailable, or any other + failure of the read. """ if self._submissions is None: msg = "The simulation service is not configured here" raise SubmissionsNotConfiguredError(msg) if report_input.kind == "budget_window": report_id = self._submissions.submit_budget_window(report_input).report_id + elif report_input.kind == "household": + report_id = self._submissions.submit_household_report( + report_input + ).report_id else: report_id = self._submissions.submit_report(report_input).report_id try: diff --git a/policyengine_api/services/v2/stored_reports/transformations.py b/policyengine_api/services/v2/stored_reports/transformations.py index f58c37926..358dd28f1 100644 --- a/policyengine_api/services/v2/stored_reports/transformations.py +++ b/policyengine_api/services/v2/stored_reports/transformations.py @@ -9,6 +9,8 @@ BudgetWindowReportSummaryRead, BudgetWindowRunRead, BudgetWindowYearRead, + HouseholdReportRead, + HouseholdReportSummaryRead, ReportDefinitionRead, ReportRead, ReportRunRead, @@ -35,15 +37,16 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResult, ) + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, + HouseholdReportResult, + ) from policyengine_api.services.v2.stored_reports.types import ( ReportKind, + ReportStatusName, StoredReportRows, ) -# The kinds of report the report routes read; household reports are read -# once their own routes exist. -READABLE_KINDS: frozenset[ReportKind] = frozenset({"economy", "budget_window"}) - def report_kind(report: Report) -> ReportKind: """Return what the report compares, from the columns its kind sets.""" @@ -92,8 +95,6 @@ def _report_definition( label=report.label, description=report.description, year=report.year, - region_code=rows.region_code, - dataset_name=rows.dataset_name, baseline_simulation=_simulation(rows.baseline_simulation), reform_simulation=_simulation(rows.reform_simulation), options=dict(report.inputs), @@ -102,16 +103,28 @@ def _report_definition( ) +def _newest_run( + rows: StoredReportRows, +) -> tuple[ReportStatusName, ReportRunRead | None]: + """Return the report's status and newest run: ``not_run`` and None without one.""" + run = rows.newest_run + if run is None: + return "not_run", None + return run.status.value, report_run_read(run) + + def report_summary_read( rows: StoredReportRows, *, current_versions: PackageVersions ) -> ReportSummaryRead: """Return an economy report's definition and newest run, without a result.""" - run = rows.newest_run + status, run = _newest_run(rows) return ReportSummaryRead( **vars(_report_definition(rows, current_versions=current_versions)), kind="economy", - status="not_run" if run is None else run.status.value, - run=None if run is None else report_run_read(run), + region_code=rows.region_code, + dataset_name=rows.dataset_name, + status=status, + run=run, ) @@ -126,6 +139,40 @@ def report_read( return ReportRead(**vars(summary), result=result) +def household_report_summary_read( + rows: StoredReportRows, *, current_versions: PackageVersions +) -> HouseholdReportSummaryRead: + """Return a household report's definition and newest run, without a result. + + Raise ValueError for a report naming no household, which is not a + household report. + """ + report = rows.report + if report.household_id is None: + msg = f"Report {report.id} is not a household report" + raise ValueError(msg) + status, run = _newest_run(rows) + return HouseholdReportSummaryRead( + **vars(_report_definition(rows, current_versions=current_versions)), + kind="household", + household_id=report.household_id, + status=status, + run=run, + ) + + +def household_report_read( + rows: StoredReportRows, + *, + current_versions: PackageVersions, + result: HouseholdReportResult | None, + failure: HouseholdReportFailure | None, +) -> HouseholdReportRead: + """Return a household report with its newest run's outcome, if it has one.""" + summary = household_report_summary_read(rows, current_versions=current_versions) + return HouseholdReportRead(**vars(summary), result=result, failure=failure) + + def budget_window_run_read( window_run: BudgetWindowRun, years: Sequence[ReportRun] ) -> BudgetWindowRunRead: @@ -175,6 +222,8 @@ def budget_window_summary_read( return BudgetWindowReportSummaryRead( **vars(_report_definition(rows, current_versions=current_versions)), kind="budget_window", + region_code=rows.region_code, + dataset_name=rows.dataset_name, budget_window_size=report.budget_window_size, status="not_run" if window_run is None else window_run.status.value, run=( @@ -199,16 +248,11 @@ def budget_window_read( def listed_reports( rows: Iterable[StoredReportRows], *, report_ids: Sequence[UUID] ) -> list[StoredReportRows]: - """Return the economy and budget-window reports among ``rows``, as named. + """Return the reports among ``rows`` in the order of ``report_ids``. - They are in the order of ``report_ids``. A report named twice is listed - once, where it is first named; household reports are left out. + A report named twice is listed once, where it is first named. """ - by_id = { - stored.report.id: stored - for stored in rows - if report_kind(stored.report) in READABLE_KINDS - } + by_id = {stored.report.id: stored for stored in rows} listed: list[StoredReportRows] = [] for report_id in dict.fromkeys(report_ids): stored = by_id.get(report_id) diff --git a/policyengine_api/services/v2/stored_reports/types.py b/policyengine_api/services/v2/stored_reports/types.py index c4aadd560..57d51c683 100644 --- a/policyengine_api/services/v2/stored_reports/types.py +++ b/policyengine_api/services/v2/stored_reports/types.py @@ -4,10 +4,11 @@ is a report run. A budget-window report compares the same policies in each year of a window of consecutive years: each calculation of it is a budget-window run, and each year of that a report run naming the window run, -its year and that year's own simulation runs. The -simulation service creates reports and their runs, and its Modal workers -store each run's result in the result tables; the Public API reads all of -them directly. +its year and that year's own simulation runs. A household report's +simulations each calculate one stored household, and its result is each +simulation run's calculated household. The simulation service creates +reports and their runs, and its Modal workers store each run's result in the +result tables; the Public API reads all of them directly. """ from __future__ import annotations @@ -36,6 +37,10 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResult, ) + from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, + HouseholdReportResult, + ) # A report's status is its newest run's, or not_run while it has none, as a # report migrated from v1 has none. @@ -90,8 +95,6 @@ class ReportDefinitionRead: label: str description: str | None year: int | None - region_code: str | None - dataset_name: str | None baseline_simulation: ReportSimulationRead | None reform_simulation: ReportSimulationRead | None options: dict[str, JsonValue] @@ -101,9 +104,15 @@ class ReportDefinitionRead: @dataclass(frozen=True) class ReportSummaryRead(ReportDefinitionRead): - """An economy report's definition, its newest run and the current versions.""" + """An economy report's definition, its newest run and the current versions. + + ``region_code`` and ``dataset_name`` are the region and dataset both + simulations use, null when the report names none. + """ kind: Literal["economy"] + region_code: str | None + dataset_name: str | None status: ReportStatusName run: ReportRunRead | None @@ -115,6 +124,35 @@ class ReportRead(ReportSummaryRead): result: EconomyResult | None +@dataclass(frozen=True) +class HouseholdReportSummaryRead(ReportDefinitionRead): + """A household report's definition, its newest run and the current versions. + + Both simulations calculate the stored household ``household_id`` in its + default year, ``year``; ``reform_simulation`` is None for a report of the + baseline alone. It has no region or dataset. ``run`` is the newest report + run, whatever its status and versions, naming each simulation's run. + """ + + kind: Literal["household"] + household_id: UUID + status: ReportStatusName + run: ReportRunRead | None + + +@dataclass(frozen=True) +class HouseholdReportRead(HouseholdReportSummaryRead): + """A household report with its newest run's outcome. + + ``result`` holds the calculated households once that run has succeeded; + ``failure`` says why each side failed once it has failed. Both are None + otherwise. + """ + + result: HouseholdReportResult | None + failure: HouseholdReportFailure | None + + @dataclass(frozen=True) class BudgetWindowYearRead(ReportRunRead): """One year of a budget-window run: the report run calculating that year.""" @@ -153,13 +191,16 @@ class BudgetWindowRunRead: class BudgetWindowReportSummaryRead(ReportDefinitionRead): """A budget-window report's definition, its newest run and the current versions. - The window is ``budget_window_size`` years from ``year``. The report's - two simulations are its first year's; each year's report run names that - year's own simulation runs. ``run`` is the newest budget-window run, - whatever its status and versions, with its years. + The window is ``budget_window_size`` years from ``year``, in the region + ``region_code`` and dataset ``dataset_name``, null when the report names + none. The report's two simulations are its first year's; each year's + report run names that year's own simulation runs. ``run`` is the newest + budget-window run, whatever its status and versions, with its years. """ kind: Literal["budget_window"] + region_code: str | None + dataset_name: str | None budget_window_size: int status: ReportStatusName run: BudgetWindowRunRead | None @@ -172,9 +213,11 @@ class BudgetWindowReportRead(BudgetWindowReportSummaryRead): result: window_result_types.BudgetWindowResult | None -# A report of either kind these routes read. -StoredReportRead = ReportRead | BudgetWindowReportRead -StoredReportSummaryRead = ReportSummaryRead | BudgetWindowReportSummaryRead +# A report of any kind these routes read. +StoredReportRead = ReportRead | HouseholdReportRead | BudgetWindowReportRead +StoredReportSummaryRead = ( + ReportSummaryRead | HouseholdReportSummaryRead | BudgetWindowReportSummaryRead +) @dataclass(frozen=True) diff --git a/policyengine_api/services/v2/stored_reports/validators.py b/policyengine_api/services/v2/stored_reports/validators.py index bfd888c89..60194338b 100644 --- a/policyengine_api/services/v2/stored_reports/validators.py +++ b/policyengine_api/services/v2/stored_reports/validators.py @@ -7,22 +7,15 @@ from policyengine_api.services.v2.stored_reports.economy_results.types import ( EconomyResultUnreadableError, ) -from policyengine_api.services.v2.stored_reports.transformations import ( - READABLE_KINDS, - report_kind, -) if TYPE_CHECKING: from uuid import UUID from policyengine_api.data.v2.models import ReportRun from policyengine_api.services.v2.stored_reports.types import ( - ReportKind, StoredReportRows, ) -KIND_NAMES = {"household": "a household report"} - class StoredReportNotFoundError(LookupError): """No report has this ID in the requested country.""" @@ -51,14 +44,6 @@ def __init__(self, *, report_id: UUID, country_id: str) -> None: self.country_id = country_id -class ReportKindUnsupportedError(ValueError): - """The report is of a kind these routes do not read yet. - - Household reports are stored in the same tables, and are read once their - own routes exist. - """ - - def require_report( rows: StoredReportRows | None, *, report_id: UUID, country_id: str ) -> StoredReportRows: @@ -69,21 +54,6 @@ def require_report( return rows -def require_readable_report(rows: StoredReportRows) -> ReportKind: - """Return the report's kind: an economy report or a budget window. - - Raise ReportKindUnsupportedError for a household report. - """ - kind = report_kind(rows.report) - if kind not in READABLE_KINDS: - msg = ( - f"Report {rows.report.id} is {KIND_NAMES[kind]}, which this route " - "cannot read yet" - ) - raise ReportKindUnsupportedError(msg) - return kind - - def require_reform_run(report_run: ReportRun) -> UUID: """Return the reform simulation run an economy report run compares. diff --git a/pyproject.toml b/pyproject.toml index 3980e5837..24ac51b79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -480,6 +480,7 @@ runtime-evaluated-base-classes = [ "policyengine_api.services.v2.simulation_service.types.BudgetWindowReportCreationInput", "policyengine_api.services.v2.simulation_service.types.EconomyReportCreationInput", "policyengine_api.services.v2.simulation_service.types.EconomySimulationCreationInput", + "policyengine_api.services.v2.simulation_service.types.HouseholdReportCreationInput", "policyengine_api.services.v2.simulation_service.types.StrictSubmissionModel", "policyengine_api.services.v2.simulations.types.StrictContractModel", "policyengine_api.services.v2.stored_reports.economy_results.types.EconomyResultModel", diff --git a/tests/contract/registry.py b/tests/contract/registry.py index a35ea84b5..583b7988c 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -617,7 +617,9 @@ class WorkflowContract: WorkflowContract( name="detailed_save_reports_v2", current_contract="typed_v2_resources", - future_owner_pr="C1 · 8: v2 simulations and economy reports", + future_owner_pr=( + "C1 · 8: v2 simulations and economy reports; C1 · 16: household reports" + ), requests=( ContractRequest( method="POST", @@ -651,6 +653,7 @@ class WorkflowContract: "status", "message", "result.item.id", + "result.item.kind", "result.item.status", "result.item.run", "result.item.current_versions", diff --git a/tests/integration/test_v2_budget_window_report_creation.py b/tests/integration/test_v2_budget_window_report_creation.py index b027dfd2a..f1913b40f 100644 --- a/tests/integration/test_v2_budget_window_report_creation.py +++ b/tests/integration/test_v2_budget_window_report_creation.py @@ -35,6 +35,10 @@ CountryDatabaseSession, ) from policyengine_api.services.v2.countries.services import V2CountryService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService from policyengine_api.services.v2.policies.database_session import ( PolicyDatabaseSession, ) @@ -149,6 +153,7 @@ def client_for( """Serve the report routes as the runtime role, creating through the stand-in.""" submissions = SimulationSubmissionService( countries=V2CountryService(CountryDatabaseSession(sessions)), + households=V2HouseholdService(HouseholdDatabaseSession(sessions)), policies=V2PolicyService( PolicyDatabaseSession(sessions), running_policyengine_version="4.2.0" ), diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py new file mode 100644 index 000000000..ec9709bb9 --- /dev/null +++ b/tests/integration/test_v2_household_reports.py @@ -0,0 +1,605 @@ +"""Create, read and list household reports through /v2/reports on migrated PostgreSQL. + +The routes run as the Public API's runtime role, so the tests prove its +declared privileges cover every read: the countries row, the stored household +and policies the checks read, and the reports, simulations, runs and +calculated households the reads return (rule +``api.v2.runtime-privileges-tested-as-runtime-role``). The rows the +simulation service and its household functions write are stored by the +disposable database's owner, committed, and deleted after each test. The UK +is made household-only for each test and restored afterwards. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import timedelta +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient +from flask import Flask +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool +from sqlmodel import Session + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.constants import REPO +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) +from policyengine_api.services.v2.countries.database_session import ( + CountryDatabaseSession, +) +from policyengine_api.services.v2.countries.services import V2CountryService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService +from policyengine_api.services.v2.policies.database_session import ( + PolicyDatabaseSession, +) +from policyengine_api.services.v2.policies.services import V2PolicyService +from policyengine_api.services.v2.simulation_service.services import ( + SimulationSubmissionService, +) +from policyengine_api.services.v2.simulation_service.types import ( + PackageVersions, + SubmittedReport, +) +from policyengine_api.services.v2.stored_reports.database_session import ( + StoredReportDatabaseSession, +) +from policyengine_api.services.v2.stored_reports.services import V2StoredReportService +from tests.integration.simulation_rows import insert_row +from tests.integration.stored_report_rows import ( + STARTED_AT, + delete_catalog, + store_catalog, + store_report, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy import Connection, Engine + + from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + ) + from tests.integration.stored_report_rows import CatalogRows + +CURRENT_VERSIONS = {"country_package_version": "1.5.0", "policyengine_version": "4.2.0"} +STORED_HOUSEHOLD = {"people": {"you": {"age": {"2026": 40}}}} +BASELINE_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 30000.0}}} +} +REFORM_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 31250.5}}} +} + + +def current_versions(country_id: str) -> PackageVersions: + return PackageVersions(**CURRENT_VERSIONS) + + +INPUT_ERRORS = [{"name": "employment_incme", "message": "is not a variable"}] +FAILED_DOCUMENT = { + "status": "failed", + "schema_version": 1, + "error": { + "code": "invalid_inputs", + "message": "The household names inputs the model does not have.", + "input_errors": INPUT_ERRORS, + "spm_error": None, + }, +} + + +def succeeded_document(household: dict[str, Any]) -> dict[str, Any]: + """Return the household function's result document for a calculated household.""" + return { + "status": "succeeded", + "schema_version": 1, + "output": { + "household": household, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + }, + } + + +@dataclass(frozen=True) +class StoredHouseholdReport: + """A stored household report and its newest run's IDs.""" + + report_id: UUID + report_run_id: UUID + + +def store_household( + connection: Connection, catalog: CatalogRows, *, default_year: int | None = 2026 +) -> UUID: + """Store a household in the catalog's country; ``delete_catalog`` removes it.""" + household_id = uuid4() + insert_row( + connection, + "households", + id=household_id, + country_id=catalog.country, + default_year=default_year, + household_data=json.dumps(STORED_HOUSEHOLD), + canonicalization_version=1, + content_hash=uuid4().hex * 2, + ) + catalog.household_ids.append(household_id) + return household_id + + +@dataclass(frozen=True) +class SimulationRunToStore: + """A household simulation's run: its status and calculated household's document.""" + + status: str + document: dict[str, Any] | None + + @property + def finished(self) -> bool: + return self.status in {"succeeded", "failed"} + + +def store_household_simulation( + connection: Connection, + catalog: CatalogRows, + household_id: UUID, + *, + policy_id: UUID | None, + run: SimulationRunToStore, +) -> tuple[UUID, UUID]: + """Store a household simulation and one run; return both IDs.""" + simulation_id = uuid4() + insert_row( + connection, + "simulations", + id=simulation_id, + simulation_type="household", + household_id=household_id, + policy_id=policy_id, + tax_benefit_model_version_id=catalog.version_id, + year=2026, + ) + catalog.simulation_ids.append(simulation_id) + run_id = uuid4() + insert_row( + connection, + "simulation_runs", + id=run_id, + simulation_id=simulation_id, + year=2026, + tax_benefit_model_version_id=catalog.version_id, + status=run.status, + started_at=STARTED_AT, + completed_at=STARTED_AT + timedelta(minutes=1) if run.finished else None, + household_result=None if run.document is None else json.dumps(run.document), + **CURRENT_VERSIONS, + ) + return simulation_id, run_id + + +@dataclass(frozen=True) +class HouseholdReportToStore: + """A household report's run status, and the run of each of its sides. + + ``side_runs`` gives the baseline's and the reform's simulation runs. + Without it, each side's run has the report run's status and, once + succeeded, its calculated household. Without ``with_reform``, it is a + report of the baseline alone, and the reform's run is not stored. + """ + + status: str = "succeeded" + with_reform: bool = True + side_runs: tuple[SimulationRunToStore, SimulationRunToStore] | None = None + + def runs(self) -> tuple[SimulationRunToStore, SimulationRunToStore]: + """Return the baseline's and the reform's simulation runs to store.""" + if self.side_runs is not None: + return self.side_runs + succeeded = self.status == "succeeded" + return ( + SimulationRunToStore( + self.status, + succeeded_document(BASELINE_HOUSEHOLD) if succeeded else None, + ), + SimulationRunToStore( + self.status, + succeeded_document(REFORM_HOUSEHOLD) if succeeded else None, + ), + ) + + +def store_household_report( + connection: Connection, + catalog: CatalogRows, + household_id: UUID, + report: HouseholdReportToStore | None = None, +) -> StoredHouseholdReport: + """Store a household report of current law and the catalog's policy, with a run. + + Without ``report``, its run succeeded and both sides hold their households. + """ + report = report or HouseholdReportToStore() + status = report.status + with_reform = report.with_reform + baseline_run, reform_run = report.runs() + baseline_id, baseline_run_id = store_household_simulation( + connection, catalog, household_id, policy_id=None, run=baseline_run + ) + reform_id = reform_run_id = None + if with_reform: + reform_id, reform_run_id = store_household_simulation( + connection, + catalog, + household_id, + policy_id=catalog.policy_id, + run=reform_run, + ) + report_id = uuid4() + insert_row( + connection, + "reports", + id=report_id, + label="My household under the reform", + country=catalog.country, + tax_benefit_model_id=catalog.model_id, + policy_id=catalog.policy_id if with_reform else None, + baseline_simulation_id=baseline_id, + reform_simulation_id=reform_id, + household_id=household_id, + year=2026, + inputs=json.dumps({}), + ) + catalog.report_ids.append(report_id) + report_run_id = uuid4() + insert_row( + connection, + "report_runs", + id=report_run_id, + report_id=report_id, + baseline_simulation_run_id=baseline_run_id, + reform_simulation_run_id=reform_run_id, + status=status, + trigger="initial", + started_at=STARTED_AT, + completed_at=( + STARTED_AT + timedelta(minutes=1) + if status in {"succeeded", "failed"} + else None + ), + **CURRENT_VERSIONS, + ) + return StoredHouseholdReport(report_id=report_id, report_run_id=report_run_id) + + +@dataclass +class StoringHouseholdReportService: + """Store what the simulation service would create for a household report.""" + + engine: Engine + catalog: CatalogRows + household_reports: list[HouseholdReportCreationInput] = field(default_factory=list) + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + self.household_reports.append(report) + with self.engine.begin() as connection: + stored = store_household_report( + connection, + self.catalog, + report.household_id, + HouseholdReportToStore(status="pending"), + ) + return SubmittedReport( + report_id=stored.report_id, report_run_id=stored.report_run_id + ) + + +@pytest.fixture +def owner_engine(disposable_v2_database_url: str) -> Iterator[Engine]: + command.upgrade(Config(str(REPO / "alembic-v2.ini")), "head") + engine = create_engine(disposable_v2_database_url, poolclass=NullPool) + yield engine + engine.dispose() + + +@pytest.fixture +def household_only_uk(owner_engine: Engine) -> Iterator[CatalogRows]: + """Make the UK household-only, store a UK catalog, and restore both after.""" + with owner_engine.begin() as connection: + connection.execute( + text("UPDATE countries SET society_wide_enabled = false WHERE id = 'uk'") + ) + catalog = store_catalog(connection, "uk") + try: + yield catalog + finally: + # Each in its own transaction, so a failed cleanup still restores the UK. + with owner_engine.begin() as connection: + connection.execute( + text("UPDATE countries SET society_wide_enabled = true WHERE id = 'uk'") + ) + with owner_engine.begin() as connection: + delete_catalog(connection, catalog) + + +@pytest.fixture +def runtime_sessions(managed_v2_roles, v2_engine_as_role) -> Iterator[Any]: + engine = v2_engine_as_role(managed_v2_roles.runtime, poolclass=NullPool) + yield sessionmaker(engine, class_=Session) + engine.dispose() + + +def client_for( + sessions: Any, simulation_service: StoringHouseholdReportService | None = None +) -> TestClient: + """Serve the report routes as the runtime role, creating through the stand-in.""" + submissions = SimulationSubmissionService( + countries=V2CountryService(CountryDatabaseSession(sessions)), + households=V2HouseholdService(HouseholdDatabaseSession(sessions)), + policies=V2PolicyService( + PolicyDatabaseSession(sessions), running_policyengine_version="4.2.0" + ), + client_factory=lambda: simulation_service, + ) + dependencies = NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=lambda: None, + metadata_reader_factory=lambda: None, + v2_stored_report_service_factory=lambda: V2StoredReportService( + StoredReportDatabaseSession(sessions), + current_versions=current_versions, + submissions=submissions, + ), + ) + settings = RouteImplementationSettings( + health=RouteImplementation.FLASK_FALLBACK, + specification=RouteImplementation.FLASK_FALLBACK, + metadata=RouteImplementation.FLASK_FALLBACK, + ) + return TestClient( + create_asgi_app( + Flask(__name__), dependencies=dependencies, route_settings=settings + ) + ) + + +def household_body(household_id: UUID, policy_id: UUID) -> dict[str, Any]: + return { + "country_id": "uk", + "kind": "household", + "label": "My household under the reform", + "household_id": str(household_id), + "baseline": {}, + "reform": {"policy_id": str(policy_id)}, + } + + +def test_a_household_report_answers_both_calculated_households( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + catalog = household_only_uk + with owner_engine.begin() as connection: + household_id = store_household(connection, catalog) + stored = store_household_report(connection, catalog, household_id) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 200, response.text + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["household_id"] == str(household_id) + assert item["options"] == {} + assert item["status"] == "succeeded" + assert item["run"]["id"] == str(stored.report_run_id) + assert item["reform_simulation"]["policy_id"] == str(catalog.policy_id) + assert item["result"]["baseline"]["household"] == BASELINE_HOUSEHOLD + assert item["result"]["reform"]["household"] == REFORM_HOUSEHOLD + + +def test_a_household_report_of_the_baseline_alone_answers_no_reform( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk) + stored = store_household_report( + connection, + household_only_uk, + household_id, + HouseholdReportToStore(with_reform=False), + ) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 200, response.text + item = response.json()["result"]["item"] + assert item["reform_simulation"] is None + assert item["run"]["reform_simulation_run_id"] is None + assert item["result"]["baseline"]["household"] == BASELINE_HOUSEHOLD + assert item["result"]["reform"] is None + + +def test_a_failed_household_report_answers_each_sides_failure( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk) + stored = store_household_report( + connection, + household_only_uk, + household_id, + # Both sides are calculated: the baseline succeeded, the reform's + # calculation was refused, so the report run failed. + HouseholdReportToStore( + status="failed", + side_runs=( + SimulationRunToStore( + "succeeded", succeeded_document(BASELINE_HOUSEHOLD) + ), + SimulationRunToStore("failed", FAILED_DOCUMENT), + ), + ), + ) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 200, response.text + item = response.json()["result"]["item"] + assert (item["status"], item["result"]) == ("failed", None) + assert item["failure"] == {"baseline": None, "reform": FAILED_DOCUMENT["error"]} + + +def test_a_succeeded_household_report_missing_a_calculation_answers_500( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk) + stored = store_household_report( + connection, + household_only_uk, + household_id, + HouseholdReportToStore( + side_runs=( + SimulationRunToStore( + "succeeded", succeeded_document(BASELINE_HOUSEHOLD) + ), + SimulationRunToStore("succeeded", None), + ) + ), + ) + + response = client_for(runtime_sessions).get( + f"/v2/reports/{stored.report_id}?country_id=uk" + ) + + assert response.status_code == 500 + assert response.json() == { + "status": "error", + "message": "The report's stored result cannot be read", + } + + +def test_household_reports_are_listed_beside_economy_reports( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + catalog = household_only_uk + with owner_engine.begin() as connection: + household_id = store_household(connection, catalog) + household_report = store_household_report( + connection, catalog, household_id, HouseholdReportToStore(status="running") + ) + economy_report = store_report(connection, catalog) + + response = client_for(runtime_sessions).get( + "/v2/reports?country_id=uk" + f"&report_id={economy_report.report_id}" + f"&report_id={household_report.report_id}" + ) + + assert response.status_code == 200, response.text + items = response.json()["result"]["items"] + assert [(item["id"], item["kind"]) for item in items] == [ + (str(economy_report.report_id), "economy"), + (str(household_report.report_id), "household"), + ] + assert items[1]["status"] == "running" + assert all("result" not in item for item in items) + + +def test_a_household_only_country_creates_a_household_report( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + catalog = household_only_uk + with owner_engine.begin() as connection: + household_id = store_household(connection, catalog) + simulation_service = StoringHouseholdReportService(owner_engine, catalog) + + response = client_for(runtime_sessions, simulation_service).post( + "/v2/reports?country_id=uk", + json=household_body(household_id, catalog.policy_id), + ) + + assert response.status_code == 201, response.text + item = response.json()["result"]["item"] + assert item["id"] == str(catalog.report_ids[0]) + assert item["kind"] == "household" + assert item["status"] == "pending" + assert item["result"] is None + (sent,) = simulation_service.household_reports + assert sent.household_id == household_id + + +def test_a_household_the_country_does_not_store_is_not_sent( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + simulation_service = StoringHouseholdReportService(owner_engine, household_only_uk) + + response = client_for(runtime_sessions, simulation_service).post( + "/v2/reports?country_id=uk", + json=household_body(uuid4(), household_only_uk.policy_id), + ) + + assert response.status_code == 404 + assert simulation_service.household_reports == [] + + +def test_a_household_with_no_default_year_is_not_sent( + owner_engine: Engine, household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + with owner_engine.begin() as connection: + household_id = store_household(connection, household_only_uk, default_year=None) + simulation_service = StoringHouseholdReportService(owner_engine, household_only_uk) + + response = client_for(runtime_sessions, simulation_service).post( + "/v2/reports?country_id=uk", + json=household_body(household_id, household_only_uk.policy_id), + ) + + assert response.status_code == 422 + assert response.json()["message"] == ( + "This household has no default year to calculate." + ) + assert simulation_service.household_reports == [] + + +def test_a_household_only_country_still_refuses_economy_reports( + household_only_uk: CatalogRows, runtime_sessions: Any +) -> None: + response = client_for(runtime_sessions).post( + "/v2/reports?country_id=uk", + json={ + "country_id": "uk", + "kind": "economy", + "label": "Economy", + "year": 2026, + "region_code": "country/wales", + "baseline": {}, + "reform": {}, + }, + ) + + assert response.status_code == 422 + assert "Economy calculations are not available" in response.json()["message"] diff --git a/tests/integration/test_v2_report_creation.py b/tests/integration/test_v2_report_creation.py index 4e5f64807..98efc910a 100644 --- a/tests/integration/test_v2_report_creation.py +++ b/tests/integration/test_v2_report_creation.py @@ -33,6 +33,10 @@ CountryDatabaseSession, ) from policyengine_api.services.v2.countries.services import V2CountryService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService from policyengine_api.services.v2.policies.database_session import ( PolicyDatabaseSession, ) @@ -171,6 +175,7 @@ def client_for( """Serve the routes as the runtime role, creating through ``simulation_service``.""" submissions = SimulationSubmissionService( countries=V2CountryService(CountryDatabaseSession(sessions)), + households=V2HouseholdService(HouseholdDatabaseSession(sessions)), policies=V2PolicyService( PolicyDatabaseSession(sessions), running_policyengine_version="4.2.0" ), diff --git a/tests/integration/test_v2_report_reads.py b/tests/integration/test_v2_report_reads.py index d1e89a070..81c79453e 100644 --- a/tests/integration/test_v2_report_reads.py +++ b/tests/integration/test_v2_report_reads.py @@ -43,7 +43,6 @@ V2StoredSimulationService, ) from tests.fixtures.economy_results import uk_economy_result, us_economy_result -from tests.integration.simulation_rows import insert_row from tests.integration.stored_report_rows import ( REGION_CODES, STARTED_AT, @@ -302,30 +301,6 @@ def test_a_succeeded_run_without_result_rows_is_unreadable( } -def test_a_household_report_cannot_be_read_here_yet( - owner_engine: Engine, catalogs: dict[str, CatalogRows], client: TestClient -) -> None: - catalog = catalogs["us"] - household_id = uuid4() - with owner_engine.begin() as connection: - insert_row( - connection, - "households", - id=household_id, - country_id="us", - household_data="{}", - canonicalization_version=1, - content_hash=uuid4().hex * 2, - ) - catalog.household_ids.append(household_id) - report = store_report(connection, catalog, household_id=household_id) - - response = client.get(f"/v2/reports/{report.report_id}?country_id=us") - - assert response.status_code == 422 - assert "household report" in response.json()["message"] - - def test_listing_reads_named_reports_in_the_order_asked( owner_engine: Engine, catalogs: dict[str, CatalogRows], client: TestClient ) -> None: diff --git a/tests/unit/libs/test_simulation_household_report_requests.py b/tests/unit/libs/test_simulation_household_report_requests.py new file mode 100644 index 000000000..500151c0f --- /dev/null +++ b/tests/unit/libs/test_simulation_household_report_requests.py @@ -0,0 +1,176 @@ +"""Create household reports through the simulation service's ``POST /reports``. + +A household report goes to the same route as an economy report and is +answered the same way, so these tests check its request body and a sample of +the answers; ``test_simulation_reports`` checks every answer. +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +import pytest + +from policyengine_api.libs.simulation_reports import ( + REPORTS_PATH, + household_report_request, +) +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + SimulationServiceInternalError, + SimulationServiceInvalidRequestError, + SimulationServiceUnavailableError, + SubmittedReport, +) +from tests.unit.libs.test_simulation_reports import ( + ENVIRONMENT_VARIABLES, + POLICY_ID, + REPORT_CREATED, + REPORT_ID, + REPORT_RUN_ID, + SIMULATION_URL, + RecordingService, +) + +HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000b101") + + +@pytest.fixture(autouse=True) +def configured_simulation_service(monkeypatch): + """Name the Cloud Run simulation service; set no gateway credentials.""" + for name in ENVIRONMENT_VARIABLES: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", f"{SIMULATION_URL}/") + + +def household_report_input(**changes: Any) -> HouseholdReportCreationInput: + values: dict[str, Any] = { + "country_id": "uk", + "kind": "household", + "label": "My household under the reform", + "household_id": str(HOUSEHOLD_ID), + "baseline": {}, + "reform": {"policy_id": str(POLICY_ID)}, + } + return HouseholdReportCreationInput.model_validate({**values, **changes}) + + +def test_a_household_report_is_posted_and_the_created_report_returned() -> None: + service = RecordingService() + + submitted = service.client().submit_household_report( + household_report_input(description="Doubles the credit") + ) + + assert submitted == SubmittedReport( + report_id=REPORT_ID, report_run_id=REPORT_RUN_ID + ) + (request,) = service.requests + assert request.method == "POST" + assert str(request.url) == f"{SIMULATION_URL}{REPORTS_PATH}" + assert service.sent_body() == { + "country": "uk", + "report_type": "household", + "label": "My household under the reform", + "description": "Doubles the credit", + "household_id": str(HOUSEHOLD_ID), + "baseline": {"policy_id": None}, + "reform": {"policy_id": str(POLICY_ID)}, + } + + +def test_a_report_of_the_baseline_alone_sends_a_null_reform() -> None: + body = household_report_input( + baseline={"policy_id": str(POLICY_ID)}, + reform=None, + ) + + assert household_report_request(body) == { + "country": "uk", + "report_type": "household", + "label": "My household under the reform", + "description": None, + "household_id": str(HOUSEHOLD_ID), + "baseline": {"policy_id": str(POLICY_ID)}, + "reform": None, + } + + +def test_a_household_report_the_service_refuses_is_reported_with_its_reason() -> None: + service = RecordingService( + status_code=422, + body={ + "code": "household_not_calculable", + "detail": "The household has no default year", + }, + ) + + with pytest.raises(SimulationServiceInvalidRequestError) as refused: + service.client().submit_household_report(household_report_input()) + + assert str(refused.value) == ( + "The simulation service cannot run this request (household_not_calculable): " + "The household has no default year" + ) + + +@pytest.mark.parametrize( + "code", ["unknown_household", "no_household_function", "unknown_policy"] +) +def test_a_household_400_the_public_api_checked_first_is_unavailable( + code: str, +) -> None: + # The Public API checks the household and policies before sending, and + # the default worker's household function is the service's own state. + service = RecordingService( + status_code=400, body={"code": code, "detail": "No such row."} + ) + + # The route answers SimulationServiceUnavailableError with 503. + with pytest.raises(SimulationServiceUnavailableError) as unavailable: + service.client().submit_household_report(household_report_input()) + + assert str(unavailable.value) == ( + f"The simulation service refused the report ({code}): No such row." + ) + assert unavailable.value.created_record_id is None + + +def test_a_household_report_the_unavailable_service_created_is_named() -> None: + service = RecordingService( + status_code=503, + body={ + "code": "modal_call_refused", + "detail": "Modal refused the call, and the run was failed.", + "report_id": str(REPORT_ID), + }, + ) + + with pytest.raises(SimulationServiceUnavailableError) as unavailable: + service.client().submit_household_report(household_report_input()) + + assert unavailable.value.created_record_id == REPORT_ID + assert str(unavailable.value) == ( + f"The simulation service created report {REPORT_ID}, but is " + "unavailable (503 modal_call_refused)." + ) + + +def test_a_household_report_the_service_failed_may_have_been_created() -> None: + service = RecordingService( + status_code=500, body={"code": "internal_error", "detail": "boom"} + ) + + with pytest.raises(SimulationServiceInternalError, match="500 internal_error"): + service.client().submit_household_report(household_report_input()) + + +def test_an_answer_with_fields_the_public_api_does_not_read_is_accepted() -> None: + service = RecordingService( + body={**REPORT_CREATED, "baseline_simulation_id": str(HOUSEHOLD_ID)} + ) + + submitted = service.client().submit_household_report(household_report_input()) + + assert submitted.report_id == REPORT_ID diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index 13d70219a..110108f8b 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -263,6 +263,7 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_v1_household_dual_write.py" in workflow assert "test_mysql_household_spm_dual_write.py" in workflow assert "test_v2_household_calculation_reads.py" in workflow + assert "test_v2_household_reports.py" in workflow assert "test_mysql_update_row_counts.py" in workflow assert "test_runtime_cache_redis.py" in workflow # The steps run coverage and pytest, which only the dev extra declares; diff --git a/tests/unit/v2/test_budget_window_report_create_routes.py b/tests/unit/v2/test_budget_window_report_create_routes.py index 3d5db0bb9..e051a6627 100644 --- a/tests/unit/v2/test_budget_window_report_create_routes.py +++ b/tests/unit/v2/test_budget_window_report_create_routes.py @@ -159,7 +159,11 @@ def test_the_openapi_document_tells_the_report_bodies_apart_by_kind() -> None: "application/json" ]["schema"] assert body["discriminator"]["propertyName"] == "kind" - assert set(body["discriminator"]["mapping"]) == {"economy", "budget_window"} + assert set(body["discriminator"]["mapping"]) == { + "economy", + "household", + "budget_window", + } window = document["components"]["schemas"]["BudgetWindowReportCreateRequest"] assert {"year", "budget_window_size"} <= set(window["required"]) assert "include_cliffs" not in window["properties"] diff --git a/tests/unit/v2/test_budget_window_report_read_routes.py b/tests/unit/v2/test_budget_window_report_read_routes.py index 9936e567e..3ae423f4c 100644 --- a/tests/unit/v2/test_budget_window_report_read_routes.py +++ b/tests/unit/v2/test_budget_window_report_read_routes.py @@ -153,8 +153,9 @@ def test_the_openapi_document_tells_the_report_kinds_apart_by_kind() -> None: schemas = document["components"]["schemas"] item = schemas["ReportDetailResult"]["properties"]["item"] assert item["discriminator"]["propertyName"] == "kind" - assert set(item["discriminator"]["mapping"]) == {"economy", "budget_window"} + every_kind = {"economy", "household", "budget_window"} + assert set(item["discriminator"]["mapping"]) == every_kind listed = schemas["ReportListResult"]["properties"]["items"]["items"] - assert set(listed["discriminator"]["mapping"]) == {"economy", "budget_window"} + assert set(listed["discriminator"]["mapping"]) == every_kind assert "result" in schemas["BudgetWindowReportItem"]["properties"] assert "years" in schemas["BudgetWindowRunItem"]["properties"] diff --git a/tests/unit/v2/test_budget_window_submissions.py b/tests/unit/v2/test_budget_window_submissions.py index f636b472e..b85336f75 100644 --- a/tests/unit/v2/test_budget_window_submissions.py +++ b/tests/unit/v2/test_budget_window_submissions.py @@ -31,6 +31,7 @@ from policyengine_api.services.v2.simulation_service.validators import ( EconomyCalculationsUnsupportedError, ) +from tests.unit.v2.test_simulation_submissions import UnreadHouseholds STORED_POLICY_ID = UUID("00000000-0000-0000-0000-00000000c101") OUTDATED_POLICY_ID = UUID("00000000-0000-0000-0000-00000000c102") @@ -107,7 +108,10 @@ def submissions( client: RecordingSimulationService, policies: StoredPolicies ) -> SimulationSubmissionService: return SimulationSubmissionService( - countries=StoredCountries(), policies=policies, client_factory=lambda: client + countries=StoredCountries(), + households=UnreadHouseholds(), + policies=policies, + client_factory=lambda: client, ) diff --git a/tests/unit/v2/test_household_report_reads.py b/tests/unit/v2/test_household_report_reads.py new file mode 100644 index 000000000..1647d81e0 --- /dev/null +++ b/tests/unit/v2/test_household_report_reads.py @@ -0,0 +1,663 @@ +"""Read household reports and their calculated households from the v2 tables. + +The tables are SQLite's copy of the v2 schema. Each household report's two +simulations calculate one stored household; once the newest report run has +succeeded, each simulation run's ``household_result`` holds the household +function's result document, which the read returns as the report's result. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, create_engine + +from policyengine_api.data.v2.models import ( + V2_METADATA, + Household, + Policy, + Report, + ReportRun, + ReportRunStatus, + ReportRunTrigger, + Simulation, + SimulationRun, + SimulationStatus, + SimulationType, + TaxBenefitModel, + TaxBenefitModelVersion, +) +from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, +) +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + PackageVersions, + SubmittedReport, +) +from policyengine_api.services.v2.stored_reports.database_session import ( + StoredReportDatabaseSession, +) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, + HouseholdReportResult, + HouseholdReportSideFailure, + HouseholdResultUnreadableError, +) +from policyengine_api.services.v2.stored_reports.services import V2StoredReportService +from policyengine_api.services.v2.stored_reports.types import ( + HouseholdReportRead, + HouseholdReportSummaryRead, + ReportSummaryRead, +) +from policyengine_api.services.v2.stored_reports.validators import ( + CreatedReportUnreadableError, + StoredReportNotFoundError, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy import Engine + +STORED_AT = datetime(2026, 11, 13, 9, tzinfo=UTC) +CURRENT_VERSIONS = PackageVersions( + country_package_version="1.5.0", policyengine_version="4.2.0" +) +RUN_VERSIONS = {"country_package_version": "1.4.0", "policyengine_version": "4.1.0"} +STORED_HOUSEHOLD = {"people": {"you": {"age": {"2026": 40}}}} +BASELINE_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 30_000.0}}} +} +REFORM_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "net_income": {"2026": 31_250.5}}} +} +SPM_PROVENANCE = {"scenario": "baseline", "geography": "national"} +INPUT_ERRORS = [{"name": "employment_incme", "message": "is not a variable"}] + + +def succeeded_document( + household: dict[str, Any], **output_changes: Any +) -> dict[str, Any]: + """Return the household function's result document for a calculated household.""" + return { + "status": "succeeded", + "schema_version": 1, + "output": { + "household": household, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + **output_changes, + }, + } + + +def failed_document(**error_changes: Any) -> dict[str, Any]: + """Return the household function's failure document for a refused calculation.""" + return { + "status": "failed", + "schema_version": 1, + "error": { + "code": "invalid_inputs", + "message": "The household names inputs the model does not have.", + "input_errors": INPUT_ERRORS, + "spm_error": None, + **error_changes, + }, + } + + +def current_versions(country_id: str) -> PackageVersions: + return CURRENT_VERSIONS + + +@pytest.fixture +def engine() -> Iterator[Engine]: + database_engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + V2_METADATA.create_all(database_engine) + yield database_engine + database_engine.dispose() + + +@pytest.fixture +def sessions(engine: Engine) -> sessionmaker[Session]: + return sessionmaker(engine, class_=Session, expire_on_commit=False) + + +@pytest.fixture +def reports(sessions: sessionmaker[Session]) -> V2StoredReportService: + return V2StoredReportService( + StoredReportDatabaseSession(sessions), current_versions=current_versions + ) + + +@dataclass(frozen=True) +class StoredHouseholdReport: + """The IDs of a stored household report and the rows it names.""" + + report_id: UUID + household_id: UUID + policy_id: UUID + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + report_run_id: UUID | None + baseline_run_id: UUID | None + reform_run_id: UUID | None + + +@dataclass(frozen=True) +class RunToStore: + """A report run to store: its status, and each simulation run's result. + + Each simulation run has the status its document records, as the + household function writes it, or the report run's without one. + """ + + status: ReportRunStatus = ReportRunStatus.SUCCEEDED + baseline_result: dict[str, Any] | None = None + reform_result: dict[str, Any] | None = None + error_message: str | None = None + + @property + def finished(self) -> bool: + return self.status in {ReportRunStatus.SUCCEEDED, ReportRunStatus.FAILED} + + +def store_household_report( + session: Session, + *, + country: str = "us", + with_reform: bool = True, + run: RunToStore | None = None, +) -> StoredHouseholdReport: + """Store a household report as the simulation service and its functions do. + + Its baseline is current law and its reform a stored policy; without + ``with_reform`` it is a report of the baseline alone. ``run`` is its one + report run, with a run of each simulation; None stores no run. + """ + model = TaxBenefitModel(name=f"policyengine-{country}") + version = TaxBenefitModelVersion( + model=model, + version=f"reads-{uuid4().hex[:8]}", + current_law_id=1, + metadata_time_periods=[2026], + ) + household = Household( + country_id=country, + default_year=2026, + household_data=STORED_HOUSEHOLD, + canonicalization_version=1, + content_hash=uuid4().hex * 2, + ) + policy = Policy( + country_id=country, + tax_benefit_model=model, + tax_benefit_model_version=version, + canonicalization_version=1, + content_hash=uuid4().hex * 2, + ) + session.add_all([model, version, household, policy]) + session.flush() + + def simulation(policy_id: UUID | None) -> Simulation: + return Simulation( + simulation_type=SimulationType.HOUSEHOLD, + household_id=household.id, + policy_id=policy_id, + tax_benefit_model_version_id=version.id, + year=2026, + ) + + baseline = simulation(None) + reform = simulation(policy.id) if with_reform else None + session.add_all([side for side in (baseline, reform) if side is not None]) + session.flush() + report = Report( + label="My household under the reform", + description="Doubles the credit", + country=country, + tax_benefit_model_id=model.id, + baseline_simulation_id=baseline.id, + reform_simulation_id=None if reform is None else reform.id, + household_id=household.id, + policy_id=None if reform is None else policy.id, + year=2026, + inputs={}, + created_at=STORED_AT, + ) + session.add(report) + session.flush() + report_run_id = baseline_run_id = reform_run_id = None + if run is not None: + completed_at = STORED_AT + timedelta(minutes=1) if run.finished else None + + def simulation_run( + simulation_id: UUID, document: dict[str, Any] | None + ) -> SimulationRun: + recorded_status = (document or {}).get("status") + status = SimulationStatus( + recorded_status + if recorded_status in {"succeeded", "failed"} + else run.status.value + ) + finished = status in {SimulationStatus.SUCCEEDED, SimulationStatus.FAILED} + return SimulationRun( + simulation_id=simulation_id, + year=2026, + tax_benefit_model_version_id=version.id, + status=status, + started_at=STORED_AT, + completed_at=completed_at or (STORED_AT if finished else None), + household_result=document, + **RUN_VERSIONS, + ) + + baseline_run = simulation_run(baseline.id, run.baseline_result) + reform_run = ( + None if reform is None else simulation_run(reform.id, run.reform_result) + ) + session.add_all([side for side in (baseline_run, reform_run) if side]) + session.flush() + report_run = ReportRun( + report_id=report.id, + baseline_simulation_run_id=baseline_run.id, + reform_simulation_run_id=None if reform_run is None else reform_run.id, + status=run.status, + trigger=ReportRunTrigger.INITIAL, + created_at=STORED_AT, + started_at=STORED_AT, + completed_at=completed_at, + error_message=run.error_message, + **RUN_VERSIONS, + ) + session.add(report_run) + session.flush() + report_run_id = report_run.id + baseline_run_id = baseline_run.id + reform_run_id = None if reform_run is None else reform_run.id + session.commit() + return StoredHouseholdReport( + report_id=report.id, + household_id=household.id, + policy_id=policy.id, + baseline_simulation_id=baseline.id, + reform_simulation_id=None if reform is None else reform.id, + report_run_id=report_run_id, + baseline_run_id=baseline_run_id, + reform_run_id=reform_run_id, + ) + + +def succeeded_run(**changes: Any) -> RunToStore: + values: dict[str, Any] = { + "baseline_result": succeeded_document(BASELINE_HOUSEHOLD), + "reform_result": succeeded_document( + REFORM_HOUSEHOLD, + warnings=["ignored_input"], + spm_config={"geography_kind": "national"}, + spm_provenance=SPM_PROVENANCE, + ), + } + return RunToStore(**{**values, **changes}) + + +def test_a_succeeded_household_report_reads_both_calculated_households( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report(session, run=succeeded_run()) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.kind == "household" + assert report.household_id == stored.household_id + # A household report has no region or dataset. + assert not hasattr(report, "region_code") + assert not hasattr(report, "dataset_name") + assert (report.label, report.description, report.year) == ( + "My household under the reform", + "Doubles the credit", + 2026, + ) + assert report.options == {} + assert report.baseline_simulation is not None + assert report.baseline_simulation.id == stored.baseline_simulation_id + assert report.baseline_simulation.policy_id is None + assert report.reform_simulation is not None + assert report.reform_simulation.policy_id == stored.policy_id + assert report.status == "succeeded" + assert report.run is not None + assert report.run.id == stored.report_run_id + assert report.run.baseline_simulation_run_id == stored.baseline_run_id + assert report.run.reform_simulation_run_id == stored.reform_run_id + assert report.run.country_package_version == "1.4.0" + assert report.current_versions == CURRENT_VERSIONS + assert report.failure is None + assert report.result == HouseholdReportResult( + baseline=HouseholdCalculationOutput( + household=BASELINE_HOUSEHOLD, + warnings=(), + spm_config=None, + spm_provenance=None, + ), + reform=HouseholdCalculationOutput( + household=REFORM_HOUSEHOLD, + warnings=("ignored_input",), + spm_config={"geography_kind": "national"}, + spm_provenance=SPM_PROVENANCE, + ), + ) + + +def test_a_report_of_the_baseline_alone_has_no_reform( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report( + session, with_reform=False, run=succeeded_run(reform_result=None) + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.reform_simulation is None + assert report.run is not None + assert report.run.reform_simulation_run_id is None + assert report.result is not None + assert report.result.baseline.household == BASELINE_HOUSEHOLD + assert report.result.reform is None + + +@pytest.mark.parametrize( + "status", + [ReportRunStatus.PENDING, ReportRunStatus.RUNNING, ReportRunStatus.FAILED], +) +def test_a_household_report_whose_newest_run_has_not_succeeded_has_no_result( + sessions: sessionmaker[Session], + reports: V2StoredReportService, + status: ReportRunStatus, +) -> None: + error_message = ( + "household_function_error" if status is ReportRunStatus.FAILED else None + ) + with sessions() as session: + stored = store_household_report( + session, run=RunToStore(status=status, error_message=error_message) + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.status == status.value + assert report.run is not None + assert report.run.error_message == error_message + assert report.result is None + # Neither simulation run holds a failure document here. + assert report.failure == ( + HouseholdReportFailure(baseline=None, reform=None) + if status is ReportRunStatus.FAILED + else None + ) + + +def test_a_failed_household_report_answers_each_sides_failure( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + # Both sides are always calculated: the baseline succeeded, the reform's + # calculation was refused, so the report run failed. + with sessions() as session: + stored = store_household_report( + session, + run=RunToStore( + status=ReportRunStatus.FAILED, + error_message="household_calculation_failed: invalid_inputs", + baseline_result=succeeded_document(BASELINE_HOUSEHOLD), + reform_result=failed_document(), + ), + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.result is None + assert report.failure == HouseholdReportFailure( + baseline=None, + reform=HouseholdReportSideFailure( + code="invalid_inputs", + message="The household names inputs the model does not have.", + input_errors=tuple(INPUT_ERRORS), + spm_error=None, + ), + ) + + +@pytest.mark.parametrize( + "document", + [ + pytest.param({"status": "failed", "schema_version": 1}, id="no-error"), + pytest.param({**failed_document(), "schema_version": 2}, id="version-2"), + pytest.param({"unexpected": True}, id="another-document"), + ], +) +def test_a_failure_document_that_cannot_be_read_names_no_failure( + sessions: sessionmaker[Session], + reports: V2StoredReportService, + document: dict[str, Any], +) -> None: + # The run's error message still says why it failed. + with sessions() as session: + stored = store_household_report( + session, + with_reform=False, + run=RunToStore( + status=ReportRunStatus.FAILED, + error_message="household_calculation_failed", + baseline_result=document, + ), + ) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert report.failure == HouseholdReportFailure(baseline=None, reform=None) + + +def test_a_household_report_with_no_run_answers_its_definition( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report(session, run=None) + + report = reports.get_report(country_id="us", report_id=stored.report_id) + + assert isinstance(report, HouseholdReportRead) + assert (report.status, report.run, report.result) == ("not_run", None, None) + assert report.failure is None + + +@pytest.mark.parametrize( + ("side", "document"), + [ + pytest.param("baseline_result", None, id="no-document"), + pytest.param( + "reform_result", + {"status": "failed", "schema_version": 1, "error": {"code": "x"}}, + id="failure-document", + ), + pytest.param( + "baseline_result", + {**succeeded_document(BASELINE_HOUSEHOLD), "schema_version": 2}, + id="another-schema-version", + ), + pytest.param( + "reform_result", + {"status": "succeeded", "schema_version": 1}, + id="no-output", + ), + ], +) +def test_a_succeeded_run_whose_calculated_household_cannot_be_read_is_unreadable( + sessions: sessionmaker[Session], + reports: V2StoredReportService, + side: str, + document: dict[str, Any] | None, +) -> None: + with sessions() as session: + stored = store_household_report(session, run=succeeded_run(**{side: document})) + + with pytest.raises(HouseholdResultUnreadableError): + reports.get_report(country_id="us", report_id=stored.report_id) + + +def test_a_household_report_of_another_country_is_not_found( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + stored = store_household_report(session, country="uk", run=succeeded_run()) + + with pytest.raises(StoredReportNotFoundError): + reports.get_report(country_id="us", report_id=stored.report_id) + + +def store_economy_report(session: Session) -> UUID: + """Store an economy report with no run, in the US.""" + model = TaxBenefitModel(name="policyengine-us-economy") + session.add(model) + session.flush() + report = Report( + label="Economy", + country="us", + tax_benefit_model_id=model.id, + year=2026, + inputs={"include_cliffs": False}, + created_at=STORED_AT, + ) + session.add(report) + session.commit() + return report.id + + +def test_a_list_names_household_reports_beside_economy_reports_without_results( + sessions: sessionmaker[Session], reports: V2StoredReportService +) -> None: + with sessions() as session: + household = store_household_report(session, run=succeeded_run()) + economy_report_id = store_economy_report(session) + other_country = store_household_report(session, country="uk") + + listed = reports.list_reports( + country_id="us", + report_ids=[ + household.report_id, + other_country.report_id, + economy_report_id, + household.report_id, + ], + ) + + assert [report.id for report in listed] == [ + household.report_id, + economy_report_id, + ] + household_summary, economy_summary = listed + assert type(household_summary) is HouseholdReportSummaryRead + assert household_summary.household_id == household.household_id + assert household_summary.status == "succeeded" + assert not hasattr(household_summary, "result") + assert type(economy_summary) is ReportSummaryRead + + +@dataclass +class StoringHouseholdReportService: + """The simulation service, storing a pending household report for each one sent. + + With ``answer_unstored``, it answers with a report ID it stored nothing + for. An economy report must not reach it. + """ + + sessions: sessionmaker[Session] + answer_unstored: bool = False + sent: list[HouseholdReportCreationInput] | None = None + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + self.sent = [*(self.sent or []), report] + if self.answer_unstored: + return SubmittedReport(report_id=uuid4(), report_run_id=uuid4()) + with self.sessions() as session: + stored = store_household_report( + session, run=RunToStore(status=ReportRunStatus.PENDING) + ) + assert stored.report_run_id is not None + return SubmittedReport( + report_id=stored.report_id, report_run_id=stored.report_run_id + ) + + def submit_report(self, report: object) -> SubmittedReport: + msg = "a household report must not be sent as an economy report" + raise AssertionError(msg) + + +def household_report_input() -> HouseholdReportCreationInput: + return HouseholdReportCreationInput.model_validate( + { + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "household_id": str(uuid4()), + "baseline": {}, + "reform": None, + } + ) + + +def test_a_household_report_is_sent_as_one_and_read_back_from_the_tables( + sessions: sessionmaker[Session], +) -> None: + simulation_service = StoringHouseholdReportService(sessions) + reports = V2StoredReportService( + StoredReportDatabaseSession(sessions), + current_versions=current_versions, + submissions=simulation_service, + ) + report_input = household_report_input() + + created = reports.create_report(report_input) + + assert simulation_service.sent == [report_input] + assert isinstance(created, HouseholdReportRead) + assert (created.status, created.result, created.failure) == ( + "pending", + None, + None, + ) + + +def test_a_created_household_report_that_cannot_be_read_back_is_named( + sessions: sessionmaker[Session], +) -> None: + reports = V2StoredReportService( + StoredReportDatabaseSession(sessions), + current_versions=current_versions, + submissions=StoringHouseholdReportService(sessions, answer_unstored=True), + ) + + with pytest.raises(CreatedReportUnreadableError) as unreadable: + reports.create_report(household_report_input()) + + assert unreadable.value.country_id == "us" + assert isinstance(unreadable.value.__cause__, StoredReportNotFoundError) diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py new file mode 100644 index 000000000..e569e0898 --- /dev/null +++ b/tests/unit/v2/test_household_report_routes.py @@ -0,0 +1,370 @@ +"""Create, read and list household reports at /v2/reports over stand-in services.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.household_calculations.types import ( + HouseholdCalculationOutput, +) +from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationCountryError, + HouseholdCalculationYearError, +) +from policyengine_api.services.v2.households.validators import ( + HouseholdNotFoundError, +) +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, +) +from policyengine_api.services.v2.stored_reports.household_results.types import ( + HouseholdReportFailure, + HouseholdReportResult, + HouseholdReportSideFailure, + HouseholdResultUnreadableError, +) +from policyengine_api.services.v2.stored_reports.types import ( + HouseholdReportRead, + HouseholdReportSummaryRead, + ReportSimulationRead, +) +from tests.unit.v2.stored_report_support import ( + BASELINE_SIMULATION_ID, + CREATED_AT, + CURRENT_VERSIONS, + POLICY_ID, + REFORM_SIMULATION_ID, + REPORT_ID, + FakeReportService, + report_route_client, + report_run_read, + report_summary_read, +) + +HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000e001") +REPORTS = "/v2/reports?country_id=us" +BASELINE_HOUSEHOLD = {"people": {"you": {"net_income": {"2026": 30_000.0}}}} +REFORM_HOUSEHOLD = {"people": {"you": {"net_income": {"2026": 31_250.5}}}} + + +def household_summary_fields(**changes: Any) -> dict[str, Any]: + values: dict[str, Any] = { + "id": REPORT_ID, + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "description": None, + "year": 2026, + "household_id": HOUSEHOLD_ID, + "baseline_simulation": ReportSimulationRead( + id=BASELINE_SIMULATION_ID, policy_id=None, dynamic_id=None + ), + "reform_simulation": ReportSimulationRead( + id=REFORM_SIMULATION_ID, policy_id=POLICY_ID, dynamic_id=None + ), + "options": {}, + "created_at": CREATED_AT, + "status": "succeeded", + "run": report_run_read(), + "current_versions": CURRENT_VERSIONS, + } + return {**values, **changes} + + +def household_report_read(**changes: Any) -> HouseholdReportRead: + fields = { + "result": HouseholdReportResult( + baseline=HouseholdCalculationOutput( + household=BASELINE_HOUSEHOLD, + warnings=(), + spm_config=None, + spm_provenance=None, + ), + reform=HouseholdCalculationOutput( + household=REFORM_HOUSEHOLD, + warnings=("ignored_input",), + spm_config={"geography_kind": "national"}, + spm_provenance={"scenario": "baseline"}, + ), + ), + "failure": None, + **household_summary_fields(), + **changes, + } + return HouseholdReportRead(**fields) + + +def household_body(**changes: Any) -> dict[str, Any]: + values: dict[str, Any] = { + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "household_id": str(HOUSEHOLD_ID), + "baseline": {}, + "reform": {"policy_id": str(POLICY_ID)}, + } + return {**values, **changes} + + +def test_a_household_report_is_created_and_answered_as_stored() -> None: + service = FakeReportService( + report=household_report_read( + status="pending", + run=report_run_read(status="pending", completed_at=None), + result=None, + ) + ) + + response = report_route_client(reports=service).post(REPORTS, json=household_body()) + + assert response.status_code == 201 + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["household_id"] == str(HOUSEHOLD_ID) + assert item["status"] == "pending" + assert item["result"] is None + assert item["options"] == {} + ((name, arguments),) = service.calls + assert name == "create_report" + report_input = arguments["report_input"] + assert isinstance(report_input, HouseholdReportCreationInput) + assert report_input.model_dump() == ( + HouseholdReportCreationInput.model_validate(household_body()).model_dump() + ) + + +def test_a_household_report_of_the_baseline_alone_can_be_created() -> None: + service = FakeReportService( + report=household_report_read(reform_simulation=None, result=None) + ) + body = household_body() + del body["reform"] + + response = report_route_client(reports=service).post(REPORTS, json=body) + + assert response.status_code == 201 + assert response.json()["result"]["item"]["reform_simulation"] is None + ((_, arguments),) = service.calls + assert arguments["report_input"].reform is None + + +@pytest.mark.parametrize( + "changes", + [ + pytest.param({"label": None}, id="no-label"), + # The report is for the household's default year, so it names none. + pytest.param({"year": 2026}, id="year"), + pytest.param({"household_id": None}, id="no-household"), + pytest.param({"region_code": "us"}, id="economy-field"), + pytest.param({"include_cliffs": True}, id="economy-option"), + pytest.param({"kind": "budget"}, id="unknown-kind"), + # A household report always adds every variable the household lacks. + pytest.param({"add_missing_variables": True}, id="add-missing-variables"), + ], +) +def test_a_household_report_outside_its_schema_is_refused_with_422( + changes: dict[str, Any], +) -> None: + service = FakeReportService() + body = { + name: value + for name, value in household_body(**changes).items() + if value is not None + } + + response = report_route_client(reports=service).post(REPORTS, json=body) + + assert response.status_code == 422 + assert service.calls == [] + + +def test_a_household_report_naming_another_country_is_refused_with_400() -> None: + service = FakeReportService() + + response = report_route_client(reports=service).post( + REPORTS, json=household_body(country_id="uk") + ) + + assert response.status_code == 400 + assert service.calls == [] + + +@pytest.mark.parametrize( + ("error", "status_code"), + [ + pytest.param( + HouseholdCalculationCountryError( + "Household calculations are not available for country us." + ), + 422, + id="no-household-calculations", + ), + pytest.param( + HouseholdNotFoundError(f"Household {HOUSEHOLD_ID} was not found"), + 404, + id="household-not-stored", + ), + pytest.param( + HouseholdCalculationYearError( + "This household has no default year to calculate." + ), + 422, + id="household-without-default-year", + ), + ], +) +def test_a_household_report_the_checks_refuse_is_answered_with_its_reason( + error: Exception, status_code: int +) -> None: + service = FakeReportService(error=error) + + response = report_route_client(reports=service).post(REPORTS, json=household_body()) + + assert response.status_code == status_code + assert response.json()["message"] == str(error) + + +def test_a_household_report_answers_both_calculated_households() -> None: + service = FakeReportService(report=household_report_read()) + + response = report_route_client(reports=service).get( + f"/v2/reports/{REPORT_ID}?country_id=us" + ) + + assert response.status_code == 200 + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["household_id"] == str(HOUSEHOLD_ID) + assert item["year"] == 2026 + assert "region_code" not in item + assert item["run"]["baseline_simulation_run_id"] is not None + assert item["result"] == { + "baseline": { + "household": BASELINE_HOUSEHOLD, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + }, + "reform": { + "household": REFORM_HOUSEHOLD, + "warnings": ["ignored_input"], + "spm_config": {"geography_kind": "national"}, + "spm_provenance": {"scenario": "baseline"}, + }, + } + + +def test_a_household_report_of_the_baseline_alone_answers_no_reform() -> None: + report = household_report_read(reform_simulation=None) + assert report.result is not None + service = FakeReportService( + report=replace(report, result=replace(report.result, reform=None)) + ) + + item = ( + report_route_client(reports=service) + .get(f"/v2/reports/{REPORT_ID}?country_id=us") + .json()["result"]["item"] + ) + + assert item["reform_simulation"] is None + assert item["result"]["reform"] is None + + +def test_a_failed_household_report_answers_each_sides_failure() -> None: + service = FakeReportService( + report=household_report_read( + status="failed", + run=report_run_read( + status="failed", error_message="household_calculation_failed" + ), + result=None, + failure=HouseholdReportFailure( + baseline=None, + reform=HouseholdReportSideFailure( + code="invalid_inputs", + message="The household names inputs the model does not have.", + input_errors=({"name": "employment_incme"},), + spm_error=None, + ), + ), + ) + ) + + item = ( + report_route_client(reports=service) + .get(f"/v2/reports/{REPORT_ID}?country_id=us") + .json()["result"]["item"] + ) + + assert item["result"] is None + assert item["failure"] == { + "baseline": None, + "reform": { + "code": "invalid_inputs", + "message": "The household names inputs the model does not have.", + "input_errors": [{"name": "employment_incme"}], + "spm_error": None, + }, + } + + +def test_a_household_report_whose_households_cannot_be_read_answers_500() -> None: + service = FakeReportService( + error=HouseholdResultUnreadableError("simulation run has no result") + ) + + response = report_route_client(reports=service).get( + f"/v2/reports/{REPORT_ID}?country_id=us" + ) + + assert response.status_code == 500 + assert response.json()["message"] == "The report's stored result cannot be read" + + +def test_a_list_answers_household_and_economy_reports_by_kind_without_results() -> None: + service = FakeReportService( + listed=( + HouseholdReportSummaryRead(**household_summary_fields()), + report_summary_read(), + ) + ) + + response = report_route_client(reports=service).get( + f"/v2/reports?country_id=us&report_id={REPORT_ID}" + ) + + assert response.status_code == 200 + household_item, economy_item = response.json()["result"]["items"] + assert household_item["kind"] == "household" + assert household_item["household_id"] == str(HOUSEHOLD_ID) + assert "result" not in household_item + assert "failure" not in household_item + assert economy_item["kind"] == "economy" + assert "result" not in economy_item + + +def test_the_create_routes_schema_offers_the_household_kind() -> None: + schema = report_route_client(reports=FakeReportService()).get("/v2/openapi.json") + + components = schema.json()["components"]["schemas"] + household_request = components["HouseholdReportCreateRequest"] + assert household_request["properties"]["kind"]["const"] == "household" + assert set(household_request["required"]) >= { + "country_id", + "kind", + "label", + "household_id", + "baseline", + } + assert "year" not in household_request["properties"] + # Each side's failure is read from the household function's failure + # document, which always names its code. + side_failure = components["HouseholdReportSideFailureItem"] + assert side_failure["properties"]["code"] == {"title": "Code", "type": "string"} + assert "submit" not in side_failure["description"] + assert "HouseholdReportItem" in components diff --git a/tests/unit/v2/test_household_report_submissions.py b/tests/unit/v2/test_household_report_submissions.py new file mode 100644 index 000000000..f54ead890 --- /dev/null +++ b/tests/unit/v2/test_household_report_submissions.py @@ -0,0 +1,355 @@ +"""Check household reports before the simulation service creates them.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy.exc import OperationalError + +from policyengine_api.data.v2.catalog.catalog_selection_validation import ( + MetadataCatalogUnavailableError, +) +from policyengine_api.services.v2.countries.types import CountryRead +from policyengine_api.services.v2.household_calculations.validators import ( + HouseholdCalculationCountryError, + HouseholdCalculationYearError, +) +from policyengine_api.services.v2.households.types import HouseholdRead +from policyengine_api.services.v2.households.validators import ( + HouseholdNotFoundError, +) +from policyengine_api.services.v2.policies.validators import ( + PolicyNotFoundError, + PolicyParametersMissingError, +) +from policyengine_api.services.v2.simulation_service.services import ( + SimulationSubmissionService, +) +from policyengine_api.services.v2.simulation_service.types import ( + HouseholdReportCreationInput, + SubmittedReport, +) +from policyengine_api.services.v2.simulation_service.validators import ( + SubmissionStorageError, +) +from tests.unit.v2.test_simulation_submissions import ( + HOUSEHOLD_ONLY_UK, + OTHER_POLICY_ID, + OUTDATED_POLICY_ID, + REMOVED_PARAMETERS, + SECOND_OUTDATED_POLICY_ID, + SERVED_COUNTRIES, + STORED_POLICY_ID, + StoredCountries, + StoredPolicies, +) + +HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d001") +OTHER_HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d002") +# A stored household with no default year, which no report can calculate. +UNDATED_HOUSEHOLD_ID = UUID("00000000-0000-0000-0000-00000000d003") +STORED_AT = datetime(2026, 11, 13, tzinfo=UTC) +# A country whose countries row offers economy reports but no household +# calculations. +ECONOMY_ONLY_US = [ + CountryRead(id="uk", household_enabled=True, society_wide_enabled=True), + CountryRead(id="us", household_enabled=False, society_wide_enabled=True), +] + + +@dataclass +class StoredHouseholds: + """HOUSEHOLD_ID and UNDATED_HOUSEHOLD_ID, stored in every country. + + Any other household is not stored. UNDATED_HOUSEHOLD_ID has no default + year. + + With ``error``, every read raises it, as a database that cannot be read + does. + """ + + error: Exception | None = None + asked: list[tuple[str, UUID]] = field(default_factory=list) + + def get_household(self, *, country_id: str, household_id: UUID) -> HouseholdRead: + self.asked.append((country_id, household_id)) + if self.error is not None: + raise self.error + if household_id not in {HOUSEHOLD_ID, UNDATED_HOUSEHOLD_ID}: + msg = f"Household {household_id} was not found" + raise HouseholdNotFoundError(msg) + return HouseholdRead( + id=household_id, + country_id=country_id, + default_year=None if household_id == UNDATED_HOUSEHOLD_ID else 2026, + household_data={"people": {"you": {"age": {"2026": 40}}}}, + created_at=STORED_AT, + updated_at=STORED_AT, + ) + + +@dataclass +class RecordingHouseholdReportService: + """The simulation service's client, recording the household reports it is sent.""" + + household_reports: list[HouseholdReportCreationInput] = field(default_factory=list) + + def submit_household_report( + self, report: HouseholdReportCreationInput + ) -> SubmittedReport: + self.household_reports.append(report) + return SubmittedReport(report_id=uuid4(), report_run_id=uuid4()) + + +def unconfigured_client() -> RecordingHouseholdReportService: + msg = "the simulation service must not be asked" + raise AssertionError(msg) + + +def household_report_input(**changes: Any) -> HouseholdReportCreationInput: + values: dict[str, Any] = { + "country_id": "us", + "kind": "household", + "label": "My household under the reform", + "household_id": str(HOUSEHOLD_ID), + "baseline": {}, + "reform": {"policy_id": str(STORED_POLICY_ID)}, + } + return HouseholdReportCreationInput.model_validate({**values, **changes}) + + +def household_submissions( + client_factory: Any, + *, + countries: list[CountryRead] = SERVED_COUNTRIES, + households: StoredHouseholds | None = None, + policies: StoredPolicies | None = None, +) -> SimulationSubmissionService: + return SimulationSubmissionService( + countries=StoredCountries(countries=countries), + households=households or StoredHouseholds(), + policies=policies or StoredPolicies(), + client_factory=client_factory, + ) + + +def test_a_household_report_is_sent_once_its_country_household_and_policies_pass() -> ( + None +): + client = RecordingHouseholdReportService() + households = StoredHouseholds() + policies = StoredPolicies() + report = household_report_input(baseline={"policy_id": str(STORED_POLICY_ID)}) + + submitted = household_submissions( + lambda: client, households=households, policies=policies + ).submit_household_report(report) + + assert client.household_reports == [report] + assert isinstance(submitted, SubmittedReport) + assert households.asked == [("us", HOUSEHOLD_ID)] + assert policies.asked == [("us", STORED_POLICY_ID)] + + +def test_a_household_only_country_can_submit_household_reports() -> None: + client = RecordingHouseholdReportService() + report = household_report_input(country_id="uk") + + household_submissions( + lambda: client, countries=HOUSEHOLD_ONLY_UK + ).submit_household_report(report) + + assert client.household_reports == [report] + + +@pytest.mark.parametrize( + "countries", + [ + pytest.param(ECONOMY_ONLY_US, id="household-calculations-off"), + pytest.param(SERVED_COUNTRIES[:1], id="no-countries-row"), + ], +) +def test_a_country_without_household_calculations_is_refused_before_anything_is_read( + countries: list[CountryRead], +) -> None: + households = StoredHouseholds() + policies = StoredPolicies() + service = household_submissions( + unconfigured_client, + countries=countries, + households=households, + policies=policies, + ) + + with pytest.raises(HouseholdCalculationCountryError, match="country us"): + service.submit_household_report(household_report_input()) + assert households.asked == [] + assert policies.asked == [] + + +def test_a_household_not_stored_in_the_country_is_refused_before_the_policies() -> None: + policies = StoredPolicies() + service = household_submissions(unconfigured_client, policies=policies) + + with pytest.raises(HouseholdNotFoundError, match=str(OTHER_HOUSEHOLD_ID)): + service.submit_household_report( + household_report_input(household_id=str(OTHER_HOUSEHOLD_ID)) + ) + assert policies.asked == [] + + +def test_a_household_with_no_default_year_is_refused_before_the_policies() -> None: + policies = StoredPolicies() + service = household_submissions(unconfigured_client, policies=policies) + + with pytest.raises(HouseholdCalculationYearError): + service.submit_household_report( + household_report_input( + household_id=str(UNDATED_HOUSEHOLD_ID), + reform={"policy_id": str(OTHER_POLICY_ID)}, + ) + ) + assert policies.asked == [] + + +@pytest.mark.parametrize("side", ["baseline", "reform"]) +def test_a_household_report_naming_a_policy_not_stored_in_the_country_is_refused( + side: str, +) -> None: + service = household_submissions(unconfigured_client) + + with pytest.raises(PolicyNotFoundError, match=str(OTHER_POLICY_ID)): + service.submit_household_report( + household_report_input(**{side: {"policy_id": str(OTHER_POLICY_ID)}}) + ) + + +def test_the_baselines_policy_with_missing_parameters_is_refused_first() -> None: + service = household_submissions(unconfigured_client) + + with pytest.raises(PolicyParametersMissingError) as refused: + service.submit_household_report( + household_report_input( + baseline={"policy_id": str(OUTDATED_POLICY_ID)}, + reform={"policy_id": str(SECOND_OUTDATED_POLICY_ID)}, + ) + ) + + assert refused.value.policy_id == OUTDATED_POLICY_ID + assert refused.value.missing_parameters == REMOVED_PARAMETERS + + +def test_a_policy_not_stored_comes_before_one_with_missing_parameters() -> None: + service = household_submissions(unconfigured_client) + + with pytest.raises(PolicyNotFoundError): + service.submit_household_report( + household_report_input( + baseline={"policy_id": str(OUTDATED_POLICY_ID)}, + reform={"policy_id": str(OTHER_POLICY_ID)}, + ) + ) + + +def test_a_report_of_the_baseline_alone_checks_only_the_baselines_policy() -> None: + client = RecordingHouseholdReportService() + policies = StoredPolicies() + report = household_report_input( + baseline={"policy_id": str(STORED_POLICY_ID)}, reform=None + ) + + household_submissions(lambda: client, policies=policies).submit_household_report( + report + ) + + assert policies.asked == [("us", STORED_POLICY_ID)] + assert client.household_reports[0].reform is None + + +@pytest.mark.parametrize( + ("households", "policies"), + [ + pytest.param( + StoredHouseholds(error=OperationalError("SELECT", {}, Exception())), + StoredPolicies(), + id="households-unreadable", + ), + pytest.param( + StoredHouseholds(), + StoredPolicies(error=OperationalError("SELECT", {}, Exception())), + id="policies-unreadable", + ), + pytest.param( + StoredHouseholds(), + StoredPolicies(error=MetadataCatalogUnavailableError("no catalog")), + id="catalog-unreadable", + ), + ], +) +def test_storage_that_cannot_be_read_while_checking_is_a_storage_failure( + households: StoredHouseholds, policies: StoredPolicies +) -> None: + service = household_submissions( + unconfigured_client, households=households, policies=policies + ) + + with pytest.raises(SubmissionStorageError): + service.submit_household_report(household_report_input()) + + +def test_a_household_report_without_a_reform_is_of_the_baseline_alone() -> None: + report = HouseholdReportCreationInput.model_validate( + { + "country_id": "us", + "kind": "household", + "label": "My household", + "household_id": str(HOUSEHOLD_ID), + "baseline": {}, + } + ) + + assert report.reform is None + assert report.description is None + + +@pytest.mark.parametrize( + "changes", + [ + pytest.param({"label": ""}, id="empty-label"), + # The report is for the household's default year, so it names none. + pytest.param({"year": 2026}, id="year"), + # A household report always adds every variable the household lacks. + pytest.param({"add_missing_variables": True}, id="add-missing-variables"), + pytest.param({"baseline": {"dynamic_id": str(uuid4())}}, id="dynamic"), + pytest.param({"region_code": "us"}, id="region"), + pytest.param({"household_id": "not-a-uuid"}, id="household-not-a-uuid"), + pytest.param({"kind": "economy"}, id="another-kind"), + ], +) +def test_a_household_report_outside_its_schema_is_refused( + changes: dict[str, Any], +) -> None: + with pytest.raises(ValidationError): + household_report_input(**changes) + + +@pytest.mark.parametrize("missing", ["label", "household_id", "baseline"]) +def test_a_household_report_needs_its_label_household_and_baseline( + missing: str, +) -> None: + values: dict[str, Any] = { + "country_id": "us", + "kind": "household", + "label": "My household", + "household_id": str(HOUSEHOLD_ID), + "baseline": {}, + } + del values[missing] + + with pytest.raises(ValidationError): + HouseholdReportCreationInput.model_validate(values) diff --git a/tests/unit/v2/test_report_read_routes.py b/tests/unit/v2/test_report_read_routes.py index 5076ff51c..53f2a0678 100644 --- a/tests/unit/v2/test_report_read_routes.py +++ b/tests/unit/v2/test_report_read_routes.py @@ -12,7 +12,6 @@ EconomyResultUnreadableError, ) from policyengine_api.services.v2.stored_reports.validators import ( - ReportKindUnsupportedError, ReportStorageError, StoredReportNotFoundError, ) @@ -92,11 +91,6 @@ def test_a_report_without_a_run_answers_with_no_run_and_no_result() -> None: 404, "Report x was not found in country 'us'", ), - ( - ReportKindUnsupportedError("Report x is a household report"), - 422, - "Report x is a household report", - ), ( EconomyResultUnreadableError("the budget summary has no household_tax row"), 500, @@ -109,7 +103,7 @@ def test_a_report_without_a_run_answers_with_no_run_and_no_result() -> None: ), (RuntimeError("unexpected"), 500, "V2 report operation failed"), ], - ids=["not-found", "other-kind", "unreadable-result", "storage", "unexpected"], + ids=["not-found", "unreadable-result", "storage", "unexpected"], ) def test_report_read_failures_answer_typed_errors( error: Exception, status_code: int, message: str diff --git a/tests/unit/v2/test_simulation_submission_database_routes.py b/tests/unit/v2/test_simulation_submission_database_routes.py index cb9d33688..5c3f96a44 100644 --- a/tests/unit/v2/test_simulation_submission_database_routes.py +++ b/tests/unit/v2/test_simulation_submission_database_routes.py @@ -31,6 +31,10 @@ CountryDatabaseSession, ) from policyengine_api.services.v2.countries.services import V2CountryService +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService from policyengine_api.services.v2.policies.database_session import ( PolicyDatabaseSession, ) @@ -189,6 +193,7 @@ def submissions( sessions = sessionmaker(engine, class_=Session, expire_on_commit=False) return SimulationSubmissionService( countries=V2CountryService(CountryDatabaseSession(sessions)), + households=V2HouseholdService(HouseholdDatabaseSession(sessions)), policies=V2PolicyService( PolicyDatabaseSession(sessions), running_policyengine_version=RUNNING_VERSION, diff --git a/tests/unit/v2/test_simulation_submissions.py b/tests/unit/v2/test_simulation_submissions.py index 15a44f6b9..45fb541ab 100644 --- a/tests/unit/v2/test_simulation_submissions.py +++ b/tests/unit/v2/test_simulation_submissions.py @@ -128,6 +128,14 @@ def submit_simulation( return SubmittedSimulation(simulation_id=uuid4(), simulation_run_id=uuid4()) +class UnreadHouseholds: + """The stored households, which an economy submission never reads.""" + + def get_household(self, *, country_id: str, household_id: UUID) -> object: + msg = "an economy submission must not read a household" + raise AssertionError(msg) + + def unconfigured_client() -> RecordingSimulationService: msg = "the simulation service must not be asked" raise AssertionError(msg) @@ -162,6 +170,7 @@ def submissions( ) -> SimulationSubmissionService: return SimulationSubmissionService( countries=StoredCountries(), + households=UnreadHouseholds(), policies=policies or StoredPolicies(), client_factory=client_factory, ) @@ -197,6 +206,7 @@ def test_current_law_needs_no_stored_policy() -> None: def test_a_household_only_country_is_refused_before_sending() -> None: service = SimulationSubmissionService( countries=StoredCountries(countries=HOUSEHOLD_ONLY_UK), + households=UnreadHouseholds(), policies=StoredPolicies(), client_factory=unconfigured_client, ) @@ -210,6 +220,7 @@ def test_a_household_only_country_is_refused_before_sending() -> None: def test_a_country_without_a_countries_row_is_refused() -> None: service = SimulationSubmissionService( countries=StoredCountries(countries=SERVED_COUNTRIES[:1]), + households=UnreadHouseholds(), policies=StoredPolicies(), client_factory=unconfigured_client, )