Skip to content

Commit ede1a1b

Browse files
Merge pull request #279 from OpenSPP/reland/metric-service
feat(spp_metric_service): breakdown expansion + SQL column support (re-land from #76)
2 parents b3441ab + a8b8f08 commit ede1a1b

11 files changed

Lines changed: 738 additions & 55 deletions

spp_metric_service/README.rst

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,27 +45,27 @@ Key Capabilities
4545
Key Models
4646
~~~~~~~~~~
4747

48-
+---------------------------------+------------------------------------+
49-
| Model | Description |
50-
+=================================+====================================+
51-
| ``spp.demographic.dimension`` | Configurable dimension for |
52-
| | breakdowns (field or CEL) |
53-
+---------------------------------+------------------------------------+
54-
| ``spp.metrics.fairness`` | Abstract service: equity/parity |
55-
| | analysis |
56-
+---------------------------------+------------------------------------+
57-
| ``spp.metrics.distribution`` | Abstract service: distribution |
58-
| | statistics |
59-
+---------------------------------+------------------------------------+
60-
| ``spp.metrics.breakdown`` | Abstract service: |
61-
| | multi-dimensional grouping |
62-
+---------------------------------+------------------------------------+
63-
| ``spp.metrics.privacy`` | Abstract service: k-anonymity |
64-
| | enforcement |
65-
+---------------------------------+------------------------------------+
66-
| ``spp.metrics.dimension.cache`` | Abstract service: dimension |
67-
| | evaluation cache |
68-
+---------------------------------+------------------------------------+
48+
+--------------------------------+-------------------------------------+
49+
| Model | Description |
50+
+================================+=====================================+
51+
| ``spp.demographic.dimension`` | Configurable dimension for |
52+
| | breakdowns (field or CEL) |
53+
+--------------------------------+-------------------------------------+
54+
| ``spp.metric.fairness`` | Abstract service: equity/parity |
55+
| | analysis |
56+
+--------------------------------+-------------------------------------+
57+
| ``spp.metric.distribution`` | Abstract service: distribution |
58+
| | statistics |
59+
+--------------------------------+-------------------------------------+
60+
| ``spp.metric.breakdown`` | Abstract service: multi-dimensional |
61+
| | grouping |
62+
+--------------------------------+-------------------------------------+
63+
| ``spp.metric.privacy`` | Abstract service: k-anonymity |
64+
| | enforcement |
65+
+--------------------------------+-------------------------------------+
66+
| ``spp.metric.dimension.cache`` | Abstract service: dimension |
67+
| | evaluation cache |
68+
+--------------------------------+-------------------------------------+
6969

7070
Configuration
7171
~~~~~~~~~~~~~
@@ -96,10 +96,10 @@ Group Access
9696
Extension Points
9797
~~~~~~~~~~~~~~~~
9898

99-
- Override ``_analyze_dimension()`` in ``spp.metrics.fairness`` for
99+
- Override ``_analyze_dimension()`` in ``spp.metric.fairness`` for
100100
custom analysis logic
101101
- Add new dimension types by extending ``spp.demographic.dimension``
102-
- Override ``enforce()`` in ``spp.metrics.privacy`` for custom
102+
- Override ``enforce()`` in ``spp.metric.privacy`` for custom
103103
suppression strategies
104104

105105
Dependencies
@@ -115,6 +115,16 @@ Dependencies
115115
Changelog
116116
=========
117117

118+
19.0.2.1.0
119+
~~~~~~~~~~
120+
121+
- feat: demographic breakdown expansion and SQL column support for
122+
metric disaggregation (re-land from #76; uses the spp_cel_domain SQL
123+
CASE compiler). Expansion is all-or-nothing: any individuals-scoped
124+
dimension expands the whole registrant set to active members, so
125+
breakdown totals count members and need not reconcile with a
126+
group-level scope count.
127+
118128
19.0.2.0.0
119129
~~~~~~~~~~
120130

spp_metric_service/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "OpenSPP Metric Service",
44
"summary": "Computation services for fairness, distribution, breakdown, and privacy",
55
"category": "OpenSPP",
6-
"version": "19.0.2.0.0",
6+
"version": "19.0.2.1.0",
77
"sequence": 1,
88
"author": "OpenSPP.org",
99
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_metric_service/data/demographic_dimensions.xml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
<field name="description">Gender identity from ISO 5218 vocabulary</field>
88
<field name="sequence">10</field>
99
<field name="dimension_type">field</field>
10-
<field name="field_path">gender_id</field>
11-
<field name="applies_to">all</field>
10+
<field name="field_path">gender_id.code</field>
11+
<field name="applies_to">individuals</field>
1212
<field
1313
name="value_labels_json"
1414
>{"0": "Not Known", "1": "Male", "2": "Female", "9": "Not Applicable"}</field>
@@ -56,23 +56,27 @@
5656
>{"true": "Group/Household", "false": "Individual"}</field>
5757
</record>
5858

59-
<!-- Age Group Dimension (example using CEL expression) -->
59+
<!-- Age Group Dimension (UNICEF/WHO-aligned buckets) -->
6060
<record id="dimension_age_group" model="spp.demographic.dimension">
6161
<field name="name">age_group</field>
6262
<field name="label">Age Group</field>
63-
<field name="description">Age group based on birth date</field>
63+
<field
64+
name="description"
65+
>Age group based on birth date (UNICEF/WHO-aligned)</field>
6466
<field name="sequence">50</field>
6567
<field name="dimension_type">expression</field>
6668
<field
6769
name="cel_expression"
6870
><![CDATA[
6971
r.birthdate == null ? "unknown" :
70-
age_years(r.birthdate) < 18 ? "child" :
72+
age_years(r.birthdate) < 5 ? "under_5" :
73+
age_years(r.birthdate) < 15 ? "child" :
74+
age_years(r.birthdate) < 18 ? "adolescent" :
7175
age_years(r.birthdate) < 60 ? "adult" : "elderly"
7276
]]></field>
7377
<field name="applies_to">individuals</field>
7478
<field
7579
name="value_labels_json"
76-
>{"child": "Child (0-17)", "adult": "Adult (18-59)", "elderly": "Elderly (60+)", "unknown": "Unknown"}</field>
80+
>{"under_5": "Under 5", "child": "Child (5-14)", "adolescent": "Adolescent (15-17)", "adult": "Adult (18-59)", "elderly": "Elderly (60+)", "unknown": "Unknown"}</field>
7781
</record>
7882
</odoo>

spp_metric_service/models/breakdown_service.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N
2424
2525
Uses dimension cache for 5-10x performance improvement.
2626
27+
Expansion semantics (deliberate): if ANY requested dimension has
28+
``applies_to == "individuals"``, the ENTIRE registrant set is expanded
29+
from groups to their active individual members before evaluation —
30+
including for any non-individual dimensions mixed into the same
31+
request (mixing scopes in one breakdown is inherently ambiguous; the
32+
expansion is all-or-nothing by design). Consequently breakdown totals
33+
count members and intentionally need not reconcile with a group-level
34+
scope count.
35+
2736
:param registrant_ids: List of partner IDs
2837
:param group_by: List of dimension names
2938
:param statistics: List of statistic names (optional)
@@ -56,6 +65,14 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N
5665
if not dimensions:
5766
return {}
5867

68+
# Auto-expand groups to members when any dimension applies to individuals only
69+
needs_expansion = any(d.applies_to == "individuals" for d in dimensions)
70+
if needs_expansion:
71+
registrant_ids = self._expand_groups_to_members(registrant_ids)
72+
73+
if not registrant_ids:
74+
return {}
75+
5976
# Get cache service
6077
cache_service = self.env["spp.metric.dimension.cache"]
6178

@@ -95,3 +112,41 @@ def compute_breakdown(self, registrant_ids, group_by, statistics=None, context=N
95112
# TODO: Add per-cell statistics if needed
96113

97114
return breakdown
115+
116+
@api.model
117+
def _expand_groups_to_members(self, registrant_ids):
118+
"""
119+
Expand group IDs to their individual member IDs.
120+
121+
Groups are replaced by their active members. Individual IDs pass through.
122+
The result is deduplicated.
123+
124+
:param registrant_ids: List of partner IDs (groups and/or individuals)
125+
:returns: Deduplicated list of individual partner IDs
126+
:rtype: list
127+
"""
128+
# sudo: aggregate breakdown metrics must expand groups to their members
129+
# across all registrants regardless of the caller's record rules.
130+
# Read-only (no writes); callers are authorized at the service entry point.
131+
Partner = self.env["res.partner"].sudo() # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models
132+
records = Partner.browse(registrant_ids).exists()
133+
134+
groups = records.filtered("is_group")
135+
group_ids = groups.ids
136+
individual_ids = set((records - groups).ids)
137+
138+
if not group_ids:
139+
return list(individual_ids)
140+
141+
# Expand groups via active memberships
142+
Membership = self.env["spp.group.membership"].sudo() # nosemgrep: odoo-sudo-without-context
143+
memberships = Membership.search(
144+
[
145+
("group", "in", group_ids),
146+
("is_ended", "=", False),
147+
]
148+
)
149+
150+
individual_ids.update(memberships.individual.ids)
151+
152+
return list(individual_ids)

0 commit comments

Comments
 (0)