Skip to content

Commit 9f7dd29

Browse files
Merge pull request #280 from OpenSPP/reland/gis-report
feat(spp_gis_report): metric disaggregation in GIS reports (re-land from #76)
2 parents ede1a1b + 8f9ded7 commit 9f7dd29

23 files changed

Lines changed: 1447 additions & 94 deletions

spp_gis_report/README.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,24 @@ Dependencies
151151
Changelog
152152
=========
153153

154+
19.0.2.1.0
155+
~~~~~~~~~~
156+
157+
- feat: metric disaggregation in GIS reports (breakdown dimensions,
158+
report helpers, wizard support) (re-land from #76; uses the
159+
spp_metric_service breakdown API).
160+
- BREAKING (GeoJSON payload): disaggregation output moved from a nested
161+
``properties.disaggregation`` object to flat
162+
``disagg_<dimension>_<value>`` properties, with interpretation
163+
metadata under ``metadata.disaggregation``. External consumers of
164+
``/api/v2/GISReport/.../geojson`` (e.g. QGIS styles) that read the old
165+
nested key must be updated.
166+
- BREAKING (model fields): the boolean
167+
``disaggregate_by_gender``/``disaggregate_by_age``/``disaggregate_by_disability``
168+
fields are removed in favor of ``dimension_ids``; existing
169+
configurations are migrated automatically on upgrade (see
170+
migrations/19.0.2.1.0).
171+
154172
19.0.2.0.1
155173
~~~~~~~~~~
156174

spp_gis_report/__manifest__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "OpenSPP GIS Reports",
3-
"version": "19.0.2.0.1",
3+
"version": "19.0.2.1.0",
44
"category": "OpenSPP",
55
"summary": "Geographic visualization and reporting for social protection data",
66
"author": "OpenSPP.org, OpenSPP",
@@ -10,6 +10,7 @@
1010
"depends": [
1111
"spp_area",
1212
"spp_gis",
13+
"spp_metric_service",
1314
"spp_registry",
1415
"spp_vocabulary",
1516
"spp_cel_domain",

spp_gis_report/controllers/geojson.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,23 +100,19 @@ def list_reports(self):
100100
for report in reports:
101101
admin_levels = report.data_ids.mapped("area_level") if report.data_ids else []
102102

103-
report_list.append(
104-
{
105-
"code": report.code,
106-
"name": report.name,
107-
"category": report.category_id.name if report.category_id else None,
108-
"last_refresh": (report.last_refresh.isoformat() if report.last_refresh else None),
109-
"admin_levels_available": sorted(set(admin_levels)),
110-
"has_disaggregation": any(
111-
[
112-
report.disaggregate_by_gender,
113-
report.disaggregate_by_age,
114-
report.disaggregate_by_disability,
115-
report.disaggregate_by_tag_ids,
116-
]
117-
),
118-
}
119-
)
103+
report_data = {
104+
"code": report.code,
105+
"name": report.name,
106+
"category": report.category_id.name if report.category_id else None,
107+
"last_refresh": (report.last_refresh.isoformat() if report.last_refresh else None),
108+
"admin_levels_available": sorted(set(admin_levels)),
109+
"has_disaggregation": bool(report.dimension_ids) or bool(report.disaggregate_by_tag_ids),
110+
}
111+
if report.dimension_ids:
112+
report_data["disaggregation_dimensions"] = [
113+
{"name": d.name, "label": d.label} for d in report.dimension_ids
114+
]
115+
report_list.append(report_data)
120116

121117
return self._json_response({"reports": report_list})
122118

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Migrate legacy boolean disaggregation flags to dimension_ids.
3+
4+
Replaces the post_init_hook version, which only ran on fresh installs (where
5+
there is nothing to migrate) and hardcoded the m2m relation table name. As a
6+
post-migration script this runs on upgrade, and the relation/column names are
7+
derived from the field so they cannot drift from Odoo's canonical naming.
8+
"""
9+
10+
import logging
11+
12+
from psycopg2 import sql
13+
14+
from odoo import SUPERUSER_ID, api
15+
16+
_logger = logging.getLogger(__name__)
17+
18+
# Mapping: old boolean field name -> dimension technical name
19+
_BOOL_TO_DIMENSION = {
20+
"disaggregate_by_gender": "gender",
21+
"disaggregate_by_age": "age_group",
22+
"disaggregate_by_disability": "disability_status",
23+
}
24+
25+
26+
def migrate(cr, version):
27+
"""Link reports that had legacy boolean flags set to the matching dimensions.
28+
29+
``version`` is the previously-installed module version, or a falsy value on
30+
a fresh install (nothing to migrate). Logs a warning (does not crash) if a
31+
dimension record is not found.
32+
"""
33+
if not version:
34+
# Fresh install: the legacy boolean columns never existed.
35+
return
36+
37+
# Only columns that still physically exist in the table can be read. After
38+
# removing the boolean fields, Odoo leaves the obsolete columns in place, so
39+
# they are still readable here on the first upgrade.
40+
cr.execute(
41+
"""
42+
SELECT column_name FROM information_schema.columns
43+
WHERE table_name = 'spp_gis_report'
44+
AND column_name IN ('disaggregate_by_gender', 'disaggregate_by_age', 'disaggregate_by_disability')
45+
"""
46+
)
47+
existing_columns = {row[0] for row in cr.fetchall()}
48+
if not existing_columns:
49+
_logger.info("No legacy boolean disaggregation columns found, skipping migration")
50+
return
51+
52+
env = api.Environment(cr, SUPERUSER_ID, {})
53+
Dimension = env["spp.demographic.dimension"]
54+
55+
# Derive the m2m relation/column names from the field so this migration
56+
# cannot drift from Odoo's canonical (alphabetically sorted) table naming.
57+
m2m = env["spp.gis.report"]._fields["dimension_ids"]
58+
insert_stmt = sql.SQL("INSERT INTO {rel} ({col_report}, {col_dim}) VALUES (%s, %s) ON CONFLICT DO NOTHING").format(
59+
rel=sql.Identifier(m2m.relation),
60+
col_report=sql.Identifier(m2m.column1),
61+
col_dim=sql.Identifier(m2m.column2),
62+
)
63+
64+
for bool_field, dim_name in _BOOL_TO_DIMENSION.items():
65+
if bool_field not in existing_columns:
66+
continue
67+
68+
# bool_field is a trusted column name from _BOOL_TO_DIMENSION; quote it
69+
# safely via psycopg2.sql.
70+
query = sql.SQL("SELECT id FROM spp_gis_report WHERE {col} = true").format(col=sql.Identifier(bool_field))
71+
cr.execute(query)
72+
report_ids = [row[0] for row in cr.fetchall()]
73+
if not report_ids:
74+
continue
75+
76+
dimension = Dimension.search([("name", "=", dim_name)], limit=1)
77+
if not dimension:
78+
_logger.warning(
79+
"Dimension '%s' not found, cannot migrate %d reports with %s=True",
80+
dim_name,
81+
len(report_ids),
82+
bool_field,
83+
)
84+
continue
85+
86+
for report_id in report_ids:
87+
cr.execute(insert_stmt, (report_id, dimension.id))
88+
89+
_logger.info(
90+
"Migrated %d reports from %s=True to dimension '%s'",
91+
len(report_ids),
92+
bool_field,
93+
dim_name,
94+
)

spp_gis_report/models/area_ext.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,15 @@ def get_gis_layers(self, view_id=None, view_type="gis", **options):
153153
# Build legend info from thresholds for display purposes only.
154154
thresholds = report.threshold_ids.sorted("sequence")
155155
if thresholds:
156-
layer_dict["report_legend"] = [{"color": t.color, "label": t.label} for t in thresholds]
156+
layer_dict["report_legend"] = [
157+
{
158+
"color": t.color,
159+
"label": t.label,
160+
"min_value": t.min_value,
161+
"max_value": t.max_value,
162+
}
163+
for t in thresholds
164+
]
157165
layer_dict["report_legend_title"] = report.name
158166

159167
# Build pre-computed features for report layer

0 commit comments

Comments
 (0)