Skip to content

Commit d750b6a

Browse files
committed
fix(spp_gis): fix map widget bugs, SQL parameterization, and OSM fallback
- Fix WebGL context leak: destroy previous map before creating new one in renderMap() to prevent accumulating WebGL contexts on onPatched - Fix draw control stacking: remove previous MapboxDraw control before adding a new one in addDrawInteraction() - Fix removeSourceAndLayer: remove all three layer IDs (polygon, point, linestring) instead of the source ID which doesn't match any layer - Remove console.log debug statements from updateArea and onTrash - Remove hardcoded laos_farm.png placeholder popup on polygon click - Fix SQL injection in create_from_geojson: use SQL() with bound parameters instead of manual string escaping - Apply OSM raster tile fallback to gis_renderer (matching edit widget) - Guard GeocodingControl behind API key check in renderer - Fix early-return-in-loop in eligibility manager methods with ensure_one() - Log exceptions in preview instead of silently swallowing them - Use efficient set lookup for beneficiary exclusion - Use Command.set()/Command.clear() instead of tuple syntax in tests
1 parent debda8d commit d750b6a

5 files changed

Lines changed: 96 additions & 71 deletions

File tree

spp_gis/operators.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,12 @@ def create_from_geojson(self, geojson_dict, srid):
263263
264264
Used for complex geometry types (MultiPolygon, GeometryCollection)
265265
that cannot be easily constructed from coordinates.
266+
267+
Returns a SQL object with the GeoJSON string as a bound parameter
268+
to avoid SQL injection via string interpolation.
266269
"""
267-
geojson_str = json.dumps(geojson_dict).replace("'", "''")
268-
return self.st_setsrid(f"ST_GeomFromGeoJSON('{geojson_str}')", srid)
270+
geojson_str = json.dumps(geojson_dict)
271+
return SQL("ST_SetSRID(ST_GeomFromGeoJSON(%s), %s)", geojson_str, srid)
269272

270273
def validate_coordinates_for_point(self, coordinates):
271274
"""
@@ -505,7 +508,8 @@ def domain_query(self, operator, value):
505508
if layer_type in ("multipolygon", "geometrycollection"):
506509
# Complex types use ST_GeomFromGeoJSON directly
507510
geom = self.create_from_geojson(geojson_val, self.field.srid)
508-
return SQL(f"{self.POSTGIS_SPATIAL_RELATION[operation]}({geom}, {self.qualified_field_name})")
511+
postgis_fn = self.POSTGIS_SPATIAL_RELATION[operation]
512+
return SQL("%s(%s, %s)", SQL(postgis_fn), geom, SQL(self.qualified_field_name))
509513

510514
coordinates = geojson_val["coordinates"]
511515
return SQL(self.get_postgis_query(operation, coordinates, distance=distance, layer_type=layer_type))

spp_gis/static/src/js/views/gis/gis_renderer/gis_renderer.esm.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ export class GisRenderer extends Component {
9191
});
9292

9393
onMounted(() => {
94-
maptilersdk.config.apiKey = this.mapTilerKey;
94+
if (this.mapTilerKey) {
95+
maptilersdk.config.apiKey = this.mapTilerKey;
96+
}
9597
this.setupSourceAndLayer();
9698

9799
this.renderMap();
@@ -106,14 +108,11 @@ export class GisRenderer extends Component {
106108
async getMapTilerKey() {
107109
try {
108110
const response = await this.rpc("/get_maptiler_api_key");
109-
this.mapTilerKey = response.mapTilerKey;
110111
if (response.mapTilerKey) {
111112
this.mapTilerKey = response.mapTilerKey;
112-
} else {
113-
console.log("Error: Api Key not found.");
114113
}
115114
} catch (error) {
116-
console.error("Error fetching environment variable:", error);
115+
console.warn("Could not fetch MapTiler API key:", error);
117116
}
118117
}
119118

@@ -459,11 +458,38 @@ export class GisRenderer extends Component {
459458

460459
this.addMouseInteraction();
461460

462-
const gc = new maptilersdkMaptilerGeocoder.GeocodingControl({});
463-
this.map.addControl(gc, "top-left");
461+
if (this.mapTilerKey) {
462+
const gc = new maptilersdkMaptilerGeocoder.GeocodingControl({});
463+
this.map.addControl(gc, "top-left");
464+
}
464465
}
465466

466467
getMapStyle(layer) {
468+
if (!this.mapTilerKey) {
469+
// Fallback: OSM raster tiles (no API key required)
470+
return {
471+
version: 8,
472+
sources: {
473+
osm: {
474+
type: "raster",
475+
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
476+
tileSize: 256,
477+
attribution:
478+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
479+
},
480+
},
481+
layers: [
482+
{
483+
id: "osm-tiles",
484+
type: "raster",
485+
source: "osm",
486+
minzoom: 0,
487+
maxzoom: 19,
488+
},
489+
],
490+
};
491+
}
492+
467493
let mapStyle = maptilersdk.MapStyle.STREETS;
468494

469495
if (layer) {

spp_gis/static/src/js/widgets/gis_edit_map/field_gis_edit_map.esm.js

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ export class FieldGisEditMap extends Component {
132132
this.defaultZoom = 10;
133133
}
134134

135+
if (this.map) {
136+
this.map.remove();
137+
}
138+
135139
this.map = new maptilersdk.Map({
136140
container: this.id,
137141
style: this._getMapStyle(),
@@ -227,7 +231,9 @@ export class FieldGisEditMap extends Component {
227231
}
228232

229233
removeSourceAndLayer(source) {
230-
this.map.removeLayer(source);
234+
this.map.removeLayer(`${source}-polygon-layerid`);
235+
this.map.removeLayer(`${source}-point-layerid`);
236+
this.map.removeLayer(`${source}-linestring-layerid`);
231237
this.map.removeSource(source);
232238
}
233239

@@ -241,13 +247,16 @@ export class FieldGisEditMap extends Component {
241247
const self = this;
242248

243249
function updateArea(e) {
244-
console.log(e);
245250
var data = self.draw.getAll();
246251
self.props.record.update({
247252
[self.props.name]: JSON.stringify(data.features[0].geometry),
248253
});
249254
}
250255

256+
if (this.draw) {
257+
this.map.removeControl(this.draw);
258+
}
259+
251260
this.draw = new MapboxDraw({
252261
displayControlsDefault: false,
253262
controls: {
@@ -274,17 +283,6 @@ export class FieldGisEditMap extends Component {
274283

275284
this.map.on("draw.create", updateArea);
276285
this.map.on("draw.update", updateArea);
277-
278-
const url = `/spp_gis/static/src/images/laos_farm.png`;
279-
280-
this.map.on("click", `${this.sourceId}-polygon-layerid`, (e) => {
281-
new maptilersdk.Popup()
282-
.setLngLat(e.lngLat)
283-
.setHTML(
284-
`<img src="${url}" height="200" width="300" alt="Placeholder Image">`
285-
)
286-
.addTo(this.map);
287-
});
288286
}
289287

290288
addDrawInteractionStyle() {
@@ -398,7 +396,6 @@ export class FieldGisEditMap extends Component {
398396
const customMode = {};
399397
const self = this;
400398
customMode.onTrash = function (state) {
401-
console.log(state);
402399
self.props.record.update({[self.props.name]: null});
403400
};
404401

spp_program_geofence/models/eligibility_manager.py

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def _prepare_eligible_domain(self, membership=None):
9999
"""
100100
domain = []
101101
if membership is not None:
102-
ids = membership.mapped("partner_id.id")
102+
ids = membership.partner_id.ids
103103
domain += [("id", "in", ids)]
104104

105105
# Exclude disabled registrants (disabled is a Datetime field)
@@ -159,39 +159,38 @@ def _find_eligible_registrants(self, membership=None):
159159
return tier1
160160

161161
def enroll_eligible_registrants(self, program_memberships):
162-
for rec in self:
163-
eligible = rec._find_eligible_registrants(program_memberships)
164-
return self.env["spp.program.membership"].search(
165-
[
166-
("partner_id", "in", eligible.ids),
167-
("program_id", "=", rec.program_id.id),
168-
]
169-
)
162+
self.ensure_one()
163+
eligible = self._find_eligible_registrants(program_memberships)
164+
return self.env["spp.program.membership"].search(
165+
[
166+
("partner_id", "in", eligible.ids),
167+
("program_id", "=", self.program_id.id),
168+
]
169+
)
170170

171171
def verify_cycle_eligibility(self, cycle, membership):
172-
for rec in self:
173-
eligible = rec._find_eligible_registrants(membership)
174-
return self.env["spp.cycle.membership"].search(
175-
[
176-
("partner_id", "in", eligible.ids),
177-
("cycle_id", "=", cycle.id),
178-
]
179-
)
172+
self.ensure_one()
173+
eligible = self._find_eligible_registrants(membership)
174+
return self.env["spp.cycle.membership"].search(
175+
[
176+
("partner_id", "in", eligible.ids),
177+
("cycle_id", "=", cycle.id),
178+
]
179+
)
180180

181181
def import_eligible_registrants(self, state="draft"):
182-
ben_count = 0
183-
for rec in self:
184-
new_beneficiaries = rec._find_eligible_registrants()
185-
186-
# Exclude already-enrolled beneficiaries
187-
beneficiary_ids = rec.program_id.get_beneficiaries().mapped("partner_id")
188-
new_beneficiaries = new_beneficiaries - beneficiary_ids
189-
190-
ben_count = len(new_beneficiaries)
191-
if ben_count < 1000:
192-
rec._import_registrants(new_beneficiaries, state=state, do_count=True)
193-
else:
194-
rec._import_registrants_async(new_beneficiaries, state=state)
182+
self.ensure_one()
183+
new_beneficiaries = self._find_eligible_registrants()
184+
185+
# Exclude already-enrolled beneficiaries
186+
existing_partner_ids = set(self.program_id.program_membership_ids.partner_id.ids)
187+
new_beneficiaries = new_beneficiaries.filtered(lambda r: r.id not in existing_partner_ids)
188+
189+
ben_count = len(new_beneficiaries)
190+
if ben_count < 1000:
191+
self._import_registrants(new_beneficiaries, state=state, do_count=True)
192+
else:
193+
self._import_registrants_async(new_beneficiaries, state=state)
195194
return ben_count
196195

197196
def _import_registrants_async(self, new_beneficiaries, state="draft"):
@@ -221,9 +220,7 @@ def mark_import_as_done(self):
221220

222221
def _import_registrants(self, new_beneficiaries, state="draft", do_count=False):
223222
_logger.info("spp_program_geofence: Importing %s beneficiaries", len(new_beneficiaries))
224-
beneficiaries_val = [
225-
Command.create({"partner_id": b.id, "state": state}) for b in new_beneficiaries
226-
]
223+
beneficiaries_val = [Command.create({"partner_id": b.id, "state": state}) for b in new_beneficiaries]
227224
self.program_id.update({"program_membership_ids": beneficiaries_val})
228225

229226
if do_count:
@@ -236,9 +233,10 @@ def action_preview_eligible(self):
236233
eligible = self._find_eligible_registrants()
237234
self.preview_count = len(eligible)
238235
self.preview_error = False
239-
except Exception as e:
236+
except Exception:
237+
_logger.exception("Geofence eligibility preview failed for manager %s", self.id)
240238
self.preview_count = 0
241-
self.preview_error = str(e)
239+
self.preview_error = "Preview failed. Check the server logs for details."
242240
return {
243241
"type": "ir.actions.client",
244242
"tag": "display_notification",

spp_program_geofence/tests/test_geofence_eligibility.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def setUpClass(cls):
212212
{
213213
"name": "Geofence Test Program",
214214
"target_type": "individual",
215-
"geofence_ids": [(6, 0, [cls.geofence.id])],
215+
"geofence_ids": [Command.set([cls.geofence.id])],
216216
}
217217
)
218218

@@ -319,22 +319,22 @@ def test_hybrid_no_duplicates(self):
319319

320320
def test_multiple_geofences(self):
321321
"""Registrant in second geofence is eligible."""
322-
self.program.geofence_ids = [(6, 0, [self.geofence.id, self.geofence2.id])]
322+
self.program.geofence_ids = [Command.set([self.geofence.id, self.geofence2.id])]
323323
eligible = self.manager._find_eligible_registrants()
324324
self.assertIn(self.reg_in_geofence2, eligible)
325325
self.assertIn(self.reg_inside, eligible)
326326
# Restore
327-
self.program.geofence_ids = [(6, 0, [self.geofence.id])]
327+
self.program.geofence_ids = [Command.set([self.geofence.id])]
328328

329329
# --- No geofences ---
330330

331331
def test_no_geofences_empty_result(self):
332332
"""Program with no geofences returns no eligible registrants."""
333-
self.program.geofence_ids = [(5, 0, 0)]
333+
self.program.geofence_ids = [Command.clear()]
334334
eligible = self.manager._find_eligible_registrants()
335335
self.assertEqual(len(eligible), 0)
336336
# Restore
337-
self.program.geofence_ids = [(6, 0, [self.geofence.id])]
337+
self.program.geofence_ids = [Command.set([self.geofence.id])]
338338

339339
# --- Enrollment pipeline ---
340340

@@ -365,7 +365,7 @@ def test_import_eligible_registrants(self):
365365
{
366366
"name": "Import Test Program",
367367
"target_type": "individual",
368-
"geofence_ids": [(6, 0, [self.geofence.id])],
368+
"geofence_ids": [Command.set([self.geofence.id])],
369369
}
370370
)
371371
manager2 = self.env["spp.program.membership.manager.geofence"].create(
@@ -386,7 +386,7 @@ def test_import_excludes_already_enrolled(self):
386386
{
387387
"name": "Dedup Test Program",
388388
"target_type": "individual",
389-
"geofence_ids": [(6, 0, [self.geofence.id])],
389+
"geofence_ids": [Command.set([self.geofence.id])],
390390
}
391391
)
392392
manager3 = self.env["spp.program.membership.manager.geofence"].create(
@@ -431,25 +431,25 @@ def test_target_type_group(self):
431431

432432
def test_multipolygon_geofence(self):
433433
"""Program with multiple non-overlapping geofences uses MultiPolygon via unary_union."""
434-
self.program.geofence_ids = [(6, 0, [self.geofence.id, self.geofence2.id])]
434+
self.program.geofence_ids = [Command.set([self.geofence.id, self.geofence2.id])]
435435
eligible = self.manager._find_eligible_registrants()
436436
# Both registrants from different geofences should be eligible
437437
self.assertIn(self.reg_inside, eligible)
438438
self.assertIn(self.reg_in_geofence2, eligible)
439439
# Outside registrant should not be
440440
self.assertNotIn(self.reg_outside, eligible)
441441
# Restore
442-
self.program.geofence_ids = [(6, 0, [self.geofence.id])]
442+
self.program.geofence_ids = [Command.set([self.geofence.id])]
443443

444444
# --- Program geofence field ---
445445

446446
def test_program_geofence_count(self):
447447
"""geofence_count is computed correctly."""
448448
self.assertEqual(self.program.geofence_count, 1)
449-
self.program.geofence_ids = [(6, 0, [self.geofence.id, self.geofence2.id])]
449+
self.program.geofence_ids = [Command.set([self.geofence.id, self.geofence2.id])]
450450
self.assertEqual(self.program.geofence_count, 2)
451451
# Restore
452-
self.program.geofence_ids = [(6, 0, [self.geofence.id])]
452+
self.program.geofence_ids = [Command.set([self.geofence.id])]
453453

454454

455455
@tagged("post_install", "-at_install")
@@ -497,7 +497,7 @@ def setUpClass(cls):
497497
{
498498
"name": "Officer Test Program",
499499
"target_type": "individual",
500-
"geofence_ids": [(6, 0, [cls.geofence.id])],
500+
"geofence_ids": [Command.set([cls.geofence.id])],
501501
}
502502
)
503503

0 commit comments

Comments
 (0)