diff --git a/spp_aggregation/models/service_scope_resolver.py b/spp_aggregation/models/service_scope_resolver.py index efdca19a..271d6e76 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. 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 + .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_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..cb77bcf9 100644 --- a/spp_cel_domain/tests/test_cel_parser.py +++ b/spp_cel_domain/tests/test_cel_parser.py @@ -409,3 +409,55 @@ 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, + 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) + ast = P.parse("m.disabled != null") + result = P.evaluate(ast, {"m": partner}) + self.assertFalse( + result, + 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}) + + # 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", + ) 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"], } diff --git a/spp_statistics_dashboard/__init__.py b/spp_statistics_dashboard/__init__.py new file mode 100644 index 00000000..c4ccea79 --- /dev/null +++ b/spp_statistics_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_statistics_dashboard/__manifest__.py b/spp_statistics_dashboard/__manifest__.py new file mode 100644 index 00000000..6e31b27c --- /dev/null +++ b/spp_statistics_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_statistics_dashboard/data/ir_cron.xml b/spp_statistics_dashboard/data/ir_cron.xml new file mode 100644 index 00000000..0de3b493 --- /dev/null +++ b/spp_statistics_dashboard/data/ir_cron.xml @@ -0,0 +1,12 @@ + + + + Dashboard: Refresh Statistics + + code + model.action_refresh_all() + 1 + days + True + + diff --git a/spp_statistics_dashboard/models/__init__.py b/spp_statistics_dashboard/models/__init__.py new file mode 100644 index 00000000..8fdd694e --- /dev/null +++ b/spp_statistics_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_statistics_dashboard/models/dashboard_data.py b/spp_statistics_dashboard/models/dashboard_data.py new file mode 100644 index 00000000..c95c3d5a --- /dev/null +++ b/spp_statistics_dashboard/models/dashboard_data.py @@ -0,0 +1,433 @@ +# 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 + +from odoo.addons.spp_aggregation.services.scope_builder import ( + build_area_scope, + build_explicit_scope, +) + +_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.""" + # 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 + 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", + ) + + # ─── 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=None): + """Refresh one statistic across all scope combinations. + + 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) + 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) + 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, label, existing_map) + + # Program dimension: per-program (system-wide area) + for program in programs: + self._refresh_scope(stat, False, program, label, existing_map) + + 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) + result = self.env["spp.aggregation.service"].compute_aggregation( + scope=scope, + statistics=[stat.name], + context="dashboard", + ) + 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" + _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): + """Get areas to include in dashboard refresh. + + Uses the system parameter 'spp_statistics_dashboard.area_levels' to filter + 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(): + try: + levels = [int(level.strip()) for level in param.split(",")] + domain = [("area_level", "in", levels)] + except ValueError: + _logger.warning("Invalid spp_statistics_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 program: + # Program scope: use enrolled members as explicit partner IDs + memberships = program.get_beneficiaries(state=["enrolled"]) + partner_ids = memberships.mapped("partner_id").ids + return build_explicit_scope(partner_ids) + + if area: + return build_area_scope(area.id, include_children=True) + + # System-wide scope: delegate to the scope resolver's all_registrants + # 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, label, existing_map): + """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() + 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, {}) + + raw_value = stat_data.get("value") + is_suppressed = stat_data.get("suppressed", False) + total_count = result.get("total_count", 0) + + # 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) + 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() + + # Look up existing record from pre-loaded map + existing = existing_map.get((area_id or 0, program_id or 0)) + + 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 + + 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_statistics_dashboard/readme/DESCRIPTION.md b/spp_statistics_dashboard/readme/DESCRIPTION.md new file mode 100644 index 00000000..945a47af --- /dev/null +++ b/spp_statistics_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_statistics_dashboard.group_dashboard_read` | Read only | +| `spp_statistics_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_statistics_dashboard/security/groups.xml b/spp_statistics_dashboard/security/groups.xml new file mode 100644 index 00000000..c9f26b1a --- /dev/null +++ b/spp_statistics_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_statistics_dashboard/security/ir.model.access.csv b/spp_statistics_dashboard/security/ir.model.access.csv new file mode 100644 index 00000000..40946448 --- /dev/null +++ b/spp_statistics_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_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 diff --git a/spp_statistics_dashboard/security/privileges.xml b/spp_statistics_dashboard/security/privileges.xml new file mode 100644 index 00000000..17a60eed --- /dev/null +++ b/spp_statistics_dashboard/security/privileges.xml @@ -0,0 +1,10 @@ + + + + + + Statistics Dashboard + + Access to statistics dashboard views and data + + diff --git a/spp_statistics_dashboard/static/description/icon.png b/spp_statistics_dashboard/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_statistics_dashboard/static/description/icon.png differ diff --git a/spp_statistics_dashboard/tests/__init__.py b/spp_statistics_dashboard/tests/__init__.py new file mode 100644 index 00000000..c29e8dab --- /dev/null +++ b/spp_statistics_dashboard/tests/__init__.py @@ -0,0 +1,6 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_dashboard_data +from . import test_dashboard_refresh +from . import test_dashboard_access +from . import test_dashboard_integration diff --git a/spp_statistics_dashboard/tests/test_dashboard_access.py b/spp_statistics_dashboard/tests/test_dashboard_access.py new file mode 100644 index 00000000..2ee15a26 --- /dev/null +++ b/spp_statistics_dashboard/tests/test_dashboard_access.py @@ -0,0 +1,205 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for dashboard access rights with viewer/manager user contexts.""" + +from odoo import Command +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase + + +class TestDashboardAccess(TransactionCase): + """Test ACLs with dashboard viewer vs. manager user contexts.""" + + @classmethod + 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", + } + ) + + # Create test users + viewer_group = cls.env.ref("spp_statistics_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), + ], + } + ) + + manager_group = cls.env.ref("spp_statistics_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), + ], + } + ) + + # 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), + ], + } + ) + + def test_viewer_can_read(self): + """Test that viewer can read dashboard data.""" + data = self.dashboard_data.with_user(self.viewer_user) + data.read(["value", "value_display", "label"]) + + def test_viewer_cannot_write(self): + """Test that viewer cannot write dashboard data.""" + data = self.dashboard_data.with_user(self.viewer_user) + with self.assertRaises(AccessError): + data.write({"value": 99.0}) + + 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", + } + ) + + def test_viewer_cannot_unlink(self): + """Test that viewer cannot delete dashboard data.""" + data = self.dashboard_data.with_user(self.viewer_user) + with self.assertRaises(AccessError): + data.unlink() + + def test_manager_can_read(self): + """Test that manager can read dashboard data.""" + data = self.dashboard_data.with_user(self.manager_user) + data.read(["value", "value_display", "label"]) + + def test_manager_can_write(self): + """Test that manager can write dashboard data.""" + data = self.dashboard_data.with_user(self.manager_user) + data.write({"value": 99.0}) + self.assertEqual(data.value, 99.0) + + 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": 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_as_manager = data.with_user(self.manager_user) + data_as_manager.unlink() + + def test_no_access_user_cannot_read(self): + """Test that user without dashboard group cannot read.""" + data = self.dashboard_data.with_user(self.no_access_user) + with self.assertRaises(AccessError): + data.read(["value"]) + + def test_manager_implies_viewer(self): + """Test that manager group implies viewer group (read access).""" + 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) + self.assertIn(read_group, viewer_group.implied_ids) + + def test_viewer_has_read_group(self): + """Test that viewer user has the technical read group.""" + 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_statistics_dashboard.group_dashboard_manage")) diff --git a/spp_statistics_dashboard/tests/test_dashboard_data.py b/spp_statistics_dashboard/tests/test_dashboard_data.py new file mode 100644 index 00000000..aad0a05f --- /dev/null +++ b/spp_statistics_dashboard/tests/test_dashboard_data.py @@ -0,0 +1,264 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for spp.dashboard.data model creation, constraints, and formatting.""" + +from psycopg2 import IntegrityError + +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestDashboardData(TransactionCase): + """Test dashboard data model and constraints.""" + + @classmethod + 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", + } + ) + + 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", + } + ) + + self.assertEqual(data.statistic_name, "test_stat_dashboard") + self.assertEqual(data.category_id, self.category) + self.assertEqual(data.category_code, "demographics") + self.assertEqual(data.value, 42.0) + self.assertEqual(data.value_display, "42") + self.assertEqual(data.format, "count") + self.assertEqual(data.unit, "people") + self.assertFalse(data.is_suppressed) + + 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", + } + ) + + # 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).""" + 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(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", + } + ) + data_id = data.id + + stat.unlink() + self.assertFalse(self.env["spp.dashboard.data"].browse(data_id).exists()) + + 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", + } + ) + data_id = data.id + + area.unlink() + self.assertFalse(self.env["spp.dashboard.data"].browse(data_id).exists()) + + def test_format_value_count(self): + """Test value formatting for count format.""" + DashData = self.env["spp.dashboard.data"] + result = DashData._format_value(1234, self.statistic) + self.assertEqual(result, "1,234") + + 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, + } + ) + result = DashData._format_value(75.5, stat) + self.assertEqual(result, "75.5%") + + def test_format_value_none(self): + """Test value formatting for None value.""" + DashData = self.env["spp.dashboard.data"] + result = DashData._format_value(None, self.statistic) + self.assertEqual(result, "") + + 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", + } + ) + + # Verify stored related fields are searchable + 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"), + ] + ) + self.assertIn(data, found) + + +class TestDashboardViews(TransactionCase): + """Test that view definitions load correctly.""" + + def test_kanban_view_loads(self): + """Test kanban view can be loaded without error.""" + 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_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_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_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_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_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_statistics_dashboard.action_refresh_dashboard_data") + self.assertEqual(action.state, "code") diff --git a/spp_statistics_dashboard/tests/test_dashboard_integration.py b/spp_statistics_dashboard/tests/test_dashboard_integration.py new file mode 100644 index 00000000..48462b52 --- /dev/null +++ b/spp_statistics_dashboard/tests/test_dashboard_integration.py @@ -0,0 +1,159 @@ +# 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"], "all_registrants") + + # 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), + 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_statistics_dashboard/tests/test_dashboard_refresh.py b/spp_statistics_dashboard/tests/test_dashboard_refresh.py new file mode 100644 index 00000000..a2bac82c --- /dev/null +++ b/spp_statistics_dashboard/tests/test_dashboard_refresh.py @@ -0,0 +1,412 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for dashboard refresh logic, error handling, and stale cleanup.""" + +from unittest.mock import patch + +from odoo.tests.common import TransactionCase + + +class TestDashboardRefresh(TransactionCase): + """Test refresh logic for dashboard data.""" + + @classmethod + 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", + } + ) + + def _mock_aggregation_result(self, value=100, suppressed=False, total_count=50): + """Build a mock aggregation result dict.""" + return { + "total_count": total_count, + "statistics": { + "refresh_test_stat": { + "value": value, + "suppressed": suppressed, + }, + }, + "from_cache": False, + "computed_at": "2026-01-01T00:00:00", + "access_level": "aggregate", + } + + def test_refresh_statistic_creates_data(self): + """Test that _refresh_statistic creates dashboard data rows.""" + DashData = self.env["spp.dashboard.data"] + mock_result = self._mock_aggregation_result(value=100, total_count=50) + + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + 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), + ] + ) + self.assertEqual(len(data), 1) + self.assertEqual(data.value, 100.0) + self.assertEqual(data.value_display, "100") + self.assertFalse(data.is_suppressed) + self.assertEqual(data.underlying_count, 50) + self.assertTrue(data.refreshed_at) + + def test_refresh_statistic_with_area(self): + """Test refresh creates rows for specified areas.""" + DashData = self.env["spp.dashboard.data"] + mock_result = self._mock_aggregation_result(value=25, total_count=25) + + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + 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), + ] + ) + self.assertEqual(len(data), 2) + + area_data = data.filtered(lambda d: d.area_id == self.area) + self.assertEqual(len(area_data), 1) + self.assertEqual(area_data.value, 25.0) + + def test_refresh_statistic_upserts(self): + """Test that refresh updates existing rows instead of duplicating.""" + DashData = self.env["spp.dashboard.data"] + + # First refresh + mock_result = self._mock_aggregation_result(value=100) + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + DashData._refresh_statistic(self.statistic.id, []) + + # Second refresh with different value + mock_result = self._mock_aggregation_result(value=200) + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + 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), + ] + ) + self.assertEqual(len(data), 1) + self.assertEqual(data.value, 200.0) + + def test_refresh_statistic_suppressed_value(self): + """Test that suppressed values are handled correctly.""" + DashData = self.env["spp.dashboard.data"] + mock_result = self._mock_aggregation_result(value=3, suppressed=True, total_count=3) + + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + DashData._refresh_statistic(self.statistic.id, []) + + 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 + self.assertTrue(data.value_display) + + def test_refresh_statistic_error_isolation(self): + """Test that one aggregation failure does not abort the entire refresh.""" + DashData = self.env["spp.dashboard.data"] + call_count = 0 + + def mock_compute(self_svc, scope, statistics=None, context=None, **kwargs): + nonlocal call_count + call_count += 1 + if scope.get("area_id") == self.area.id: + raise ValueError("Simulated aggregation error") + return self._mock_aggregation_result(value=50, total_count=50) + + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + mock_compute, + ): + 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), + ] + ) + 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), + ] + ) + self.assertEqual(len(area_data), 0) + + 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, []) + # Should not raise, just log a warning + + def test_cleanup_stale_data(self): + """Test that stale data for un-published statistics is cleaned up.""" + 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", + } + ) + + # Also create data for the published statistic + 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) + + # Stale data should be deleted + self.assertFalse(stale_data.exists()) + # Fresh data should remain + self.assertTrue(fresh_data.exists()) + + def test_action_refresh_all_no_stats(self): + """Test action_refresh_all returns warning when no stats published.""" + # Un-publish all stats temporarily + self.statistic.is_published_dashboard = False + + DashData = self.env["spp.dashboard.data"] + result = DashData.action_refresh_all() + + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["type"], "warning") + + # Restore + self.statistic.is_published_dashboard = True + + def test_action_refresh_all_with_stats(self): + """Test action_refresh_all returns success notification.""" + DashData = self.env["spp.dashboard.data"] + + mock_result = self._mock_aggregation_result(value=10) + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + result = DashData.action_refresh_all() + + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["type"], "success") + + def test_get_dashboard_areas_all(self): + """Test _get_dashboard_areas returns all areas when no param set.""" + DashData = self.env["spp.dashboard.data"] + areas = DashData._get_dashboard_areas() + # Should include at least our test area + self.assertIn(self.area, areas) + + 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_statistics_dashboard.area_levels", "0") + + DashData = self.env["spp.dashboard.data"] + areas = DashData._get_dashboard_areas() + + for area in areas: + self.assertEqual(area.area_level, 0) + + self.assertIn(self.area, areas) + + # Clean up + 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.""" + DashData = self.env["spp.dashboard.data"] + programs = DashData._get_dashboard_programs() + for prog in programs: + self.assertEqual(prog.state, "active") + + def test_build_scope_system_wide(self): + """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"], "all_registrants") + + def test_build_scope_with_area(self): + """Test _build_scope with an area.""" + DashData = self.env["spp.dashboard.data"] + scope = DashData._build_scope(self.area, False) + self.assertEqual(scope["scope_type"], "area") + 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 + 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) + + with patch.object( + type(self.env["spp.aggregation.service"]), + "compute_aggregation", + return_value=mock_result, + ): + DashData._refresh_statistic(self.statistic.id, []) + + 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_statistics_dashboard/views/dashboard_data_views.xml b/spp_statistics_dashboard/views/dashboard_data_views.xml new file mode 100644 index 00000000..93bdf7ff --- /dev/null +++ b/spp_statistics_dashboard/views/dashboard_data_views.xml @@ -0,0 +1,264 @@ + + + + + + + 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

+

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

+
+
+
diff --git a/spp_statistics_dashboard/views/menus.xml b/spp_statistics_dashboard/views/menus.xml new file mode 100644 index 00000000..0bf537f4 --- /dev/null +++ b/spp_statistics_dashboard/views/menus.xml @@ -0,0 +1,25 @@ + + + + + +