Skip to content

Commit 7872c87

Browse files
committed
fix(spp_api_v2_simulation): add missing schema field and sudo, expand tests
- Add targeting_expression_explanation to ScenarioUpdateRequest (was causing 500 on PUT) - Add sudo() to simulation service call in run_simulation endpoint (was causing AccessError) - Add 27 new tests covering scenario update, run helpers, and aggregation schemas
1 parent 7570668 commit 7872c87

File tree

6 files changed

+446
-1
lines changed

6 files changed

+446
-1
lines changed

spp_api_v2_simulation/routers/scenario.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,8 @@ async def run_simulation(
438438
)
439439

440440
# Execute simulation via service
441-
service = env["spp.simulation.service"]
441+
# nosemgrep: odoo-sudo-without-context
442+
service = env["spp.simulation.service"].sudo()
442443
run = service.execute_simulation(scenario)
443444

444445
return RunSimulationResponse(

spp_api_v2_simulation/schemas/scenario.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ class ScenarioUpdateRequest(BaseModel):
6060
category: str | None = Field(default=None, description="Scenario category")
6161
target_type: str | None = Field(default=None, description="Target type: 'group' or 'individual'")
6262
targeting_expression: str | None = Field(default=None, description="CEL expression for targeting beneficiaries")
63+
targeting_expression_explanation: str | None = Field(
64+
default=None, description="Plain language explanation of targeting expression"
65+
)
6366
ideal_population_expression: str | None = Field(
6467
default=None, description="CEL expression for ideal population calculation"
6568
)

spp_api_v2_simulation/tests/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,8 @@
22

33
from . import test_comparison_api, test_convert_to_program_api, test_run_api, test_scenario_api
44
from . import test_scope_registration
5+
from . import test_aggregation_api
56
from . import test_aggregation_service
7+
from . import test_run_helpers
8+
from . import test_scenario_update
69
from . import test_simulation_service
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Tests for aggregation API schemas and endpoint logic."""
3+
4+
from odoo.tests import tagged
5+
6+
from .common import SimulationApiTestCase
7+
8+
9+
@tagged("-at_install", "post_install")
10+
class TestAggregationApi(SimulationApiTestCase):
11+
"""Test aggregation endpoint schemas and logic."""
12+
13+
def test_aggregation_scope_request_defaults(self):
14+
"""Test AggregationScopeRequest schema defaults."""
15+
from ..schemas.aggregation import AggregationScopeRequest
16+
17+
scope = AggregationScopeRequest()
18+
self.assertEqual(scope.target_type, "group")
19+
self.assertIsNone(scope.cel_expression)
20+
self.assertIsNone(scope.area_id)
21+
22+
def test_aggregation_scope_request_with_values(self):
23+
"""Test AggregationScopeRequest with explicit values."""
24+
from ..schemas.aggregation import AggregationScopeRequest
25+
26+
scope = AggregationScopeRequest(
27+
target_type="individual",
28+
cel_expression="r.age >= 18",
29+
area_id=42,
30+
)
31+
self.assertEqual(scope.target_type, "individual")
32+
self.assertEqual(scope.cel_expression, "r.age >= 18")
33+
self.assertEqual(scope.area_id, 42)
34+
35+
def test_compute_aggregation_request_minimal(self):
36+
"""Test ComputeAggregationRequest with minimal fields."""
37+
from ..schemas.aggregation import (
38+
AggregationScopeRequest,
39+
ComputeAggregationRequest,
40+
)
41+
42+
request = ComputeAggregationRequest(
43+
scope=AggregationScopeRequest(),
44+
)
45+
self.assertEqual(request.scope.target_type, "group")
46+
self.assertIsNone(request.statistics)
47+
self.assertIsNone(request.group_by)
48+
49+
def test_compute_aggregation_request_full(self):
50+
"""Test ComputeAggregationRequest with all fields."""
51+
from ..schemas.aggregation import (
52+
AggregationScopeRequest,
53+
ComputeAggregationRequest,
54+
)
55+
56+
request = ComputeAggregationRequest(
57+
scope=AggregationScopeRequest(
58+
target_type="individual",
59+
cel_expression="r.age >= 60",
60+
),
61+
statistics=["count", "average_age"],
62+
group_by=["gender", "area"],
63+
)
64+
self.assertEqual(request.scope.target_type, "individual")
65+
self.assertEqual(len(request.statistics), 2)
66+
self.assertEqual(len(request.group_by), 2)
67+
68+
def test_aggregation_response_schema(self):
69+
"""Test AggregationResponse schema."""
70+
from ..schemas.aggregation import AggregationResponse
71+
72+
response = AggregationResponse(
73+
total_count=150,
74+
statistics={"average_age": 45.2, "count": 150},
75+
breakdown={"gender": {"male": 70, "female": 80}},
76+
from_cache=False,
77+
computed_at="2024-01-01T12:00:00Z",
78+
access_level="aggregate",
79+
)
80+
self.assertEqual(response.total_count, 150)
81+
self.assertEqual(response.statistics["average_age"], 45.2)
82+
self.assertIn("gender", response.breakdown)
83+
self.assertFalse(response.from_cache)
84+
85+
def test_aggregation_response_no_breakdown(self):
86+
"""Test AggregationResponse without breakdown."""
87+
from ..schemas.aggregation import AggregationResponse
88+
89+
response = AggregationResponse(
90+
total_count=100,
91+
statistics={},
92+
from_cache=True,
93+
computed_at="2024-01-01T12:00:00Z",
94+
access_level="aggregate",
95+
)
96+
self.assertIsNone(response.breakdown)
97+
self.assertTrue(response.from_cache)
98+
99+
def test_dimension_info_schema(self):
100+
"""Test DimensionInfo schema."""
101+
from ..schemas.aggregation import DimensionInfo
102+
103+
dim = DimensionInfo(
104+
name="gender",
105+
label="Gender",
106+
description="Biological sex",
107+
dimension_type="field",
108+
applies_to="all",
109+
value_labels={"male": "Male", "female": "Female"},
110+
)
111+
self.assertEqual(dim.name, "gender")
112+
self.assertEqual(dim.label, "Gender")
113+
self.assertEqual(dim.dimension_type, "field")
114+
self.assertEqual(dim.applies_to, "all")
115+
self.assertIn("male", dim.value_labels)
116+
117+
def test_dimension_info_minimal(self):
118+
"""Test DimensionInfo with no optional fields."""
119+
from ..schemas.aggregation import DimensionInfo
120+
121+
dim = DimensionInfo(
122+
name="custom_dim",
123+
label="Custom",
124+
dimension_type="expression",
125+
applies_to="individuals",
126+
)
127+
self.assertIsNone(dim.description)
128+
self.assertIsNone(dim.value_labels)
129+
130+
def test_dimensions_list_response_schema(self):
131+
"""Test DimensionsListResponse schema."""
132+
from ..schemas.aggregation import DimensionInfo, DimensionsListResponse
133+
134+
dims = [
135+
DimensionInfo(
136+
name="gender",
137+
label="Gender",
138+
dimension_type="field",
139+
applies_to="all",
140+
),
141+
DimensionInfo(
142+
name="age_group",
143+
label="Age Group",
144+
dimension_type="expression",
145+
applies_to="individuals",
146+
),
147+
]
148+
response = DimensionsListResponse(dimensions=dims)
149+
self.assertEqual(len(response.dimensions), 2)
150+
self.assertEqual(response.dimensions[0].name, "gender")
151+
self.assertEqual(response.dimensions[1].name, "age_group")
152+
153+
def test_scope_model_dump(self):
154+
"""Test that scope model_dump produces the expected dict."""
155+
from ..schemas.aggregation import AggregationScopeRequest
156+
157+
scope = AggregationScopeRequest(
158+
target_type="group",
159+
cel_expression="r.area_id != false",
160+
area_id=5,
161+
)
162+
dumped = scope.model_dump()
163+
self.assertEqual(dumped["target_type"], "group")
164+
self.assertEqual(dumped["cel_expression"], "r.area_id != false")
165+
self.assertEqual(dumped["area_id"], 5)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Tests for run router helper functions and edge cases."""
3+
4+
from datetime import datetime
5+
6+
from odoo.tests import tagged
7+
8+
from .common import SimulationApiTestCase
9+
10+
11+
@tagged("-at_install", "post_install")
12+
class TestRunHelpers(SimulationApiTestCase):
13+
"""Test run helper functions and edge cases."""
14+
15+
def test_datetime_to_iso_with_datetime(self):
16+
"""Test _datetime_to_iso with a datetime object."""
17+
from ..routers.run import _datetime_to_iso
18+
19+
dt = datetime(2024, 6, 15, 10, 30, 0)
20+
result = _datetime_to_iso(dt)
21+
self.assertEqual(result, "2024-06-15T10:30:00")
22+
23+
def test_datetime_to_iso_with_none(self):
24+
"""Test _datetime_to_iso with None."""
25+
from ..routers.run import _datetime_to_iso
26+
27+
self.assertIsNone(_datetime_to_iso(None))
28+
29+
def test_datetime_to_iso_with_false(self):
30+
"""Test _datetime_to_iso with Odoo False."""
31+
from ..routers.run import _datetime_to_iso
32+
33+
self.assertIsNone(_datetime_to_iso(False))
34+
35+
def test_datetime_to_iso_with_string(self):
36+
"""Test _datetime_to_iso with a string (passthrough)."""
37+
from ..routers.run import _datetime_to_iso
38+
39+
result = _datetime_to_iso("2024-01-01T00:00:00Z")
40+
self.assertEqual(result, "2024-01-01T00:00:00Z")
41+
42+
def test_run_to_summary_with_no_duration(self):
43+
"""Test run to summary when execution_duration_seconds is 0."""
44+
from ..routers.run import _run_to_summary
45+
46+
run = self.env["spp.simulation.run"].create(
47+
{
48+
"scenario_id": self.scenario_ready.id,
49+
"state": "completed",
50+
"beneficiary_count": 3,
51+
"total_cost": 1500.0,
52+
"coverage_rate": 30.0,
53+
"equity_score": 75.0,
54+
"gini_coefficient": 0.2,
55+
"executed_at": datetime(2024, 3, 1, 12, 0, 0),
56+
"execution_duration_seconds": 0.0,
57+
}
58+
)
59+
60+
summary = _run_to_summary(run)
61+
self.assertIsNone(summary.execution_duration_seconds)
62+
63+
def test_run_to_response_failed_with_no_error_message(self):
64+
"""Test response for failed run with no error message."""
65+
from ..routers.run import _run_to_response
66+
67+
run = self.env["spp.simulation.run"].create(
68+
{
69+
"scenario_id": self.scenario_ready.id,
70+
"state": "failed",
71+
"beneficiary_count": 0,
72+
"total_cost": 0.0,
73+
}
74+
)
75+
76+
response = _run_to_response(run, include_details=False)
77+
self.assertEqual(response.state, "failed")
78+
self.assertIsNone(response.error_message)
79+
80+
def test_run_to_response_details_not_loaded_for_failed(self):
81+
"""Test that include_details=True doesn't load details for failed runs."""
82+
from ..routers.run import _run_to_response
83+
84+
self.run_failed.distribution_json = {
85+
"count": 0,
86+
"total": 0.0,
87+
}
88+
89+
response = _run_to_response(self.run_failed, include_details=True)
90+
91+
# Details should NOT be loaded for failed runs
92+
self.assertIsNone(response.distribution_data)
93+
self.assertIsNone(response.fairness_data)
94+
95+
def test_run_to_response_with_empty_json_fields(self):
96+
"""Test response when JSON fields are empty dicts/lists."""
97+
from ..routers.run import _run_to_response
98+
99+
# Empty JSON fields should not create data objects
100+
self.run_completed.distribution_json = {}
101+
self.run_completed.fairness_json = {}
102+
self.run_completed.geographic_json = []
103+
self.run_completed.metric_results_json = {}
104+
105+
response = _run_to_response(self.run_completed, include_details=True)
106+
107+
# Empty dicts/lists are falsy, so data should be None
108+
self.assertIsNone(response.distribution_data)
109+
self.assertIsNone(response.fairness_data)
110+
self.assertIsNone(response.geographic_data)
111+
self.assertIsNone(response.metric_results)
112+
113+
def test_run_to_response_with_scenario_snapshot_no_ideal(self):
114+
"""Test scenario snapshot without ideal_population_expression."""
115+
from ..routers.run import _run_to_response
116+
117+
self.run_completed.scenario_snapshot_json = {
118+
"name": "Test",
119+
"target_type": "group",
120+
"targeting_expression": "true",
121+
"budget_amount": 5000.0,
122+
"budget_strategy": "none",
123+
"entitlement_rules": [],
124+
}
125+
126+
response = _run_to_response(self.run_completed, include_details=True)
127+
128+
self.assertIsNotNone(response.scenario_snapshot)
129+
self.assertEqual(response.scenario_snapshot.name, "Test")
130+
self.assertIsNone(response.scenario_snapshot.ideal_population_expression)

0 commit comments

Comments
 (0)