Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
be273cd
feat(spp_dashboard): add statistics dashboard module
jeremi Mar 6, 2026
ebeef24
fix(spp_dashboard): use Odoo 19 models.Constraint and remove numbercall
jeremi Mar 6, 2026
634fadb
fix(spp_dashboard): fix Odoo 19 view compatibility issues
jeremi Mar 6, 2026
73ee076
fix(spp_dashboard): fix test data for spp.area and view assertions
jeremi Mar 6, 2026
0aed174
fix(spp_dashboard): use functional unique index and fix Odoo 19 field…
jeremi Mar 6, 2026
4ab80b7
fix(spp_dashboard): fix access tests for has_group and unique constraint
jeremi Mar 6, 2026
06e1456
chore(spp_dashboard): apply ruff and prettier formatting
jeremi Mar 6, 2026
0c67053
fix(spp_dashboard): narrow exception handling and fix ACL entry IDs
jeremi Mar 6, 2026
95ff93b
fix(spp_dashboard): fix system-wide scope and suppression display
jeremi Mar 6, 2026
cd687d7
fix(spp_dashboard): use explicit scope for system-wide stats
jeremi Mar 6, 2026
44f6d00
fix(spp_mis_demo_v2): remove disabled_members CEL variable and statistic
jeremi Mar 6, 2026
e331eb5
fix(spp_cel_domain): normalize Odoo False to None for null comparisons
jeremi Mar 6, 2026
d594d85
refactor: rename spp_dashboard to spp_statistics_dashboard
jeremi Mar 6, 2026
109da6f
feat(spp_statistics_dashboard): add per-program statistics refresh
jeremi Mar 6, 2026
cdef2d9
feat(spp_statistics_dashboard): improve search filters and sidebar
jeremi Mar 6, 2026
b846144
fix(spp_statistics_dashboard): add nosemgrep annotations for sudo() c…
jeremi Mar 6, 2026
6d8dc59
feat(spp_aggregation): add all_registrants scope type
jeremi Mar 6, 2026
3623bed
refactor(spp_statistics_dashboard): simplify refresh logic and fix mi…
jeremi Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions spp_aggregation/models/service_scope_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
# -------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions spp_aggregation/tests/test_scope_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
9 changes: 8 additions & 1 deletion spp_cel_domain/services/cel_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions spp_cel_domain/tests/test_cel_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
29 changes: 0 additions & 29 deletions spp_mis_demo_v2/data/demo_statistics.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,6 @@
<field name="sequence">40</field>
</record>

<!-- Disabled Members -->
<record id="cel_var_disabled_members" model="spp.cel.variable">
<field name="name">demo_disabled_members</field>
<field name="cel_accessor">disabled_members</field>
<field name="source_type">aggregate</field>
<field name="aggregate_type">count</field>
<field name="aggregate_target">members</field>
<field name="aggregate_filter">m.disabled != null</field>
<field name="value_type">number</field>
<field name="applies_to">group</field>
<field name="category_id" ref="spp_studio.variable_category_characteristics" />
<field name="state">active</field>
<field name="sequence">50</field>
</record>

<!-- Female Members -->
<record id="cel_var_female_members" model="spp.cel.variable">
<field name="name">demo_female_members</field>
Expand Down Expand Up @@ -235,20 +220,6 @@
<field name="is_published_dashboard" eval="True" />
</record>

<!-- Disabled Members -->
<record id="stat_disabled_members" model="spp.statistic">
<field name="name">disabled_members</field>
<field name="label">Disabled Members</field>
<field name="description">Count of household members with disabilities</field>
<field name="variable_id" ref="cel_var_disabled_members" />
<field name="format">count</field>
<field name="unit">people</field>
<field name="category_id" ref="spp_statistic.category_vulnerability" />
<field name="sequence">50</field>
<field name="is_published_gis" eval="True" />
<field name="is_published_dashboard" eval="True" />
</record>

<!-- Program Enrollment -->
<record id="stat_enrolled_any_program" model="spp.statistic">
<field name="name">enrolled_any_program</field>
Expand Down
4 changes: 1 addition & 3 deletions spp_mis_demo_v2/tests/test_demo_statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -102,7 +101,6 @@ def test_statistics_categories_exist(self):
"female_members",
"male_members",
],
"vulnerability": ["disabled_members"],
"programs": ["enrolled_any_program"],
}

Expand Down
3 changes: 3 additions & 0 deletions spp_statistics_dashboard/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.

from . import models
34 changes: 34 additions & 0 deletions spp_statistics_dashboard/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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,
}
12 changes: 12 additions & 0 deletions spp_statistics_dashboard/data/ir_cron.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="ir_cron_dashboard_refresh" model="ir.cron">
<field name="name">Dashboard: Refresh Statistics</field>
<field name="model_id" ref="model_spp_dashboard_data" />
<field name="state">code</field>
<field name="code">model.action_refresh_all()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>
3 changes: 3 additions & 0 deletions spp_statistics_dashboard/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.

from . import dashboard_data
Loading
Loading