From be273cde35126fe23b8e2f7917cc7240f3c8e859 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:40:08 +0700 Subject: [PATCH 01/18] feat(spp_dashboard): add statistics dashboard module Materializes published statistics into spp.dashboard.data snapshot table and renders via kanban/list/pivot/graph views with area and program filtering. Includes queue_job background refresh, k-anonymity suppression, three-tier security, and daily cron. --- spp_dashboard/__init__.py | 3 + spp_dashboard/__manifest__.py | 34 ++ spp_dashboard/data/ir_cron.xml | 13 + spp_dashboard/models/__init__.py | 3 + spp_dashboard/models/dashboard_data.py | 410 ++++++++++++++++++ spp_dashboard/readme/DESCRIPTION.md | 47 ++ spp_dashboard/security/groups.xml | 50 +++ spp_dashboard/security/ir.model.access.csv | 3 + spp_dashboard/security/privileges.xml | 10 + spp_dashboard/static/description/icon.png | Bin 0 -> 15480 bytes spp_dashboard/tests/__init__.py | 5 + spp_dashboard/tests/test_dashboard_access.py | 167 +++++++ spp_dashboard/tests/test_dashboard_data.py | 238 ++++++++++ spp_dashboard/tests/test_dashboard_refresh.py | 349 +++++++++++++++ spp_dashboard/views/dashboard_data_views.xml | 207 +++++++++ spp_dashboard/views/menus.xml | 11 + 16 files changed, 1550 insertions(+) create mode 100644 spp_dashboard/__init__.py create mode 100644 spp_dashboard/__manifest__.py create mode 100644 spp_dashboard/data/ir_cron.xml create mode 100644 spp_dashboard/models/__init__.py create mode 100644 spp_dashboard/models/dashboard_data.py create mode 100644 spp_dashboard/readme/DESCRIPTION.md create mode 100644 spp_dashboard/security/groups.xml create mode 100644 spp_dashboard/security/ir.model.access.csv create mode 100644 spp_dashboard/security/privileges.xml create mode 100644 spp_dashboard/static/description/icon.png create mode 100644 spp_dashboard/tests/__init__.py create mode 100644 spp_dashboard/tests/test_dashboard_access.py create mode 100644 spp_dashboard/tests/test_dashboard_data.py create mode 100644 spp_dashboard/tests/test_dashboard_refresh.py create mode 100644 spp_dashboard/views/dashboard_data_views.xml create mode 100644 spp_dashboard/views/menus.xml diff --git a/spp_dashboard/__init__.py b/spp_dashboard/__init__.py new file mode 100644 index 00000000..c4ccea79 --- /dev/null +++ b/spp_dashboard/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/spp_dashboard/__manifest__.py b/spp_dashboard/__manifest__.py new file mode 100644 index 00000000..6e31b27c --- /dev/null +++ b/spp_dashboard/__manifest__.py @@ -0,0 +1,34 @@ +{ + "name": "OpenSPP Statistics Dashboard", + "summary": "Dashboard views for published statistics with area and program filtering.", + "category": "OpenSPP/Monitoring", + "version": "19.0.1.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi"], + "depends": [ + "spp_statistic", + "spp_aggregation", + "spp_area", + "spp_programs", + "spp_security", + "queue_job", + ], + "data": [ + "security/privileges.xml", + "security/groups.xml", + "security/ir.model.access.csv", + "data/ir_cron.xml", + "views/dashboard_data_views.xml", + "views/menus.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": True, + "installable": True, + "auto_install": False, +} diff --git a/spp_dashboard/data/ir_cron.xml b/spp_dashboard/data/ir_cron.xml new file mode 100644 index 00000000..96b84497 --- /dev/null +++ b/spp_dashboard/data/ir_cron.xml @@ -0,0 +1,13 @@ + + + + Dashboard: Refresh Statistics + + code + model.action_refresh_all() + 1 + days + -1 + + + diff --git a/spp_dashboard/models/__init__.py b/spp_dashboard/models/__init__.py new file mode 100644 index 00000000..8fdd694e --- /dev/null +++ b/spp_dashboard/models/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import dashboard_data diff --git a/spp_dashboard/models/dashboard_data.py b/spp_dashboard/models/dashboard_data.py new file mode 100644 index 00000000..2a8077e3 --- /dev/null +++ b/spp_dashboard/models/dashboard_data.py @@ -0,0 +1,410 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Dashboard Data - Materialized snapshot of published statistics.""" + +import logging + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class DashboardData(models.Model): + """Pre-computed dashboard statistic values. + + Stores one row per (statistic, area, program) combination. + Refreshed via queue_job (manual trigger or daily cron). + """ + + _name = "spp.dashboard.data" + _description = "Dashboard Statistic Data" + _order = "category_id, statistic_name" + + def init(self): + """Create database indexes for dashboard query performance.""" + # Composite index for grouped list view (category + area filtering) + self.env.cr.execute(""" + CREATE INDEX IF NOT EXISTS idx_dashboard_data_category_area + ON spp_dashboard_data(category_id, area_id) + """) + + # Composite index for filtered queries (area + program) + self.env.cr.execute(""" + CREATE INDEX IF NOT EXISTS idx_dashboard_data_area_program + ON spp_dashboard_data(area_id, program_id) + """) + + # Index for area level filtering + self.env.cr.execute(""" + CREATE INDEX IF NOT EXISTS idx_dashboard_data_area_level + ON spp_dashboard_data(area_level) + """) + + _logger.info("Dashboard data performance indexes created/verified") + + # ─── Relations ─────────────────────────────────────────────────────── + + statistic_id = fields.Many2one( + comodel_name="spp.statistic", + string="Statistic", + required=True, + ondelete="cascade", + index=True, + ) + statistic_name = fields.Char( + string="Statistic Name", + related="statistic_id.name", + store=True, + ) + label = fields.Char( + string="Label", + help="Display label from statistic context config for dashboard", + ) + + category_id = fields.Many2one( + comodel_name="spp.metric.category", + string="Category", + related="statistic_id.category_id", + store=True, + index=True, + ) + category_code = fields.Char( + string="Category Code", + related="statistic_id.category_id.code", + store=True, + ) + + area_id = fields.Many2one( + comodel_name="spp.area", + string="Area", + ondelete="cascade", + index=True, + help="Area scope. Empty means system-wide.", + ) + area_name = fields.Char( + string="Area Name", + related="area_id.name", + store=True, + ) + area_level = fields.Integer( + string="Area Level", + related="area_id.area_level", + store=True, + ) + + program_id = fields.Many2one( + comodel_name="spp.program", + string="Program", + ondelete="cascade", + index=True, + help="Program scope. Empty means all programs.", + ) + program_name = fields.Char( + string="Program Name", + related="program_id.name", + store=True, + ) + + # ─── Values ────────────────────────────────────────────────────────── + + value = fields.Float( + string="Value", + digits=(16, 4), + help="Computed statistic value", + ) + value_display = fields.Char( + string="Display Value", + help="Formatted display value (handles suppression: '<5', '*')", + ) + is_suppressed = fields.Boolean( + string="Suppressed", + default=False, + help="Whether k-anonymity suppression was applied", + ) + underlying_count = fields.Integer( + string="Underlying Count", + help="Count before aggregation (for suppression check)", + ) + + # ─── Presentation (from statistic) ─────────────────────────────────── + + format = fields.Selection( + string="Format", + related="statistic_id.format", + store=True, + ) + unit = fields.Char( + string="Unit", + related="statistic_id.unit", + store=True, + ) + + # ─── Metadata ──────────────────────────────────────────────────────── + + refreshed_at = fields.Datetime( + string="Refreshed At", + help="When this value was last computed", + ) + + # ─── Constraints ───────────────────────────────────────────────────── + + _sql_constraints = [ + ( + "statistic_area_program_unique", + "UNIQUE(statistic_id, area_id, program_id)", + "Duplicate dashboard data row for this statistic/area/program combination.", + ), + ] + + # ─── Refresh Logic ─────────────────────────────────────────────────── + + @api.model + def action_refresh_all(self): + """Enqueue refresh jobs for all dashboard statistics. + + Each statistic gets its own queue_job so failures are isolated. + Returns a notification action to inform the user. + """ + stats = self.env["spp.statistic"].get_published_for_context("dashboard") + + if not stats: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Dashboard Refresh"), + "message": _("No statistics are published for the dashboard."), + "type": "warning", + "sticky": False, + }, + } + + # Clean up stale data for un-published statistics + self._cleanup_stale_data(stats) + + areas = self._get_dashboard_areas() + programs = self._get_dashboard_programs() + + for stat in stats: + if hasattr(self, "with_delay"): + self.with_delay( + priority=10, + description=f"Dashboard refresh: {stat.label}", + )._refresh_statistic(stat.id, areas.ids, programs.ids) + else: + self._refresh_statistic(stat.id, areas.ids, programs.ids) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Dashboard Refresh"), + "message": _( + "Statistics refresh has been queued. Data will update shortly." + ), + "type": "success", + "sticky": False, + }, + } + + def _refresh_statistic(self, stat_id, area_ids, program_ids): + """Refresh one statistic across all area/program combinations. + + Called as a queue_job. Handles errors per-combination so one failure + does not abort the entire statistic refresh. + """ + stat = self.env["spp.statistic"].browse(stat_id) + if not stat.exists(): + _logger.warning("Statistic ID %s no longer exists, skipping refresh", stat_id) + return + + areas = self.env["spp.area"].browse(area_ids) + programs = self.env["spp.program"].browse(program_ids) + + # False = system-wide / all programs + area_list = [False] + list(areas) + program_list = [False] + list(programs) + + for area in area_list: + for program in program_list: + try: + scope = self._build_scope(area, program) + result = self.env["spp.aggregation.service"].compute_aggregation( + scope=scope, + statistics=[stat.name], + context="dashboard", + ) + self._upsert_data(stat, area, program, result) + except Exception: + _logger.exception( + "Dashboard refresh failed for stat=%s area=%s program=%s", + stat.name, + area.code if area else "all", + program.name if program else "all", + ) + + @api.model + def _get_dashboard_areas(self): + """Get areas to include in dashboard refresh. + + Uses the system parameter 'spp_dashboard.area_levels' to filter + by admin level (comma-separated integers). If not set, includes + all areas. + """ + param = self.env["ir.config_parameter"].sudo().get_param( + "spp_dashboard.area_levels", "" + ) + domain = [] + if param.strip(): + try: + levels = [int(level.strip()) for level in param.split(",")] + domain = [("area_level", "in", levels)] + except ValueError: + _logger.warning( + "Invalid spp_dashboard.area_levels parameter: %s", param + ) + return self.env["spp.area"].search(domain) + + @api.model + def _get_dashboard_programs(self): + """Get active programs to include in dashboard refresh.""" + return self.env["spp.program"].search([("state", "=", "active")]) + + @api.model + def _build_scope(self, area, program): + """Build aggregation scope dict for compute_aggregation. + + Args: + area: spp.area record or False (system-wide) + program: spp.program record or False (all programs) + + Returns: + dict: scope definition for spp.aggregation.service + """ + if area: + scope = { + "scope_type": "area", + "area_id": area.id, + "include_child_areas": True, + } + else: + # System-wide scope + scope = { + "scope_type": "area", + "area_id": False, + "include_child_areas": True, + } + + if program: + scope["program_id"] = program.id + + return scope + + def _upsert_data(self, stat, area, program, result): + """Insert or update a dashboard data row from aggregation result. + + Args: + stat: spp.statistic record + area: spp.area record or False + program: spp.program record or False + result: dict from compute_aggregation() + """ + stat_results = result.get("statistics", {}) + stat_data = stat_results.get(stat.name, {}) + + raw_value = stat_data.get("value") + is_suppressed = stat_data.get("suppressed", False) + total_count = result.get("total_count", 0) + + # Get context config for label + config = stat.get_context_config("dashboard") + label = config.get("label", stat.label) if config else stat.label + + # Apply suppression for display value + if is_suppressed or raw_value is None: + display_value, _ = stat.apply_suppression( + raw_value if raw_value is not None else 0, + count=total_count, + context="dashboard", + ) + value_display = str(display_value) if display_value is not None else "" + numeric_value = 0.0 + else: + value_display = self._format_value(raw_value, stat) + numeric_value = float(raw_value) if raw_value is not None else 0.0 + + area_id = area.id if area else False + program_id = program.id if program else False + now = fields.Datetime.now() + + # Try to find existing record + existing = self.search([ + ("statistic_id", "=", stat.id), + ("area_id", "=", area_id), + ("program_id", "=", program_id), + ], limit=1) + + vals = { + "value": numeric_value, + "value_display": value_display, + "is_suppressed": is_suppressed, + "underlying_count": total_count, + "label": label, + "refreshed_at": now, + } + + if existing: + existing.write(vals) + else: + vals.update({ + "statistic_id": stat.id, + "area_id": area_id, + "program_id": program_id, + }) + self.create(vals) + + @api.model + def _format_value(self, value, stat): + """Format a numeric value for display based on statistic format. + + Args: + value: numeric value + stat: spp.statistic record + + Returns: + str: formatted display string + """ + if value is None: + return "" + + fmt = stat.format + decimal_places = stat.decimal_places or 0 + + if fmt == "percent": + return f"{value:.{decimal_places}f}%" + elif fmt == "currency": + return f"{value:,.{decimal_places}f}" + elif fmt == "ratio": + return f"{value:.{decimal_places}f}" + elif fmt in ("count", "sum"): + if decimal_places == 0: + return f"{int(value):,}" + return f"{value:,.{decimal_places}f}" + else: + # avg or unknown + return f"{value:.{decimal_places}f}" + + @api.model + def _cleanup_stale_data(self, published_stats): + """Delete dashboard data rows for statistics no longer published. + + Args: + published_stats: recordset of currently published spp.statistic + """ + stale = self.search([ + ("statistic_id", "not in", published_stats.ids), + ]) + if stale: + _logger.info( + "Cleaning up %d stale dashboard data rows", len(stale) + ) + stale.unlink() diff --git a/spp_dashboard/readme/DESCRIPTION.md b/spp_dashboard/readme/DESCRIPTION.md new file mode 100644 index 00000000..dcb0a26c --- /dev/null +++ b/spp_dashboard/readme/DESCRIPTION.md @@ -0,0 +1,47 @@ +Dashboard for statistics published via `spp.statistic` with `is_published_dashboard=True`. Materializes +pre-computed values into a snapshot table and renders them through kanban, list, pivot, and graph views +with area and program filtering. + +### Key Capabilities + +- Kanban KPI cards grouped by category for at-a-glance overview +- List view with category grouping, optional columns, and native Excel/CSV export +- Pivot cross-tab of statistics by area or program with Excel export +- Background refresh via `queue_job` (manual trigger or daily cron) +- k-anonymity suppression applied to small-cell values + +### Key Models + +| Model | Description | +| ------------------ | ------------------------------------------------------- | +| `spp.dashboard.data` | Materialized snapshot of computed statistic values | + +### Configuration + +After installing: + +1. Mark statistics for dashboard publication via **Settings > Statistics > [Statistic] > Publish to Dashboard** +2. Trigger an initial refresh from the dashboard action menu ("Refresh Statistics (Background)") +3. The **Dashboard Refresh** scheduled action runs daily by default + +### UI Location + +- **Menu**: Statistics Dashboard (top-level) +- **URL**: `/odoo/statistics-dashboard` + +### Security + +| Group | Access | +| ---------------------------------------- | ----------------------------------- | +| `spp_dashboard.group_dashboard_read` | Read only | +| `spp_dashboard.group_dashboard_manage` | Read/Write (for refresh upsert) | + +### Extension Points + +- Override `_get_dashboard_areas()` to customize which areas appear in the dashboard +- Override `_get_dashboard_programs()` to customize which programs appear +- Override `_build_scope()` to customize aggregation scope construction + +### Dependencies + +`spp_statistic`, `spp_aggregation`, `spp_area`, `spp_programs`, `spp_security`, `queue_job` diff --git a/spp_dashboard/security/groups.xml b/spp_dashboard/security/groups.xml new file mode 100644 index 00000000..c9f26b1a --- /dev/null +++ b/spp_dashboard/security/groups.xml @@ -0,0 +1,50 @@ + + + + + + + + Dashboard: Read + Technical group for read access to dashboard data. + + + + Dashboard: Manage + Technical group for dashboard management. Grants permission to trigger data refresh. + + + + + + + Viewer + + Can view dashboard statistics and export data. Cannot trigger refresh. + + + + + Manager + + Can view dashboard statistics, export data, and trigger data refresh. + + + + + + + + diff --git a/spp_dashboard/security/ir.model.access.csv b/spp_dashboard/security/ir.model.access.csv new file mode 100644 index 00000000..3f31b625 --- /dev/null +++ b/spp_dashboard/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dashboard_data_read,Dashboard Data Viewer Access,model_spp_dashboard_data,group_dashboard_read,1,0,0,0 +access_dashboard_data_manage,Dashboard Data Manager Access,model_spp_dashboard_data,group_dashboard_manage,1,1,1,1 diff --git a/spp_dashboard/security/privileges.xml b/spp_dashboard/security/privileges.xml new file mode 100644 index 00000000..17a60eed --- /dev/null +++ b/spp_dashboard/security/privileges.xml @@ -0,0 +1,10 @@ + + + + + + Statistics Dashboard + + Access to statistics dashboard views and data + + diff --git a/spp_dashboard/static/description/icon.png b/spp_dashboard/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c7dbdaaf1dace8f0ccf8c2087047ddfcf584af0c GIT binary patch literal 15480 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 + + + + + + + spp.dashboard.data.kanban.main + spp.dashboard.data + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + + + + + + +
+
+
+
+ + + + + + + + +
+
+ + +
+
+
+
+
+
+
+ + + + + + spp.dashboard.data.list.main + spp.dashboard.data + + + + + + + + + + + + + + + + + + + + spp.dashboard.data.search.main + spp.dashboard.data + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + spp.dashboard.data.pivot.main + spp.dashboard.data + + + + + + + + + + + + + + + spp.dashboard.data.graph.main + spp.dashboard.data + + + + + + + + + + + + + Refresh Statistics (Background) + + + list,kanban + + code + action = model.action_refresh_all() + + + + + + + Statistics Dashboard + statistics-dashboard + spp.dashboard.data + kanban,list,pivot,graph + {'search_default_system_wide': 1} + +

No statistics data yet

+

Click "Refresh Data" to compute statistics, or wait for the + scheduled daily refresh.

+
+
+ +
diff --git a/spp_dashboard/views/menus.xml b/spp_dashboard/views/menus.xml new file mode 100644 index 00000000..3ce6903c --- /dev/null +++ b/spp_dashboard/views/menus.xml @@ -0,0 +1,11 @@ + + + + From ebeef241dfa7a6dfd5fd93b23fd8761886e29a01 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:42:05 +0700 Subject: [PATCH 02/18] fix(spp_dashboard): use Odoo 19 models.Constraint and remove numbercall Replace _sql_constraints with models.Constraint (Odoo 19 API) and remove invalid numbercall field from ir.cron definition. --- spp_dashboard/data/ir_cron.xml | 3 +-- spp_dashboard/models/dashboard_data.py | 11 ++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/spp_dashboard/data/ir_cron.xml b/spp_dashboard/data/ir_cron.xml index 96b84497..0de3b493 100644 --- a/spp_dashboard/data/ir_cron.xml +++ b/spp_dashboard/data/ir_cron.xml @@ -7,7 +7,6 @@ model.action_refresh_all() 1 days - -1 - + True diff --git a/spp_dashboard/models/dashboard_data.py b/spp_dashboard/models/dashboard_data.py index 2a8077e3..6be28136 100644 --- a/spp_dashboard/models/dashboard_data.py +++ b/spp_dashboard/models/dashboard_data.py @@ -147,13 +147,10 @@ def init(self): # ─── Constraints ───────────────────────────────────────────────────── - _sql_constraints = [ - ( - "statistic_area_program_unique", - "UNIQUE(statistic_id, area_id, program_id)", - "Duplicate dashboard data row for this statistic/area/program combination.", - ), - ] + _statistic_area_program_unique = models.Constraint( + "UNIQUE(statistic_id, area_id, program_id)", + "Duplicate dashboard data row for this statistic/area/program combination.", + ) # ─── Refresh Logic ─────────────────────────────────────────────────── From 634fadb8dbab30979d43db0d2aebde95df0b6f39 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:43:40 +0700 Subject: [PATCH 03/18] fix(spp_dashboard): fix Odoo 19 view compatibility issues Remove invalid groups_id field from ir.actions.server and add title attributes to FontAwesome icons in kanban view template. --- spp_dashboard/views/dashboard_data_views.xml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/spp_dashboard/views/dashboard_data_views.xml b/spp_dashboard/views/dashboard_data_views.xml index 0802fd6b..dfc365d9 100644 --- a/spp_dashboard/views/dashboard_data_views.xml +++ b/spp_dashboard/views/dashboard_data_views.xml @@ -52,16 +52,16 @@
- + - +
- +
@@ -183,7 +183,6 @@ list,kanban - code action = model.action_refresh_all() From 73ee0765b457f560700386683387f750de821dde Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:46:15 +0700 Subject: [PATCH 04/18] fix(spp_dashboard): fix test data for spp.area and view assertions Use draft_name instead of name when creating spp.area records (name is computed). Fix view load tests to assert on 'arch' key. Use correct area_level values (computed from parent hierarchy). --- spp_dashboard/tests/test_dashboard_access.py | 5 +- spp_dashboard/tests/test_dashboard_data.py | 64 ++++++++----------- spp_dashboard/tests/test_dashboard_refresh.py | 12 ++-- 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/spp_dashboard/tests/test_dashboard_access.py b/spp_dashboard/tests/test_dashboard_access.py index ccf2b969..6b8ef307 100644 --- a/spp_dashboard/tests/test_dashboard_access.py +++ b/spp_dashboard/tests/test_dashboard_access.py @@ -131,9 +131,8 @@ def test_manager_can_unlink(self): "value_display": "77", "label": "To Delete", "area_id": self.env["spp.area"].create({ - "name": "Delete Test Area", - "code": "delete_test_area", - "area_level": 3, + "draft_name": "Delete Test Area", + "code": "delete_test_area_dash", }).id, }) data_as_manager = data.with_user(self.manager_user) diff --git a/spp_dashboard/tests/test_dashboard_data.py b/spp_dashboard/tests/test_dashboard_data.py index 67917d8c..cda1fede 100644 --- a/spp_dashboard/tests/test_dashboard_data.py +++ b/spp_dashboard/tests/test_dashboard_data.py @@ -36,9 +36,8 @@ def setUpClass(cls): }) cls.area = cls.env["spp.area"].create({ - "name": "Test Area", + "draft_name": "Test Area", "code": "test_area_dash", - "area_level": 1, }) def test_create_dashboard_data(self): @@ -69,8 +68,10 @@ def test_create_with_area(self): "label": "Test Stat", }) - self.assertEqual(data.area_name, "Test Area") - self.assertEqual(data.area_level, 1) + # area_name is computed from draft_name + code + self.assertIn("Test Area", data.area_name) + # Root area has area_level=0 + self.assertEqual(data.area_level, 0) def test_unique_constraint(self): """Test SQL unique constraint on (statistic_id, area_id, program_id).""" @@ -120,9 +121,8 @@ def test_cascade_delete_statistic(self): def test_cascade_delete_area(self): """Test that deleting an area cascades to dashboard data.""" area = self.env["spp.area"].create({ - "name": "Cascade Area", - "code": "cascade_area", - "area_level": 2, + "draft_name": "Cascade Area", + "code": "cascade_area_dash", }) data = self.env["spp.dashboard.data"].create({ "statistic_id": self.statistic.id, @@ -186,44 +186,34 @@ class TestDashboardViews(TransactionCase): """Test that view definitions load correctly.""" def test_kanban_view_loads(self): - """Test kanban view can be loaded.""" - result = self.env["spp.dashboard.data"].get_view( - self.env.ref("spp_dashboard.spp_dashboard_data_view_kanban").id, - view_type="kanban", - ) - self.assertEqual(result["type"], "kanban") + """Test kanban view can be loaded without error.""" + view = self.env.ref("spp_dashboard.spp_dashboard_data_view_kanban") + result = self.env["spp.dashboard.data"].get_view(view.id, view_type="kanban") + self.assertIn("arch", result) def test_list_view_loads(self): - """Test list view can be loaded.""" - result = self.env["spp.dashboard.data"].get_view( - self.env.ref("spp_dashboard.spp_dashboard_data_view_list").id, - view_type="list", - ) - self.assertEqual(result["type"], "list") + """Test list view can be loaded without error.""" + view = self.env.ref("spp_dashboard.spp_dashboard_data_view_list") + result = self.env["spp.dashboard.data"].get_view(view.id, view_type="list") + self.assertIn("arch", result) def test_search_view_loads(self): - """Test search view can be loaded.""" - result = self.env["spp.dashboard.data"].get_view( - self.env.ref("spp_dashboard.spp_dashboard_data_view_search").id, - view_type="search", - ) - self.assertEqual(result["type"], "search") + """Test search view can be loaded without error.""" + view = self.env.ref("spp_dashboard.spp_dashboard_data_view_search") + result = self.env["spp.dashboard.data"].get_view(view.id, view_type="search") + self.assertIn("arch", result) def test_pivot_view_loads(self): - """Test pivot view can be loaded.""" - result = self.env["spp.dashboard.data"].get_view( - self.env.ref("spp_dashboard.spp_dashboard_data_view_pivot").id, - view_type="pivot", - ) - self.assertEqual(result["type"], "pivot") + """Test pivot view can be loaded without error.""" + view = self.env.ref("spp_dashboard.spp_dashboard_data_view_pivot") + result = self.env["spp.dashboard.data"].get_view(view.id, view_type="pivot") + self.assertIn("arch", result) def test_graph_view_loads(self): - """Test graph view can be loaded.""" - result = self.env["spp.dashboard.data"].get_view( - self.env.ref("spp_dashboard.spp_dashboard_data_view_graph").id, - view_type="graph", - ) - self.assertEqual(result["type"], "graph") + """Test graph view can be loaded without error.""" + view = self.env.ref("spp_dashboard.spp_dashboard_data_view_graph") + result = self.env["spp.dashboard.data"].get_view(view.id, view_type="graph") + self.assertIn("arch", result) def test_action_window_exists(self): """Test action window record exists with correct settings.""" diff --git a/spp_dashboard/tests/test_dashboard_refresh.py b/spp_dashboard/tests/test_dashboard_refresh.py index 30409112..dc2a9aea 100644 --- a/spp_dashboard/tests/test_dashboard_refresh.py +++ b/spp_dashboard/tests/test_dashboard_refresh.py @@ -39,9 +39,8 @@ def setUpClass(cls): }) cls.area = cls.env["spp.area"].create({ - "name": "Refresh Area", - "code": "refresh_area", - "area_level": 1, + "draft_name": "Refresh Area", + "code": "refresh_area_dash", }) def _mock_aggregation_result(self, value=100, suppressed=False, total_count=50): @@ -275,15 +274,18 @@ def test_get_dashboard_areas_all(self): def test_get_dashboard_areas_filtered(self): """Test _get_dashboard_areas filters by area_levels system parameter.""" + # Our test area is a root area (area_level=0) self.env["ir.config_parameter"].sudo().set_param( - "spp_dashboard.area_levels", "1" + "spp_dashboard.area_levels", "0" ) DashData = self.env["spp.dashboard.data"] areas = DashData._get_dashboard_areas() for area in areas: - self.assertEqual(area.area_level, 1) + self.assertEqual(area.area_level, 0) + + self.assertIn(self.area, areas) # Clean up self.env["ir.config_parameter"].sudo().set_param( From 0aed1745cfeeb72da80c566e450cec4ab192e2d5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:48:20 +0700 Subject: [PATCH 05/18] fix(spp_dashboard): use functional unique index and fix Odoo 19 field names Replace models.Constraint with COALESCE-based unique index in init() to handle NULL area_id/program_id correctly. Fix groups_id -> group_ids for Odoo 19 user model. --- spp_dashboard/models/dashboard_data.py | 18 +++++++++++------- spp_dashboard/tests/test_dashboard_access.py | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/spp_dashboard/models/dashboard_data.py b/spp_dashboard/models/dashboard_data.py index 6be28136..e25869c4 100644 --- a/spp_dashboard/models/dashboard_data.py +++ b/spp_dashboard/models/dashboard_data.py @@ -21,6 +21,17 @@ class DashboardData(models.Model): def init(self): """Create database indexes for dashboard query performance.""" + # Unique index using COALESCE to treat NULLs as 0 for uniqueness + # (PostgreSQL UNIQUE considers NULLs as distinct by default) + self.env.cr.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS idx_dashboard_data_stat_area_prog + ON spp_dashboard_data( + statistic_id, + COALESCE(area_id, 0), + COALESCE(program_id, 0) + ) + """) + # Composite index for grouped list view (category + area filtering) self.env.cr.execute(""" CREATE INDEX IF NOT EXISTS idx_dashboard_data_category_area @@ -145,13 +156,6 @@ def init(self): help="When this value was last computed", ) - # ─── Constraints ───────────────────────────────────────────────────── - - _statistic_area_program_unique = models.Constraint( - "UNIQUE(statistic_id, area_id, program_id)", - "Duplicate dashboard data row for this statistic/area/program combination.", - ) - # ─── Refresh Logic ─────────────────────────────────────────────────── @api.model diff --git a/spp_dashboard/tests/test_dashboard_access.py b/spp_dashboard/tests/test_dashboard_access.py index 6b8ef307..0217efa6 100644 --- a/spp_dashboard/tests/test_dashboard_access.py +++ b/spp_dashboard/tests/test_dashboard_access.py @@ -158,9 +158,9 @@ def test_manager_implies_viewer(self): def test_viewer_has_read_group(self): """Test that viewer user has the technical read group.""" read_group = self.env.ref("spp_dashboard.group_dashboard_read") - self.assertIn(read_group, self.viewer_user.groups_id) + self.assertIn(read_group, self.viewer_user.group_ids) def test_manager_has_manage_group(self): """Test that manager user has the technical manage group.""" manage_group = self.env.ref("spp_dashboard.group_dashboard_manage") - self.assertIn(manage_group, self.manager_user.groups_id) + self.assertIn(manage_group, self.manager_user.group_ids) From 4ab80b71ee2e181a31add0dbeac76b7190647a70 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:50:00 +0700 Subject: [PATCH 06/18] fix(spp_dashboard): fix access tests for has_group and unique constraint Use has_group() for group membership checks instead of assertIn on group_ids. Avoid unique constraint collision in manager_can_create test. --- spp_dashboard/tests/test_dashboard_access.py | 29 +++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/spp_dashboard/tests/test_dashboard_access.py b/spp_dashboard/tests/test_dashboard_access.py index 0217efa6..2708bf72 100644 --- a/spp_dashboard/tests/test_dashboard_access.py +++ b/spp_dashboard/tests/test_dashboard_access.py @@ -112,11 +112,24 @@ def test_manager_can_write(self): def test_manager_can_create(self): """Test that manager can create dashboard data.""" + # Create a separate statistic to avoid unique constraint with setUpClass data + cel_var = self.env["spp.cel.variable"].create({ + "name": "access_create_var", + "cel_accessor": "access_create_var", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + }) + stat = self.env["spp.statistic"].create({ + "name": "access_create_stat", + "label": "Access Create Stat", + "variable_id": cel_var.id, + "is_published_dashboard": True, + }) + DashData = self.env["spp.dashboard.data"].with_user(self.manager_user) data = DashData.create({ - "statistic_id": self.statistic.id, - "area_id": False, - "program_id": False, + "statistic_id": stat.id, "value": 55.0, "value_display": "55", "label": "Manager Created", @@ -157,10 +170,12 @@ def test_manager_implies_viewer(self): def test_viewer_has_read_group(self): """Test that viewer user has the technical read group.""" - read_group = self.env.ref("spp_dashboard.group_dashboard_read") - self.assertIn(read_group, self.viewer_user.group_ids) + self.assertTrue( + self.viewer_user.has_group("spp_dashboard.group_dashboard_read") + ) def test_manager_has_manage_group(self): """Test that manager user has the technical manage group.""" - manage_group = self.env.ref("spp_dashboard.group_dashboard_manage") - self.assertIn(manage_group, self.manager_user.group_ids) + self.assertTrue( + self.manager_user.has_group("spp_dashboard.group_dashboard_manage") + ) From 06e1456f730960cde838b242a002bdf0749bf9b0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:52:00 +0700 Subject: [PATCH 07/18] chore(spp_dashboard): apply ruff and prettier formatting --- spp_dashboard/models/dashboard_data.py | 49 ++-- spp_dashboard/tests/test_dashboard_access.py | 206 ++++++++------- spp_dashboard/tests/test_dashboard_data.py | 242 ++++++++++-------- spp_dashboard/tests/test_dashboard_refresh.py | 206 ++++++++------- spp_dashboard/views/dashboard_data_views.xml | 35 ++- 5 files changed, 421 insertions(+), 317 deletions(-) diff --git a/spp_dashboard/models/dashboard_data.py b/spp_dashboard/models/dashboard_data.py index e25869c4..a908208a 100644 --- a/spp_dashboard/models/dashboard_data.py +++ b/spp_dashboard/models/dashboard_data.py @@ -199,9 +199,7 @@ def action_refresh_all(self): "tag": "display_notification", "params": { "title": _("Dashboard Refresh"), - "message": _( - "Statistics refresh has been queued. Data will update shortly." - ), + "message": _("Statistics refresh has been queued. Data will update shortly."), "type": "success", "sticky": False, }, @@ -251,18 +249,14 @@ def _get_dashboard_areas(self): by admin level (comma-separated integers). If not set, includes all areas. """ - param = self.env["ir.config_parameter"].sudo().get_param( - "spp_dashboard.area_levels", "" - ) + param = self.env["ir.config_parameter"].sudo().get_param("spp_dashboard.area_levels", "") domain = [] if param.strip(): try: levels = [int(level.strip()) for level in param.split(",")] domain = [("area_level", "in", levels)] except ValueError: - _logger.warning( - "Invalid spp_dashboard.area_levels parameter: %s", param - ) + _logger.warning("Invalid spp_dashboard.area_levels parameter: %s", param) return self.env["spp.area"].search(domain) @api.model @@ -338,11 +332,14 @@ def _upsert_data(self, stat, area, program, result): now = fields.Datetime.now() # Try to find existing record - existing = self.search([ - ("statistic_id", "=", stat.id), - ("area_id", "=", area_id), - ("program_id", "=", program_id), - ], limit=1) + existing = self.search( + [ + ("statistic_id", "=", stat.id), + ("area_id", "=", area_id), + ("program_id", "=", program_id), + ], + limit=1, + ) vals = { "value": numeric_value, @@ -356,11 +353,13 @@ def _upsert_data(self, stat, area, program, result): if existing: existing.write(vals) else: - vals.update({ - "statistic_id": stat.id, - "area_id": area_id, - "program_id": program_id, - }) + vals.update( + { + "statistic_id": stat.id, + "area_id": area_id, + "program_id": program_id, + } + ) self.create(vals) @api.model @@ -401,11 +400,11 @@ def _cleanup_stale_data(self, published_stats): Args: published_stats: recordset of currently published spp.statistic """ - stale = self.search([ - ("statistic_id", "not in", published_stats.ids), - ]) + stale = self.search( + [ + ("statistic_id", "not in", published_stats.ids), + ] + ) if stale: - _logger.info( - "Cleaning up %d stale dashboard data rows", len(stale) - ) + _logger.info("Cleaning up %d stale dashboard data rows", len(stale)) stale.unlink() diff --git a/spp_dashboard/tests/test_dashboard_access.py b/spp_dashboard/tests/test_dashboard_access.py index 2708bf72..d345ea9c 100644 --- a/spp_dashboard/tests/test_dashboard_access.py +++ b/spp_dashboard/tests/test_dashboard_access.py @@ -13,63 +13,77 @@ class TestDashboardAccess(TransactionCase): def setUpClass(cls): super().setUpClass() - cls.category = cls.env["spp.metric.category"].create({ - "name": "Access Test Category", - "code": "access_test_cat", - }) - - cls.cel_variable = cls.env["spp.cel.variable"].create({ - "name": "access_test_var", - "cel_accessor": "access_test_var", - "source_type": "computed", - "cel_expression": "true", - "state": "active", - }) - - cls.statistic = cls.env["spp.statistic"].create({ - "name": "access_test_stat", - "label": "Access Test Stat", - "variable_id": cls.cel_variable.id, - "category_id": cls.category.id, - "is_published_dashboard": True, - }) - - cls.dashboard_data = cls.env["spp.dashboard.data"].create({ - "statistic_id": cls.statistic.id, - "value": 42.0, - "value_display": "42", - "label": "Access Test Stat", - }) + cls.category = cls.env["spp.metric.category"].create( + { + "name": "Access Test Category", + "code": "access_test_cat", + } + ) + + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "access_test_var", + "cel_accessor": "access_test_var", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + } + ) + + cls.statistic = cls.env["spp.statistic"].create( + { + "name": "access_test_stat", + "label": "Access Test Stat", + "variable_id": cls.cel_variable.id, + "category_id": cls.category.id, + "is_published_dashboard": True, + } + ) + + cls.dashboard_data = cls.env["spp.dashboard.data"].create( + { + "statistic_id": cls.statistic.id, + "value": 42.0, + "value_display": "42", + "label": "Access Test Stat", + } + ) # Create test users viewer_group = cls.env.ref("spp_dashboard.group_dashboard_viewer") - cls.viewer_user = cls.env["res.users"].create({ - "name": "Dashboard Viewer", - "login": "test_dashboard_viewer", - "group_ids": [ - Command.link(cls.env.ref("base.group_user").id), - Command.link(viewer_group.id), - ], - }) + cls.viewer_user = cls.env["res.users"].create( + { + "name": "Dashboard Viewer", + "login": "test_dashboard_viewer", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(viewer_group.id), + ], + } + ) manager_group = cls.env.ref("spp_dashboard.group_dashboard_manager") - cls.manager_user = cls.env["res.users"].create({ - "name": "Dashboard Manager", - "login": "test_dashboard_manager", - "group_ids": [ - Command.link(cls.env.ref("base.group_user").id), - Command.link(manager_group.id), - ], - }) + cls.manager_user = cls.env["res.users"].create( + { + "name": "Dashboard Manager", + "login": "test_dashboard_manager", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(manager_group.id), + ], + } + ) # Create a user with no dashboard group - cls.no_access_user = cls.env["res.users"].create({ - "name": "No Access User", - "login": "test_no_dashboard_access", - "group_ids": [ - Command.link(cls.env.ref("base.group_user").id), - ], - }) + cls.no_access_user = cls.env["res.users"].create( + { + "name": "No Access User", + "login": "test_no_dashboard_access", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + ], + } + ) def test_viewer_can_read(self): """Test that viewer can read dashboard data.""" @@ -86,12 +100,14 @@ def test_viewer_cannot_create(self): """Test that viewer cannot create dashboard data.""" DashData = self.env["spp.dashboard.data"].with_user(self.viewer_user) with self.assertRaises(AccessError): - DashData.create({ - "statistic_id": self.statistic.id, - "value": 1.0, - "value_display": "1", - "label": "Forbidden", - }) + DashData.create( + { + "statistic_id": self.statistic.id, + "value": 1.0, + "value_display": "1", + "label": "Forbidden", + } + ) def test_viewer_cannot_unlink(self): """Test that viewer cannot delete dashboard data.""" @@ -113,41 +129,53 @@ def test_manager_can_write(self): def test_manager_can_create(self): """Test that manager can create dashboard data.""" # Create a separate statistic to avoid unique constraint with setUpClass data - cel_var = self.env["spp.cel.variable"].create({ - "name": "access_create_var", - "cel_accessor": "access_create_var", - "source_type": "computed", - "cel_expression": "true", - "state": "active", - }) - stat = self.env["spp.statistic"].create({ - "name": "access_create_stat", - "label": "Access Create Stat", - "variable_id": cel_var.id, - "is_published_dashboard": True, - }) + cel_var = self.env["spp.cel.variable"].create( + { + "name": "access_create_var", + "cel_accessor": "access_create_var", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + } + ) + stat = self.env["spp.statistic"].create( + { + "name": "access_create_stat", + "label": "Access Create Stat", + "variable_id": cel_var.id, + "is_published_dashboard": True, + } + ) DashData = self.env["spp.dashboard.data"].with_user(self.manager_user) - data = DashData.create({ - "statistic_id": stat.id, - "value": 55.0, - "value_display": "55", - "label": "Manager Created", - }) + data = DashData.create( + { + "statistic_id": stat.id, + "value": 55.0, + "value_display": "55", + "label": "Manager Created", + } + ) self.assertTrue(data.exists()) def test_manager_can_unlink(self): """Test that manager can delete dashboard data.""" - data = self.env["spp.dashboard.data"].create({ - "statistic_id": self.statistic.id, - "value": 77.0, - "value_display": "77", - "label": "To Delete", - "area_id": self.env["spp.area"].create({ - "draft_name": "Delete Test Area", - "code": "delete_test_area_dash", - }).id, - }) + data = self.env["spp.dashboard.data"].create( + { + "statistic_id": self.statistic.id, + "value": 77.0, + "value_display": "77", + "label": "To Delete", + "area_id": self.env["spp.area"] + .create( + { + "draft_name": "Delete Test Area", + "code": "delete_test_area_dash", + } + ) + .id, + } + ) data_as_manager = data.with_user(self.manager_user) data_as_manager.unlink() @@ -170,12 +198,8 @@ def test_manager_implies_viewer(self): def test_viewer_has_read_group(self): """Test that viewer user has the technical read group.""" - self.assertTrue( - self.viewer_user.has_group("spp_dashboard.group_dashboard_read") - ) + self.assertTrue(self.viewer_user.has_group("spp_dashboard.group_dashboard_read")) def test_manager_has_manage_group(self): """Test that manager user has the technical manage group.""" - self.assertTrue( - self.manager_user.has_group("spp_dashboard.group_dashboard_manage") - ) + self.assertTrue(self.manager_user.has_group("spp_dashboard.group_dashboard_manage")) diff --git a/spp_dashboard/tests/test_dashboard_data.py b/spp_dashboard/tests/test_dashboard_data.py index cda1fede..a518422f 100644 --- a/spp_dashboard/tests/test_dashboard_data.py +++ b/spp_dashboard/tests/test_dashboard_data.py @@ -1,8 +1,10 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for spp.dashboard.data model creation, constraints, and formatting.""" -from odoo.exceptions import ValidationError +from psycopg2 import IntegrityError + from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger class TestDashboardData(TransactionCase): @@ -12,42 +14,52 @@ class TestDashboardData(TransactionCase): def setUpClass(cls): super().setUpClass() - cls.category = cls.env["spp.metric.category"].create({ - "name": "Demographics", - "code": "demographics", - }) - - cls.cel_variable = cls.env["spp.cel.variable"].create({ - "name": "test_dashboard_var", - "cel_accessor": "test_dashboard_var", - "source_type": "computed", - "cel_expression": "true", - "state": "active", - }) - - cls.statistic = cls.env["spp.statistic"].create({ - "name": "test_stat_dashboard", - "label": "Test Stat", - "variable_id": cls.cel_variable.id, - "category_id": cls.category.id, - "format": "count", - "unit": "people", - "is_published_dashboard": True, - }) - - cls.area = cls.env["spp.area"].create({ - "draft_name": "Test Area", - "code": "test_area_dash", - }) + cls.category = cls.env["spp.metric.category"].create( + { + "name": "Demographics", + "code": "demographics", + } + ) + + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "test_dashboard_var", + "cel_accessor": "test_dashboard_var", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + } + ) + + cls.statistic = cls.env["spp.statistic"].create( + { + "name": "test_stat_dashboard", + "label": "Test Stat", + "variable_id": cls.cel_variable.id, + "category_id": cls.category.id, + "format": "count", + "unit": "people", + "is_published_dashboard": True, + } + ) + + cls.area = cls.env["spp.area"].create( + { + "draft_name": "Test Area", + "code": "test_area_dash", + } + ) def test_create_dashboard_data(self): """Test creating a dashboard data record.""" - data = self.env["spp.dashboard.data"].create({ - "statistic_id": self.statistic.id, - "value": 42.0, - "value_display": "42", - "label": "Test Stat", - }) + data = self.env["spp.dashboard.data"].create( + { + "statistic_id": self.statistic.id, + "value": 42.0, + "value_display": "42", + "label": "Test Stat", + } + ) self.assertEqual(data.statistic_name, "test_stat_dashboard") self.assertEqual(data.category_id, self.category) @@ -60,13 +72,15 @@ def test_create_dashboard_data(self): def test_create_with_area(self): """Test creating dashboard data with area scope.""" - data = self.env["spp.dashboard.data"].create({ - "statistic_id": self.statistic.id, - "area_id": self.area.id, - "value": 10.0, - "value_display": "10", - "label": "Test Stat", - }) + data = self.env["spp.dashboard.data"].create( + { + "statistic_id": self.statistic.id, + "area_id": self.area.id, + "value": 10.0, + "value_display": "10", + "label": "Test Stat", + } + ) # area_name is computed from draft_name + code self.assertIn("Test Area", data.area_name) @@ -75,44 +89,54 @@ def test_create_with_area(self): def test_unique_constraint(self): """Test SQL unique constraint on (statistic_id, area_id, program_id).""" - self.env["spp.dashboard.data"].create({ - "statistic_id": self.statistic.id, - "area_id": self.area.id, - "value": 10.0, - "value_display": "10", - "label": "Test Stat", - }) - - with self.assertRaises(Exception): - self.env["spp.dashboard.data"].create({ + self.env["spp.dashboard.data"].create( + { "statistic_id": self.statistic.id, "area_id": self.area.id, - "value": 20.0, - "value_display": "20", + "value": 10.0, + "value_display": "10", "label": "Test Stat", - }) + } + ) + + with self.assertRaises(IntegrityError), mute_logger("odoo.sql_db"): + self.env["spp.dashboard.data"].create( + { + "statistic_id": self.statistic.id, + "area_id": self.area.id, + "value": 20.0, + "value_display": "20", + "label": "Test Stat", + } + ) def test_cascade_delete_statistic(self): """Test that deleting a statistic cascades to dashboard data.""" - cel_var = self.env["spp.cel.variable"].create({ - "name": "cascade_test_var", - "cel_accessor": "cascade_test_var", - "source_type": "computed", - "cel_expression": "true", - "state": "active", - }) - stat = self.env["spp.statistic"].create({ - "name": "cascade_test_stat", - "label": "Cascade Test", - "variable_id": cel_var.id, - "is_published_dashboard": True, - }) - data = self.env["spp.dashboard.data"].create({ - "statistic_id": stat.id, - "value": 5.0, - "value_display": "5", - "label": "Cascade Test", - }) + cel_var = self.env["spp.cel.variable"].create( + { + "name": "cascade_test_var", + "cel_accessor": "cascade_test_var", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + } + ) + stat = self.env["spp.statistic"].create( + { + "name": "cascade_test_stat", + "label": "Cascade Test", + "variable_id": cel_var.id, + "is_published_dashboard": True, + } + ) + data = self.env["spp.dashboard.data"].create( + { + "statistic_id": stat.id, + "value": 5.0, + "value_display": "5", + "label": "Cascade Test", + } + ) data_id = data.id stat.unlink() @@ -120,17 +144,21 @@ def test_cascade_delete_statistic(self): def test_cascade_delete_area(self): """Test that deleting an area cascades to dashboard data.""" - area = self.env["spp.area"].create({ - "draft_name": "Cascade Area", - "code": "cascade_area_dash", - }) - data = self.env["spp.dashboard.data"].create({ - "statistic_id": self.statistic.id, - "area_id": area.id, - "value": 7.0, - "value_display": "7", - "label": "Test", - }) + area = self.env["spp.area"].create( + { + "draft_name": "Cascade Area", + "code": "cascade_area_dash", + } + ) + data = self.env["spp.dashboard.data"].create( + { + "statistic_id": self.statistic.id, + "area_id": area.id, + "value": 7.0, + "value_display": "7", + "label": "Test", + } + ) data_id = data.id area.unlink() @@ -145,13 +173,15 @@ def test_format_value_count(self): def test_format_value_percent(self): """Test value formatting for percent format.""" DashData = self.env["spp.dashboard.data"] - stat = self.env["spp.statistic"].create({ - "name": "pct_stat", - "label": "Pct Stat", - "variable_id": self.cel_variable.id, - "format": "percent", - "decimal_places": 1, - }) + stat = self.env["spp.statistic"].create( + { + "name": "pct_stat", + "label": "Pct Stat", + "variable_id": self.cel_variable.id, + "format": "percent", + "decimal_places": 1, + } + ) result = DashData._format_value(75.5, stat) self.assertEqual(result, "75.5%") @@ -163,22 +193,28 @@ def test_format_value_none(self): def test_related_fields_stored(self): """Test that related fields are stored correctly for search/export.""" - data = self.env["spp.dashboard.data"].create({ - "statistic_id": self.statistic.id, - "value": 1.0, - "value_display": "1", - "label": "Test", - }) + data = self.env["spp.dashboard.data"].create( + { + "statistic_id": self.statistic.id, + "value": 1.0, + "value_display": "1", + "label": "Test", + } + ) # Verify stored related fields are searchable - found = self.env["spp.dashboard.data"].search([ - ("statistic_name", "=", "test_stat_dashboard"), - ]) + found = self.env["spp.dashboard.data"].search( + [ + ("statistic_name", "=", "test_stat_dashboard"), + ] + ) self.assertIn(data, found) - found = self.env["spp.dashboard.data"].search([ - ("category_code", "=", "demographics"), - ]) + found = self.env["spp.dashboard.data"].search( + [ + ("category_code", "=", "demographics"), + ] + ) self.assertIn(data, found) diff --git a/spp_dashboard/tests/test_dashboard_refresh.py b/spp_dashboard/tests/test_dashboard_refresh.py index dc2a9aea..217a5ea3 100644 --- a/spp_dashboard/tests/test_dashboard_refresh.py +++ b/spp_dashboard/tests/test_dashboard_refresh.py @@ -13,35 +13,43 @@ class TestDashboardRefresh(TransactionCase): def setUpClass(cls): super().setUpClass() - cls.category = cls.env["spp.metric.category"].create({ - "name": "Test Category", - "code": "test_refresh_cat", - }) - - cls.cel_variable = cls.env["spp.cel.variable"].create({ - "name": "refresh_test_var", - "cel_accessor": "refresh_test_var", - "source_type": "computed", - "cel_expression": "true", - "state": "active", - }) - - cls.statistic = cls.env["spp.statistic"].create({ - "name": "refresh_test_stat", - "label": "Refresh Test Stat", - "variable_id": cls.cel_variable.id, - "category_id": cls.category.id, - "format": "count", - "unit": "people", - "minimum_count": 5, - "suppression_display": "less_than", - "is_published_dashboard": True, - }) - - cls.area = cls.env["spp.area"].create({ - "draft_name": "Refresh Area", - "code": "refresh_area_dash", - }) + cls.category = cls.env["spp.metric.category"].create( + { + "name": "Test Category", + "code": "test_refresh_cat", + } + ) + + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "refresh_test_var", + "cel_accessor": "refresh_test_var", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + } + ) + + cls.statistic = cls.env["spp.statistic"].create( + { + "name": "refresh_test_stat", + "label": "Refresh Test Stat", + "variable_id": cls.cel_variable.id, + "category_id": cls.category.id, + "format": "count", + "unit": "people", + "minimum_count": 5, + "suppression_display": "less_than", + "is_published_dashboard": True, + } + ) + + cls.area = cls.env["spp.area"].create( + { + "draft_name": "Refresh Area", + "code": "refresh_area_dash", + } + ) def _mock_aggregation_result(self, value=100, suppressed=False, total_count=50): """Build a mock aggregation result dict.""" @@ -71,11 +79,13 @@ def test_refresh_statistic_creates_data(self): DashData._refresh_statistic(self.statistic.id, [], []) # Should create one row: system-wide, no program - data = DashData.search([ - ("statistic_id", "=", self.statistic.id), - ("area_id", "=", False), - ("program_id", "=", False), - ]) + data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("area_id", "=", False), + ("program_id", "=", False), + ] + ) self.assertEqual(len(data), 1) self.assertEqual(data.value, 100.0) self.assertEqual(data.value_display, "100") @@ -96,9 +106,11 @@ def test_refresh_statistic_with_area(self): DashData._refresh_statistic(self.statistic.id, [self.area.id], []) # Should create 2 rows: system-wide + one area - data = DashData.search([ - ("statistic_id", "=", self.statistic.id), - ]) + data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ] + ) self.assertEqual(len(data), 2) area_data = data.filtered(lambda d: d.area_id == self.area) @@ -128,11 +140,13 @@ def test_refresh_statistic_upserts(self): DashData._refresh_statistic(self.statistic.id, [], []) # Should still be one row, with updated value - data = DashData.search([ - ("statistic_id", "=", self.statistic.id), - ("area_id", "=", False), - ("program_id", "=", False), - ]) + data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("area_id", "=", False), + ("program_id", "=", False), + ] + ) self.assertEqual(len(data), 1) self.assertEqual(data.value, 200.0) @@ -148,11 +162,13 @@ def test_refresh_statistic_suppressed_value(self): ): DashData._refresh_statistic(self.statistic.id, [], []) - data = DashData.search([ - ("statistic_id", "=", self.statistic.id), - ("area_id", "=", False), - ("program_id", "=", False), - ]) + data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("area_id", "=", False), + ("program_id", "=", False), + ] + ) self.assertEqual(len(data), 1) self.assertTrue(data.is_suppressed) # The display value should reflect suppression @@ -178,19 +194,23 @@ def mock_compute(self_svc, scope, statistics=None, context=None, **kwargs): DashData._refresh_statistic(self.statistic.id, [self.area.id], []) # System-wide row should exist despite area failure - system_wide = DashData.search([ - ("statistic_id", "=", self.statistic.id), - ("area_id", "=", False), - ("program_id", "=", False), - ]) + system_wide = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("area_id", "=", False), + ("program_id", "=", False), + ] + ) self.assertEqual(len(system_wide), 1) self.assertEqual(system_wide.value, 50.0) # Area row should NOT exist due to error - area_data = DashData.search([ - ("statistic_id", "=", self.statistic.id), - ("area_id", "=", self.area.id), - ]) + area_data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("area_id", "=", self.area.id), + ] + ) self.assertEqual(len(area_data), 0) def test_refresh_nonexistent_statistic(self): @@ -205,26 +225,32 @@ def test_cleanup_stale_data(self): DashData = self.env["spp.dashboard.data"] # Create an un-published statistic with dashboard data - unpub_stat = self.env["spp.statistic"].create({ - "name": "unpub_stat", - "label": "Unpublished Stat", - "variable_id": self.cel_variable.id, - "is_published_dashboard": False, - }) - stale_data = DashData.create({ - "statistic_id": unpub_stat.id, - "value": 99.0, - "value_display": "99", - "label": "Stale", - }) + unpub_stat = self.env["spp.statistic"].create( + { + "name": "unpub_stat", + "label": "Unpublished Stat", + "variable_id": self.cel_variable.id, + "is_published_dashboard": False, + } + ) + stale_data = DashData.create( + { + "statistic_id": unpub_stat.id, + "value": 99.0, + "value_display": "99", + "label": "Stale", + } + ) # Also create data for the published statistic - fresh_data = DashData.create({ - "statistic_id": self.statistic.id, - "value": 10.0, - "value_display": "10", - "label": "Fresh", - }) + fresh_data = DashData.create( + { + "statistic_id": self.statistic.id, + "value": 10.0, + "value_display": "10", + "label": "Fresh", + } + ) published_stats = self.env["spp.statistic"].get_published_for_context("dashboard") DashData._cleanup_stale_data(published_stats) @@ -275,9 +301,7 @@ def test_get_dashboard_areas_all(self): def test_get_dashboard_areas_filtered(self): """Test _get_dashboard_areas filters by area_levels system parameter.""" # Our test area is a root area (area_level=0) - self.env["ir.config_parameter"].sudo().set_param( - "spp_dashboard.area_levels", "0" - ) + self.env["ir.config_parameter"].sudo().set_param("spp_dashboard.area_levels", "0") DashData = self.env["spp.dashboard.data"] areas = DashData._get_dashboard_areas() @@ -288,9 +312,7 @@ def test_get_dashboard_areas_filtered(self): self.assertIn(self.area, areas) # Clean up - self.env["ir.config_parameter"].sudo().set_param( - "spp_dashboard.area_levels", "" - ) + self.env["ir.config_parameter"].sudo().set_param("spp_dashboard.area_levels", "") def test_get_dashboard_programs(self): """Test _get_dashboard_programs returns active programs.""" @@ -327,11 +349,13 @@ def test_build_scope_with_program(self): def test_label_from_context_config(self): """Test that label is taken from statistic context config.""" # Create a context config with a dashboard label override - self.env["spp.statistic.context"].create({ - "statistic_id": self.statistic.id, - "context": "dashboard", - "label": "Dashboard Custom Label", - }) + self.env["spp.statistic.context"].create( + { + "statistic_id": self.statistic.id, + "context": "dashboard", + "label": "Dashboard Custom Label", + } + ) DashData = self.env["spp.dashboard.data"] mock_result = self._mock_aggregation_result(value=42, total_count=42) @@ -343,9 +367,11 @@ def test_label_from_context_config(self): ): DashData._refresh_statistic(self.statistic.id, [], []) - data = DashData.search([ - ("statistic_id", "=", self.statistic.id), - ("area_id", "=", False), - ("program_id", "=", False), - ]) + data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("area_id", "=", False), + ("program_id", "=", False), + ] + ) self.assertEqual(data.label, "Dashboard Custom Label") diff --git a/spp_dashboard/views/dashboard_data_views.xml b/spp_dashboard/views/dashboard_data_views.xml index dfc365d9..4856aae9 100644 --- a/spp_dashboard/views/dashboard_data_views.xml +++ b/spp_dashboard/views/dashboard_data_views.xml @@ -1,6 +1,5 @@ - @@ -30,7 +29,10 @@
-
+
@@ -50,7 +52,9 @@
-
+
@@ -131,10 +135,26 @@ - - - - + + + + @@ -202,5 +222,4 @@ scheduled daily refresh.

- From 0c6705320b5e3679a4f71364c692681b8cae3001 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 10:55:31 +0700 Subject: [PATCH 08/18] fix(spp_dashboard): narrow exception handling and fix ACL entry IDs Narrow except clause in _refresh_statistic to catch only expected errors (ValueError, TypeError, KeyError, AttributeError) instead of broad Exception. Fix ACL entry IDs to follow access_{model}_{group} naming convention. --- spp_dashboard/models/dashboard_data.py | 7 ++++--- spp_dashboard/security/ir.model.access.csv | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/spp_dashboard/models/dashboard_data.py b/spp_dashboard/models/dashboard_data.py index a908208a..d34c0cae 100644 --- a/spp_dashboard/models/dashboard_data.py +++ b/spp_dashboard/models/dashboard_data.py @@ -233,12 +233,13 @@ def _refresh_statistic(self, stat_id, area_ids, program_ids): context="dashboard", ) self._upsert_data(stat, area, program, result) - except Exception: - _logger.exception( - "Dashboard refresh failed for stat=%s area=%s program=%s", + except (ValueError, TypeError, KeyError, AttributeError) as e: + _logger.warning( + "Dashboard refresh failed for stat=%s area=%s program=%s: %s", stat.name, area.code if area else "all", program.name if program else "all", + e, ) @api.model diff --git a/spp_dashboard/security/ir.model.access.csv b/spp_dashboard/security/ir.model.access.csv index 3f31b625..40946448 100644 --- a/spp_dashboard/security/ir.model.access.csv +++ b/spp_dashboard/security/ir.model.access.csv @@ -1,3 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_dashboard_data_read,Dashboard Data Viewer Access,model_spp_dashboard_data,group_dashboard_read,1,0,0,0 -access_dashboard_data_manage,Dashboard Data Manager Access,model_spp_dashboard_data,group_dashboard_manage,1,1,1,1 +access_spp_dashboard_data_read,Dashboard Data Viewer Access,model_spp_dashboard_data,group_dashboard_read,1,0,0,0 +access_spp_dashboard_data_manage,Dashboard Data Manager Access,model_spp_dashboard_data,group_dashboard_manage,1,1,1,1 From 95ff93bcd1e218e0e59bed289a2876c82b402db6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 15:40:48 +0700 Subject: [PATCH 09/18] fix(spp_dashboard): fix system-wide scope and suppression display Three issues found during live testing with demo data: 1. System-wide scope used scope_type "area" with area_id=False, which the area resolver treats as empty (returns 0 registrants). Changed to scope_type "cel" with expression "true" to match all registrants. 2. Double suppression: aggregation service already applies suppression and returns display strings (e.g., "<5"). _upsert_data then called stat.apply_suppression() again. Removed the redundant call. 3. Refresh menu not accessible when dashboard is empty (chicken-and-egg). Added "Refresh Statistics" as a dedicated submenu item, always accessible to managers. Also removed program_id iteration from refresh since the scope resolver does not support program filtering (was creating duplicate identical rows). --- spp_dashboard/models/dashboard_data.py | 85 +++++++++---------- spp_dashboard/tests/test_dashboard_refresh.py | 32 +++---- spp_dashboard/views/dashboard_data_views.xml | 4 +- spp_dashboard/views/menus.xml | 16 +++- 4 files changed, 66 insertions(+), 71 deletions(-) diff --git a/spp_dashboard/models/dashboard_data.py b/spp_dashboard/models/dashboard_data.py index d34c0cae..f42abb1a 100644 --- a/spp_dashboard/models/dashboard_data.py +++ b/spp_dashboard/models/dashboard_data.py @@ -183,16 +183,15 @@ def action_refresh_all(self): self._cleanup_stale_data(stats) areas = self._get_dashboard_areas() - programs = self._get_dashboard_programs() for stat in stats: if hasattr(self, "with_delay"): self.with_delay( priority=10, description=f"Dashboard refresh: {stat.label}", - )._refresh_statistic(stat.id, areas.ids, programs.ids) + )._refresh_statistic(stat.id, areas.ids) else: - self._refresh_statistic(stat.id, areas.ids, programs.ids) + self._refresh_statistic(stat.id, areas.ids) return { "type": "ir.actions.client", @@ -205,10 +204,10 @@ def action_refresh_all(self): }, } - def _refresh_statistic(self, stat_id, area_ids, program_ids): - """Refresh one statistic across all area/program combinations. + def _refresh_statistic(self, stat_id, area_ids): + """Refresh one statistic across all area combinations. - Called as a queue_job. Handles errors per-combination so one failure + Called as a queue_job. Handles errors per-area so one failure does not abort the entire statistic refresh. """ stat = self.env["spp.statistic"].browse(stat_id) @@ -217,30 +216,26 @@ def _refresh_statistic(self, stat_id, area_ids, program_ids): return areas = self.env["spp.area"].browse(area_ids) - programs = self.env["spp.program"].browse(program_ids) - # False = system-wide / all programs + # False = system-wide area_list = [False] + list(areas) - program_list = [False] + list(programs) for area in area_list: - for program in program_list: - try: - scope = self._build_scope(area, program) - result = self.env["spp.aggregation.service"].compute_aggregation( - scope=scope, - statistics=[stat.name], - context="dashboard", - ) - self._upsert_data(stat, area, program, result) - except (ValueError, TypeError, KeyError, AttributeError) as e: - _logger.warning( - "Dashboard refresh failed for stat=%s area=%s program=%s: %s", - stat.name, - area.code if area else "all", - program.name if program else "all", - e, - ) + try: + scope = self._build_scope(area, False) + result = self.env["spp.aggregation.service"].compute_aggregation( + scope=scope, + statistics=[stat.name], + context="dashboard", + ) + self._upsert_data(stat, area, False, result) + except (ValueError, TypeError, KeyError, AttributeError) as e: + _logger.warning( + "Dashboard refresh failed for stat=%s area=%s: %s", + stat.name, + area.code if area else "all", + e, + ) @api.model def _get_dashboard_areas(self): @@ -271,29 +266,24 @@ def _build_scope(self, area, program): Args: area: spp.area record or False (system-wide) - program: spp.program record or False (all programs) + program: spp.program record or False (unused, kept for API compatibility) Returns: dict: scope definition for spp.aggregation.service """ if area: - scope = { + return { "scope_type": "area", "area_id": area.id, "include_child_areas": True, } - else: - # System-wide scope - scope = { - "scope_type": "area", - "area_id": False, - "include_child_areas": True, - } - if program: - scope["program_id"] = program.id - - return scope + # System-wide scope: use CEL expression that matches all registrants + return { + "scope_type": "cel", + "cel_expression": "true", + "cel_profile": "registry_individuals", + } def _upsert_data(self, stat, area, program, result): """Insert or update a dashboard data row from aggregation result. @@ -315,14 +305,15 @@ def _upsert_data(self, stat, area, program, result): config = stat.get_context_config("dashboard") label = config.get("label", stat.label) if config else stat.label - # Apply suppression for display value - if is_suppressed or raw_value is None: - display_value, _ = stat.apply_suppression( - raw_value if raw_value is not None else 0, - count=total_count, - context="dashboard", - ) - value_display = str(display_value) if display_value is not None else "" + # The aggregation service already applies suppression. + # If suppressed, the value is the suppression marker (e.g., "<5"). + # We store the raw numeric value for pivot/graph and a display string. + if is_suppressed: + # Value from service is the suppression display (string like "<5") + value_display = str(raw_value) if raw_value is not None else "" + numeric_value = 0.0 + elif raw_value is None: + value_display = "" numeric_value = 0.0 else: value_display = self._format_value(raw_value, stat) diff --git a/spp_dashboard/tests/test_dashboard_refresh.py b/spp_dashboard/tests/test_dashboard_refresh.py index 217a5ea3..f58ce967 100644 --- a/spp_dashboard/tests/test_dashboard_refresh.py +++ b/spp_dashboard/tests/test_dashboard_refresh.py @@ -76,7 +76,7 @@ def test_refresh_statistic_creates_data(self): "compute_aggregation", return_value=mock_result, ): - DashData._refresh_statistic(self.statistic.id, [], []) + DashData._refresh_statistic(self.statistic.id, []) # Should create one row: system-wide, no program data = DashData.search( @@ -103,7 +103,7 @@ def test_refresh_statistic_with_area(self): "compute_aggregation", return_value=mock_result, ): - DashData._refresh_statistic(self.statistic.id, [self.area.id], []) + DashData._refresh_statistic(self.statistic.id, [self.area.id]) # Should create 2 rows: system-wide + one area data = DashData.search( @@ -128,7 +128,7 @@ def test_refresh_statistic_upserts(self): "compute_aggregation", return_value=mock_result, ): - DashData._refresh_statistic(self.statistic.id, [], []) + DashData._refresh_statistic(self.statistic.id, []) # Second refresh with different value mock_result = self._mock_aggregation_result(value=200) @@ -137,7 +137,7 @@ def test_refresh_statistic_upserts(self): "compute_aggregation", return_value=mock_result, ): - DashData._refresh_statistic(self.statistic.id, [], []) + DashData._refresh_statistic(self.statistic.id, []) # Should still be one row, with updated value data = DashData.search( @@ -160,7 +160,7 @@ def test_refresh_statistic_suppressed_value(self): "compute_aggregation", return_value=mock_result, ): - DashData._refresh_statistic(self.statistic.id, [], []) + DashData._refresh_statistic(self.statistic.id, []) data = DashData.search( [ @@ -191,7 +191,7 @@ def mock_compute(self_svc, scope, statistics=None, context=None, **kwargs): "compute_aggregation", mock_compute, ): - DashData._refresh_statistic(self.statistic.id, [self.area.id], []) + DashData._refresh_statistic(self.statistic.id, [self.area.id]) # System-wide row should exist despite area failure system_wide = DashData.search( @@ -217,7 +217,7 @@ def test_refresh_nonexistent_statistic(self): """Test refresh with a deleted statistic does not crash.""" DashData = self.env["spp.dashboard.data"] # Use a non-existent ID - DashData._refresh_statistic(999999, [], []) + DashData._refresh_statistic(999999, []) # Should not raise, just log a warning def test_cleanup_stale_data(self): @@ -322,12 +322,11 @@ def test_get_dashboard_programs(self): self.assertEqual(prog.state, "active") def test_build_scope_system_wide(self): - """Test _build_scope with no area or program.""" + """Test _build_scope with no area returns CEL scope for all registrants.""" DashData = self.env["spp.dashboard.data"] scope = DashData._build_scope(False, False) - self.assertEqual(scope["scope_type"], "area") - self.assertFalse(scope["area_id"]) - self.assertNotIn("program_id", scope) + self.assertEqual(scope["scope_type"], "cel") + self.assertEqual(scope["cel_expression"], "true") def test_build_scope_with_area(self): """Test _build_scope with an area.""" @@ -337,15 +336,6 @@ def test_build_scope_with_area(self): self.assertEqual(scope["area_id"], self.area.id) self.assertTrue(scope["include_child_areas"]) - def test_build_scope_with_program(self): - """Test _build_scope with a program.""" - DashData = self.env["spp.dashboard.data"] - program = self.env["spp.program"].search([], limit=1) - if not program: - self.skipTest("No programs available for testing") - scope = DashData._build_scope(False, program) - self.assertEqual(scope["program_id"], program.id) - def test_label_from_context_config(self): """Test that label is taken from statistic context config.""" # Create a context config with a dashboard label override @@ -365,7 +355,7 @@ def test_label_from_context_config(self): "compute_aggregation", return_value=mock_result, ): - DashData._refresh_statistic(self.statistic.id, [], []) + DashData._refresh_statistic(self.statistic.id, []) data = DashData.search( [ diff --git a/spp_dashboard/views/dashboard_data_views.xml b/spp_dashboard/views/dashboard_data_views.xml index 4856aae9..df8e1fde 100644 --- a/spp_dashboard/views/dashboard_data_views.xml +++ b/spp_dashboard/views/dashboard_data_views.xml @@ -218,8 +218,8 @@ {'search_default_system_wide': 1}

No statistics data yet

-

Click "Refresh Data" to compute statistics, or wait for the - scheduled daily refresh.

+

Use the "Refresh Statistics" menu to compute statistics, + or wait for the scheduled daily refresh.

diff --git a/spp_dashboard/views/menus.xml b/spp_dashboard/views/menus.xml index 3ce6903c..2662ca24 100644 --- a/spp_dashboard/views/menus.xml +++ b/spp_dashboard/views/menus.xml @@ -3,9 +3,23 @@ + + From cd687d7b5d05f33ecf94ff0aaf5b5fe59ee363ab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 15:50:12 +0700 Subject: [PATCH 10/18] fix(spp_dashboard): use explicit scope for system-wide stats The CEL scope type fails because the scope resolver's env.get() check on the AbstractModel executor returns a falsy empty recordset, causing it to log "CEL executor not available" and return 0 registrants. This made all system-wide values show as suppressed (<5) regardless of actual population size. Fix: for system-wide scope, query all registrant IDs directly and use the "explicit" scope type which doesn't depend on the CEL executor. Add integration tests that call through the real aggregation pipeline (no mocks) to catch scope resolution and suppression issues. --- spp_dashboard/models/dashboard_data.py | 10 +- spp_dashboard/tests/__init__.py | 1 + .../tests/test_dashboard_integration.py | 158 ++++++++++++++++++ spp_dashboard/tests/test_dashboard_refresh.py | 8 +- 4 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 spp_dashboard/tests/test_dashboard_integration.py diff --git a/spp_dashboard/models/dashboard_data.py b/spp_dashboard/models/dashboard_data.py index f42abb1a..837e02f3 100644 --- a/spp_dashboard/models/dashboard_data.py +++ b/spp_dashboard/models/dashboard_data.py @@ -278,11 +278,13 @@ def _build_scope(self, area, program): "include_child_areas": True, } - # System-wide scope: use CEL expression that matches all registrants + # System-wide scope: query all registrant IDs directly and use + # explicit scope. We can't use CEL scope because the scope resolver's + # env.get() check on the AbstractModel executor returns falsy. + all_ids = self.env["res.partner"].sudo().search([("is_registrant", "=", True)]).ids return { - "scope_type": "cel", - "cel_expression": "true", - "cel_profile": "registry_individuals", + "scope_type": "explicit", + "explicit_partner_ids": all_ids, } def _upsert_data(self, stat, area, program, result): diff --git a/spp_dashboard/tests/__init__.py b/spp_dashboard/tests/__init__.py index 27e60f9c..c29e8dab 100644 --- a/spp_dashboard/tests/__init__.py +++ b/spp_dashboard/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_dashboard_data from . import test_dashboard_refresh from . import test_dashboard_access +from . import test_dashboard_integration diff --git a/spp_dashboard/tests/test_dashboard_integration.py b/spp_dashboard/tests/test_dashboard_integration.py new file mode 100644 index 00000000..0110148e --- /dev/null +++ b/spp_dashboard/tests/test_dashboard_integration.py @@ -0,0 +1,158 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Integration tests for dashboard refresh using real aggregation pipeline. + +These tests call through the real aggregation service (no mocks) to verify +that the dashboard correctly computes and displays statistics. +""" + +import logging + +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestDashboardIntegration(TransactionCase): + """Integration tests that exercise the real aggregation pipeline.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.category = cls.env["spp.metric.category"].create( + { + "name": "Integration Test", + "code": "integration_test", + } + ) + + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "integ_test_total", + "cel_accessor": "integ_test_total", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + } + ) + + cls.statistic = cls.env["spp.statistic"].create( + { + "name": "integ_test_stat", + "label": "Integration Total", + "variable_id": cls.cel_variable.id, + "category_id": cls.category.id, + "format": "count", + "unit": "people", + "is_published_dashboard": True, + } + ) + + # Create real registrants (individuals) + cls.registrants = cls.env["res.partner"].create( + [ + { + "name": f"Integration Test Person {i}", + "is_registrant": True, + "is_group": False, + } + for i in range(20) + ] + ) + + def test_scope_resolution_system_wide(self): + """Test that system-wide scope resolves to actual registrants.""" + DashData = self.env["spp.dashboard.data"] + scope = DashData._build_scope(False, False) + + self.assertEqual(scope["scope_type"], "explicit") + + # The explicit_partner_ids should contain our registrants + partner_ids = scope["explicit_partner_ids"] + self.assertGreaterEqual( + len(partner_ids), + len(self.registrants), + f"Expected at least {len(self.registrants)} registrants, got {len(partner_ids)}", + ) + + # Our specific registrants should be in the result + for reg in self.registrants: + self.assertIn(reg.id, partner_ids) + + def test_aggregation_service_returns_values(self): + """Test that compute_aggregation returns non-suppressed values for large populations.""" + DashData = self.env["spp.dashboard.data"] + scope = DashData._build_scope(False, False) + + result = self.env["spp.aggregation.service"].compute_aggregation( + scope=scope, + statistics=[self.statistic.name], + context="dashboard", + ) + + # total_count should reflect actual registrants + self.assertGreaterEqual( + result["total_count"], + len(self.registrants), + f"Expected total_count >= {len(self.registrants)}, got {result['total_count']}", + ) + + # The statistic should be in results + self.assertIn(self.statistic.name, result["statistics"]) + + stat_data = result["statistics"][self.statistic.name] + _logger.info( + "Aggregation result: value=%s, suppressed=%s, total_count=%s", + stat_data.get("value"), + stat_data.get("suppressed"), + result["total_count"], + ) + + # With 20+ registrants, the value should NOT be suppressed (default k=5) + self.assertFalse( + stat_data.get("suppressed"), + f"Value should not be suppressed with {result['total_count']} registrants. Got: {stat_data}", + ) + + def test_refresh_produces_real_values(self): + """Test end-to-end: refresh creates dashboard rows with real computed values.""" + DashData = self.env["spp.dashboard.data"] + + # Run refresh (no mocks) + DashData._refresh_statistic(self.statistic.id, []) + + # Check the system-wide row + data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("area_id", "=", False), + ("program_id", "=", False), + ] + ) + self.assertEqual(len(data), 1, "Expected exactly one system-wide row") + + _logger.info( + "Dashboard row: value=%s, value_display=%s, is_suppressed=%s, underlying_count=%s", + data.value, + data.value_display, + data.is_suppressed, + data.underlying_count, + ) + + # With 20+ registrants, the value should NOT be suppressed + self.assertFalse( + data.is_suppressed, + f"Dashboard value should not be suppressed. " + f"value_display={data.value_display}, underlying_count={data.underlying_count}", + ) + + # The numeric value should be > 0 + self.assertGreater( + data.value, + 0, + f"Dashboard value should be > 0, got {data.value}", + ) + + # The display value should be a formatted number, not a suppression marker + self.assertNotIn("<", data.value_display, f"Display value looks suppressed: {data.value_display}") + self.assertNotEqual(data.value_display, "*", f"Display value looks suppressed: {data.value_display}") diff --git a/spp_dashboard/tests/test_dashboard_refresh.py b/spp_dashboard/tests/test_dashboard_refresh.py index f58ce967..fd37bc16 100644 --- a/spp_dashboard/tests/test_dashboard_refresh.py +++ b/spp_dashboard/tests/test_dashboard_refresh.py @@ -322,11 +322,13 @@ def test_get_dashboard_programs(self): self.assertEqual(prog.state, "active") def test_build_scope_system_wide(self): - """Test _build_scope with no area returns CEL scope for all registrants.""" + """Test _build_scope with no area returns explicit scope with all registrants.""" DashData = self.env["spp.dashboard.data"] scope = DashData._build_scope(False, False) - self.assertEqual(scope["scope_type"], "cel") - self.assertEqual(scope["cel_expression"], "true") + self.assertEqual(scope["scope_type"], "explicit") + self.assertIn("explicit_partner_ids", scope) + # Should contain at least one registrant (test data creates some) + self.assertIsInstance(scope["explicit_partner_ids"], list) def test_build_scope_with_area(self): """Test _build_scope with an area.""" From 44f6d00b697e3d120b92af614cf8a45b36d5c5dc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 16:03:20 +0700 Subject: [PATCH 11/18] fix(spp_mis_demo_v2): remove disabled_members CEL variable and statistic The disabled_members variable used `m.disabled != null` as its aggregate filter, but `res_partner.disabled` is Odoo's archival timestamp field (when a record was deactivated), not a disability indicator. The CEL evaluator returns False for NULL Datetime fields instead of None, so `False != None` evaluated to True for every member, producing incorrect counts (431 "disabled" members when none actually had disabilities). Removes both the CEL variable (cel_var_disabled_members) and the statistic (stat_disabled_members) from demo data, and updates tests. --- spp_mis_demo_v2/data/demo_statistics.xml | 29 ------------------- spp_mis_demo_v2/tests/test_demo_statistics.py | 4 +-- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/spp_mis_demo_v2/data/demo_statistics.xml b/spp_mis_demo_v2/data/demo_statistics.xml index a59c977a..5c03a9a0 100644 --- a/spp_mis_demo_v2/data/demo_statistics.xml +++ b/spp_mis_demo_v2/data/demo_statistics.xml @@ -61,21 +61,6 @@ 40 - - - demo_disabled_members - disabled_members - aggregate - count - members - m.disabled != null - number - group - - active - 50 - - demo_female_members @@ -235,20 +220,6 @@ - - - disabled_members - Disabled Members - Count of household members with disabilities - - count - people - - 50 - - - - enrolled_any_program diff --git a/spp_mis_demo_v2/tests/test_demo_statistics.py b/spp_mis_demo_v2/tests/test_demo_statistics.py index 5d428879..3005fd74 100644 --- a/spp_mis_demo_v2/tests/test_demo_statistics.py +++ b/spp_mis_demo_v2/tests/test_demo_statistics.py @@ -28,12 +28,11 @@ def setUpClass(cls): "elderly_60_plus", "female_members", "male_members", - "disabled_members", "enrolled_any_program", ] def test_all_demo_statistics_exist(self): - """Verify all 9 demo statistics are in the database.""" + """Verify all 8 demo statistics are in the database.""" for stat_name in self.required_stats: with self.subTest(statistic=stat_name): stat = self.stat_model.search([("name", "=", stat_name)], limit=1) @@ -102,7 +101,6 @@ def test_statistics_categories_exist(self): "female_members", "male_members", ], - "vulnerability": ["disabled_members"], "programs": ["enrolled_any_program"], } From e331eb55caa921720464f04945c902e26fd2129e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 16:07:15 +0700 Subject: [PATCH 12/18] fix(spp_cel_domain): normalize Odoo False to None for null comparisons Odoo ORM returns False for unset non-boolean fields (Datetime, Date, Char, Many2one, etc.), but CEL null maps to Python None. This caused expressions like `m.disabled != null` to evaluate as `False != None` which is True, matching every record even when the field is actually NULL in the database. _safe_getattr now detects Odoo records (via _fields attribute) and converts False to None for non-boolean fields, so CEL null comparisons behave correctly. Boolean fields retain their False value. --- spp_cel_domain/services/cel_parser.py | 9 +++- spp_cel_domain/tests/test_cel_parser.py | 56 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/spp_cel_domain/services/cel_parser.py b/spp_cel_domain/services/cel_parser.py index 98dcc282..18720c54 100644 --- a/spp_cel_domain/services/cel_parser.py +++ b/spp_cel_domain/services/cel_parser.py @@ -77,7 +77,14 @@ def _safe_getattr(obj: Any, name: str, default: Any = None) -> Any: raise AttributeError(f"Access to attribute '{name}' is not allowed") if hasattr(obj, name): - return getattr(obj, name) + value = getattr(obj, name) + # Odoo ORM returns False for unset non-boolean fields (Datetime, + # Date, Char, Many2one, etc.). Normalize to None so that CEL null + # comparisons work correctly (e.g., `m.disabled != null`). + if value is False and hasattr(obj, "_fields") and name in obj._fields: + if obj._fields[name].type != "boolean": + return None + return value if isinstance(obj, dict): return obj.get(name, default) return default diff --git a/spp_cel_domain/tests/test_cel_parser.py b/spp_cel_domain/tests/test_cel_parser.py index 8b78a8ea..737b1893 100644 --- a/spp_cel_domain/tests/test_cel_parser.py +++ b/spp_cel_domain/tests/test_cel_parser.py @@ -409,3 +409,59 @@ def test_position_tracking_in_tokens(self): for tok in tokens: self.assertIsInstance(tok.pos, int) self.assertGreaterEqual(tok.pos, 0) + + def test_odoo_null_field_equals_null(self): + """Odoo returns False for unset non-boolean fields; CEL null comparison should treat as None. + + When an Odoo Datetime/Date/Char/etc. field is NULL in the DB, the ORM + returns False. CEL 'null' maps to Python None. Without normalization, + `m.disabled != null` becomes `False != None` which is True for every + record, even though the field IS null. + """ + # Create a registrant with an unset disabled (Datetime) field + partner = self.env["res.partner"].create( + {"name": "CEL Null Test Person", "is_registrant": True, "is_group": False} + ) + + # Odoo ORM returns False for unset Datetime fields + self.assertIs(partner.disabled, False) + + # CEL: m.disabled == null should be True (field is unset) + ast = P.parse("m.disabled == null") + result = P.evaluate(ast, {"m": partner}) + self.assertTrue( + result, + "m.disabled == null should be True when disabled is unset " + f"(ORM returns {partner.disabled!r})", + ) + + # CEL: m.disabled != null should be False (field is unset) + ast = P.parse("m.disabled != null") + result = P.evaluate(ast, {"m": partner}) + self.assertFalse( + result, + "m.disabled != null should be False when disabled is unset " + f"(ORM returns {partner.disabled!r})", + ) + + def test_odoo_boolean_false_not_treated_as_null(self): + """Boolean fields that are legitimately False should NOT be normalized to None.""" + partner = self.env["res.partner"].create( + {"name": "CEL Bool Test", "is_registrant": False} + ) + + # is_registrant is a Boolean field, False is a real value + ast = P.parse("m.is_registrant == false") + result = P.evaluate(ast, {"m": partner}) + self.assertTrue( + result, + "Boolean False should remain False, not become None", + ) + + # Boolean False should NOT equal null + ast = P.parse("m.is_registrant == null") + result = P.evaluate(ast, {"m": partner}) + self.assertFalse( + result, + "Boolean False should not equal null", + ) From d594d85d2a9137853e234b94720aeb06ef9c7ce3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 16:18:26 +0700 Subject: [PATCH 13/18] refactor: rename spp_dashboard to spp_statistics_dashboard More descriptive module name that clearly indicates this is the dashboard layer for the statistics subsystem. Updates all XML ID references, system parameter prefixes, and test refs. --- .../__init__.py | 0 .../__manifest__.py | 0 .../data/ir_cron.xml | 0 .../models/__init__.py | 0 .../models/dashboard_data.py | 6 +++--- .../readme/DESCRIPTION.md | 4 ++-- .../security/groups.xml | 0 .../security/ir.model.access.csv | 0 .../security/privileges.xml | 0 .../static/description/icon.png | Bin .../tests/__init__.py | 0 .../tests/test_dashboard_access.py | 16 ++++++++-------- .../tests/test_dashboard_data.py | 14 +++++++------- .../tests/test_dashboard_integration.py | 0 .../tests/test_dashboard_refresh.py | 4 ++-- .../views/dashboard_data_views.xml | 0 .../views/menus.xml | 2 +- 17 files changed, 23 insertions(+), 23 deletions(-) rename {spp_dashboard => spp_statistics_dashboard}/__init__.py (100%) rename {spp_dashboard => spp_statistics_dashboard}/__manifest__.py (100%) rename {spp_dashboard => spp_statistics_dashboard}/data/ir_cron.xml (100%) rename {spp_dashboard => spp_statistics_dashboard}/models/__init__.py (100%) rename {spp_dashboard => spp_statistics_dashboard}/models/dashboard_data.py (98%) rename {spp_dashboard => spp_statistics_dashboard}/readme/DESCRIPTION.md (90%) rename {spp_dashboard => spp_statistics_dashboard}/security/groups.xml (100%) rename {spp_dashboard => spp_statistics_dashboard}/security/ir.model.access.csv (100%) rename {spp_dashboard => spp_statistics_dashboard}/security/privileges.xml (100%) rename {spp_dashboard => spp_statistics_dashboard}/static/description/icon.png (100%) rename {spp_dashboard => spp_statistics_dashboard}/tests/__init__.py (100%) rename {spp_dashboard => spp_statistics_dashboard}/tests/test_dashboard_access.py (90%) rename {spp_dashboard => spp_statistics_dashboard}/tests/test_dashboard_data.py (93%) rename {spp_dashboard => spp_statistics_dashboard}/tests/test_dashboard_integration.py (100%) rename {spp_dashboard => spp_statistics_dashboard}/tests/test_dashboard_refresh.py (98%) rename {spp_dashboard => spp_statistics_dashboard}/views/dashboard_data_views.xml (100%) rename {spp_dashboard => spp_statistics_dashboard}/views/menus.xml (89%) diff --git a/spp_dashboard/__init__.py b/spp_statistics_dashboard/__init__.py similarity index 100% rename from spp_dashboard/__init__.py rename to spp_statistics_dashboard/__init__.py diff --git a/spp_dashboard/__manifest__.py b/spp_statistics_dashboard/__manifest__.py similarity index 100% rename from spp_dashboard/__manifest__.py rename to spp_statistics_dashboard/__manifest__.py diff --git a/spp_dashboard/data/ir_cron.xml b/spp_statistics_dashboard/data/ir_cron.xml similarity index 100% rename from spp_dashboard/data/ir_cron.xml rename to spp_statistics_dashboard/data/ir_cron.xml diff --git a/spp_dashboard/models/__init__.py b/spp_statistics_dashboard/models/__init__.py similarity index 100% rename from spp_dashboard/models/__init__.py rename to spp_statistics_dashboard/models/__init__.py diff --git a/spp_dashboard/models/dashboard_data.py b/spp_statistics_dashboard/models/dashboard_data.py similarity index 98% rename from spp_dashboard/models/dashboard_data.py rename to spp_statistics_dashboard/models/dashboard_data.py index 837e02f3..77442673 100644 --- a/spp_dashboard/models/dashboard_data.py +++ b/spp_statistics_dashboard/models/dashboard_data.py @@ -241,18 +241,18 @@ def _refresh_statistic(self, stat_id, area_ids): def _get_dashboard_areas(self): """Get areas to include in dashboard refresh. - Uses the system parameter 'spp_dashboard.area_levels' to filter + Uses the system parameter 'spp_statistics_dashboard.area_levels' to filter by admin level (comma-separated integers). If not set, includes all areas. """ - param = self.env["ir.config_parameter"].sudo().get_param("spp_dashboard.area_levels", "") + param = self.env["ir.config_parameter"].sudo().get_param("spp_statistics_dashboard.area_levels", "") domain = [] if param.strip(): try: levels = [int(level.strip()) for level in param.split(",")] domain = [("area_level", "in", levels)] except ValueError: - _logger.warning("Invalid spp_dashboard.area_levels parameter: %s", param) + _logger.warning("Invalid spp_statistics_dashboard.area_levels parameter: %s", param) return self.env["spp.area"].search(domain) @api.model diff --git a/spp_dashboard/readme/DESCRIPTION.md b/spp_statistics_dashboard/readme/DESCRIPTION.md similarity index 90% rename from spp_dashboard/readme/DESCRIPTION.md rename to spp_statistics_dashboard/readme/DESCRIPTION.md index dcb0a26c..945a47af 100644 --- a/spp_dashboard/readme/DESCRIPTION.md +++ b/spp_statistics_dashboard/readme/DESCRIPTION.md @@ -33,8 +33,8 @@ After installing: | Group | Access | | ---------------------------------------- | ----------------------------------- | -| `spp_dashboard.group_dashboard_read` | Read only | -| `spp_dashboard.group_dashboard_manage` | Read/Write (for refresh upsert) | +| `spp_statistics_dashboard.group_dashboard_read` | Read only | +| `spp_statistics_dashboard.group_dashboard_manage` | Read/Write (for refresh upsert) | ### Extension Points diff --git a/spp_dashboard/security/groups.xml b/spp_statistics_dashboard/security/groups.xml similarity index 100% rename from spp_dashboard/security/groups.xml rename to spp_statistics_dashboard/security/groups.xml diff --git a/spp_dashboard/security/ir.model.access.csv b/spp_statistics_dashboard/security/ir.model.access.csv similarity index 100% rename from spp_dashboard/security/ir.model.access.csv rename to spp_statistics_dashboard/security/ir.model.access.csv diff --git a/spp_dashboard/security/privileges.xml b/spp_statistics_dashboard/security/privileges.xml similarity index 100% rename from spp_dashboard/security/privileges.xml rename to spp_statistics_dashboard/security/privileges.xml diff --git a/spp_dashboard/static/description/icon.png b/spp_statistics_dashboard/static/description/icon.png similarity index 100% rename from spp_dashboard/static/description/icon.png rename to spp_statistics_dashboard/static/description/icon.png diff --git a/spp_dashboard/tests/__init__.py b/spp_statistics_dashboard/tests/__init__.py similarity index 100% rename from spp_dashboard/tests/__init__.py rename to spp_statistics_dashboard/tests/__init__.py diff --git a/spp_dashboard/tests/test_dashboard_access.py b/spp_statistics_dashboard/tests/test_dashboard_access.py similarity index 90% rename from spp_dashboard/tests/test_dashboard_access.py rename to spp_statistics_dashboard/tests/test_dashboard_access.py index d345ea9c..2ee15a26 100644 --- a/spp_dashboard/tests/test_dashboard_access.py +++ b/spp_statistics_dashboard/tests/test_dashboard_access.py @@ -50,7 +50,7 @@ def setUpClass(cls): ) # Create test users - viewer_group = cls.env.ref("spp_dashboard.group_dashboard_viewer") + viewer_group = cls.env.ref("spp_statistics_dashboard.group_dashboard_viewer") cls.viewer_user = cls.env["res.users"].create( { "name": "Dashboard Viewer", @@ -62,7 +62,7 @@ def setUpClass(cls): } ) - manager_group = cls.env.ref("spp_dashboard.group_dashboard_manager") + manager_group = cls.env.ref("spp_statistics_dashboard.group_dashboard_manager") cls.manager_user = cls.env["res.users"].create( { "name": "Dashboard Manager", @@ -187,10 +187,10 @@ def test_no_access_user_cannot_read(self): def test_manager_implies_viewer(self): """Test that manager group implies viewer group (read access).""" - manager_group = self.env.ref("spp_dashboard.group_dashboard_manager") - viewer_group = self.env.ref("spp_dashboard.group_dashboard_viewer") - read_group = self.env.ref("spp_dashboard.group_dashboard_read") - manage_group = self.env.ref("spp_dashboard.group_dashboard_manage") + manager_group = self.env.ref("spp_statistics_dashboard.group_dashboard_manager") + viewer_group = self.env.ref("spp_statistics_dashboard.group_dashboard_viewer") + read_group = self.env.ref("spp_statistics_dashboard.group_dashboard_read") + manage_group = self.env.ref("spp_statistics_dashboard.group_dashboard_manage") self.assertIn(viewer_group, manager_group.implied_ids) self.assertIn(manage_group, manager_group.implied_ids) @@ -198,8 +198,8 @@ def test_manager_implies_viewer(self): def test_viewer_has_read_group(self): """Test that viewer user has the technical read group.""" - self.assertTrue(self.viewer_user.has_group("spp_dashboard.group_dashboard_read")) + self.assertTrue(self.viewer_user.has_group("spp_statistics_dashboard.group_dashboard_read")) def test_manager_has_manage_group(self): """Test that manager user has the technical manage group.""" - self.assertTrue(self.manager_user.has_group("spp_dashboard.group_dashboard_manage")) + self.assertTrue(self.manager_user.has_group("spp_statistics_dashboard.group_dashboard_manage")) diff --git a/spp_dashboard/tests/test_dashboard_data.py b/spp_statistics_dashboard/tests/test_dashboard_data.py similarity index 93% rename from spp_dashboard/tests/test_dashboard_data.py rename to spp_statistics_dashboard/tests/test_dashboard_data.py index a518422f..aad0a05f 100644 --- a/spp_dashboard/tests/test_dashboard_data.py +++ b/spp_statistics_dashboard/tests/test_dashboard_data.py @@ -223,42 +223,42 @@ class TestDashboardViews(TransactionCase): def test_kanban_view_loads(self): """Test kanban view can be loaded without error.""" - view = self.env.ref("spp_dashboard.spp_dashboard_data_view_kanban") + view = self.env.ref("spp_statistics_dashboard.spp_dashboard_data_view_kanban") result = self.env["spp.dashboard.data"].get_view(view.id, view_type="kanban") self.assertIn("arch", result) def test_list_view_loads(self): """Test list view can be loaded without error.""" - view = self.env.ref("spp_dashboard.spp_dashboard_data_view_list") + view = self.env.ref("spp_statistics_dashboard.spp_dashboard_data_view_list") result = self.env["spp.dashboard.data"].get_view(view.id, view_type="list") self.assertIn("arch", result) def test_search_view_loads(self): """Test search view can be loaded without error.""" - view = self.env.ref("spp_dashboard.spp_dashboard_data_view_search") + view = self.env.ref("spp_statistics_dashboard.spp_dashboard_data_view_search") result = self.env["spp.dashboard.data"].get_view(view.id, view_type="search") self.assertIn("arch", result) def test_pivot_view_loads(self): """Test pivot view can be loaded without error.""" - view = self.env.ref("spp_dashboard.spp_dashboard_data_view_pivot") + view = self.env.ref("spp_statistics_dashboard.spp_dashboard_data_view_pivot") result = self.env["spp.dashboard.data"].get_view(view.id, view_type="pivot") self.assertIn("arch", result) def test_graph_view_loads(self): """Test graph view can be loaded without error.""" - view = self.env.ref("spp_dashboard.spp_dashboard_data_view_graph") + view = self.env.ref("spp_statistics_dashboard.spp_dashboard_data_view_graph") result = self.env["spp.dashboard.data"].get_view(view.id, view_type="graph") self.assertIn("arch", result) def test_action_window_exists(self): """Test action window record exists with correct settings.""" - action = self.env.ref("spp_dashboard.action_dashboard_data") + action = self.env.ref("spp_statistics_dashboard.action_dashboard_data") self.assertEqual(action.res_model, "spp.dashboard.data") self.assertEqual(action.path, "statistics-dashboard") self.assertIn("search_default_system_wide", action.context) def test_server_action_exists(self): """Test server action for refresh exists.""" - action = self.env.ref("spp_dashboard.action_refresh_dashboard_data") + action = self.env.ref("spp_statistics_dashboard.action_refresh_dashboard_data") self.assertEqual(action.state, "code") diff --git a/spp_dashboard/tests/test_dashboard_integration.py b/spp_statistics_dashboard/tests/test_dashboard_integration.py similarity index 100% rename from spp_dashboard/tests/test_dashboard_integration.py rename to spp_statistics_dashboard/tests/test_dashboard_integration.py diff --git a/spp_dashboard/tests/test_dashboard_refresh.py b/spp_statistics_dashboard/tests/test_dashboard_refresh.py similarity index 98% rename from spp_dashboard/tests/test_dashboard_refresh.py rename to spp_statistics_dashboard/tests/test_dashboard_refresh.py index fd37bc16..edbfef2f 100644 --- a/spp_dashboard/tests/test_dashboard_refresh.py +++ b/spp_statistics_dashboard/tests/test_dashboard_refresh.py @@ -301,7 +301,7 @@ def test_get_dashboard_areas_all(self): def test_get_dashboard_areas_filtered(self): """Test _get_dashboard_areas filters by area_levels system parameter.""" # Our test area is a root area (area_level=0) - self.env["ir.config_parameter"].sudo().set_param("spp_dashboard.area_levels", "0") + self.env["ir.config_parameter"].sudo().set_param("spp_statistics_dashboard.area_levels", "0") DashData = self.env["spp.dashboard.data"] areas = DashData._get_dashboard_areas() @@ -312,7 +312,7 @@ def test_get_dashboard_areas_filtered(self): self.assertIn(self.area, areas) # Clean up - self.env["ir.config_parameter"].sudo().set_param("spp_dashboard.area_levels", "") + self.env["ir.config_parameter"].sudo().set_param("spp_statistics_dashboard.area_levels", "") def test_get_dashboard_programs(self): """Test _get_dashboard_programs returns active programs.""" diff --git a/spp_dashboard/views/dashboard_data_views.xml b/spp_statistics_dashboard/views/dashboard_data_views.xml similarity index 100% rename from spp_dashboard/views/dashboard_data_views.xml rename to spp_statistics_dashboard/views/dashboard_data_views.xml diff --git a/spp_dashboard/views/menus.xml b/spp_statistics_dashboard/views/menus.xml similarity index 89% rename from spp_dashboard/views/menus.xml rename to spp_statistics_dashboard/views/menus.xml index 2662ca24..0bf537f4 100644 --- a/spp_dashboard/views/menus.xml +++ b/spp_statistics_dashboard/views/menus.xml @@ -5,7 +5,7 @@ name="Statistics Dashboard" groups="group_dashboard_read" sequence="90" - web_icon="spp_dashboard,static/description/icon.png" + web_icon="spp_statistics_dashboard,static/description/icon.png" /> Date: Fri, 6 Mar 2026 16:20:34 +0700 Subject: [PATCH 14/18] 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. --- spp_cel_domain/tests/test_cel_parser.py | 10 +-- .../models/dashboard_data.py | 72 +++++++++++++------ .../tests/test_dashboard_refresh.py | 46 ++++++++++++ 3 files changed, 100 insertions(+), 28 deletions(-) diff --git a/spp_cel_domain/tests/test_cel_parser.py b/spp_cel_domain/tests/test_cel_parser.py index 737b1893..cb77bcf9 100644 --- a/spp_cel_domain/tests/test_cel_parser.py +++ b/spp_cel_domain/tests/test_cel_parser.py @@ -431,8 +431,7 @@ def test_odoo_null_field_equals_null(self): result = P.evaluate(ast, {"m": partner}) self.assertTrue( result, - "m.disabled == null should be True when disabled is unset " - f"(ORM returns {partner.disabled!r})", + f"m.disabled == null should be True when disabled is unset (ORM returns {partner.disabled!r})", ) # CEL: m.disabled != null should be False (field is unset) @@ -440,15 +439,12 @@ def test_odoo_null_field_equals_null(self): result = P.evaluate(ast, {"m": partner}) self.assertFalse( result, - "m.disabled != null should be False when disabled is unset " - f"(ORM returns {partner.disabled!r})", + f"m.disabled != null should be False when disabled is unset (ORM returns {partner.disabled!r})", ) def test_odoo_boolean_false_not_treated_as_null(self): """Boolean fields that are legitimately False should NOT be normalized to None.""" - partner = self.env["res.partner"].create( - {"name": "CEL Bool Test", "is_registrant": False} - ) + partner = self.env["res.partner"].create({"name": "CEL Bool Test", "is_registrant": False}) # is_registrant is a Boolean field, False is a real value ast = P.parse("m.is_registrant == false") diff --git a/spp_statistics_dashboard/models/dashboard_data.py b/spp_statistics_dashboard/models/dashboard_data.py index 77442673..148c3c0e 100644 --- a/spp_statistics_dashboard/models/dashboard_data.py +++ b/spp_statistics_dashboard/models/dashboard_data.py @@ -205,9 +205,14 @@ def action_refresh_all(self): } def _refresh_statistic(self, stat_id, area_ids): - """Refresh one statistic across all area combinations. + """Refresh one statistic across all scope combinations. - Called as a queue_job. Handles errors per-area so one failure + Computes values for: + - System-wide (no area, no program) + - Per area (each configured area) + - Per program (each active program with enrolled members) + + Called as a queue_job. Handles errors per-scope so one failure does not abort the entire statistic refresh. """ stat = self.env["spp.statistic"].browse(stat_id) @@ -216,26 +221,42 @@ def _refresh_statistic(self, stat_id, area_ids): return areas = self.env["spp.area"].browse(area_ids) + programs = self._get_dashboard_programs() - # False = system-wide - area_list = [False] + list(areas) + # Area dimension: system-wide + per-area + for area in [False] + list(areas): + self._refresh_scope(stat, area, False) - for area in area_list: - try: - scope = self._build_scope(area, False) - result = self.env["spp.aggregation.service"].compute_aggregation( - scope=scope, - statistics=[stat.name], - context="dashboard", - ) - self._upsert_data(stat, area, False, result) - except (ValueError, TypeError, KeyError, AttributeError) as e: - _logger.warning( - "Dashboard refresh failed for stat=%s area=%s: %s", - stat.name, - area.code if area else "all", - e, - ) + # Program dimension: per-program (system-wide area) + for program in programs: + self._refresh_scope(stat, False, program) + + def _refresh_scope(self, stat, area, program): + """Refresh a single (stat, area, program) combination. + + Args: + stat: spp.statistic record + area: spp.area record or False + program: spp.program record or False + """ + try: + scope = self._build_scope(area, program) + result = self.env["spp.aggregation.service"].compute_aggregation( + scope=scope, + statistics=[stat.name], + context="dashboard", + ) + self._upsert_data(stat, area, program, result) + except (ValueError, TypeError, KeyError, AttributeError) as e: + area_label = area.code if area else "all" + prog_label = program.name if program else "all" + _logger.warning( + "Dashboard refresh failed for stat=%s area=%s program=%s: %s", + stat.name, + area_label, + prog_label, + e, + ) @api.model def _get_dashboard_areas(self): @@ -266,11 +287,20 @@ def _build_scope(self, area, program): Args: area: spp.area record or False (system-wide) - program: spp.program record or False (unused, kept for API compatibility) + program: spp.program record or False (all programs) Returns: dict: scope definition for spp.aggregation.service """ + if program: + # Program scope: use enrolled members as explicit partner IDs + memberships = program.get_beneficiaries(state=["enrolled"]) + partner_ids = memberships.mapped("partner_id").ids + return { + "scope_type": "explicit", + "explicit_partner_ids": partner_ids, + } + if area: return { "scope_type": "area", diff --git a/spp_statistics_dashboard/tests/test_dashboard_refresh.py b/spp_statistics_dashboard/tests/test_dashboard_refresh.py index edbfef2f..f50afe06 100644 --- a/spp_statistics_dashboard/tests/test_dashboard_refresh.py +++ b/spp_statistics_dashboard/tests/test_dashboard_refresh.py @@ -338,6 +338,52 @@ def test_build_scope_with_area(self): self.assertEqual(scope["area_id"], self.area.id) self.assertTrue(scope["include_child_areas"]) + def test_build_scope_with_program(self): + """Test _build_scope with a program returns explicit scope from enrolled members.""" + # Create a program with enrolled members + program = self.env["spp.program"].create({"name": "Scope Test Program", "target_type": "group"}) + registrant = self.env["res.partner"].create( + {"name": "Scope Program Person", "is_registrant": True, "is_group": False} + ) + self.env["spp.program.membership"].create( + {"partner_id": registrant.id, "program_id": program.id, "state": "enrolled"} + ) + + DashData = self.env["spp.dashboard.data"] + scope = DashData._build_scope(False, program) + self.assertEqual(scope["scope_type"], "explicit") + self.assertIn(registrant.id, scope["explicit_partner_ids"]) + + def test_refresh_statistic_with_programs(self): + """Test that refresh creates rows for each active program.""" + DashData = self.env["spp.dashboard.data"] + + program = self.env["spp.program"].create({"name": "Refresh Program Test", "target_type": "group"}) + registrant = self.env["res.partner"].create( + {"name": "Program Refresh Person", "is_registrant": True, "is_group": False} + ) + self.env["spp.program.membership"].create( + {"partner_id": registrant.id, "program_id": program.id, "state": "enrolled"} + ) + + mock_result = self._mock_aggregation_result(value=10, total_count=10) + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + DashData._refresh_statistic(self.statistic.id, []) + + # Should create at least 2 rows: system-wide + one per active program + program_data = DashData.search( + [ + ("statistic_id", "=", self.statistic.id), + ("program_id", "=", program.id), + ] + ) + self.assertEqual(len(program_data), 1, "Expected one row for the program") + self.assertEqual(program_data.value, 10.0) + def test_label_from_context_config(self): """Test that label is taken from statistic context config.""" # Create a context config with a dashboard label override From cdef2d9188a24593b3859cad1442fb58f536125c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 16:29:15 +0700 Subject: [PATCH 15/18] feat(spp_statistics_dashboard): improve search filters and sidebar - Add Program to search panel sidebar (multi-select with counters) - Add area level filters (Level 0-3) for quick admin-level filtering - Add "Per Program" scope filter - Add "Area Level" group-by option - Show program_name column by default in list view --- .../views/dashboard_data_views.xml | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/spp_statistics_dashboard/views/dashboard_data_views.xml b/spp_statistics_dashboard/views/dashboard_data_views.xml index df8e1fde..93bdf7ff 100644 --- a/spp_statistics_dashboard/views/dashboard_data_views.xml +++ b/spp_statistics_dashboard/views/dashboard_data_views.xml @@ -93,7 +93,7 @@ - + @@ -123,6 +123,34 @@ string="System-wide Totals" domain="[('area_id', '=', False), ('program_id', '=', False)]" /> + + + + + + + + @@ -145,6 +173,11 @@ string="Area" context="{'group_by': 'area_id'}" /> + - + + From b8461440e02844a679c6bf1ee3c59713ff0d221e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 23:38:21 +0700 Subject: [PATCH 16/18] fix(spp_statistics_dashboard): add nosemgrep annotations for sudo() calls Both sudo() usages are intentional: - ir.config_parameter.sudo(): standard Odoo pattern for system params - res.partner.sudo(): needed for queue_job cron context, reads IDs only --- spp_statistics_dashboard/models/dashboard_data.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spp_statistics_dashboard/models/dashboard_data.py b/spp_statistics_dashboard/models/dashboard_data.py index 148c3c0e..3652f8ee 100644 --- a/spp_statistics_dashboard/models/dashboard_data.py +++ b/spp_statistics_dashboard/models/dashboard_data.py @@ -266,6 +266,7 @@ def _get_dashboard_areas(self): by admin level (comma-separated integers). If not set, includes all areas. """ + # nosemgrep: semgrep.odoo-sudo-without-context - standard pattern for reading system parameters param = self.env["ir.config_parameter"].sudo().get_param("spp_statistics_dashboard.area_levels", "") domain = [] if param.strip(): @@ -311,6 +312,9 @@ def _build_scope(self, area, program): # System-wide scope: query all registrant IDs directly and use # explicit scope. We can't use CEL scope because the scope resolver's # env.get() check on the AbstractModel executor returns falsy. + # sudo() is needed because this runs as a queue_job (cron user context). + # Only reads IDs, no sensitive data is exposed. + # nosemgrep: semgrep.odoo-sudo-on-sensitive-models, semgrep.odoo-sudo-without-context all_ids = self.env["res.partner"].sudo().search([("is_registrant", "=", True)]).ids return { "scope_type": "explicit", From 6d8dc5969a722f708de5aa78a7b2bc1c498d0abf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Mar 2026 23:47:35 +0700 Subject: [PATCH 17/18] feat(spp_aggregation): add all_registrants scope type Adds an all_registrants scope type to the scope resolver that searches for all registrants server-side. This avoids loading millions of IDs into the caller's memory, which was a scalability concern when the dashboard used explicit scope with all registrant IDs. The dashboard's system-wide scope now uses {"scope_type": "all_registrants"} instead of querying all IDs and passing them via explicit_partner_ids. --- .../models/service_scope_resolver.py | 18 ++++++++++++++++++ spp_aggregation/tests/test_scope_resolver.py | 14 ++++++++++++++ .../models/dashboard_data.py | 15 ++++----------- .../tests/test_dashboard_integration.py | 7 ++++--- .../tests/test_dashboard_refresh.py | 7 ++----- 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/spp_aggregation/models/service_scope_resolver.py b/spp_aggregation/models/service_scope_resolver.py index efdca19a..e902ca73 100644 --- a/spp_aggregation/models/service_scope_resolver.py +++ b/spp_aggregation/models/service_scope_resolver.py @@ -63,6 +63,7 @@ def _resolve_inline(self, scope_dict): "spatial_polygon": self._resolve_spatial_polygon_inline, "spatial_buffer": self._resolve_spatial_buffer_inline, "explicit": self._resolve_explicit_inline, + "all_registrants": self._resolve_all_registrants_inline, } resolver = resolver_map.get(scope_type) @@ -321,6 +322,23 @@ def _resolve_explicit_inline(self, scope_dict): return valid_ids + # ------------------------------------------------------------------------- + # All Registrants Resolution + # ------------------------------------------------------------------------- + def _resolve_all_registrants_inline(self, scope_dict): + """Resolve all registrants scope. + + Returns IDs of all registrants in the system. This is more efficient + than passing all IDs via explicit scope, as the search happens + server-side without transferring the full ID list through the caller. + """ + return ( + self.env["res.partner"] # nosemgrep: odoo-sudo-without-context, odoo-sudo-on-sensitive-models + .sudo() + .search([("is_registrant", "=", True)]) + .ids + ) + # ------------------------------------------------------------------------- # Batch Resolution # ------------------------------------------------------------------------- diff --git a/spp_aggregation/tests/test_scope_resolver.py b/spp_aggregation/tests/test_scope_resolver.py index d489a8e9..d03d38d8 100644 --- a/spp_aggregation/tests/test_scope_resolver.py +++ b/spp_aggregation/tests/test_scope_resolver.py @@ -133,6 +133,20 @@ def test_resolve_spatial_polygon_without_bridge(self): # Without PostGIS bridge, returns empty self.assertEqual(ids, []) + def test_resolve_all_registrants_scope(self): + """Test resolving all_registrants scope returns all registrants.""" + resolver = self.env["spp.aggregation.scope.resolver"] + ids = resolver.resolve({"scope_type": "all_registrants"}) + + # Should include all our test registrants + for reg in self.registrants: + self.assertIn(reg.id, ids) + + # All returned IDs should be actual registrants + partners = self.env["res.partner"].browse(ids) + for partner in partners: + self.assertTrue(partner.is_registrant) + def test_resolve_inline_area_scope(self): """Test resolving inline area scope definition.""" resolver = self.env["spp.aggregation.scope.resolver"] diff --git a/spp_statistics_dashboard/models/dashboard_data.py b/spp_statistics_dashboard/models/dashboard_data.py index 3652f8ee..ec543338 100644 --- a/spp_statistics_dashboard/models/dashboard_data.py +++ b/spp_statistics_dashboard/models/dashboard_data.py @@ -309,17 +309,10 @@ def _build_scope(self, area, program): "include_child_areas": True, } - # System-wide scope: query all registrant IDs directly and use - # explicit scope. We can't use CEL scope because the scope resolver's - # env.get() check on the AbstractModel executor returns falsy. - # sudo() is needed because this runs as a queue_job (cron user context). - # Only reads IDs, no sensitive data is exposed. - # nosemgrep: semgrep.odoo-sudo-on-sensitive-models, semgrep.odoo-sudo-without-context - all_ids = self.env["res.partner"].sudo().search([("is_registrant", "=", True)]).ids - return { - "scope_type": "explicit", - "explicit_partner_ids": all_ids, - } + # System-wide scope: delegate to the scope resolver's all_registrants + # type, which searches server-side without loading all IDs into the + # caller's memory. + return {"scope_type": "all_registrants"} def _upsert_data(self, stat, area, program, result): """Insert or update a dashboard data row from aggregation result. diff --git a/spp_statistics_dashboard/tests/test_dashboard_integration.py b/spp_statistics_dashboard/tests/test_dashboard_integration.py index 0110148e..48462b52 100644 --- a/spp_statistics_dashboard/tests/test_dashboard_integration.py +++ b/spp_statistics_dashboard/tests/test_dashboard_integration.py @@ -65,10 +65,11 @@ def test_scope_resolution_system_wide(self): DashData = self.env["spp.dashboard.data"] scope = DashData._build_scope(False, False) - self.assertEqual(scope["scope_type"], "explicit") + self.assertEqual(scope["scope_type"], "all_registrants") - # The explicit_partner_ids should contain our registrants - partner_ids = scope["explicit_partner_ids"] + # Resolve through the scope resolver to verify it returns our registrants + resolver = self.env["spp.aggregation.scope.resolver"] + partner_ids = resolver.resolve(scope) self.assertGreaterEqual( len(partner_ids), len(self.registrants), diff --git a/spp_statistics_dashboard/tests/test_dashboard_refresh.py b/spp_statistics_dashboard/tests/test_dashboard_refresh.py index f50afe06..a2bac82c 100644 --- a/spp_statistics_dashboard/tests/test_dashboard_refresh.py +++ b/spp_statistics_dashboard/tests/test_dashboard_refresh.py @@ -322,13 +322,10 @@ def test_get_dashboard_programs(self): self.assertEqual(prog.state, "active") def test_build_scope_system_wide(self): - """Test _build_scope with no area returns explicit scope with all registrants.""" + """Test _build_scope with no area returns all_registrants scope.""" DashData = self.env["spp.dashboard.data"] scope = DashData._build_scope(False, False) - self.assertEqual(scope["scope_type"], "explicit") - self.assertIn("explicit_partner_ids", scope) - # Should contain at least one registrant (test data creates some) - self.assertIsInstance(scope["explicit_partner_ids"], list) + self.assertEqual(scope["scope_type"], "all_registrants") def test_build_scope_with_area(self): """Test _build_scope with an area.""" From 3623bedb97656f6b0de87e75201c460df8744221 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Mar 2026 10:06:56 +0700 Subject: [PATCH 18/18] refactor(spp_statistics_dashboard): simplify refresh logic and fix minor issues - Use scope builders from spp_aggregation.services instead of inline dicts - Bulk-load existing rows to eliminate N+1 search per upsert - Hoist label/config lookup out of per-scope loop - Pass program_ids from action_refresh_all to avoid repeated queries - Remove dead `is not None` branch and redundant `or 0` - Fix misleading docstrings on all_registrants scope --- .../models/service_scope_resolver.py | 6 +- .../models/dashboard_data.py | 72 ++++++++++--------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/spp_aggregation/models/service_scope_resolver.py b/spp_aggregation/models/service_scope_resolver.py index e902ca73..271d6e76 100644 --- a/spp_aggregation/models/service_scope_resolver.py +++ b/spp_aggregation/models/service_scope_resolver.py @@ -328,9 +328,9 @@ def _resolve_explicit_inline(self, scope_dict): def _resolve_all_registrants_inline(self, scope_dict): """Resolve all registrants scope. - Returns IDs of all registrants in the system. This is more efficient - than passing all IDs via explicit scope, as the search happens - server-side without transferring the full ID list through the caller. + Returns IDs of all registrants in the system. Callers use this + instead of explicit scope so they don't need to enumerate IDs + up front; the search is done here in a single query. """ return ( self.env["res.partner"] # nosemgrep: odoo-sudo-without-context, odoo-sudo-on-sensitive-models diff --git a/spp_statistics_dashboard/models/dashboard_data.py b/spp_statistics_dashboard/models/dashboard_data.py index ec543338..c95c3d5a 100644 --- a/spp_statistics_dashboard/models/dashboard_data.py +++ b/spp_statistics_dashboard/models/dashboard_data.py @@ -5,6 +5,11 @@ from odoo import _, api, fields, models +from odoo.addons.spp_aggregation.services.scope_builder import ( + build_area_scope, + build_explicit_scope, +) + _logger = logging.getLogger(__name__) @@ -183,15 +188,16 @@ def action_refresh_all(self): self._cleanup_stale_data(stats) areas = self._get_dashboard_areas() + programs = self._get_dashboard_programs() for stat in stats: if hasattr(self, "with_delay"): self.with_delay( priority=10, description=f"Dashboard refresh: {stat.label}", - )._refresh_statistic(stat.id, areas.ids) + )._refresh_statistic(stat.id, areas.ids, programs.ids) else: - self._refresh_statistic(stat.id, areas.ids) + self._refresh_statistic(stat.id, areas.ids, programs.ids) return { "type": "ir.actions.client", @@ -204,7 +210,7 @@ def action_refresh_all(self): }, } - def _refresh_statistic(self, stat_id, area_ids): + def _refresh_statistic(self, stat_id, area_ids, program_ids=None): """Refresh one statistic across all scope combinations. Computes values for: @@ -221,23 +227,36 @@ def _refresh_statistic(self, stat_id, area_ids): return areas = self.env["spp.area"].browse(area_ids) - programs = self._get_dashboard_programs() + if program_ids is not None: + programs = self.env["spp.program"].browse(program_ids) + else: + programs = self._get_dashboard_programs() + + # Pre-compute label (same for all scopes of this statistic) + config = stat.get_context_config("dashboard") + label = config.get("label", stat.label) if config else stat.label + + # Bulk-load existing rows to avoid N+1 searches in _upsert_data + existing_rows = self.search([("statistic_id", "=", stat.id)]) + existing_map = {(r.area_id.id or 0, r.program_id.id or 0): r for r in existing_rows} # Area dimension: system-wide + per-area for area in [False] + list(areas): - self._refresh_scope(stat, area, False) + self._refresh_scope(stat, area, False, label, existing_map) # Program dimension: per-program (system-wide area) for program in programs: - self._refresh_scope(stat, False, program) + self._refresh_scope(stat, False, program, label, existing_map) - def _refresh_scope(self, stat, area, program): + def _refresh_scope(self, stat, area, program, label, existing_map): """Refresh a single (stat, area, program) combination. Args: stat: spp.statistic record area: spp.area record or False program: spp.program record or False + label: pre-computed display label for this statistic + existing_map: dict mapping (area_id, program_id) to existing records """ try: scope = self._build_scope(area, program) @@ -246,7 +265,7 @@ def _refresh_scope(self, stat, area, program): statistics=[stat.name], context="dashboard", ) - self._upsert_data(stat, area, program, result) + self._upsert_data(stat, area, program, result, label, existing_map) except (ValueError, TypeError, KeyError, AttributeError) as e: area_label = area.code if area else "all" prog_label = program.name if program else "all" @@ -297,24 +316,16 @@ def _build_scope(self, area, program): # Program scope: use enrolled members as explicit partner IDs memberships = program.get_beneficiaries(state=["enrolled"]) partner_ids = memberships.mapped("partner_id").ids - return { - "scope_type": "explicit", - "explicit_partner_ids": partner_ids, - } + return build_explicit_scope(partner_ids) if area: - return { - "scope_type": "area", - "area_id": area.id, - "include_child_areas": True, - } + return build_area_scope(area.id, include_children=True) # System-wide scope: delegate to the scope resolver's all_registrants - # type, which searches server-side without loading all IDs into the - # caller's memory. + # type so the caller doesn't need to enumerate all registrant IDs. return {"scope_type": "all_registrants"} - def _upsert_data(self, stat, area, program, result): + def _upsert_data(self, stat, area, program, result, label, existing_map): """Insert or update a dashboard data row from aggregation result. Args: @@ -322,6 +333,8 @@ def _upsert_data(self, stat, area, program, result): area: spp.area record or False program: spp.program record or False result: dict from compute_aggregation() + label: pre-computed display label + existing_map: dict mapping (area_id, program_id) to existing records """ stat_results = result.get("statistics", {}) stat_data = stat_results.get(stat.name, {}) @@ -330,10 +343,6 @@ def _upsert_data(self, stat, area, program, result): is_suppressed = stat_data.get("suppressed", False) total_count = result.get("total_count", 0) - # Get context config for label - config = stat.get_context_config("dashboard") - label = config.get("label", stat.label) if config else stat.label - # The aggregation service already applies suppression. # If suppressed, the value is the suppression marker (e.g., "<5"). # We store the raw numeric value for pivot/graph and a display string. @@ -346,21 +355,14 @@ def _upsert_data(self, stat, area, program, result): numeric_value = 0.0 else: value_display = self._format_value(raw_value, stat) - numeric_value = float(raw_value) if raw_value is not None else 0.0 + numeric_value = float(raw_value) area_id = area.id if area else False program_id = program.id if program else False now = fields.Datetime.now() - # Try to find existing record - existing = self.search( - [ - ("statistic_id", "=", stat.id), - ("area_id", "=", area_id), - ("program_id", "=", program_id), - ], - limit=1, - ) + # Look up existing record from pre-loaded map + existing = existing_map.get((area_id or 0, program_id or 0)) vals = { "value": numeric_value, @@ -398,7 +400,7 @@ def _format_value(self, value, stat): return "" fmt = stat.format - decimal_places = stat.decimal_places or 0 + decimal_places = stat.decimal_places if fmt == "percent": return f"{value:.{decimal_places}f}%"