Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fdb4b1c
Test reading a report by the run a caller names (failing)
anth-volk Nov 13, 2026
bb3813a
Read a report by the run a caller names
anth-volk Nov 13, 2026
ba9ac43
Test answering the run a create or re-run ended on (failing)
anth-volk Nov 13, 2026
54f8b0f
Answer the run a create or re-run ended on
anth-volk Nov 13, 2026
7bd818d
Test recording each user's requests for report runs (failing)
anth-volk Nov 13, 2026
f0f47ca
Record each user's requests for report runs
anth-volk Nov 13, 2026
642a514
Test that a sign-in merge moves a browser's run records (failing)
anth-volk Nov 13, 2026
f873437
Move a browser's run records into the account at a sign-in merge
anth-volk Nov 13, 2026
bda9199
Test that a saved report takes its owner's newest request (failing)
anth-volk Nov 13, 2026
7b72740
Take a saved report's last_run_at from its owner's newest request
anth-volk Nov 13, 2026
aff2dac
Run the users' report-run integration tests in CI
anth-volk Nov 13, 2026
08f0e02
Test the report routes' requester over stand-ins
anth-volk Nov 13, 2026
63e8738
Test review round 1's findings (failing)
anth-volk Nov 13, 2026
97a194e
Fix review round 1's findings
anth-volk Nov 13, 2026
60b3064
Merge remote-tracking branch 'origin/v2plan/c1-25-shared-report-keys'…
anth-volk Nov 13, 2026
80242bb
Test recording each year of a budget window's run (failing)
anth-volk Nov 13, 2026
b06864c
Record each year of a budget window's run
anth-volk Nov 13, 2026
f37e966
Test that only admin users may force a re-run (failing)
anth-volk Nov 13, 2026
0206057
Let only admin users force a re-run
anth-volk Nov 13, 2026
99ecc42
Test what each user reads of a shared report (failing)
anth-volk Nov 13, 2026
5b3a08c
Read reports as the requesting user sees them
anth-volk Nov 13, 2026
89e4907
Merge remote-tracking branch 'origin/v2plan/c1-25-shared-report-keys'…
anth-volk Nov 13, 2026
8a0897f
Describe answers 6, 7, 8 and force in the changelog fragment
anth-volk Nov 13, 2026
c39146e
Test review round 2's findings (failing)
anth-volk Nov 13, 2026
99659c2
Fix review round 2's findings
anth-volk Nov 13, 2026
0c292fa
Fix review round 3's findings
anth-volk Nov 13, 2026
db29dae
Test review round 4's findings (failing)
anth-volk Nov 14, 2026
5bd99be
Fix review round 4's findings
anth-volk Nov 14, 2026
d228b9e
Test that a create's label and description are optional and unsent (f…
anth-volk Nov 14, 2026
71a083c
Take a create's label and description as optional, and send neither
anth-volk Nov 14, 2026
e59adcc
Test review round 5's findings (failing)
anth-volk Nov 14, 2026
79c0183
Fix review round 5's findings
anth-volk Nov 14, 2026
77c940a
Fix review round 6's documentation findings
anth-volk Nov 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/v2-integration-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ jobs:
run: uv run --frozen --extra dev python scripts/reconcile_v2_privileges.py apply --create-missing-roles
- name: Verify the managed v2 roles hold their declared privileges
run: uv run --frozen --extra dev python scripts/reconcile_v2_privileges.py verify
- name: Test v2 metadata publication, resource routes, the countries list, reports and simulations
run: uv run --frozen --extra dev coverage run --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_catalog_publication_time_periods.py tests/integration/test_v2_catalog_publication_pruning.py tests/integration/test_v2_catalog_spm_availability.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_report_reads.py tests/integration/test_v2_report_creation.py tests/integration/test_v2_budget_window_report_reads.py tests/integration/test_v2_budget_window_report_creation.py tests/integration/test_v2_report_runs.py
- name: Test v2 metadata publication, resource routes, the countries list, reports, simulations and users' report runs
run: uv run --frozen --extra dev coverage run --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_catalog_publication_time_periods.py tests/integration/test_v2_catalog_publication_pruning.py tests/integration/test_v2_catalog_spm_availability.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_report_reads.py tests/integration/test_v2_report_creation.py tests/integration/test_v2_budget_window_report_reads.py tests/integration/test_v2_budget_window_report_creation.py tests/integration/test_v2_report_runs.py tests/integration/test_v2_report_reads_by_run.py tests/integration/test_v2_user_report_runs.py tests/integration/test_v2_user_report_run_requesters.py tests/integration/test_v2_user_budget_window_runs.py tests/integration/test_v2_forced_report_reruns.py tests/integration/test_v2_user_report_views.py
- name: Test v2 policy persistence and immediate v1 mirroring
run: uv run --frozen --extra dev coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py
- name: Test signed-in identity provisioning, user associations, saved detailed-save records and user types
Expand Down
1 change: 1 addition & 0 deletions changelog.d/user-report-runs.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`/v2/reports` records each user's report runs and reads a report as each user sees it. `POST /v2/reports` and `POST /v2/reports/{report_id}/runs` take an optional user credential (a bearer access token, the `PolicyEngine-Unauthenticated-User` header, or a server forwarding either); with one, the request is recorded in `user_report_run_associations` for the run it ended on, created or joined (each year's report run for a budget window), and the user's saved reports of the report take that time as `last_run_at`. Both answer the report with the run the simulation service named, which needn't be its newest, and name it as `run_id` when the report can't be read back (502) or the request can't be recorded (503); only admin users may force a re-run. `GET /v2/reports/{report_id}?run_id=` reads a chosen run; without `run_id`, a read answers the requesting user's newest recorded run, else the newest succeeded run that wasn't forced, else the newest run that wasn't forced. A report's label and description are the requesting user's saved report's, null without one; a create's `label` and `description` are optional and no longer sent to the simulation service. A saved detailed-save report's `last_run_at` is its owner's newest request, and a sign-in that merges a browser into an existing account moves the browser's run records with it.
65 changes: 54 additions & 11 deletions policyengine_api/fastapi_routes/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@ def _default_v2_stored_report_service_factory() -> V2StoredReportResourceService
from policyengine_api.data.v2.database import (
get_v2_session_factory,
)
from policyengine_api.services.v2.report_run_records.database_session import (
ReportRunRecordDatabaseSession,
)
from policyengine_api.services.v2.report_run_records.services import (
ReportRunRecordService,
)
from policyengine_api.services.v2.stored_reports.database_session import (
StoredReportDatabaseSession,
)
Expand All @@ -250,6 +256,9 @@ def _default_v2_stored_report_service_factory() -> V2StoredReportResourceService
StoredReportDatabaseSession(get_v2_session_factory()),
current_versions=_current_package_versions,
submissions=_default_simulation_submissions(),
run_records=ReportRunRecordService(
ReportRunRecordDatabaseSession(get_v2_session_factory())
),
)


Expand Down Expand Up @@ -290,6 +299,26 @@ def _default_v2_stored_simulation_service_factory() -> (
)


def _user_authorization(
request: Request, credentials: HTTPAuthorizationCredentials | None
) -> str | None:
"""Return the user's bearer credential, or None when the request sends none.

When the credential check before routing found a server credential, it
is the one that server forwards (see ``user_auth_support``).
"""
caller = current_request_caller()
if caller is not None and caller.kind is CallerKind.SERVER:
# Authorization carries the server's own token; the user is the one
# the server forwards.
return forwarded_user_credential(
request.headers.getlist(FORWARDED_USER_AUTHORIZATION_HEADER)
)
if credentials is not None:
return f"Bearer {credentials.credentials}"
return None


def _default_require_user(
request: Request,
credentials: Annotated[
Expand All @@ -302,17 +331,28 @@ def _default_require_user(
When the credential check before routing found a server credential, the
account is the user that server forwards (see ``user_auth_support``).
"""
caller = current_request_caller()
if caller is not None and caller.kind is CallerKind.SERVER:
# Authorization carries the server's own token; the user is the one
# the server forwards.
user_authorization = forwarded_user_credential(
request.headers.getlist(FORWARDED_USER_AUTHORIZATION_HEADER)
)
elif credentials is not None:
user_authorization = f"Bearer {credentials.credentials}"
else:
user_authorization = None
return authenticate_account_request(
_user_authorization(request, credentials), unauthenticated_user
)


def _default_optional_user(
request: Request,
credentials: Annotated[
HTTPAuthorizationCredentials | None, Depends(_user_bearer_scheme)
],
unauthenticated_user: Annotated[str | None, Depends(_unauthenticated_user_scheme)],
) -> AccountPrincipal | None:
"""Authenticate the request's account, or return None when it names no user.

A request sending no user credential, such as an API caller's, or a
server's forwarding none, acts as nobody. One that sends a credential is
authenticated exactly as ``_default_require_user`` authenticates it, and
refused the same way when it is not valid.
"""
user_authorization = _user_authorization(request, credentials)
if user_authorization is None and unauthenticated_user is None:
return None
return authenticate_account_request(user_authorization, unauthenticated_user)


Expand Down Expand Up @@ -498,6 +538,8 @@ class NativeRouteDependencies:
# fails closed (401/403/503); tests replace it with a callable returning a
# fixed principal.
require_user: Callable[..., AccountPrincipal] = _default_require_user
# The account of a route a user may call without signing in, or None.
optional_user: Callable[..., AccountPrincipal | None] = _default_optional_user

@classmethod
def defaults(cls) -> NativeRouteDependencies:
Expand Down Expand Up @@ -529,4 +571,5 @@ def defaults(cls) -> NativeRouteDependencies:
v2_account_service_factory=_default_v2_account_service_factory,
v2_user_type_service_factory=_default_v2_user_type_service_factory,
require_user=_default_require_user,
optional_user=_default_optional_user,
)
80 changes: 62 additions & 18 deletions policyengine_api/fastapi_routes/resource_service_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,29 +191,59 @@ def get_calculation(self, calculation_id: UUID) -> HouseholdCalculationRead:
class V2StoredReportResourceService(Protocol):
"""Route-independent report operations for one request."""

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, a household report or a budget
window. Raise ``StoredReportNotFoundError`` when the country has no
such report, and ``EconomyResultUnreadableError``,
``HouseholdResultUnreadableError`` or
def get_report(
self,
*,
country_id: str,
report_id: UUID,
run_id: UUID | None = None,
requester: AccountPrincipal | None = None,
) -> StoredReportRead:
"""Return the country's report, a run and that run's result, as a user sees it.

The run is ``run_id``; without it, the requester's newest recorded
run, else the report's newest succeeded unforced run, else its newest
unforced run. The label and description are the requester's saved
report's. The report is an economy report, a household report or a
budget window. Raise ``StoredReportNotFoundError`` when the country
has no such report, ``ReportRunNotFoundError`` when it has no run
``run_id``, ``StandardUserRequiresSignInError`` for an
unauthenticated credential naming a signed-in user, and
``EconomyResultUnreadableError``, ``HouseholdResultUnreadableError`` or
``BudgetWindowResultUnreadableError`` when a succeeded run's result
cannot be read.
"""
...

def list_reports(
self, *, country_id: str, report_ids: Sequence[UUID]
self,
*,
country_id: str,
report_ids: Sequence[UUID],
requester: AccountPrincipal | None = None,
) -> tuple[StoredReportSummaryRead, ...]:
"""Return the country's reports of every kind among those named, in order."""
"""Return the country's reports of every kind among those named, in order.

Each is read as ``get_report`` reads it without a run, as the
requester sees it.
"""
...

def create_report(self, report_input: ReportCreationInput) -> StoredReportRead:
def create_report(
self,
report_input: ReportCreationInput,
*,
requester: AccountPrincipal | None = None,
) -> StoredReportRead:
"""Create the report through the simulation service; return it as stored.

Raise ``EconomyCalculationsUnsupportedError`` for an economy report in
a country without economy reports,
It is answered with the run the simulation service named, created or
joined. With a ``requester``, the request is recorded for its user
once the simulation service has answered. Raise
``StandardUserRequiresSignInError`` for an unauthenticated credential
that names a signed-in user, before anything is asked;
``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,
Expand All @@ -223,25 +253,39 @@ def create_report(self, report_input: ReportCreationInput) -> StoredReportRead:
``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.
simulation service's errors, ``ReportRunNotRecordedError`` when the
request could not be recorded, and ``CreatedReportUnreadableError``
when the report it created cannot be read back.
"""
...

def rerun_report(
self, *, country_id: str, report_id: UUID, force: bool
self,
*,
country_id: str,
report_id: UUID,
force: bool,
requester: AccountPrincipal | None = None,
) -> StoredReportRead:
"""Run the country's report again; return it with its new run as its newest.
"""Run the country's report again; return it with the run the request ended on.

That is the run the simulation service named, created or joined.
With a ``requester``, the request is recorded for its user once the
simulation service has answered, and
``StandardUserRequiresSignInError`` is raised, before anything is
asked, for an unauthenticated credential that names a signed-in user.
Raise ``StoredReportNotFoundError`` when the country has no such
report; the errors of the country check a new report of its kind makes
(economy calculations, or household calculations for a household
report) and of the policy checks of its simulations, as
``create_report`` raises them (a household report's household is not
checked again); the simulation service's errors, among them
``SimulationServiceConflictError`` when it will not run the report
again now, and ``RerunReportUnreadableError`` when the report cannot
be read back once its new run was started.
again now, ``ReportRunNotRecordedError`` when the request could not be
recorded, and ``RerunReportUnreadableError`` when the report cannot
be read back once its run was started, or when a budget window's
answer names no window run (its ``run_id`` is then None, and nothing
is recorded).
"""
...

Expand Down
Loading
Loading