Skip to content

Commit eda39a4

Browse files
committed
feat(spp_program_geofence): add area type filter and geofence management UI
- Add fallback_area_type_id field to restrict Tier 2 area fallback to a specific administrative level (e.g. District), preventing overly broad matches from large provinces or regions - Add geofence list/form/search views with menu under Area top-level, so users can browse and manage geofences independently - Allow inline geofence creation from the program form - Add 3 tests for area type filter behavior
1 parent b71955e commit eda39a4

6 files changed

Lines changed: 199 additions & 26 deletions

File tree

spp_program_geofence/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
],
1818
"data": [
1919
"security/ir.model.access.csv",
20+
"views/geofence_view.xml",
2021
"views/eligibility_manager_view.xml",
2122
"views/program_view.xml",
2223
],

spp_program_geofence/models/eligibility_manager.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ class GeofenceMembershipManager(models.Model):
4545
help="When enabled, registrants whose administrative area intersects the geofence "
4646
"are included even if their coordinates are not set.",
4747
)
48+
fallback_area_type_id = fields.Many2one(
49+
"spp.area.type",
50+
string="Fallback Area Type",
51+
help="When set, only areas of this type are considered for the area fallback. "
52+
"Use this to restrict matching to a specific administrative level (e.g. District) "
53+
"and avoid overly broad matches from large provinces or regions.",
54+
)
4855
program_geofence_ids = fields.Many2many(
4956
"spp.gis.geofence",
5057
related="program_id.geofence_ids",
@@ -138,6 +145,8 @@ def _find_eligible_registrants(self, membership=None):
138145
# Tier 2: registrants whose area intersects the geofence
139146
if self.include_area_fallback:
140147
area_domain = [("geo_polygon", "gis_intersects", combined_geojson)]
148+
if self.fallback_area_type_id:
149+
area_domain += [("area_type_id", "=", self.fallback_area_type_id.id)]
141150
matching_areas = self.env["spp.area"].search(area_domain)
142151
if matching_areas:
143152
tier2_domain = base_domain + [

spp_program_geofence/tests/test_geofence_eligibility.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ def setUpClass(cls):
5252
}
5353
)
5454

55-
# -- Area that overlaps the first geofence --
55+
# -- Area types --
5656
cls.area_type = cls.env["spp.area.type"].create({"name": "Test District"})
57+
cls.area_type_province = cls.env["spp.area.type"].create({"name": "Test Province"})
58+
59+
# -- Area that overlaps the first geofence --
5760
cls.area_inside = cls.env["spp.area"].create(
5861
{
5962
"draft_name": "Area Inside",
@@ -99,6 +102,29 @@ def setUpClass(cls):
99102
}
100103
)
101104

105+
# -- Area (province type) that also overlaps the first geofence --
106+
cls.area_province = cls.env["spp.area"].create(
107+
{
108+
"draft_name": "Province Overlap",
109+
"code": "AREA_PROV",
110+
"area_type_id": cls.area_type_province.id,
111+
"geo_polygon": json.dumps(
112+
{
113+
"type": "Polygon",
114+
"coordinates": [
115+
[
116+
[99, -1],
117+
[102, -1],
118+
[102, 2],
119+
[99, 2],
120+
[99, -1],
121+
]
122+
],
123+
}
124+
),
125+
}
126+
)
127+
102128
# -- Registrants --
103129
point_inside = json.dumps({"type": "Point", "coordinates": [100.5, 0.5]})
104130
point_outside = json.dumps({"type": "Point", "coordinates": [50, 50]})
@@ -136,6 +162,14 @@ def setUpClass(cls):
136162
"area_id": cls.area_outside.id,
137163
}
138164
)
165+
cls.reg_in_province = cls.env["res.partner"].create(
166+
{
167+
"name": "No Coords In Province",
168+
"is_registrant": True,
169+
"is_group": False,
170+
"area_id": cls.area_province.id,
171+
}
172+
)
139173
cls.reg_in_geofence2 = cls.env["res.partner"].create(
140174
{
141175
"name": "In Geofence 2",
@@ -238,6 +272,41 @@ def test_tier2_disabled(self):
238272
# Restore
239273
self.manager.include_area_fallback = True
240274

275+
# --- Tier 2: area type filter ---
276+
277+
def test_tier2_area_type_filter_includes_matching_type(self):
278+
"""When fallback_area_type_id is set, only areas of that type are matched."""
279+
self.manager.fallback_area_type_id = self.area_type
280+
eligible = self.manager._find_eligible_registrants()
281+
# District area matches, so registrant in district area is eligible
282+
self.assertIn(self.reg_no_coords_in_area, eligible)
283+
# Province area does NOT match the filter, so registrant in province is excluded
284+
self.assertNotIn(self.reg_in_province, eligible)
285+
# Restore
286+
self.manager.fallback_area_type_id = False
287+
288+
def test_tier2_area_type_filter_excludes_non_matching(self):
289+
"""When fallback_area_type_id is set to a type with no matching areas, Tier 2 is empty."""
290+
# Set filter to province type; but our geofence is small enough that
291+
# the province area also overlaps. The point is that district registrants
292+
# should be excluded.
293+
self.manager.fallback_area_type_id = self.area_type_province
294+
eligible = self.manager._find_eligible_registrants()
295+
# Province area overlaps, so province registrant IS eligible
296+
self.assertIn(self.reg_in_province, eligible)
297+
# District registrant is NOT eligible (wrong area type)
298+
self.assertNotIn(self.reg_no_coords_in_area, eligible)
299+
# Restore
300+
self.manager.fallback_area_type_id = False
301+
302+
def test_tier2_no_area_type_filter_includes_all(self):
303+
"""When fallback_area_type_id is not set, all area types are matched."""
304+
self.manager.fallback_area_type_id = False
305+
eligible = self.manager._find_eligible_registrants()
306+
# Both district and province registrants should be eligible
307+
self.assertIn(self.reg_no_coords_in_area, eligible)
308+
self.assertIn(self.reg_in_province, eligible)
309+
241310
# --- Hybrid union ---
242311

243312
def test_hybrid_no_duplicates(self):

spp_program_geofence/views/eligibility_manager_view.xml

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
<?xml version="1.0" encoding="UTF-8" ?>
22
<odoo>
33
<!-- Geofence Eligibility Manager Form -->
4-
<record
5-
id="view_eligibility_manager_geofence_form"
6-
model="ir.ui.view"
7-
>
4+
<record id="view_eligibility_manager_geofence_form" model="ir.ui.view">
85
<field name="name">spp.program.membership.manager.geofence.form</field>
96
<field name="model">spp.program.membership.manager.geofence</field>
107
<field name="arch" type="xml">
@@ -39,13 +36,15 @@
3936
widget="boolean_toggle"
4037
help="When enabled, registrants without GPS coordinates will be matched by their assigned administrative area if it overlaps the geofence. Disable to require precise GPS coordinates only."
4138
/>
39+
<field
40+
name="fallback_area_type_id"
41+
invisible="not include_area_fallback"
42+
options="{'no_create': True}"
43+
help="Restrict area fallback to a specific administrative level (e.g. District). Leave empty to match all area types."
44+
/>
4245
</group>
4346

44-
<group
45-
string="Program Geofences"
46-
colspan="4"
47-
col="1"
48-
>
47+
<group string="Program Geofences" colspan="4" col="1">
4948
<div class="alert alert-info small py-2" role="alert">
5049
<i class="fa fa-info-circle me-2" />
5150
This manager checks whether registrants fall within
@@ -79,11 +78,8 @@
7978
>
8079
<i class="fa fa-map-marker me-2" />
8180
<strong>
82-
<field
83-
name="preview_count"
84-
class="oe_inline"
85-
/>
86-
</strong>
81+
<field name="preview_count" class="oe_inline" />
82+
</strong>
8783
registrants match the current geographic scope.
8884
</div>
8985
<div
@@ -92,11 +88,7 @@
9288
invisible="not preview_error"
9389
>
9490
<i class="fa fa-exclamation-triangle me-2" />
95-
<field
96-
name="preview_error"
97-
nolabel="1"
98-
class="oe_inline"
99-
/>
91+
<field name="preview_error" nolabel="1" class="oe_inline" />
10092
</div>
10193
</group>
10294
</sheet>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<odoo>
3+
<!-- Geofence List View -->
4+
<record id="view_geofence_list" model="ir.ui.view">
5+
<field name="name">spp.gis.geofence.list</field>
6+
<field name="model">spp.gis.geofence</field>
7+
<field name="arch" type="xml">
8+
<list>
9+
<field name="name" />
10+
<field name="geofence_type" />
11+
<field name="area_sqkm" string="Area (sq km)" />
12+
<field name="tag_ids" widget="many2many_tags" />
13+
<field name="created_from" optional="hide" />
14+
</list>
15+
</field>
16+
</record>
17+
18+
<!-- Geofence Form View -->
19+
<record id="view_geofence_form" model="ir.ui.view">
20+
<field name="name">spp.gis.geofence.form</field>
21+
<field name="model">spp.gis.geofence</field>
22+
<field name="arch" type="xml">
23+
<form string="Geofence">
24+
<sheet>
25+
<div class="oe_title mb24">
26+
<label for="name" string="Name:" />
27+
<h1>
28+
<field name="name" placeholder="Enter geofence name..." />
29+
</h1>
30+
</div>
31+
<group>
32+
<group>
33+
<field name="geofence_type" />
34+
<field name="created_from" readonly="1" />
35+
</group>
36+
<group>
37+
<field
38+
name="area_sqkm"
39+
string="Area (sq km)"
40+
readonly="1"
41+
/>
42+
<field
43+
name="uuid"
44+
readonly="1"
45+
groups="base.group_no_one"
46+
/>
47+
</group>
48+
</group>
49+
<group>
50+
<field name="tag_ids" widget="many2many_tags" colspan="2" />
51+
<field name="description" colspan="2" />
52+
</group>
53+
<notebook>
54+
<page name="geometry" string="Geometry">
55+
<field name="geometry" nolabel="1" />
56+
</page>
57+
</notebook>
58+
</sheet>
59+
</form>
60+
</field>
61+
</record>
62+
63+
<!-- Geofence Search View -->
64+
<record id="view_geofence_search" model="ir.ui.view">
65+
<field name="name">spp.gis.geofence.search</field>
66+
<field name="model">spp.gis.geofence</field>
67+
<field name="arch" type="xml">
68+
<search>
69+
<field name="name" />
70+
<field name="tag_ids" />
71+
<filter
72+
name="archived"
73+
string="Archived"
74+
domain="[('active', '=', False)]"
75+
/>
76+
<filter
77+
name="group_type"
78+
string="Type"
79+
context="{'group_by': 'geofence_type'}"
80+
/>
81+
<filter
82+
name="group_created_from"
83+
string="Source"
84+
context="{'group_by': 'created_from'}"
85+
/>
86+
</search>
87+
</field>
88+
</record>
89+
90+
<!-- Geofence Action -->
91+
<record id="action_geofence_list" model="ir.actions.act_window">
92+
<field name="name">Geofences</field>
93+
<field name="res_model">spp.gis.geofence</field>
94+
<field name="view_mode">list,form</field>
95+
<field name="search_view_id" ref="view_geofence_search" />
96+
</record>
97+
98+
<!-- Menu: under Area top-level -->
99+
<menuitem
100+
id="menu_geofence_list"
101+
name="Geofences"
102+
action="action_geofence_list"
103+
parent="spp_area.area_main_top_menu"
104+
sequence="20"
105+
/>
106+
</odoo>

spp_program_geofence/views/program_view.xml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,7 @@
1616
name="action_open_geofences"
1717
invisible="not geofence_ids"
1818
>
19-
<field
20-
name="geofence_count"
21-
widget="statinfo"
22-
string="Geofences"
23-
/>
19+
<field name="geofence_count" widget="statinfo" string="Geofences" />
2420
</button>
2521
</xpath>
2622

@@ -42,7 +38,7 @@
4238
colspan="2"
4339
readonly="state == 'ended'"
4440
>
45-
<list create="0" delete="1" editable="bottom">
41+
<list delete="1" editable="bottom">
4642
<field name="name" readonly="1" />
4743
<field name="geofence_type" optional="hide" readonly="1" />
4844
<field name="area_sqkm" string="Area (sq km)" readonly="1" />

0 commit comments

Comments
 (0)