Issue 856 — Simulation Explore Flow¶
Summary of all backend changes made after commit 7e6de8a (which introduced the ?carbon_project_type query-param migration).
1. API contract: remove X-Co2-Simulation, use ?carbon_project_type¶
Why¶
Using a client-sent header (X-Co2-Simulation: 1) to decide whether a request should resolve against Simulator vs Calculator reports was brittle and easy to spoof. The Simulator/Calculator distinction is now derived from the explicit query parameter carbon_project_type, which is validated server-side and maps deterministically to CarbonReportType.
Backend changes¶
File: backend/app/api/v1/carbon_report_module.py
- The request header resolver
_resolve_is_simulator(request)was removed. - A new query param was added to all module endpoints:
carbon_project_type: int = Query(default=0, ge=0, le=2)- The param is mapped via
_resolve_carbon_report_type(carbon_project_type): 0 → CALCULATOR1 → SIMULATOR_EXPLORE2 → SIMULATOR_PLAN
File: backend/app/api/v1/carbon_report_module_stats.py
- The
Requestdependency was removed fromGET /{carbon_report_id}/validated-totals. validated_onlyis now derived from the DB report type (joinCarbonReport → CarbonProject) instead of any client signal:validated_only = report_type != SIMULATOR_EXPLORE- Unknown
carbon_report_iddefaults tovalidated_only=True(safe default).
Frontend changes¶
File: frontend/src/api/http.ts
The ky client no longer sets X-Co2-Simulation. For simulation routes, it now appends ?carbon_project_type=1 to the request URL in beforeRequest, so all simulator requests are explicit and consistent with backend resolution.
2. Schema: last_updated exposed on CarbonReportRead¶
File: backend/app/schemas/carbon_report.py
last_updated: Optional[int] was added to CarbonReportRead. The field was already persisted in the DB but not returned by the API, which meant the GET explore endpoint could not compute the TTL age on the client (or in the background-task logic).
3. Unique constraints added (model + migration)¶
Why¶
bulk_upsert in CarbonReportRepository previously used ON CONFLICT (unit_id, year). That index was dropped when carbon_project_id replaced the old uniqueness model, causing a runtime crash. Two named unique constraints were added to fix the conflict target and to enforce data integrity at the DB level.
Model changes¶
backend/app/models/carbon_project.py
__table_args__ = (
UniqueConstraint("unit_id", "carbon_report_type", name="uq_carbon_projects_unit_type"),
)
Guarantees at most one CarbonProject per (unit_id, carbon_report_type) combination (e.g., one Calculator project and one Simulator-Explore project per unit).
backend/app/models/carbon_report.py
__table_args__ = (
UniqueConstraint("carbon_project_id", "year", name="uq_carbon_reports_project_year"),
)
Guarantees at most one CarbonReport per (carbon_project_id, year).
Migration¶
backend/alembic/versions/2026_05_04_0945-05d68c9a6054_add_simulation_carbon_reports.py
Two op.create_unique_constraint calls added to upgrade() and two op.drop_constraint calls added to downgrade(). Consolidated into the existing migration rather than creating a separate file.
Repository fix¶
backend/app/repositories/carbon_report_repo.py
# Before
.on_conflict_do_nothing(index_elements=["unit_id", "year"])
# After
.on_conflict_do_nothing(constraint="uq_carbon_reports_project_year")
4. Simulator Explore endpoints: GET/POST split + TTL background task¶
File: backend/app/api/v1/carbon_report.py
Before¶
A single GET endpoint (get_or_create_simulator_explore_carbon_report) combined retrieval and creation, and managed the 24 h TTL inline by deleting the old report and creating a new one before returning — which blocked the response.
After¶
GET /simulator/explore/unit/{unit_id}/reference-year/{reference_year}/
- Returns the existing explore report or 404 (never creates).
- Computes
age = now - last_updated. - If
last_updatedisNoneorage > 24 h, schedules_refresh_explore_backgroundas a FastAPIBackgroundTaskstask and still returns the stale report immediately so the user is not blocked.
POST /simulator/explore/unit/{unit_id}/reference-year/{reference_year}/ (status 201)
- Calls
service.create_explore(...)and commits. - Pure creation; callers are responsible for not calling this if a report already exists.
_refresh_explore_background(unit_id, old_report_id, reference_year)
Background coroutine that opens its own SessionLocal session (same pattern as audit sync tasks), deletes the stale report, and creates a fresh one. Runs after the response is sent.
_EXPLORE_TTL_SECONDS = 24 * 60 * 60
All four call sites to the old _require_unit_access local function were updated to the imported require_unit_access (see §4).
5. require_unit_access centralised in policy.py¶
File: backend/app/core/policy.py
The ad-hoc _require_unit_access function that lived in carbon_report.py duplicated role-walking logic and used a hand-rolled institutional_id check that diverged from the rest of the codebase. It was replaced by a shared helper added to policy.py:
def require_unit_access(current_user: User, unit: Unit | None) -> None:
Key differences vs. the old implementation:
Old _require_unit_access | New require_unit_access |
|---|---|
Walked role.on dict/attr manually | Uses pick_role_for_institutional_id (same function used by all other access checks) |
Checked role.on.get("institutional_id") or role.on.institutional_id | Delegates to role_priority module |
Defined locally in carbon_report.py | In app.core.policy; importable everywhere |
All four call sites in carbon_report.py updated from _require_unit_access(...) to require_unit_access(...).
6. CarbonReportService: get/create separation¶
File: backend/app/services/carbon_report_service.py
_get_or_create_project → _get_project + _create_project¶
The single method was split into two pure methods:
_get_project(unit_id, report_type) -> Optional[CarbonProject]— read-only, never mutates._create_project(unit_id, report_type) -> CarbonProject— write-only, always creates.
Call sites use the or short-circuit idiom:
project = await self._get_project(unit_id, T) or await self._create_project(unit_id, T)
get_or_create_explore → get_explore + create_explore¶
get_explore(unit_id, reference_year) -> Optional[CarbonReportRead]— idempotent read; returnsNoneif no report exists.create_explore(unit_id, reference_year) -> CarbonReportRead— creates the SIMULATOR_EXPLORE project (if absent), the report, and all module records. Setslast_updatedto the current timestamp. Does not seed from the Calculator report (see §6).
TTL management moved entirely to the API layer (_refresh_explore_background).
_seed_research_facility_entries removed¶
The method that copied research-facility DataEntry rows from the Calculator report into the new Explore report was removed. Simulator Explore is not supposed to contain Calculator data; the seeding was dead/incorrect behaviour.
Removed unused imports: sa_select, sqm_col, DataEntry, ModuleTypeEnum.
7. Tests¶
tests/unit/v1/test_carbon_report.py¶
Fixes: Three existing tests were patching module._require_unit_access, which no longer exists in the module after the centralisation. Updated to patch.object(module, "require_unit_access").
New tests (5):
| Test | What it asserts |
|---|---|
test_get_simulator_explore_found_fresh_no_refresh | Fresh report returned; background_tasks.add_task not called |
test_get_simulator_explore_not_found_raises_404 | Missing report → HTTP 404 |
test_get_simulator_explore_expired_schedules_background_refresh | Stale report (>24 h) returned immediately; add_task called with correct args |
test_get_simulator_explore_null_last_updated_schedules_refresh | last_updated=None treated as expired |
test_create_simulator_explore_commits_and_returns | POST calls service.create_explore, commits, returns report |
tests/unit/services/test_carbon_report_service.py¶
New tests (7):
| Test | What it asserts |
|---|---|
test_get_explore_returns_none_when_not_found | Returns None on empty DB |
test_get_explore_is_idempotent_on_empty_db | Two consecutive calls both return None |
test_create_explore_creates_report_and_modules | Report created with correct year/unit_id; all modules auto-created as NOT_STARTED |
test_get_explore_returns_existing_report | Round-trip: create_explore then get_explore returns same ID |
test_get_explore_does_not_cross_units | Different unit_id returns None |
test_get_explore_does_not_cross_years | Different reference_year returns None |
test_bulk_upsert_resolves_project_ids_before_repo_call | Service enriches all items with non-null carbon_project_id; both unit_id=1 rows share one project; unit_id=2 gets a distinct one |
tests/unit/v1/test_carbon_report_module_stats.py (new file)¶
Tests the security fix in get_validated_totals: validated_only is derived from CarbonProject.carbon_report_type (DB join), not from any client-supplied signal.
| Test | report_type | Expected validated_only |
|---|---|---|
test_get_validated_totals_calculator_uses_validated_only_true | CALCULATOR | True |
test_get_validated_totals_simulator_explore_uses_validated_only_false | SIMULATOR_EXPLORE | False |
test_get_validated_totals_simulator_plan_uses_validated_only_true | SIMULATOR_PLAN | True |
test_get_validated_totals_unknown_report_id_uses_validated_only_true | None (unknown ID) | True (safe default) |
Each test asserts both the DataEntryEmissionService and DataEntryService calls receive the correct validated_only kwarg.