diff --git a/spp_drims/README.rst b/spp_drims/README.rst
index 2ab588c6c..817b42add 100644
--- a/spp_drims/README.rst
+++ b/spp_drims/README.rst
@@ -179,6 +179,14 @@ Dependencies
Changelog
=========
+19.0.2.1.0
+~~~~~~~~~~
+
+- feat: incident areas expose ``effective_severity_id`` (area override
+ or incident severity) and a numeric 1-5 ``severity_numeric`` derived
+ from CAP severity codes for choropleth visualization (re-land from
+ #76, follows the spp_hazard severity vocabulary change).
+
19.0.2.0.0
~~~~~~~~~~
diff --git a/spp_drims/__manifest__.py b/spp_drims/__manifest__.py
index 5cd7472c7..c41f94e22 100644
--- a/spp_drims/__manifest__.py
+++ b/spp_drims/__manifest__.py
@@ -5,7 +5,7 @@
"and distribution tracking. Links to hazard incidents with multi-tier "
"approval workflows and warehouse operations.",
"category": "OpenSPP/Inventory",
- "version": "19.0.2.0.0",
+ "version": "19.0.2.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_drims/models/hazard_incident_area.py b/spp_drims/models/hazard_incident_area.py
index 4ecfa632e..e3f4d62f6 100644
--- a/spp_drims/models/hazard_incident_area.py
+++ b/spp_drims/models/hazard_incident_area.py
@@ -1,6 +1,16 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
+# Map CAP severity vocabulary codes to a 1-5 numeric scale (higher = more
+# severe) for choropleth map visualization.
+CAP_SEVERITY_NUMERIC = {
+ "extreme": 5,
+ "severe": 4,
+ "moderate": 3,
+ "minor": 2,
+ "unknown": 1,
+}
+
class HazardIncidentArea(models.Model):
"""Extend incident area with GIS polygon for map visualization."""
@@ -17,17 +27,12 @@ class HazardIncidentArea(models.Model):
)
# Computed severity that falls back to incident severity
- effective_severity = fields.Selection(
- [
- ("1", "Level 1 - Minor"),
- ("2", "Level 2 - Moderate"),
- ("3", "Level 3 - Significant"),
- ("4", "Level 4 - Severe"),
- ("5", "Level 5 - Catastrophic"),
- ],
+ effective_severity_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Effective Severity",
compute="_compute_effective_severity",
store=True,
- help="Area-specific severity or inherited from incident",
+ help="Area-specific severity override, or inherited from the incident",
)
# Additional fields for GIS popup display and grouping
@@ -61,14 +66,14 @@ class HazardIncidentArea(models.Model):
help="Numeric severity (1-5) for choropleth map visualization",
)
- @api.depends("severity_override", "incident_id.severity")
+ @api.depends("severity_override_id", "incident_id.severity_id")
def _compute_effective_severity(self):
- """Compute effective severity from override or incident default."""
+ """Compute effective severity from area override or incident default."""
for rec in self:
- rec.effective_severity = rec.severity_override or rec.incident_id.severity or "3"
+ rec.effective_severity_id = rec.severity_override_id or rec.incident_id.severity_id
- @api.depends("effective_severity")
+ @api.depends("effective_severity_id")
def _compute_severity_numeric(self):
- """Compute numeric severity from selection value for choropleth."""
+ """Compute numeric severity from the CAP severity code for choropleth."""
for rec in self:
- rec.severity_numeric = int(rec.effective_severity) if rec.effective_severity else 3
+ rec.severity_numeric = CAP_SEVERITY_NUMERIC.get(rec.effective_severity_id.code, 0)
diff --git a/spp_drims/readme/HISTORY.md b/spp_drims/readme/HISTORY.md
index 4aaf9afef..eb677e216 100644
--- a/spp_drims/readme/HISTORY.md
+++ b/spp_drims/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.2.1.0
+
+- feat: incident areas expose `effective_severity_id` (area override or incident severity) and a numeric 1-5 `severity_numeric` derived from CAP severity codes for choropleth visualization (re-land from #76, follows the spp_hazard severity vocabulary change).
+
### 19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_drims/static/description/index.html b/spp_drims/static/description/index.html
index dbf283ed0..67c6f8b92 100644
--- a/spp_drims/static/description/index.html
+++ b/spp_drims/static/description/index.html
@@ -565,6 +565,15 @@
+
19.0.2.1.0
+
+- feat: incident areas expose effective_severity_id (area override
+or incident severity) and a numeric 1-5 severity_numeric derived
+from CAP severity codes for choropleth visualization (re-land from
+#76, follows the spp_hazard severity vocabulary change).
+
+
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_drims/tests/__init__.py b/spp_drims/tests/__init__.py
index bb8d0dc9a..e23e8a2c1 100644
--- a/spp_drims/tests/__init__.py
+++ b/spp_drims/tests/__init__.py
@@ -7,6 +7,7 @@
from . import test_coordination
from . import test_donation
from . import test_incident
+from . import test_incident_area_severity
from . import test_personnel
from . import test_request
from . import test_request_from_template_wizard
diff --git a/spp_drims/tests/test_incident_area_severity.py b/spp_drims/tests/test_incident_area_severity.py
new file mode 100644
index 000000000..be6286af5
--- /dev/null
+++ b/spp_drims/tests/test_incident_area_severity.py
@@ -0,0 +1,50 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+from .common import DrimsTestCommon
+
+
+class TestIncidentAreaSeverity(DrimsTestCommon):
+ """Effective severity / numeric severity for the GIS choropleth.
+
+ Guards the spp_hazard severity migration (Selection -> CAP vocabulary
+ Many2one): spp_drims extends spp.hazard.incident.area and must follow the
+ new severity_id / severity_override_id fields.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ VocabCode = cls.env["spp.vocabulary.code"]
+ ns = "urn:oasis:names:tc:cap:severity"
+ cls.sev_extreme = VocabCode.get_code(ns, "extreme")
+ cls.sev_severe = VocabCode.get_code(ns, "severe")
+ cls.sev_moderate = VocabCode.get_code(ns, "moderate")
+
+ def _make_area(self, severity_override_id=False):
+ return self.env["spp.hazard.incident.area"].create(
+ {
+ "incident_id": self.incident.id,
+ "area_id": self.area.id,
+ "severity_override_id": severity_override_id,
+ }
+ )
+
+ def test_effective_severity_uses_override(self):
+ """Area override takes precedence over the incident severity."""
+ self.incident.severity_id = self.sev_moderate
+ area = self._make_area(severity_override_id=self.sev_extreme.id)
+ self.assertEqual(area.effective_severity_id, self.sev_extreme)
+ self.assertEqual(area.severity_numeric, 5)
+
+ def test_effective_severity_falls_back_to_incident(self):
+ """Without an override, severity is inherited from the incident."""
+ self.incident.severity_id = self.sev_severe
+ area = self._make_area()
+ self.assertEqual(area.effective_severity_id, self.sev_severe)
+ self.assertEqual(area.severity_numeric, 4)
+
+ def test_severity_numeric_zero_when_unset(self):
+ """No override and no incident severity -> numeric 0 (no choropleth value)."""
+ self.incident.severity_id = False
+ area = self._make_area()
+ self.assertFalse(area.effective_severity_id)
+ self.assertEqual(area.severity_numeric, 0)
diff --git a/spp_drims_sl_demo/README.rst b/spp_drims_sl_demo/README.rst
index 07011511f..d3b0f8851 100644
--- a/spp_drims_sl_demo/README.rst
+++ b/spp_drims_sl_demo/README.rst
@@ -130,6 +130,13 @@ Dependencies
Changelog
=========
+19.0.2.0.1
+~~~~~~~~~~
+
+- fix: demo generator resolves scenario severity levels to CAP severity
+ vocabulary codes (follows the spp_hazard severity vocabulary change,
+ re-land from #76).
+
19.0.2.0.0
~~~~~~~~~~
diff --git a/spp_drims_sl_demo/__manifest__.py b/spp_drims_sl_demo/__manifest__.py
index 17074375b..8bdd5e9d2 100644
--- a/spp_drims_sl_demo/__manifest__.py
+++ b/spp_drims_sl_demo/__manifest__.py
@@ -4,7 +4,7 @@
"summary": "Demo data generator for DRIMS Sri Lanka implementation. "
"Creates sample incidents, donations, requests, and stock for demonstrations.",
"category": "OpenSPP/Inventory",
- "version": "19.0.2.0.0",
+ "version": "19.0.2.0.1",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_drims_sl_demo/readme/HISTORY.md b/spp_drims_sl_demo/readme/HISTORY.md
index 4aaf9afef..168badcd6 100644
--- a/spp_drims_sl_demo/readme/HISTORY.md
+++ b/spp_drims_sl_demo/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.2.0.1
+
+- fix: demo generator resolves scenario severity levels to CAP severity vocabulary codes (follows the spp_hazard severity vocabulary change, re-land from #76).
+
### 19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_drims_sl_demo/static/description/index.html b/spp_drims_sl_demo/static/description/index.html
index f0a2f58e5..cca9febe5 100644
--- a/spp_drims_sl_demo/static/description/index.html
+++ b/spp_drims_sl_demo/static/description/index.html
@@ -503,6 +503,14 @@
+
19.0.2.0.1
+
+- fix: demo generator resolves scenario severity levels to CAP severity
+vocabulary codes (follows the spp_hazard severity vocabulary change,
+re-land from #76).
+
+
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_drims_sl_demo/wizard/drims_demo_generator.py b/spp_drims_sl_demo/wizard/drims_demo_generator.py
index deda0fc0a..da7ff63f9 100644
--- a/spp_drims_sl_demo/wizard/drims_demo_generator.py
+++ b/spp_drims_sl_demo/wizard/drims_demo_generator.py
@@ -49,6 +49,19 @@ def _simplify_geometry(geometry, tolerance=0.001):
return mapping(simplified)
+# Severity is stored as a CAP vocabulary code (spp.vocabulary.code). The demo
+# scenarios describe severity with the legacy 1-5 scale, mapped here to the
+# equivalent CAP severity code.
+CAP_SEVERITY_NS = "urn:oasis:names:tc:cap:severity"
+_LEGACY_SEVERITY_TO_CAP = {
+ "5": "extreme",
+ "4": "severe",
+ "3": "moderate",
+ "2": "minor",
+ "1": "unknown",
+}
+
+
class DrimsDemoGenerator(models.TransientModel):
_name = "spp.drims.demo.generator"
_description = "DRIMS Demo Data Generator"
@@ -653,6 +666,13 @@ def _add_warehouse_gps_coordinates(self):
)
_logger.debug("Added GPS coordinates to warehouse %s", wh_name)
+ def _resolve_severity(self, legacy_value):
+ """Resolve a legacy 1-5 severity string to a CAP severity vocab code."""
+ code = _LEGACY_SEVERITY_TO_CAP.get(legacy_value)
+ if not code:
+ return self.env["spp.vocabulary.code"]
+ return self.env["spp.vocabulary.code"].get_code(CAP_SEVERITY_NS, code)
+
def _generate_incidents(self):
"""Generate demo hazard incidents."""
Incident = self.env["spp.hazard.incident"]
@@ -792,7 +812,7 @@ def _generate_incidents(self):
area_details.append(
{
"area_id": area.id,
- "severity_override": area_info.get("severity"),
+ "severity_override_id": self._resolve_severity(area_info.get("severity")).id,
"affected_population_estimate": area_info.get("population", 0),
"notes": f"Affected area in {scenario['name']}",
}
@@ -805,7 +825,7 @@ def _generate_incidents(self):
"name": scenario["name"],
"code": scenario["code"],
"category_id": scenario["category_id"],
- "severity": scenario.get("severity", "3"),
+ "severity_id": self._resolve_severity(scenario.get("severity", "3")).id,
"start_date": scenario["start_date"],
"description": scenario.get("description", ""),
"status": "active",
diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst
index 4634a07c0..4c875053b 100644
--- a/spp_hazard/README.rst
+++ b/spp_hazard/README.rst
@@ -1186,6 +1186,22 @@ encounter unexpected behavior, please report it as a new issue.
Changelog
=========
+19.0.2.1.0
+~~~~~~~~~~
+
+- feat: severity is now a CAP v1.2 vocabulary code (``severity_id``,
+ ``severity_override_id`` on incident areas) instead of a hardcoded 1-5
+ Selection; adds CAP urgency/certainty/message-type/event fields, alert
+ ingestion (``create_incident_from_alert``), and incident ``uuid``
+ (re-land from #76).
+- feat: migration backfills legacy 1-5 severity values onto the
+ vocabulary fields when upgrading from 19.0.2.0.x (1→minor, 2→moderate,
+ 3→severe, 4→severe, 5→extreme); existing values are never overwritten
+ and legacy columns are kept.
+- fix: demo incident-area records now set ``severity_override_id``
+ vocabulary refs (the removed ``severity_override`` field broke demo
+ installs).
+
19.0.2.0.2
~~~~~~~~~~
diff --git a/spp_hazard/__manifest__.py b/spp_hazard/__manifest__.py
index 336923eb1..b1e9eddeb 100644
--- a/spp_hazard/__manifest__.py
+++ b/spp_hazard/__manifest__.py
@@ -8,7 +8,7 @@
"for emergency response. Links registrants to disaster events with geographic scope "
"and severity tracking to enable targeted humanitarian assistance.",
"category": "OpenSPP/Targeting",
- "version": "19.0.2.0.2",
+ "version": "19.0.2.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -21,11 +21,13 @@
"spp_registry",
"spp_area",
"spp_gis",
+ "spp_vocabulary",
],
"data": [
"security/privileges.xml",
"security/groups.xml",
"security/ir.model.access.csv",
+ "data/vocabulary_cap.xml",
"data/impact_type_data.xml",
"data/user_roles.xml",
"views/hazard_category_views.xml",
diff --git a/spp_hazard/data/vocabulary_cap.xml b/spp_hazard/data/vocabulary_cap.xml
new file mode 100644
index 000000000..339ee03cf
--- /dev/null
+++ b/spp_hazard/data/vocabulary_cap.xml
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+ CAP Severity
+ urn:oasis:names:tc:cap:severity
+ 1.2
+ True
+ core
+ https://docs.oasis-open.org/emergency/cap/v1.2/
+ Severity values from the Common Alerting Protocol (CAP) v1.2 standard.
+
+
+
+
+ extreme
+ Extreme
+ Extraordinary threat to life or property
+ 1
+
+
+
+ severe
+ Severe
+ Significant threat to life or property
+ 2
+
+
+
+ moderate
+ Moderate
+ Possible threat to life or property
+ 3
+
+
+
+ minor
+ Minor
+ Minimal to no known threat to life or property
+ 4
+
+
+
+ unknown
+ Unknown
+ Severity unknown
+ 5
+
+
+
+
+ CAP Urgency
+ urn:oasis:names:tc:cap:urgency
+ 1.2
+ True
+ core
+ https://docs.oasis-open.org/emergency/cap/v1.2/
+ Urgency values from the Common Alerting Protocol (CAP) v1.2 standard.
+
+
+
+
+ immediate
+ Immediate
+ Responsive action should be taken immediately
+ 1
+
+
+
+ expected
+ Expected
+ Responsive action should be taken soon (within next hour)
+ 2
+
+
+
+ future
+ Future
+ Responsive action should be taken in the near future
+ 3
+
+
+
+ past
+ Past
+ Responsive action is no longer required
+ 4
+
+
+
+ unknown
+ Unknown
+ Urgency not known
+ 5
+
+
+
+
+ CAP Certainty
+ urn:oasis:names:tc:cap:certainty
+ 1.2
+ True
+ core
+ https://docs.oasis-open.org/emergency/cap/v1.2/
+ Certainty values from the Common Alerting Protocol (CAP) v1.2 standard.
+
+
+
+
+ observed
+ Observed
+ Determined to have occurred or to be ongoing
+ 1
+
+
+
+ likely
+ Likely
+ Likely (p > ~50%)
+ 2
+
+
+
+ possible
+ Possible
+ Possible but not likely (p <= ~50%)
+ 3
+
+
+
+ unlikely
+ Unlikely
+ Not expected to occur (p ~ 0)
+ 4
+
+
+
+ unknown
+ Unknown
+ Certainty not known
+ 5
+
+
+
+
+ CAP Message Type
+ urn:oasis:names:tc:cap:msg-type
+ 1.2
+ True
+ core
+ https://docs.oasis-open.org/emergency/cap/v1.2/
+ Message type values from the Common Alerting Protocol (CAP) v1.2 standard.
+
+
+
+
+ alert
+ Alert
+ Initial information requiring attention by targeted recipients
+ 1
+
+
+
+ update
+ Update
+ Updates and supersedes the earlier message(s)
+ 2
+
+
+
+ cancel
+ Cancel
+ Cancels the earlier message(s)
+ 3
+
+
diff --git a/spp_hazard/demo/hazard_demo.xml b/spp_hazard/demo/hazard_demo.xml
index 4f8b06339..db4663888 100644
--- a/spp_hazard/demo/hazard_demo.xml
+++ b/spp_hazard/demo/hazard_demo.xml
@@ -156,7 +156,7 @@
2024-11-15
recovery
- 4
+
2024-10-01
2024-10-15
closed
- 3
+
2024-03-01
active
- 3
+
- 5
+
12500
- 4
+
8200
- 3
+
3400
- 3
+
6700
minor
+ 2 (Moderate) -> moderate
+ 3 (Significant) -> severe (CAP: "Significant threat to life or property")
+ 4 (Severe) -> severe
+ 5 (Catastrophic) -> extreme
+
+Rows whose new column is already set are never overwritten. Legacy columns are
+kept as a safety net. Fresh installs have no legacy columns and skip cleanly.
+"""
+
+import logging
+
+# Fixed name: this file is loaded by Odoo's migration runner (and by tests via
+# importlib), where __name__ differs; a stable logger keeps output filterable.
+_logger = logging.getLogger("odoo.addons.spp_hazard.migrations.severity")
+
+CAP_SEVERITY_NS = "urn:oasis:names:tc:cap:severity"
+
+LEGACY_SEVERITY_TO_CAP = {
+ "1": "minor",
+ "2": "moderate",
+ "3": "severe",
+ "4": "severe",
+ "5": "extreme",
+}
+
+# Literal, fully parameterized SQL per target table (no identifier
+# composition, so SQL scanners can verify there is no injection surface).
+_BACKFILL_INCIDENT = """
+ UPDATE spp_hazard_incident t
+ SET severity_id = c.id
+ FROM spp_vocabulary_code c
+ JOIN spp_vocabulary v ON c.vocabulary_id = v.id
+ WHERE v.namespace_uri = %s
+ AND c.code = CASE t.severity
+ WHEN %s THEN %s WHEN %s THEN %s WHEN %s THEN %s
+ WHEN %s THEN %s WHEN %s THEN %s END
+ AND t.severity IS NOT NULL
+ AND t.severity_id IS NULL
+"""
+
+_UNMAPPED_INCIDENT = """
+ SELECT DISTINCT t.severity
+ FROM spp_hazard_incident t
+ WHERE t.severity IS NOT NULL
+ AND t.severity_id IS NULL
+"""
+
+_BACKFILL_AREA = """
+ UPDATE spp_hazard_incident_area t
+ SET severity_override_id = c.id
+ FROM spp_vocabulary_code c
+ JOIN spp_vocabulary v ON c.vocabulary_id = v.id
+ WHERE v.namespace_uri = %s
+ AND c.code = CASE t.severity_override
+ WHEN %s THEN %s WHEN %s THEN %s WHEN %s THEN %s
+ WHEN %s THEN %s WHEN %s THEN %s END
+ AND t.severity_override IS NOT NULL
+ AND t.severity_override_id IS NULL
+"""
+
+_UNMAPPED_AREA = """
+ SELECT DISTINCT t.severity_override
+ FROM spp_hazard_incident_area t
+ WHERE t.severity_override IS NOT NULL
+ AND t.severity_override_id IS NULL
+"""
+
+# (table, legacy column, new column, backfill query, unmapped query)
+TARGETS = [
+ ("spp_hazard_incident", "severity", "severity_id", _BACKFILL_INCIDENT, _UNMAPPED_INCIDENT),
+ (
+ "spp_hazard_incident_area",
+ "severity_override",
+ "severity_override_id",
+ _BACKFILL_AREA,
+ _UNMAPPED_AREA,
+ ),
+]
+
+
+def _column_exists(cr, table, column):
+ cr.execute(
+ """
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_name = %s AND column_name = %s
+ AND table_schema = current_schema()
+ """,
+ (table, column),
+ )
+ return bool(cr.fetchone())
+
+
+def migrate(cr, version):
+ case_params = [p for pair in LEGACY_SEVERITY_TO_CAP.items() for p in pair]
+ for table, legacy_col, new_col, backfill_query, unmapped_query in TARGETS:
+ if not _column_exists(cr, table, legacy_col):
+ _logger.info("spp_hazard severity migration: %s.%s absent, skipping", table, legacy_col)
+ continue
+
+ cr.execute(backfill_query, [CAP_SEVERITY_NS, *case_params])
+ _logger.info(
+ "spp_hazard severity migration: backfilled %s rows in %s.%s",
+ cr.rowcount,
+ table,
+ new_col,
+ )
+
+ cr.execute(unmapped_query)
+ unmapped = [row[0] for row in cr.fetchall()]
+ if unmapped:
+ _logger.warning(
+ "spp_hazard severity migration: %s.%s has unmapped legacy values %s; left empty for manual review",
+ table,
+ legacy_col,
+ unmapped,
+ )
diff --git a/spp_hazard/models/hazard_incident.py b/spp_hazard/models/hazard_incident.py
index 93dd776a8..08a17d92b 100644
--- a/spp_hazard/models/hazard_incident.py
+++ b/spp_hazard/models/hazard_incident.py
@@ -1,12 +1,44 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+import json
import logging
+import uuid as uuid_lib
+from datetime import UTC, datetime
-from odoo import _, api, fields, models
+import psycopg2
+
+from odoo import Command, _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
+# CAP vocabulary namespace URIs
+CAP_SEVERITY_NS = "urn:oasis:names:tc:cap:severity"
+CAP_URGENCY_NS = "urn:oasis:names:tc:cap:urgency"
+CAP_CERTAINTY_NS = "urn:oasis:names:tc:cap:certainty"
+CAP_MSG_TYPE_NS = "urn:oasis:names:tc:cap:msg-type"
+
+
+def _parse_datetime_string(value):
+ """Parse a datetime string in either ISO 8601 or Odoo format.
+
+ Handles both '2026-04-01T00:00:00Z' (ISO 8601) and
+ '2026-04-01 00:00:00' (Odoo Datetime format).
+
+ Returns:
+ datetime object
+ """
+ if isinstance(value, datetime):
+ dt = value
+ else:
+ # Replace 'Z' with '+00:00' for fromisoformat compatibility
+ normalized = value.replace("Z", "+00:00")
+ dt = datetime.fromisoformat(normalized)
+ # Odoo requires naive (UTC) datetimes
+ if dt.tzinfo is not None:
+ dt = dt.astimezone(UTC).replace(tzinfo=None)
+ return dt
+
class HazardIncident(models.Model):
"""
@@ -22,6 +54,13 @@ class HazardIncident(models.Model):
_order = "start_date desc, name"
_inherit = ["mail.thread", "mail.activity.mixin"]
+ uuid = fields.Char(
+ default=lambda self: str(uuid_lib.uuid4()),
+ readonly=True,
+ copy=False,
+ index=True,
+ help="External identifier for this incident",
+ )
name = fields.Char(
required=True,
tracking=True,
@@ -35,7 +74,6 @@ class HazardIncident(models.Model):
category_id = fields.Many2one(
"spp.hazard.category",
string="Hazard Category",
- required=True,
tracking=True,
ondelete="restrict",
domain=[("active", "=", True)],
@@ -45,7 +83,6 @@ class HazardIncident(models.Model):
help="Narrative details about the incident",
)
start_date = fields.Date(
- required=True,
tracking=True,
help="When the hazard began",
)
@@ -65,16 +102,52 @@ class HazardIncident(models.Model):
tracking=True,
help="Current status of the incident",
)
- severity = fields.Selection(
- [
- ("1", "Level 1 - Minor"),
- ("2", "Level 2 - Moderate"),
- ("3", "Level 3 - Significant"),
- ("4", "Level 4 - Severe"),
- ("5", "Level 5 - Catastrophic"),
- ],
+ severity_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Severity",
+ tracking=True,
+ domain=f"[('namespace_uri', '=', '{CAP_SEVERITY_NS}')]",
+ help="Overall magnitude/severity of the incident (CAP vocabulary)",
+ )
+
+ # CAP (Common Alerting Protocol) fields
+ cap_urgency_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Urgency",
+ tracking=True,
+ domain=f"[('namespace_uri', '=', '{CAP_URGENCY_NS}')]",
+ help="CAP urgency: how quickly action is needed",
+ )
+ cap_certainty_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Certainty",
tracking=True,
- help="Overall magnitude/severity of the incident",
+ domain=f"[('namespace_uri', '=', '{CAP_CERTAINTY_NS}')]",
+ help="CAP certainty: confidence in the observation or prediction",
+ )
+ cap_event = fields.Char(
+ string="Event Type",
+ help="Raw event type from CAP alert (e.g., 'Flood', 'Typhoon'). Complements the structured category_id field.",
+ )
+ cap_msg_type_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Message Type",
+ domain=f"[('namespace_uri', '=', '{CAP_MSG_TYPE_NS}')]",
+ help="CAP message type: alert (new), update, or cancel",
+ )
+ effective = fields.Datetime(
+ help="When the alert becomes active (CAP effective time)",
+ )
+ expires = fields.Datetime(
+ help="When the alert expires (CAP expiry time)",
+ )
+ source = fields.Char(
+ help="Organization that issued the alert (e.g., 'INAM Mozambique')",
+ )
+ source_alert_id = fields.Char(
+ index=True,
+ help="External alert reference ID from the EWS (e.g., 'MOZ-FLOOD-2026-042'). "
+ "Used for duplicate detection on API create.",
)
is_ongoing = fields.Boolean(
compute="_compute_is_ongoing",
@@ -124,6 +197,22 @@ class HazardIncident(models.Model):
"An incident with this code already exists!",
)
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Auto-populate start_date/end_date from effective/expires if not set."""
+ for vals in vals_list:
+ if not vals.get("start_date") and vals.get("effective"):
+ effective = vals["effective"]
+ if isinstance(effective, str):
+ effective = _parse_datetime_string(effective)
+ vals["start_date"] = effective.date()
+ if not vals.get("end_date") and vals.get("expires"):
+ expires = vals["expires"]
+ if isinstance(expires, str):
+ expires = _parse_datetime_string(expires)
+ vals["end_date"] = expires.date()
+ return super().create(vals_list)
+
@api.constrains("start_date", "end_date")
def _check_dates(self):
"""Validate that end_date is after start_date if provided."""
@@ -252,6 +341,299 @@ def identify_potentially_affected_registrants(self):
]
)
+ # --- Alert ingestion methods ---
+
+ @api.model
+ def create_from_alert(self, geometry_dict, properties):
+ """Create an incident from an external alert with geometry.
+
+ Creates the incident record, a hazard_zone geofence from the geometry,
+ and auto-links intersecting administrative areas.
+
+ Args:
+ geometry_dict: GeoJSON geometry (Polygon or MultiPolygon)
+ properties: dict with CAP-aligned properties (event, headline,
+ severity, urgency, certainty, effective, expires, source,
+ source_alert_id, cap_msg_type)
+
+ Returns:
+ spp.hazard.incident record
+ """
+ self._validate_alert_geometry(geometry_dict)
+ vals = self._map_alert_properties_to_vals(properties)
+ incident = self.create(vals)
+
+ # Create hazard_zone geofence from the alert geometry
+ self.env["spp.gis.geofence"].create_from_geojson(
+ geojson_str=geometry_dict,
+ name=f"Alert zone: {incident.name}",
+ geofence_type="hazard_zone",
+ created_from="api",
+ incident_id=incident.id,
+ )
+
+ # Auto-link intersecting admin areas
+ incident._link_areas_from_geometry(geometry_dict)
+
+ return incident
+
+ def update_from_alert(self, geometry_dict, properties):
+ """Update an existing incident from an alert update.
+
+ Updates incident properties. If geometry_dict is provided, updates
+ (or creates) the linked hazard_zone geofence and re-links areas.
+
+ Args:
+ geometry_dict: GeoJSON geometry or None (skip geofence update)
+ properties: dict with CAP-aligned properties
+ """
+ self.ensure_one()
+ vals = self._map_alert_properties_to_vals(properties)
+
+ if geometry_dict:
+ self._validate_alert_geometry(geometry_dict)
+ # Find existing hazard_zone geofence for this incident
+ # nosemgrep: odoo-sudo-without-context
+ geofence = (
+ # nosemgrep: odoo-sudo-without-context (system-context geofence lookup for alert/incident processing)
+ self.env["spp.gis.geofence"]
+ .sudo()
+ .search(
+ [
+ ("incident_id", "=", self.id),
+ ("geofence_type", "=", "hazard_zone"),
+ ],
+ limit=1,
+ order="create_date",
+ )
+ )
+ if geofence:
+ geofence.write({"geometry": json.dumps(geometry_dict)})
+ else:
+ self.env["spp.gis.geofence"].create_from_geojson(
+ geojson_str=geometry_dict,
+ name=f"Alert zone: {self.name}",
+ geofence_type="hazard_zone",
+ created_from="api",
+ incident_id=self.id,
+ )
+ # Re-link areas from updated geometry
+ self._link_areas_from_geometry(geometry_dict)
+
+ # Handle cancellation: merge close fields into a single write rather
+ # than calling action_close() separately (avoids two ORM writes).
+ cap_msg_type_id = vals.get("cap_msg_type_id")
+ if cap_msg_type_id:
+ VocabCode = self.env["spp.vocabulary.code"]
+ cancel_code = VocabCode.get_code(CAP_MSG_TYPE_NS, "cancel")
+ if cancel_code and cap_msg_type_id == cancel_code.id:
+ vals["status"] = "closed"
+ vals["end_date"] = self.end_date or fields.Date.today()
+
+ self.write(vals)
+
+ def _validate_alert_geometry(self, geometry_dict):
+ """Validate that geometry is Polygon or MultiPolygon.
+
+ Args:
+ geometry_dict: GeoJSON geometry dict
+
+ Raises:
+ ValidationError: If geometry type is not allowed
+ """
+ allowed = {"Polygon", "MultiPolygon"}
+ geom_type = geometry_dict.get("type", "") if isinstance(geometry_dict, dict) else ""
+ if geom_type not in allowed:
+ raise ValidationError(_("Alert geometry must be Polygon or MultiPolygon, got '%s'.") % geom_type)
+
+ def _map_alert_properties_to_vals(self, properties):
+ """Map CAP-aligned properties dict to incident field values.
+
+ Args:
+ properties: dict with keys like event, headline, severity, etc.
+
+ Returns:
+ dict: Odoo field values for create/write
+ """
+ VocabCode = self.env["spp.vocabulary.code"]
+ vals = {}
+
+ if "headline" in properties:
+ vals["name"] = properties["headline"]
+ if "event" in properties:
+ vals["cap_event"] = properties["event"]
+ # Try to resolve event to a hazard category
+ category = self._resolve_category_from_event(properties["event"])
+ if category:
+ vals["category_id"] = category.id
+ if "source" in properties:
+ vals["source"] = properties["source"]
+ if "source_alert_id" in properties:
+ vals["source_alert_id"] = properties["source_alert_id"]
+ if "effective" in properties and properties["effective"]:
+ vals["effective"] = _parse_datetime_string(properties["effective"])
+ if "expires" in properties and properties["expires"]:
+ vals["expires"] = _parse_datetime_string(properties["expires"])
+
+ # Resolve vocabulary-backed fields by code
+ for prop_key, field_name, namespace in [
+ ("severity", "severity_id", CAP_SEVERITY_NS),
+ ("urgency", "cap_urgency_id", CAP_URGENCY_NS),
+ ("certainty", "cap_certainty_id", CAP_CERTAINTY_NS),
+ ("cap_msg_type", "cap_msg_type_id", CAP_MSG_TYPE_NS),
+ ]:
+ if prop_key in properties and properties[prop_key]:
+ code_rec = VocabCode.get_code(namespace, properties[prop_key])
+ if code_rec:
+ vals[field_name] = code_rec.id
+
+ # Auto-generate code if not provided (for create)
+ if "code" not in vals and "source_alert_id" in properties and properties["source_alert_id"]:
+ vals["code"] = properties["source_alert_id"]
+ if "code" not in vals:
+ vals["code"] = f"INC-{uuid_lib.uuid4().hex[:8].upper()}"
+
+ return vals
+
+ def _resolve_category_from_event(self, event_str):
+ """Try to match a CAP event string to a hazard category.
+
+ Searches spp.hazard.category by name or code (case-insensitive).
+
+ Args:
+ event_str: Event type string (e.g., "Flood", "Typhoon")
+
+ Returns:
+ spp.hazard.category record or None
+ """
+ if not event_str:
+ return None
+ Category = self.env["spp.hazard.category"]
+ # Try exact match on name first, then code
+ category = Category.search([("name", "=ilike", event_str)], limit=1)
+ if not category:
+ category = Category.search([("code", "=ilike", event_str)], limit=1)
+ return category or None
+
+ def _link_areas_from_geometry(self, geometry_dict):
+ """Find administrative areas that intersect the geometry and link them.
+
+ Uses PostGIS ST_Intersects to find spp.area records whose polygon
+ overlaps the alert geometry. Populates area_ids on the incident.
+
+ Args:
+ geometry_dict: GeoJSON geometry dict
+ """
+ self.ensure_one()
+ geojson_str = json.dumps(geometry_dict) if isinstance(geometry_dict, dict) else geometry_dict
+
+ try:
+ # Savepoint: a failed query (e.g. invalid geometry) would otherwise
+ # abort the whole transaction, not just this optional linking step.
+ with self.env.cr.savepoint():
+ self.env.cr.execute(
+ """
+ SELECT id FROM spp_area
+ WHERE geo_polygon IS NOT NULL
+ AND ST_Intersects(
+ geo_polygon::geometry,
+ ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326)
+ )
+ """,
+ (geojson_str,),
+ )
+ area_ids = [row[0] for row in self.env.cr.fetchall()]
+ if area_ids:
+ self.write({"area_ids": [Command.set(area_ids)]})
+ else:
+ _logger.info(
+ "No intersecting areas found for incident %s",
+ self.code,
+ )
+ except psycopg2.Error as e:
+ _logger.warning(
+ "Failed to link areas from geometry for incident %s: %s",
+ self.code,
+ e,
+ )
+
+ def to_geojson(self):
+ """Return GeoJSON Feature representation of this incident.
+
+ Geometry is pulled from the first linked hazard_zone geofence.
+ Returns null geometry if no geofence is linked.
+
+ Returns:
+ dict: GeoJSON Feature
+ """
+ self.ensure_one()
+ return {
+ "type": "Feature",
+ "id": self.uuid,
+ "geometry": self._get_alert_geometry(),
+ "properties": self._get_geojson_properties(),
+ }
+
+ def _get_alert_geometry(self):
+ """Get geometry from the first linked hazard_zone geofence.
+
+ Returns:
+ dict: GeoJSON geometry or None
+ """
+ from shapely.geometry import mapping
+
+ # nosemgrep: odoo-sudo-without-context
+ geofence = (
+ # nosemgrep: odoo-sudo-without-context (system-context geofence lookup for alert/incident processing)
+ self.env["spp.gis.geofence"]
+ .sudo()
+ .search(
+ [
+ ("incident_id", "=", self.id),
+ ("geofence_type", "=", "hazard_zone"),
+ ],
+ limit=1,
+ order="create_date",
+ )
+ )
+ if not geofence or not geofence.geometry:
+ return None
+ try:
+ return mapping(geofence.geometry)
+ except (ValueError, TypeError, AttributeError) as e:
+ _logger.warning(
+ "Failed to convert geometry for incident %s geofence %s: %s",
+ self.id,
+ geofence.id,
+ e,
+ )
+ return None
+
+ def _get_geojson_properties(self):
+ """CAP-aligned properties for GeoJSON response.
+
+ Returns:
+ dict: Properties dictionary
+ """
+ self.ensure_one()
+ return {
+ "code": self.code,
+ "event": self.cap_event or (self.category_id.name if self.category_id else None),
+ "severity": self.severity_id.code if self.severity_id else None,
+ "urgency": self.cap_urgency_id.code if self.cap_urgency_id else None,
+ "certainty": self.cap_certainty_id.code if self.cap_certainty_id else None,
+ "msg_type": self.cap_msg_type_id.code if self.cap_msg_type_id else None,
+ "effective": self.effective.isoformat() if self.effective else None,
+ "expires": self.expires.isoformat() if self.expires else None,
+ "headline": self.name,
+ "source": self.source,
+ "source_alert_id": self.source_alert_id,
+ "status": self.status,
+ "start_date": str(self.start_date) if self.start_date else None,
+ "end_date": str(self.end_date) if self.end_date else None,
+ "created_at": self.create_date.isoformat() if self.create_date else None,
+ }
+
class HazardIncidentArea(models.Model):
"""
@@ -280,14 +662,10 @@ class HazardIncidentArea(models.Model):
ondelete="restrict",
index=True,
)
- severity_override = fields.Selection(
- [
- ("1", "Level 1 - Minor"),
- ("2", "Level 2 - Moderate"),
- ("3", "Level 3 - Significant"),
- ("4", "Level 4 - Severe"),
- ("5", "Level 5 - Catastrophic"),
- ],
+ severity_override_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Severity Override",
+ domain=f"[('namespace_uri', '=', '{CAP_SEVERITY_NS}')]",
help="Area-specific severity (overrides incident-wide severity)",
)
notes = fields.Text(
@@ -304,5 +682,6 @@ class HazardIncidentArea(models.Model):
@api.depends("incident_id.name", "area_id.name")
def _compute_display_name(self):
+ """Compute a descriptive display name for the record."""
for rec in self:
rec.display_name = f"{rec.incident_id.name} - {rec.area_id.name}"
diff --git a/spp_hazard/readme/HISTORY.md b/spp_hazard/readme/HISTORY.md
index c02593c5b..f59af2e12 100644
--- a/spp_hazard/readme/HISTORY.md
+++ b/spp_hazard/readme/HISTORY.md
@@ -1,3 +1,9 @@
+### 19.0.2.1.0
+
+- feat: severity is now a CAP v1.2 vocabulary code (`severity_id`, `severity_override_id` on incident areas) instead of a hardcoded 1-5 Selection; adds CAP urgency/certainty/message-type/event fields, alert ingestion (`create_incident_from_alert`), and incident `uuid` (re-land from #76).
+- feat: migration backfills legacy 1-5 severity values onto the vocabulary fields when upgrading from 19.0.2.0.x (1→minor, 2→moderate, 3→severe, 4→severe, 5→extreme); existing values are never overwritten and legacy columns are kept.
+- fix: demo incident-area records now set `severity_override_id` vocabulary refs (the removed `severity_override` field broke demo installs).
+
### 19.0.2.0.2
- fix(security): grant `group_hazard_viewer` to spp_user_roles roles (Registry Viewer, Program Manager, Global/Local Registrar) that the OP#951 menu audit identifies as needing read-only Hazard menu access. Other affected roles defined outside this module (program/CR/farm roles) are wired in their own modules.
diff --git a/spp_hazard/static/description/index.html b/spp_hazard/static/description/index.html
index 9df1ed8e5..907076179 100644
--- a/spp_hazard/static/description/index.html
+++ b/spp_hazard/static/description/index.html
@@ -2454,6 +2454,23 @@
+
19.0.2.1.0
+
+- feat: severity is now a CAP v1.2 vocabulary code (severity_id,
+severity_override_id on incident areas) instead of a hardcoded 1-5
+Selection; adds CAP urgency/certainty/message-type/event fields, alert
+ingestion (create_incident_from_alert), and incident uuid
+(re-land from #76).
+- feat: migration backfills legacy 1-5 severity values onto the
+vocabulary fields when upgrading from 19.0.2.0.x (1→minor, 2→moderate,
+3→severe, 4→severe, 5→extreme); existing values are never overwritten
+and legacy columns are kept.
+- fix: demo incident-area records now set severity_override_id
+vocabulary refs (the removed severity_override field broke demo
+installs).
+
+
+
19.0.2.0.2
- fix(security): grant group_hazard_viewer to spp_user_roles roles
@@ -2469,7 +2486,7 @@
19.0.2.0.2
Support).
-
+
19.0.2.0.1
- fix(views): apply spp_registry.x2many_no_padding widget to the
@@ -2477,7 +2494,7 @@
19.0.2.0.1
(showing a muted info line instead) (#943).
-
+
19.0.2.0.1
+
+- fix: program view shows the vocabulary-backed severity_id field
+(follows the spp_hazard severity vocabulary change, re-land from #76).
+
+
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_hazard_programs/views/program_views.xml b/spp_hazard_programs/views/program_views.xml
index 910715cd6..3db6dce23 100644
--- a/spp_hazard_programs/views/program_views.xml
+++ b/spp_hazard_programs/views/program_views.xml
@@ -62,7 +62,7 @@
-
+