Skip to content

Commit 7a773ff

Browse files
fix: improve spp_hazard performance, security, UX, and test coverage
- Replace len(rec.xxx_ids) with read_group for computed counts - Use SQL COUNT(DISTINCT) for affected_registrant_count - Batch bulk_create_impacts with BATCH_SIZE=500 - Replace deprecated name_get() with _compute_display_name() - Fix action_close() multi-record bug (iterate per record) - Restrict officer write/create on config models (category, impact_type) - Add base.group_user read ACL for spp.hazard.impact - Apply master data vs workflow entity form patterns - Add alert banners, badge decorations, extension points - Add accessibility titles to icon elements - Add demo areas, registrants, and impact records - Add registrant test suite (9 tests) and security tests (7 tests) - Add 5 new incident tests (areas, display_name, constraints, close) - Update DESCRIPTION.md and add USAGE.md QA testing guide
1 parent 9c2376e commit 7a773ff

24 files changed

Lines changed: 4266 additions & 224 deletions

spp_hazard/README.rst

Lines changed: 1086 additions & 34 deletions
Large diffs are not rendered by default.

spp_hazard/demo/hazard_demo.xml

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,145 @@
195195
<p>Below-normal rainfall for 6+ months.</p>
196196
]]></field>
197197
</record>
198+
199+
<!-- Demo Areas (spp_area provides no demo data, so minimal records are created here) -->
200+
<record id="demo_area_northern_province" model="spp.area">
201+
<field name="draft_name">Northern Province</field>
202+
<field name="code">DEMO-AREA-NORTH</field>
203+
</record>
204+
205+
<record id="demo_area_coastal_district" model="spp.area">
206+
<field name="draft_name">Coastal District</field>
207+
<field name="code">DEMO-AREA-COAST</field>
208+
</record>
209+
210+
<record id="demo_area_inland_valley" model="spp.area">
211+
<field name="draft_name">Inland Valley</field>
212+
<field name="code">DEMO-AREA-INLAND</field>
213+
</record>
214+
215+
<!-- Demo Incident Area Links (Task 6.1) -->
216+
<record id="incident_area_typhoon_northern" model="spp.hazard.incident.area">
217+
<field name="incident_id" ref="incident_typhoon_demo" />
218+
<field name="area_id" ref="demo_area_northern_province" />
219+
<field name="severity_override">5</field>
220+
<field name="affected_population_estimate">12500</field>
221+
<field
222+
name="notes"
223+
>Direct hit from typhoon eye; severe structural damage reported.</field>
224+
</record>
225+
226+
<record id="incident_area_typhoon_coastal" model="spp.hazard.incident.area">
227+
<field name="incident_id" ref="incident_typhoon_demo" />
228+
<field name="area_id" ref="demo_area_coastal_district" />
229+
<field name="severity_override">4</field>
230+
<field name="affected_population_estimate">8200</field>
231+
<field
232+
name="notes"
233+
>Storm surge and coastal flooding; fishing communities heavily impacted.</field>
234+
</record>
235+
236+
<record id="incident_area_flood_coastal" model="spp.hazard.incident.area">
237+
<field name="incident_id" ref="incident_flood_demo" />
238+
<field name="area_id" ref="demo_area_coastal_district" />
239+
<field name="severity_override">3</field>
240+
<field name="affected_population_estimate">3400</field>
241+
<field
242+
name="notes"
243+
>Riverine flooding submerged low-lying settlements for 14 days.</field>
244+
</record>
245+
246+
<record id="incident_area_drought_inland" model="spp.hazard.incident.area">
247+
<field name="incident_id" ref="incident_drought_demo" />
248+
<field name="area_id" ref="demo_area_inland_valley" />
249+
<field name="severity_override">3</field>
250+
<field name="affected_population_estimate">6700</field>
251+
<field
252+
name="notes"
253+
>Agricultural zone with total crop failure reported in two consecutive seasons.</field>
254+
</record>
255+
256+
<!-- Demo Registrants (spp_registry provides no demo data, so minimal records are created here) -->
257+
<record id="demo_registrant_santos" model="res.partner">
258+
<field name="name">SANTOS, MARIA</field>
259+
<field name="is_registrant">True</field>
260+
<field name="is_group">False</field>
261+
<field name="area_id" ref="demo_area_northern_province" />
262+
</record>
263+
264+
<record id="demo_registrant_reyes" model="res.partner">
265+
<field name="name">REYES, JOSE</field>
266+
<field name="is_registrant">True</field>
267+
<field name="is_group">False</field>
268+
<field name="area_id" ref="demo_area_coastal_district" />
269+
</record>
270+
271+
<record id="demo_registrant_cruz" model="res.partner">
272+
<field name="name">CRUZ, ANA</field>
273+
<field name="is_registrant">True</field>
274+
<field name="is_group">False</field>
275+
<field name="area_id" ref="demo_area_inland_valley" />
276+
</record>
277+
278+
<!-- Demo Impact Records (Task 6.2) -->
279+
<!-- Typhoon impacts - mix of verification statuses and damage levels -->
280+
<record id="impact_typhoon_santos_displacement" model="spp.hazard.impact">
281+
<field name="incident_id" ref="incident_typhoon_demo" />
282+
<field name="registrant_id" ref="demo_registrant_santos" />
283+
<field name="impact_type_id" ref="spp_hazard.impact_type_displacement" />
284+
<field name="damage_level">severe</field>
285+
<field name="impact_date">2024-11-15</field>
286+
<field name="verification_status">verified</field>
287+
<field
288+
name="notes"
289+
>Family evacuated to barangay hall; home partially submerged.</field>
290+
</record>
291+
292+
<record id="impact_typhoon_santos_property" model="spp.hazard.impact">
293+
<field name="incident_id" ref="incident_typhoon_demo" />
294+
<field name="registrant_id" ref="demo_registrant_santos" />
295+
<field name="impact_type_id" ref="spp_hazard.impact_type_property_damage" />
296+
<field name="damage_level">totally_damaged</field>
297+
<field name="impact_date">2024-11-16</field>
298+
<field name="verification_status">verified</field>
299+
<field name="notes">Roof collapsed; walls structurally compromised.</field>
300+
</record>
301+
302+
<record id="impact_typhoon_reyes_livelihood" model="spp.hazard.impact">
303+
<field name="incident_id" ref="incident_typhoon_demo" />
304+
<field name="registrant_id" ref="demo_registrant_reyes" />
305+
<field name="impact_type_id" ref="spp_hazard.impact_type_livelihood_loss" />
306+
<field name="damage_level">moderate</field>
307+
<field name="impact_date">2024-11-18</field>
308+
<field name="verification_status">reported</field>
309+
<field
310+
name="notes"
311+
>Fishing boat damaged; unable to work for several weeks.</field>
312+
</record>
313+
314+
<!-- Flood impact -->
315+
<record id="impact_flood_reyes_property" model="spp.hazard.impact">
316+
<field name="incident_id" ref="incident_flood_demo" />
317+
<field name="registrant_id" ref="demo_registrant_reyes" />
318+
<field name="impact_type_id" ref="spp_hazard.impact_type_property_damage" />
319+
<field name="damage_level">partially_damaged</field>
320+
<field name="impact_date">2024-10-03</field>
321+
<field name="verification_status">closed</field>
322+
<field
323+
name="notes"
324+
>Ground floor flooded; damage assessed and assistance provided.</field>
325+
</record>
326+
327+
<!-- Drought impacts -->
328+
<record id="impact_drought_cruz_crop_loss" model="spp.hazard.impact">
329+
<field name="incident_id" ref="incident_drought_demo" />
330+
<field name="registrant_id" ref="demo_registrant_cruz" />
331+
<field name="impact_type_id" ref="spp_hazard.impact_type_crop_loss" />
332+
<field name="damage_level">critical</field>
333+
<field name="impact_date">2024-05-01</field>
334+
<field name="verification_status">verified</field>
335+
<field
336+
name="notes"
337+
>Total loss of rice crop due to lack of irrigation water.</field>
338+
</record>
198339
</odoo>

spp_hazard/models/hazard_category.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22

3-
import logging
4-
53
from odoo import api, fields, models
64

7-
_logger = logging.getLogger(__name__)
8-
95

106
class HazardCategory(models.Model):
117
"""
@@ -83,8 +79,14 @@ def _compute_complete_name(self):
8379
@api.depends("incident_ids")
8480
def _compute_incident_count(self):
8581
"""Compute the number of incidents linked to this category."""
82+
data = self.env["spp.hazard.incident"].read_group(
83+
[("category_id", "in", self.ids)],
84+
["category_id"],
85+
["category_id"],
86+
)
87+
mapped = {d["category_id"][0]: d["category_id_count"] for d in data}
8688
for rec in self:
87-
rec.incident_count = len(rec.incident_ids)
89+
rec.incident_count = mapped.get(rec.id, 0)
8890

8991
def action_view_incidents(self):
9092
"""Open a list view of incidents for this category."""

spp_hazard/models/hazard_impact.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
_logger = logging.getLogger(__name__)
99

10+
BATCH_SIZE = 500
11+
1012

1113
class HazardImpact(models.Model):
1214
"""
@@ -125,8 +127,6 @@ class HazardImpact(models.Model):
125127
@api.constrains("impact_date", "incident_id")
126128
def _check_impact_date(self):
127129
"""Validate that impact_date is not before the incident start_date."""
128-
# Prefetch incidents to avoid N+1 queries
129-
self.mapped("incident_id")
130130
for rec in self:
131131
if rec.impact_date and rec.incident_id.start_date:
132132
if rec.impact_date < rec.incident_id.start_date:
@@ -222,5 +222,15 @@ def bulk_create_impacts(self, incident, area, impact_type, damage_level):
222222
)
223223

224224
if vals_list:
225-
return self.create(vals_list)
225+
created = self.browse()
226+
for i in range(0, len(vals_list), BATCH_SIZE):
227+
batch = vals_list[i : i + BATCH_SIZE]
228+
created |= self.create(batch)
229+
_logger.info(
230+
"Created %d impact records for incident '%s' in area '%s'",
231+
len(created),
232+
incident.name,
233+
area.name,
234+
)
235+
return created
226236
return self.browse()

spp_hazard/models/hazard_impact_type.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22

3-
import logging
4-
53
from odoo import api, fields, models
64

7-
_logger = logging.getLogger(__name__)
8-
95

106
class HazardImpactType(models.Model):
117
"""
@@ -68,5 +64,11 @@ class HazardImpactType(models.Model):
6864
@api.depends("impact_ids")
6965
def _compute_impact_count(self):
7066
"""Compute the number of impact records using this type."""
67+
data = self.env["spp.hazard.impact"].read_group(
68+
[("impact_type_id", "in", self.ids)],
69+
["impact_type_id"],
70+
["impact_type_id"],
71+
)
72+
mapped = {d["impact_type_id"][0]: d["impact_type_id_count"] for d in data}
7173
for rec in self:
72-
rec.impact_count = len(rec.impact_ids)
74+
rec.impact_count = mapped.get(rec.id, 0)

spp_hazard/models/hazard_incident.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,33 @@ def _compute_area_count(self):
150150
@api.depends("impact_ids")
151151
def _compute_impact_count(self):
152152
"""Compute the number of impact records."""
153+
data = self.env["spp.hazard.impact"].read_group(
154+
[("incident_id", "in", self.ids)],
155+
["incident_id"],
156+
["incident_id"],
157+
)
158+
mapped = {d["incident_id"][0]: d["incident_id_count"] for d in data}
153159
for rec in self:
154-
rec.impact_count = len(rec.impact_ids)
160+
rec.impact_count = mapped.get(rec.id, 0)
155161

156162
@api.depends("impact_ids.registrant_id")
157163
def _compute_affected_registrant_count(self):
158164
"""Compute the number of unique affected registrants."""
165+
if not self.ids:
166+
self.affected_registrant_count = 0
167+
return
168+
self.env.cr.execute(
169+
"""
170+
SELECT incident_id, COUNT(DISTINCT registrant_id)
171+
FROM spp_hazard_impact
172+
WHERE incident_id IN %s
173+
GROUP BY incident_id
174+
""",
175+
[tuple(self.ids)],
176+
)
177+
mapped = dict(self.env.cr.fetchall())
159178
for rec in self:
160-
rec.affected_registrant_count = len(rec.impact_ids.mapped("registrant_id"))
179+
rec.affected_registrant_count = mapped.get(rec.id, 0)
161180

162181
def action_set_active(self):
163182
"""Set incident status to active."""
@@ -169,11 +188,17 @@ def action_set_recovery(self):
169188

170189
def action_close(self):
171190
"""Close the incident."""
172-
self.write(
173-
{
174-
"status": "closed",
175-
"end_date": self.end_date or fields.Date.today(),
176-
}
191+
for rec in self:
192+
rec.write(
193+
{
194+
"status": "closed",
195+
"end_date": rec.end_date or fields.Date.today(),
196+
}
197+
)
198+
_logger.info(
199+
"Closed %d incident(s): %s",
200+
len(self),
201+
", ".join(self.mapped("name")),
177202
)
178203

179204
def action_view_impacts(self):
@@ -230,6 +255,7 @@ class HazardIncidentArea(models.Model):
230255
_name = "spp.hazard.incident.area"
231256
_description = "Hazard Incident Area"
232257
_order = "incident_id, area_id"
258+
_rec_name = "display_name"
233259

234260
incident_id = fields.Many2one(
235261
"spp.hazard.incident",
@@ -267,13 +293,7 @@ class HazardIncidentArea(models.Model):
267293
"This area is already linked to this incident!",
268294
)
269295

270-
def name_get(self):
271-
"""Return a descriptive name for the record."""
272-
# Prefetch related records to avoid N+1 queries
273-
self.mapped("incident_id")
274-
self.mapped("area_id")
275-
result = []
296+
@api.depends("incident_id.name", "area_id.name")
297+
def _compute_display_name(self):
276298
for rec in self:
277-
name = f"{rec.incident_id.name} - {rec.area_id.name}"
278-
result.append((rec.id, name))
279-
return result
299+
rec.display_name = f"{rec.incident_id.name} - {rec.area_id.name}"

spp_hazard/models/registrant.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22

3-
import logging
4-
53
from odoo import _, api, fields, models
64

7-
_logger = logging.getLogger(__name__)
8-
95

106
class ResPartner(models.Model):
117
"""
@@ -29,23 +25,34 @@ class ResPartner(models.Model):
2925
)
3026
has_active_impact = fields.Boolean(
3127
compute="_compute_has_active_impact",
32-
string="Has Active Impact",
3328
store=True,
3429
help="Whether the registrant has an impact from an active incident",
3530
)
3631

3732
@api.depends("hazard_impact_ids")
3833
def _compute_hazard_impact_count(self):
3934
"""Compute the number of hazard impacts for this registrant."""
35+
data = self.env["spp.hazard.impact"].read_group(
36+
[("registrant_id", "in", self.ids)],
37+
["registrant_id"],
38+
["registrant_id"],
39+
)
40+
mapped = {d["registrant_id"][0]: d["registrant_id_count"] for d in data}
4041
for rec in self:
41-
rec.hazard_impact_count = len(rec.hazard_impact_ids)
42+
rec.hazard_impact_count = mapped.get(rec.id, 0)
4243

4344
@api.depends("hazard_impact_ids", "hazard_impact_ids.incident_id.status")
4445
def _compute_has_active_impact(self):
4546
"""Compute whether the registrant has an impact from an active incident."""
4647
for rec in self:
4748
rec.has_active_impact = bool(
48-
rec.hazard_impact_ids.filtered(lambda i: i.incident_id.status in ("alert", "active", "recovery"))
49+
self.env["spp.hazard.impact"].search_count(
50+
[
51+
("registrant_id", "=", rec.id),
52+
("incident_id.status", "in", ("alert", "active", "recovery")),
53+
],
54+
limit=1,
55+
)
4956
)
5057

5158
def action_view_hazard_impacts(self):

0 commit comments

Comments
 (0)