Skip to content

Commit 109da6f

Browse files
committed
feat(spp_statistics_dashboard): add per-program statistics refresh
The dashboard now computes statistics for each active program in addition to system-wide and per-area dimensions. Program scope is built by querying enrolled members via get_beneficiaries() and passing their IDs as an explicit scope to the aggregation service. Refactors _refresh_statistic into _refresh_scope for clearer separation of the iteration logic from the per-scope computation.
1 parent d594d85 commit 109da6f

3 files changed

Lines changed: 100 additions & 28 deletions

File tree

spp_cel_domain/tests/test_cel_parser.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -431,24 +431,20 @@ def test_odoo_null_field_equals_null(self):
431431
result = P.evaluate(ast, {"m": partner})
432432
self.assertTrue(
433433
result,
434-
"m.disabled == null should be True when disabled is unset "
435-
f"(ORM returns {partner.disabled!r})",
434+
f"m.disabled == null should be True when disabled is unset (ORM returns {partner.disabled!r})",
436435
)
437436

438437
# CEL: m.disabled != null should be False (field is unset)
439438
ast = P.parse("m.disabled != null")
440439
result = P.evaluate(ast, {"m": partner})
441440
self.assertFalse(
442441
result,
443-
"m.disabled != null should be False when disabled is unset "
444-
f"(ORM returns {partner.disabled!r})",
442+
f"m.disabled != null should be False when disabled is unset (ORM returns {partner.disabled!r})",
445443
)
446444

447445
def test_odoo_boolean_false_not_treated_as_null(self):
448446
"""Boolean fields that are legitimately False should NOT be normalized to None."""
449-
partner = self.env["res.partner"].create(
450-
{"name": "CEL Bool Test", "is_registrant": False}
451-
)
447+
partner = self.env["res.partner"].create({"name": "CEL Bool Test", "is_registrant": False})
452448

453449
# is_registrant is a Boolean field, False is a real value
454450
ast = P.parse("m.is_registrant == false")

spp_statistics_dashboard/models/dashboard_data.py

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,14 @@ def action_refresh_all(self):
205205
}
206206

207207
def _refresh_statistic(self, stat_id, area_ids):
208-
"""Refresh one statistic across all area combinations.
208+
"""Refresh one statistic across all scope combinations.
209209
210-
Called as a queue_job. Handles errors per-area so one failure
210+
Computes values for:
211+
- System-wide (no area, no program)
212+
- Per area (each configured area)
213+
- Per program (each active program with enrolled members)
214+
215+
Called as a queue_job. Handles errors per-scope so one failure
211216
does not abort the entire statistic refresh.
212217
"""
213218
stat = self.env["spp.statistic"].browse(stat_id)
@@ -216,26 +221,42 @@ def _refresh_statistic(self, stat_id, area_ids):
216221
return
217222

218223
areas = self.env["spp.area"].browse(area_ids)
224+
programs = self._get_dashboard_programs()
219225

220-
# False = system-wide
221-
area_list = [False] + list(areas)
226+
# Area dimension: system-wide + per-area
227+
for area in [False] + list(areas):
228+
self._refresh_scope(stat, area, False)
222229

223-
for area in area_list:
224-
try:
225-
scope = self._build_scope(area, False)
226-
result = self.env["spp.aggregation.service"].compute_aggregation(
227-
scope=scope,
228-
statistics=[stat.name],
229-
context="dashboard",
230-
)
231-
self._upsert_data(stat, area, False, result)
232-
except (ValueError, TypeError, KeyError, AttributeError) as e:
233-
_logger.warning(
234-
"Dashboard refresh failed for stat=%s area=%s: %s",
235-
stat.name,
236-
area.code if area else "all",
237-
e,
238-
)
230+
# Program dimension: per-program (system-wide area)
231+
for program in programs:
232+
self._refresh_scope(stat, False, program)
233+
234+
def _refresh_scope(self, stat, area, program):
235+
"""Refresh a single (stat, area, program) combination.
236+
237+
Args:
238+
stat: spp.statistic record
239+
area: spp.area record or False
240+
program: spp.program record or False
241+
"""
242+
try:
243+
scope = self._build_scope(area, program)
244+
result = self.env["spp.aggregation.service"].compute_aggregation(
245+
scope=scope,
246+
statistics=[stat.name],
247+
context="dashboard",
248+
)
249+
self._upsert_data(stat, area, program, result)
250+
except (ValueError, TypeError, KeyError, AttributeError) as e:
251+
area_label = area.code if area else "all"
252+
prog_label = program.name if program else "all"
253+
_logger.warning(
254+
"Dashboard refresh failed for stat=%s area=%s program=%s: %s",
255+
stat.name,
256+
area_label,
257+
prog_label,
258+
e,
259+
)
239260

240261
@api.model
241262
def _get_dashboard_areas(self):
@@ -266,11 +287,20 @@ def _build_scope(self, area, program):
266287
267288
Args:
268289
area: spp.area record or False (system-wide)
269-
program: spp.program record or False (unused, kept for API compatibility)
290+
program: spp.program record or False (all programs)
270291
271292
Returns:
272293
dict: scope definition for spp.aggregation.service
273294
"""
295+
if program:
296+
# Program scope: use enrolled members as explicit partner IDs
297+
memberships = program.get_beneficiaries(state=["enrolled"])
298+
partner_ids = memberships.mapped("partner_id").ids
299+
return {
300+
"scope_type": "explicit",
301+
"explicit_partner_ids": partner_ids,
302+
}
303+
274304
if area:
275305
return {
276306
"scope_type": "area",

spp_statistics_dashboard/tests/test_dashboard_refresh.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,52 @@ def test_build_scope_with_area(self):
338338
self.assertEqual(scope["area_id"], self.area.id)
339339
self.assertTrue(scope["include_child_areas"])
340340

341+
def test_build_scope_with_program(self):
342+
"""Test _build_scope with a program returns explicit scope from enrolled members."""
343+
# Create a program with enrolled members
344+
program = self.env["spp.program"].create({"name": "Scope Test Program", "target_type": "group"})
345+
registrant = self.env["res.partner"].create(
346+
{"name": "Scope Program Person", "is_registrant": True, "is_group": False}
347+
)
348+
self.env["spp.program.membership"].create(
349+
{"partner_id": registrant.id, "program_id": program.id, "state": "enrolled"}
350+
)
351+
352+
DashData = self.env["spp.dashboard.data"]
353+
scope = DashData._build_scope(False, program)
354+
self.assertEqual(scope["scope_type"], "explicit")
355+
self.assertIn(registrant.id, scope["explicit_partner_ids"])
356+
357+
def test_refresh_statistic_with_programs(self):
358+
"""Test that refresh creates rows for each active program."""
359+
DashData = self.env["spp.dashboard.data"]
360+
361+
program = self.env["spp.program"].create({"name": "Refresh Program Test", "target_type": "group"})
362+
registrant = self.env["res.partner"].create(
363+
{"name": "Program Refresh Person", "is_registrant": True, "is_group": False}
364+
)
365+
self.env["spp.program.membership"].create(
366+
{"partner_id": registrant.id, "program_id": program.id, "state": "enrolled"}
367+
)
368+
369+
mock_result = self._mock_aggregation_result(value=10, total_count=10)
370+
with patch.object(
371+
type(self.env["spp.aggregation.service"]),
372+
"compute_aggregation",
373+
return_value=mock_result,
374+
):
375+
DashData._refresh_statistic(self.statistic.id, [])
376+
377+
# Should create at least 2 rows: system-wide + one per active program
378+
program_data = DashData.search(
379+
[
380+
("statistic_id", "=", self.statistic.id),
381+
("program_id", "=", program.id),
382+
]
383+
)
384+
self.assertEqual(len(program_data), 1, "Expected one row for the program")
385+
self.assertEqual(program_data.value, 10.0)
386+
341387
def test_label_from_context_config(self):
342388
"""Test that label is taken from statistic context config."""
343389
# Create a context config with a dashboard label override

0 commit comments

Comments
 (0)