From a4dd8e83fd9cef7fcbc20b02f3004c8c249a51b5 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 22:25:45 +0800 Subject: [PATCH 1/8] feat(spp_hazard): re-land CAP vocabulary severity with upgrade migration (from #76) Re-lands the spp_hazard severity->vocabulary change from reverted PR #76, together with the dependent adaptations in spp_drims, spp_drims_sl_demo and spp_hazard_programs, plus the two fixes the original PR lacked: - migrations/19.0.2.1.0/post-migration.py backfills severity_id / severity_override_id from the legacy 1-5 Selection columns for databases upgrading from v19.0.2.0.x (release Biliran ships the old schema). Mapping: 1->minor, 2->moderate, 3->severe, 4->severe, 5->extreme. - hazard_demo.xml incident-area records now use severity_override_id vocabulary refs; the stale severity_override field broke demo installs. Includes migration regression tests (mapping, area override, idempotency, unmapped values, fresh-install no-op). --- spp_drims/__manifest__.py | 2 +- spp_drims/models/hazard_incident_area.py | 35 +- spp_drims/readme/HISTORY.md | 4 + spp_drims/tests/__init__.py | 1 + .../tests/test_incident_area_severity.py | 50 +++ spp_drims_sl_demo/__manifest__.py | 2 +- spp_drims_sl_demo/readme/HISTORY.md | 4 + .../wizard/drims_demo_generator.py | 24 +- spp_hazard/__manifest__.py | 4 +- spp_hazard/data/vocabulary_cap.xml | 202 +++++++++ spp_hazard/demo/hazard_demo.xml | 14 +- .../migrations/19.0.2.1.0/post-migration.py | 99 +++++ spp_hazard/models/hazard_incident.py | 413 +++++++++++++++++- spp_hazard/readme/HISTORY.md | 6 + spp_hazard/tests/__init__.py | 2 + spp_hazard/tests/common.py | 11 + spp_hazard/tests/test_alert_ingestion.py | 364 +++++++++++++++ spp_hazard/tests/test_geofence.py | 2 +- spp_hazard/tests/test_hazard_incident.py | 6 +- spp_hazard/tests/test_migration_severity.py | 111 +++++ spp_hazard/tests/test_registrant.py | 2 +- spp_hazard/views/hazard_incident_views.xml | 113 ++++- spp_hazard_programs/__manifest__.py | 2 +- spp_hazard_programs/readme/HISTORY.md | 4 + spp_hazard_programs/views/program_views.xml | 2 +- 25 files changed, 1409 insertions(+), 70 deletions(-) create mode 100644 spp_drims/tests/test_incident_area_severity.py create mode 100644 spp_hazard/data/vocabulary_cap.xml create mode 100644 spp_hazard/migrations/19.0.2.1.0/post-migration.py create mode 100644 spp_hazard/tests/test_alert_ingestion.py create mode 100644 spp_hazard/tests/test_migration_severity.py 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/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/__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/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/__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", +} + +# (table, legacy column, new column) - identifiers are constants, never user input +TARGETS = [ + ("spp_hazard_incident", "severity", "severity_id"), + ("spp_hazard_incident_area", "severity_override", "severity_override_id"), +] + + +def _column_exists(cr, table, column): + cr.execute( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_name = %s AND column_name = %s + """, + (table, column), + ) + return bool(cr.fetchone()) + + +def migrate(cr, version): + for table, legacy_col, new_col in TARGETS: + if not _column_exists(cr, table, legacy_col): + _logger.info("spp_hazard severity migration: %s.%s absent, skipping", table, legacy_col) + continue + + case_parts = " ".join("WHEN %s THEN %s" for _ in LEGACY_SEVERITY_TO_CAP) + case_params = [p for pair in LEGACY_SEVERITY_TO_CAP.items() for p in pair] + cr.execute( + f""" + UPDATE {table} t + SET {new_col} = 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.{legacy_col} {case_parts} END + AND t.{legacy_col} IS NOT NULL + AND t.{new_col} IS NULL + """, + [CAP_SEVERITY_NS, *case_params], + ) + _logger.info( + "spp_hazard severity migration: backfilled %s rows in %s.%s", + cr.rowcount, + table, + new_col, + ) + + cr.execute( + f""" + SELECT DISTINCT t.{legacy_col} + FROM {table} t + WHERE t.{legacy_col} IS NOT NULL + AND t.{new_col} IS NULL + """ + ) + 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..938115aa7 100644 --- a/spp_hazard/models/hazard_incident.py +++ b/spp_hazard/models/hazard_incident.py @@ -1,12 +1,41 @@ # 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 + """ + # 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 +51,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 +71,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 +80,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 +99,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 +194,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 +338,296 @@ 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: + 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 +656,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 +676,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/tests/__init__.py b/spp_hazard/tests/__init__.py index 9956de3b9..746c00317 100644 --- a/spp_hazard/tests/__init__.py +++ b/spp_hazard/tests/__init__.py @@ -5,4 +5,6 @@ from . import test_hazard_impact from . import test_hazard_impact_type from . import test_geofence +from . import test_alert_ingestion from . import test_registrant +from . import test_migration_severity diff --git a/spp_hazard/tests/common.py b/spp_hazard/tests/common.py index baef332d1..329497408 100644 --- a/spp_hazard/tests/common.py +++ b/spp_hazard/tests/common.py @@ -53,6 +53,17 @@ def setUpClass(cls): } ) + # Resolve CAP vocabulary codes (loaded from data/vocabulary_cap.xml) + VocabCode = cls.env["spp.vocabulary.code"] + cls.severity_extreme = VocabCode.get_code("urn:oasis:names:tc:cap:severity", "extreme") + cls.severity_severe = VocabCode.get_code("urn:oasis:names:tc:cap:severity", "severe") + cls.severity_moderate = VocabCode.get_code("urn:oasis:names:tc:cap:severity", "moderate") + cls.severity_minor = VocabCode.get_code("urn:oasis:names:tc:cap:severity", "minor") + cls.urgency_immediate = VocabCode.get_code("urn:oasis:names:tc:cap:urgency", "immediate") + cls.certainty_observed = VocabCode.get_code("urn:oasis:names:tc:cap:certainty", "observed") + cls.msg_type_alert = VocabCode.get_code("urn:oasis:names:tc:cap:msg-type", "alert") + cls.msg_type_cancel = VocabCode.get_code("urn:oasis:names:tc:cap:msg-type", "cancel") + # Create impact type cls.impact_type_displacement = cls.env["spp.hazard.impact.type"].create( { diff --git a/spp_hazard/tests/test_alert_ingestion.py b/spp_hazard/tests/test_alert_ingestion.py new file mode 100644 index 000000000..c349d0cf6 --- /dev/null +++ b/spp_hazard/tests/test_alert_ingestion.py @@ -0,0 +1,364 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for alert ingestion business logic (create_from_alert, update_from_alert, etc.).""" + +import logging + +from odoo.exceptions import ValidationError +from odoo.tests import tagged + +from .common import HazardTestCase + +_logger = logging.getLogger(__name__) + +# Sample polygon covering a small area +SAMPLE_POLYGON = { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0], + ] + ], +} + +SAMPLE_POLYGON_2 = { + "type": "Polygon", + "coordinates": [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 3.0], + [102.0, 2.0], + ] + ], +} + + +@tagged("post_install", "-at_install") +class TestCreateFromAlert(HazardTestCase): + """Tests for create_from_alert method.""" + + def test_create_from_alert_happy_path(self): + """Create incident from alert with all properties.""" + Incident = self.env["spp.hazard.incident"] + properties = { + "event": "Flood", + "headline": "Major Flood in Region A", + "severity": "extreme", + "urgency": "immediate", + "certainty": "observed", + "effective": "2026-04-01T00:00:00Z", + "expires": "2026-04-15T00:00:00Z", + "source": "INAM Mozambique", + "source_alert_id": "MOZ-FLOOD-2026-001", + "cap_msg_type": "alert", + } + + incident = Incident.create_from_alert(SAMPLE_POLYGON, properties) + + self.assertTrue(incident) + self.assertEqual(incident.name, "Major Flood in Region A") + self.assertEqual(incident.cap_event, "Flood") + self.assertEqual(incident.severity_id, self.severity_extreme) + self.assertEqual(incident.cap_urgency_id, self.urgency_immediate) + self.assertEqual(incident.cap_certainty_id, self.certainty_observed) + self.assertEqual(incident.source, "INAM Mozambique") + self.assertEqual(incident.source_alert_id, "MOZ-FLOOD-2026-001") + self.assertTrue(incident.uuid) + # Auto-populated dates + self.assertTrue(incident.start_date) + self.assertTrue(incident.effective) + + def test_create_from_alert_creates_geofence(self): + """create_from_alert creates a hazard_zone geofence linked to the incident.""" + Incident = self.env["spp.hazard.incident"] + properties = { + "event": "Storm", + "headline": "Storm Alert", + "source_alert_id": "STORM-001", + } + + incident = Incident.create_from_alert(SAMPLE_POLYGON, properties) + + geofence = self.env["spp.gis.geofence"].search( + [("incident_id", "=", incident.id), ("geofence_type", "=", "hazard_zone")] + ) + self.assertEqual(len(geofence), 1) + self.assertEqual(geofence.created_from, "api") + + def test_create_from_alert_resolves_category(self): + """create_from_alert resolves event string to hazard category by name.""" + Incident = self.env["spp.hazard.incident"] + properties = { + "event": "Typhoon", + "headline": "Typhoon Alert", + "source_alert_id": "TYP-001", + } + + incident = Incident.create_from_alert(SAMPLE_POLYGON, properties) + + self.assertEqual(incident.category_id, self.category_typhoon) + self.assertEqual(incident.cap_event, "Typhoon") + + def test_create_from_alert_missing_category_ok(self): + """create_from_alert works when event doesn't match any category.""" + Incident = self.env["spp.hazard.incident"] + properties = { + "event": "Alien Invasion", + "headline": "Unknown Event", + "source_alert_id": "UNK-001", + } + + incident = Incident.create_from_alert(SAMPLE_POLYGON, properties) + + self.assertFalse(incident.category_id) + self.assertEqual(incident.cap_event, "Alien Invasion") + + def test_create_from_alert_auto_code(self): + """create_from_alert auto-generates code from source_alert_id.""" + Incident = self.env["spp.hazard.incident"] + properties = { + "event": "Flood", + "headline": "Auto Code Test", + "source_alert_id": "AUTO-CODE-001", + } + + incident = Incident.create_from_alert(SAMPLE_POLYGON, properties) + + self.assertEqual(incident.code, "AUTO-CODE-001") + + def test_create_from_alert_bad_geometry_raises(self): + """create_from_alert raises ValidationError for Point geometry.""" + Incident = self.env["spp.hazard.incident"] + bad_geom = {"type": "Point", "coordinates": [100.0, 0.0]} + properties = { + "event": "Flood", + "headline": "Bad Geom", + "source_alert_id": "BAD-GEOM-001", + } + + with self.assertRaises(ValidationError): + Incident.create_from_alert(bad_geom, properties) + + def test_create_from_alert_minimal_properties(self): + """create_from_alert works with only event and headline.""" + Incident = self.env["spp.hazard.incident"] + properties = { + "event": "Drought", + "headline": "Minimal Alert", + "source_alert_id": "MIN-001", + } + + incident = Incident.create_from_alert(SAMPLE_POLYGON, properties) + + self.assertTrue(incident) + self.assertEqual(incident.name, "Minimal Alert") + self.assertFalse(incident.severity_id) + self.assertFalse(incident.cap_urgency_id) + + +@tagged("post_install", "-at_install") +class TestUpdateFromAlert(HazardTestCase): + """Tests for update_from_alert method.""" + + def setUp(self): + super().setUp() + Incident = self.env["spp.hazard.incident"] + self.incident = Incident.create_from_alert( + SAMPLE_POLYGON, + { + "event": "Flood", + "headline": "Initial Flood Alert", + "severity": "moderate", + "source_alert_id": "UPD-001", + }, + ) + + def test_update_properties_only(self): + """update_from_alert updates properties without geometry change.""" + self.incident.update_from_alert( + None, + { + "headline": "Updated Flood Alert", + "severity": "extreme", + }, + ) + + self.assertEqual(self.incident.name, "Updated Flood Alert") + self.assertEqual(self.incident.severity_id, self.severity_extreme) + + def test_update_with_new_geometry(self): + """update_from_alert updates the linked geofence geometry.""" + self.incident.update_from_alert( + SAMPLE_POLYGON_2, + {"headline": "Flood Moved East"}, + ) + + geofence = self.env["spp.gis.geofence"].search( + [("incident_id", "=", self.incident.id), ("geofence_type", "=", "hazard_zone")], + limit=1, + ) + self.assertTrue(geofence) + self.assertEqual(self.incident.name, "Flood Moved East") + + # Verify the geofence geometry was updated to SAMPLE_POLYGON_2. + # SAMPLE_POLYGON_2 starts at longitude 102, which is distinct from + # SAMPLE_POLYGON which starts at longitude 100. + self.assertTrue(geofence.geometry, "Geofence geometry must not be empty after update") + from shapely.geometry import mapping + + stored_geom = mapping(geofence.geometry) + coords = stored_geom.get("coordinates", [[[]]]) + outer_ring = coords[0] + longitudes = [pt[0] for pt in outer_ring] + self.assertTrue( + any(lon >= 102.0 for lon in longitudes), + f"Expected geometry with longitudes >= 102 (SAMPLE_POLYGON_2), got: {longitudes}", + ) + + def test_update_cancel_closes_incident(self): + """update_from_alert with cap_msg_type=cancel closes the incident.""" + self.incident.update_from_alert( + None, + {"cap_msg_type": "cancel"}, + ) + + self.assertEqual(self.incident.status, "closed") + + +@tagged("post_install", "-at_install") +class TestToGeojson(HazardTestCase): + """Tests for to_geojson and related methods.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + Incident = cls.env["spp.hazard.incident"] + cls.incident = Incident.create_from_alert( + SAMPLE_POLYGON, + { + "event": "Flood", + "headline": "GeoJSON Test Flood", + "severity": "severe", + "urgency": "immediate", + "source": "Test Agency", + "source_alert_id": "GEO-001", + }, + ) + + def test_to_geojson_structure(self): + """to_geojson returns valid GeoJSON Feature.""" + feature = self.incident.to_geojson() + + self.assertEqual(feature["type"], "Feature") + self.assertEqual(feature["id"], self.incident.uuid) + self.assertIsNotNone(feature["geometry"]) + self.assertEqual(feature["geometry"]["type"], "Polygon") + self.assertIsInstance(feature["properties"], dict) + + def test_to_geojson_properties(self): + """to_geojson includes all CAP-aligned properties.""" + props = self.incident.to_geojson()["properties"] + + self.assertEqual(props["code"], "GEO-001") + self.assertEqual(props["event"], "Flood") + self.assertEqual(props["severity"], "severe") + self.assertEqual(props["urgency"], "immediate") + self.assertEqual(props["headline"], "GeoJSON Test Flood") + self.assertEqual(props["source"], "Test Agency") + self.assertEqual(props["source_alert_id"], "GEO-001") + + def test_to_geojson_without_geofence(self): + """to_geojson returns null geometry if no geofence linked.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "No Geofence Incident", + "code": "NOGEO-001", + "start_date": "2026-01-01", + } + ) + + feature = incident.to_geojson() + + self.assertEqual(feature["type"], "Feature") + self.assertIsNone(feature["geometry"]) + + def test_to_geojson_multiple_geofences_uses_first(self): + """to_geojson uses the first (oldest) geofence geometry.""" + # Create a second geofence for the same incident + self.env["spp.gis.geofence"].create_from_geojson( + geojson_str=SAMPLE_POLYGON_2, + name="Second zone", + geofence_type="hazard_zone", + created_from="api", + incident_id=self.incident.id, + ) + + feature = self.incident.to_geojson() + + # Should use first geofence (SAMPLE_POLYGON), not second + self.assertIsNotNone(feature["geometry"]) + + +@tagged("post_install", "-at_install") +class TestLinkAreasFromGeometry(HazardTestCase): + """Tests for _link_areas_from_geometry method.""" + + def test_link_areas_no_match(self): + """_link_areas_from_geometry with non-overlapping geometry links no areas.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "No Match Incident", + "code": "NOMATCH-001", + "start_date": "2026-01-01", + } + ) + + # Polygon far from any test area + far_polygon = { + "type": "Polygon", + "coordinates": [ + [ + [170.0, 70.0], + [171.0, 70.0], + [171.0, 71.0], + [170.0, 71.0], + [170.0, 70.0], + ] + ], + } + + incident._link_areas_from_geometry(far_polygon) + + # Should not fail, just log and link nothing + self.assertEqual(len(incident.area_ids), 0) + + def test_auto_populate_dates_from_effective(self): + """Auto-populate start_date from effective on create.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "Date Auto Test", + "code": "DATE-AUTO-001", + "effective": "2026-06-15 10:00:00", + } + ) + + self.assertEqual(str(incident.start_date), "2026-06-15") + + def test_auto_populate_end_date_from_expires(self): + """Auto-populate end_date from expires on create.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "Date Auto End Test", + "code": "DATE-AUTO-002", + "effective": "2026-06-15 10:00:00", + "expires": "2026-07-01 10:00:00", + } + ) + + self.assertEqual(str(incident.start_date), "2026-06-15") + self.assertEqual(str(incident.end_date), "2026-07-01") diff --git a/spp_hazard/tests/test_geofence.py b/spp_hazard/tests/test_geofence.py index 13a935f27..4091cd4eb 100644 --- a/spp_hazard/tests/test_geofence.py +++ b/spp_hazard/tests/test_geofence.py @@ -35,7 +35,7 @@ def setUpClass(cls): "code": "GEO-TEST-INC-001", "category_id": cls.category_typhoon.id, "start_date": "2024-06-01", - "severity": "3", + "severity_id": cls.severity_severe.id, } ) diff --git a/spp_hazard/tests/test_hazard_incident.py b/spp_hazard/tests/test_hazard_incident.py index c92f4e43b..c8c94c309 100644 --- a/spp_hazard/tests/test_hazard_incident.py +++ b/spp_hazard/tests/test_hazard_incident.py @@ -22,7 +22,7 @@ def setUpClass(cls): "code": "TEST-INC-001", "category_id": cls.category_typhoon.id, "start_date": "2024-01-01", - "severity": "3", + "severity_id": cls.severity_severe.id, } ) @@ -108,12 +108,12 @@ def test_07_incident_area_details(self): { "incident_id": self.incident.id, "area_id": self.area.id, - "severity_override": "5", + "severity_override_id": self.severity_extreme.id, "affected_population_estimate": 1000, } ) self.assertTrue(incident_area) - self.assertEqual(incident_area.severity_override, "5") + self.assertEqual(incident_area.severity_override_id, self.severity_extreme) self.assertEqual(incident_area.affected_population_estimate, 1000) def test_08_identify_potentially_affected(self): diff --git a/spp_hazard/tests/test_migration_severity.py b/spp_hazard/tests/test_migration_severity.py new file mode 100644 index 000000000..50fd51b61 --- /dev/null +++ b/spp_hazard/tests/test_migration_severity.py @@ -0,0 +1,111 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import importlib.util +from pathlib import Path + +from odoo.tests.common import TransactionCase + +CAP_SEVERITY_NS = "urn:oasis:names:tc:cap:severity" + +MIGRATION_PATH = Path(__file__).parent.parent / "migrations" / "19.0.2.1.0" / "post-migration.py" + + +def _load_migration(): + spec = importlib.util.spec_from_file_location("spp_hazard_severity_migration", MIGRATION_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestSeverityMigration(TransactionCase): + """Exercise the 19.0.2.1.0 post-migration that backfills severity + vocabulary codes from the legacy 1-5 Selection columns. + + The legacy columns do not exist in a fresh database, so each test + recreates them with SQL before running the migration (Postgres DDL is + transactional and rolls back with the test). + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.VocabCode = cls.env["spp.vocabulary.code"] + cls.category = cls.env["spp.hazard.category"].create({"name": "Migration Cat", "code": "MIG-CAT"}) + cls.area = cls.env["spp.area"].create({"draft_name": "Migration Area", "code": "MIG-AREA"}) + + def _make_incident(self, code): + return self.env["spp.hazard.incident"].create( + {"name": f"Migration incident {code}", "code": code, "category_id": self.category.id} + ) + + def _add_legacy_columns(self): + self.env.cr.execute("ALTER TABLE spp_hazard_incident ADD COLUMN IF NOT EXISTS severity varchar") + self.env.cr.execute("ALTER TABLE spp_hazard_incident_area ADD COLUMN IF NOT EXISTS severity_override varchar") + + def _set_legacy(self, incident, value): + self.env.cr.execute("UPDATE spp_hazard_incident SET severity = %s WHERE id = %s", (value, incident.id)) + + def _cap_code(self, code): + return self.VocabCode.get_code(CAP_SEVERITY_NS, code) + + def _migrate(self): + _load_migration().migrate(self.env.cr, "19.0.2.0.2") + self.env.invalidate_all() + + def test_01_mapping_a_backfills_incident_severity(self): + """Legacy 1-5 values map label-faithfully to CAP codes (mapping A).""" + expected = {"1": "minor", "2": "moderate", "3": "severe", "4": "severe", "5": "extreme"} + self._add_legacy_columns() + incidents = {} + for legacy in expected: + incidents[legacy] = self._make_incident(f"MIG-{legacy}") + self._set_legacy(incidents[legacy], legacy) + + self._migrate() + + for legacy, cap in expected.items(): + self.assertEqual( + incidents[legacy].severity_id, + self._cap_code(cap), + f"legacy severity {legacy!r} should map to CAP {cap!r}", + ) + + def test_02_backfills_area_severity_override(self): + self._add_legacy_columns() + incident = self._make_incident("MIG-AREA-1") + area_link = self.env["spp.hazard.incident.area"].create( + {"incident_id": incident.id, "area_id": self.area.id} + ) + self.env.cr.execute( + "UPDATE spp_hazard_incident_area SET severity_override = %s WHERE id = %s", ("5", area_link.id) + ) + + self._migrate() + + self.assertEqual(area_link.severity_override_id, self._cap_code("extreme")) + + def test_03_does_not_overwrite_existing_value(self): + """Idempotent: a severity_id already set (e.g. manually re-entered) wins.""" + self._add_legacy_columns() + incident = self._make_incident("MIG-KEEP") + incident.severity_id = self._cap_code("minor") + self._set_legacy(incident, "5") + + self._migrate() + + self.assertEqual(incident.severity_id, self._cap_code("minor")) + + def test_04_unmapped_value_left_empty(self): + self._add_legacy_columns() + incident = self._make_incident("MIG-JUNK") + self._set_legacy(incident, "not-a-level") + + with self.assertLogs("odoo.addons.spp_hazard.migrations.severity", level="WARNING"): + self._migrate() + + self.assertFalse(incident.severity_id) + + def test_05_noop_without_legacy_columns(self): + """Fresh installs have no legacy columns; the migration must not fail.""" + incident = self._make_incident("MIG-FRESH") + self._migrate() + self.assertFalse(incident.severity_id) diff --git a/spp_hazard/tests/test_registrant.py b/spp_hazard/tests/test_registrant.py index e343295b7..a4c578875 100644 --- a/spp_hazard/tests/test_registrant.py +++ b/spp_hazard/tests/test_registrant.py @@ -17,7 +17,7 @@ def setUpClass(cls): "code": "REG-TEST-INC-001", "category_id": cls.category_typhoon.id, "start_date": "2024-01-01", - "severity": "3", + "severity_id": cls.severity_severe.id, } ) cls.impact = cls.env["spp.hazard.impact"].create( diff --git a/spp_hazard/views/hazard_incident_views.xml b/spp_hazard/views/hazard_incident_views.xml index 50a7f6c28..6bfba2971 100644 --- a/spp_hazard/views/hazard_incident_views.xml +++ b/spp_hazard/views/hazard_incident_views.xml @@ -11,10 +11,10 @@ decoration-warning="status == 'recovery'" decoration-muted="status == 'closed'" > - - - - + + + + + + - - @@ -142,13 +141,22 @@ /> - + - + @@ -161,6 +169,55 @@ readonly="status == 'closed'" /> + + + + + + + + + + + + + + + + + + + +

- + +

No impacts recorded. Impacts track damage assessments linked to this incident.

@@ -245,6 +309,8 @@ + + + + + - + diff --git a/spp_hazard_programs/__manifest__.py b/spp_hazard_programs/__manifest__.py index 1871df01a..dfb115f96 100644 --- a/spp_hazard_programs/__manifest__.py +++ b/spp_hazard_programs/__manifest__.py @@ -7,7 +7,7 @@ "summary": "Links hazard impacts to program eligibility and entitlements. " "Enables emergency programs to use hazard data for targeting and benefit calculation.", "category": "OpenSPP/Targeting", - "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_hazard_programs/readme/HISTORY.md b/spp_hazard_programs/readme/HISTORY.md index 4aaf9afef..fa327bcbc 100644 --- a/spp_hazard_programs/readme/HISTORY.md +++ b/spp_hazard_programs/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 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 @@ - +
From 8a749083fc3c30892674d142a4ad65171ff5a4bb Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 23:09:37 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20address=20CI=20findings=20=E2=80=94?= =?UTF-8?q?=20psycopg2.sql=20composition=20in=20migration,=20regenerate=20?= =?UTF-8?q?READMEs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semgrep flagged f-string SQL in the severity migration; identifiers were constants but composed SQL via psycopg2.sql removes the pattern entirely. READMEs regenerated from the updated HISTORY fragments (in-scope modules only). --- spp_drims/README.rst | 8 + spp_drims/static/description/index.html | 9 + spp_drims_sl_demo/README.rst | 7 + .../static/description/index.html | 8 + spp_hazard/README.rst | 159 ++++++++++-------- .../migrations/19.0.2.1.0/post-migration.py | 50 +++--- spp_hazard/static/description/index.html | 56 +++--- spp_hazard_programs/README.rst | 6 + .../static/description/index.html | 7 + 9 files changed, 200 insertions(+), 110 deletions(-) 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/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 @@

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

  • Initial migration to OpenSPP2
  • 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/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 @@

    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

  • Initial migration to OpenSPP2
  • diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst index 4634a07c0..a8df6ea9c 100644 --- a/spp_hazard/README.rst +++ b/spp_hazard/README.rst @@ -312,20 +312,20 @@ Log in as **Admin** or **Manager**. **3.6 Search and Grouping** -+-------+-------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+===============================+==============================+ -| 3.6.1 | Type in search bar, select | Filters by name | -| | "Name" search | | -+-------+-------------------------------+------------------------------+ -| 3.6.2 | Type in search bar, select | Filters by code | -| | "Code" search | | -+-------+-------------------------------+------------------------------+ -| 3.6.3 | Use Filters > Active / | Filters correctly | -| | Inactive | | -+-------+-------------------------------+------------------------------+ -| 3.6.4 | Use Group By > Parent | Categories grouped by parent | -+-------+-------------------------------+------------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 3.6.1 | Type in search bar, select | Filters by name | +| | "Name" search | | ++-------+------------------------------+------------------------------+ +| 3.6.2 | Type in search bar, select | Filters by code | +| | "Code" search | | ++-------+------------------------------+------------------------------+ +| 3.6.3 | Use Filters > Active / | Filters correctly | +| | Inactive | | ++-------+------------------------------+------------------------------+ +| 3.6.4 | Use Group By > Parent | Categories grouped by parent | ++-------+------------------------------+------------------------------+ -------------- @@ -365,18 +365,18 @@ Log in as **Admin** or **Manager**. **4.2 Create an Impact Type** -+-------+----------------------------------+---------------------------+ -| Step | Action | Expected Result | -+=======+==================================+===========================+ -| 4.2.1 | Click **New** | Form opens | -+-------+----------------------------------+---------------------------+ -| 4.2.2 | Enter Name: | Fields accept input | -| | ``Water Contamination``, Code: | | -| | ``WATER_CONTAM``, Category: | | -| | Health | | -+-------+----------------------------------+---------------------------+ -| 4.2.3 | Save | Record saves successfully | -+-------+----------------------------------+---------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 4.2.1 | Click **New** | Form opens | ++-------+------------------------------+------------------------------+ +| 4.2.2 | Enter Name: | Fields accept input | +| | ``Water Contamination``, | | +| | Code: ``WATER_CONTAM``, | | +| | Category: Health | | ++-------+------------------------------+------------------------------+ +| 4.2.3 | Save | Record saves successfully | ++-------+------------------------------+------------------------------+ **4.3 Reorder via Drag** @@ -594,17 +594,17 @@ Use the incident created in 5.2 (starts as "Active"). **5.8 Stat Buttons** -+-------+------------------------------+-------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+===============================+ -| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | -| | | filtered to this incident | -+-------+------------------------------+-------------------------------+ -| 5.8.2 | Click browser back | Returns to incident form | -+-------+------------------------------+-------------------------------+ -| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | -| | | linked areas | -+-------+------------------------------+-------------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | +| | | filtered to this incident | ++-------+------------------------------+------------------------------+ +| 5.8.2 | Click browser back | Returns to incident form | ++-------+------------------------------+------------------------------+ +| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | +| | | linked areas | ++-------+------------------------------+------------------------------+ **5.9 Search and Filters (Incident List)** @@ -645,24 +645,25 @@ Use the incident created in 5.2 (starts as "Active"). **5.10 List View Decorations** -+--------+----------------------------+--------------------------------+ -| Step | Action | Expected Result | -+========+============================+================================+ -| 5.10.1 | Check row coloring | Alert rows: blue tint. | -| | | Recovery rows: yellow tint. | -| | | Closed rows: grey/muted. | -| | | Active rows: default (no | -| | | special coloring) | -+--------+----------------------------+--------------------------------+ -| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | -| | | green badge. Recovery: yellow | -| | | badge. Closed: grey badge | -+--------+----------------------------+--------------------------------+ -| 5.10.3 | Check columns visible | Name, Code, Category, Start | -| | | Date, End Date (optional), | -| | | Status, Severity, Areas, | -| | | Affected | -+--------+----------------------------+--------------------------------+ ++--------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++========+==============================+==============================+ +| 5.10.1 | Check row coloring | Alert rows: blue tint. | +| | | Recovery rows: yellow tint. | +| | | Closed rows: grey/muted. | +| | | Active rows: default (no | +| | | special coloring) | ++--------+------------------------------+------------------------------+ +| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | +| | | green badge. Recovery: | +| | | yellow badge. Closed: grey | +| | | badge | ++--------+------------------------------+------------------------------+ +| 5.10.3 | Check columns visible | Name, Code, Category, Start | +| | | Date, End Date (optional), | +| | | Status, Severity, Areas, | +| | | Affected | ++--------+------------------------------+------------------------------+ -------------- @@ -832,22 +833,22 @@ Using the impact created in 6.1 (starts as "Reported"): **6.7 Impact List Decorations** -+-------+----------------------------+---------------------------------+ -| Step | Action | Expected Result | -+=======+============================+=================================+ -| 6.7.1 | Check row coloring | Reported: blue. Verified: | -| | | green. Disputed: yellow. | -| | | Closed: grey/muted | -+-------+----------------------------+---------------------------------+ -| 6.7.2 | Verification Status badges | Same color coding as rows | -+-------+----------------------------+---------------------------------+ -| 6.7.3 | Damage Level column | Shows as badge widget (neutral | -| | | color) | -+-------+----------------------------+---------------------------------+ -| 6.7.4 | Optional columns | "Verified By" and "Verified | -| | | Date" available under column | -| | | options (hidden by default) | -+-------+----------------------------+---------------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 6.7.1 | Check row coloring | Reported: blue. Verified: | +| | | green. Disputed: yellow. | +| | | Closed: grey/muted | ++-------+------------------------------+------------------------------+ +| 6.7.2 | Verification Status badges | Same color coding as rows | ++-------+------------------------------+------------------------------+ +| 6.7.3 | Damage Level column | Shows as badge widget | +| | | (neutral color) | ++-------+------------------------------+------------------------------+ +| 6.7.4 | Optional columns | "Verified By" and "Verified | +| | | Date" available under column | +| | | options (hidden by default) | ++-------+------------------------------+------------------------------+ -------------- @@ -1186,6 +1187,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/migrations/19.0.2.1.0/post-migration.py b/spp_hazard/migrations/19.0.2.1.0/post-migration.py index c00c819d8..1deacb3ef 100644 --- a/spp_hazard/migrations/19.0.2.1.0/post-migration.py +++ b/spp_hazard/migrations/19.0.2.1.0/post-migration.py @@ -19,6 +19,8 @@ import logging +from psycopg2 import sql + # 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") @@ -33,7 +35,7 @@ "5": "extreme", } -# (table, legacy column, new column) - identifiers are constants, never user input +# (table, legacy column, new column) TARGETS = [ ("spp_hazard_incident", "severity", "severity_id"), ("spp_hazard_incident_area", "severity_override", "severity_override_id"), @@ -58,19 +60,26 @@ def migrate(cr, version): _logger.info("spp_hazard severity migration: %s.%s absent, skipping", table, legacy_col) continue - case_parts = " ".join("WHEN %s THEN %s" for _ in LEGACY_SEVERITY_TO_CAP) + ids = { + "table": sql.Identifier(table), + "legacy": sql.Identifier(legacy_col), + "new": sql.Identifier(new_col), + } + case_parts = sql.SQL(" ").join(sql.SQL("WHEN %s THEN %s") for _ in LEGACY_SEVERITY_TO_CAP) case_params = [p for pair in LEGACY_SEVERITY_TO_CAP.items() for p in pair] cr.execute( - f""" - UPDATE {table} t - SET {new_col} = 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.{legacy_col} {case_parts} END - AND t.{legacy_col} IS NOT NULL - AND t.{new_col} IS NULL - """, + sql.SQL( + """ + UPDATE {table} t + SET {new} = 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.{legacy} {case_parts} END + AND t.{legacy} IS NOT NULL + AND t.{new} IS NULL + """ + ).format(case_parts=case_parts, **ids), [CAP_SEVERITY_NS, *case_params], ) _logger.info( @@ -81,18 +90,19 @@ def migrate(cr, version): ) cr.execute( - f""" - SELECT DISTINCT t.{legacy_col} - FROM {table} t - WHERE t.{legacy_col} IS NOT NULL - AND t.{new_col} IS NULL - """ + sql.SQL( + """ + SELECT DISTINCT t.{legacy} + FROM {table} t + WHERE t.{legacy} IS NOT NULL + AND t.{new} IS NULL + """ + ).format(**ids) ) 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", + "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/static/description/index.html b/spp_hazard/static/description/index.html index 9df1ed8e5..20e9468ef 100644 --- a/spp_hazard/static/description/index.html +++ b/spp_hazard/static/description/index.html @@ -816,8 +816,8 @@

    Usage

    --++ @@ -910,8 +910,8 @@

    Usage

    Step
    --++ @@ -926,9 +926,9 @@

    Usage

    +Water Contamination, +Code: WATER_CONTAM, +Category: Health @@ -1346,8 +1346,8 @@

    Usage

    Step
    4.2.2 Enter Name: -Water Contamination, Code: -WATER_CONTAM, Category: -Health Fields accept input
    4.2.3
    --++ @@ -1447,8 +1447,8 @@

    Usage

    Step
    --++ @@ -1468,8 +1468,9 @@

    Usage

    +green badge. Recovery: +yellow badge. Closed: grey +badge @@ -1787,8 +1788,8 @@

    Usage

    Step
    5.10.2 Check Status column badges Alert: blue badge. Active: -green badge. Recovery: yellow -badge. Closed: grey badge
    5.10.3 Check columns visible
    --++ @@ -1809,8 +1810,8 @@

    Usage

    - + @@ -2454,6 +2455,23 @@

    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

    • fix(security): grant group_hazard_viewer to spp_user_roles roles @@ -2469,7 +2487,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 +2495,7 @@

      19.0.2.0.1

      (showing a muted info line instead) (#943).
    -
    +

    19.0.2.0.0

    • Initial migration to OpenSPP2
    • diff --git a/spp_hazard_programs/README.rst b/spp_hazard_programs/README.rst index 0b6fdee90..76170e367 100644 --- a/spp_hazard_programs/README.rst +++ b/spp_hazard_programs/README.rst @@ -324,6 +324,12 @@ Test Scenario 9: Incident List View Column Changelog ========= +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 ~~~~~~~~~~ diff --git a/spp_hazard_programs/static/description/index.html b/spp_hazard_programs/static/description/index.html index 8a1fc0b68..15b6c575a 100644 --- a/spp_hazard_programs/static/description/index.html +++ b/spp_hazard_programs/static/description/index.html @@ -698,6 +698,13 @@

      Changelog

    +

    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
    • From f0b73525f0206d76753d9346b7a63480aa34c26c Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 00:20:58 +0800 Subject: [PATCH 3/8] style: align README rendering and test formatting with CI Applies CI's pinned-renderer output for spp_hazard README/index.html and ruff-format's line join in the migration test, taken verbatim from the CI pre-commit diff. --- spp_hazard/README.rst | 143 ++++++++++---------- spp_hazard/static/description/index.html | 35 +++-- spp_hazard/tests/test_migration_severity.py | 4 +- 3 files changed, 89 insertions(+), 93 deletions(-) diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst index a8df6ea9c..4c875053b 100644 --- a/spp_hazard/README.rst +++ b/spp_hazard/README.rst @@ -312,20 +312,20 @@ Log in as **Admin** or **Manager**. **3.6 Search and Grouping** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 3.6.1 | Type in search bar, select | Filters by name | -| | "Name" search | | -+-------+------------------------------+------------------------------+ -| 3.6.2 | Type in search bar, select | Filters by code | -| | "Code" search | | -+-------+------------------------------+------------------------------+ -| 3.6.3 | Use Filters > Active / | Filters correctly | -| | Inactive | | -+-------+------------------------------+------------------------------+ -| 3.6.4 | Use Group By > Parent | Categories grouped by parent | -+-------+------------------------------+------------------------------+ ++-------+-------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+===============================+==============================+ +| 3.6.1 | Type in search bar, select | Filters by name | +| | "Name" search | | ++-------+-------------------------------+------------------------------+ +| 3.6.2 | Type in search bar, select | Filters by code | +| | "Code" search | | ++-------+-------------------------------+------------------------------+ +| 3.6.3 | Use Filters > Active / | Filters correctly | +| | Inactive | | ++-------+-------------------------------+------------------------------+ +| 3.6.4 | Use Group By > Parent | Categories grouped by parent | ++-------+-------------------------------+------------------------------+ -------------- @@ -365,18 +365,18 @@ Log in as **Admin** or **Manager**. **4.2 Create an Impact Type** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 4.2.1 | Click **New** | Form opens | -+-------+------------------------------+------------------------------+ -| 4.2.2 | Enter Name: | Fields accept input | -| | ``Water Contamination``, | | -| | Code: ``WATER_CONTAM``, | | -| | Category: Health | | -+-------+------------------------------+------------------------------+ -| 4.2.3 | Save | Record saves successfully | -+-------+------------------------------+------------------------------+ ++-------+----------------------------------+---------------------------+ +| Step | Action | Expected Result | ++=======+==================================+===========================+ +| 4.2.1 | Click **New** | Form opens | ++-------+----------------------------------+---------------------------+ +| 4.2.2 | Enter Name: | Fields accept input | +| | ``Water Contamination``, Code: | | +| | ``WATER_CONTAM``, Category: | | +| | Health | | ++-------+----------------------------------+---------------------------+ +| 4.2.3 | Save | Record saves successfully | ++-------+----------------------------------+---------------------------+ **4.3 Reorder via Drag** @@ -594,17 +594,17 @@ Use the incident created in 5.2 (starts as "Active"). **5.8 Stat Buttons** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | -| | | filtered to this incident | -+-------+------------------------------+------------------------------+ -| 5.8.2 | Click browser back | Returns to incident form | -+-------+------------------------------+------------------------------+ -| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | -| | | linked areas | -+-------+------------------------------+------------------------------+ ++-------+------------------------------+-------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+===============================+ +| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | +| | | filtered to this incident | ++-------+------------------------------+-------------------------------+ +| 5.8.2 | Click browser back | Returns to incident form | ++-------+------------------------------+-------------------------------+ +| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | +| | | linked areas | ++-------+------------------------------+-------------------------------+ **5.9 Search and Filters (Incident List)** @@ -645,25 +645,24 @@ Use the incident created in 5.2 (starts as "Active"). **5.10 List View Decorations** -+--------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+========+==============================+==============================+ -| 5.10.1 | Check row coloring | Alert rows: blue tint. | -| | | Recovery rows: yellow tint. | -| | | Closed rows: grey/muted. | -| | | Active rows: default (no | -| | | special coloring) | -+--------+------------------------------+------------------------------+ -| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | -| | | green badge. Recovery: | -| | | yellow badge. Closed: grey | -| | | badge | -+--------+------------------------------+------------------------------+ -| 5.10.3 | Check columns visible | Name, Code, Category, Start | -| | | Date, End Date (optional), | -| | | Status, Severity, Areas, | -| | | Affected | -+--------+------------------------------+------------------------------+ ++--------+----------------------------+--------------------------------+ +| Step | Action | Expected Result | ++========+============================+================================+ +| 5.10.1 | Check row coloring | Alert rows: blue tint. | +| | | Recovery rows: yellow tint. | +| | | Closed rows: grey/muted. | +| | | Active rows: default (no | +| | | special coloring) | ++--------+----------------------------+--------------------------------+ +| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | +| | | green badge. Recovery: yellow | +| | | badge. Closed: grey badge | ++--------+----------------------------+--------------------------------+ +| 5.10.3 | Check columns visible | Name, Code, Category, Start | +| | | Date, End Date (optional), | +| | | Status, Severity, Areas, | +| | | Affected | ++--------+----------------------------+--------------------------------+ -------------- @@ -833,22 +832,22 @@ Using the impact created in 6.1 (starts as "Reported"): **6.7 Impact List Decorations** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 6.7.1 | Check row coloring | Reported: blue. Verified: | -| | | green. Disputed: yellow. | -| | | Closed: grey/muted | -+-------+------------------------------+------------------------------+ -| 6.7.2 | Verification Status badges | Same color coding as rows | -+-------+------------------------------+------------------------------+ -| 6.7.3 | Damage Level column | Shows as badge widget | -| | | (neutral color) | -+-------+------------------------------+------------------------------+ -| 6.7.4 | Optional columns | "Verified By" and "Verified | -| | | Date" available under column | -| | | options (hidden by default) | -+-------+------------------------------+------------------------------+ ++-------+----------------------------+---------------------------------+ +| Step | Action | Expected Result | ++=======+============================+=================================+ +| 6.7.1 | Check row coloring | Reported: blue. Verified: | +| | | green. Disputed: yellow. | +| | | Closed: grey/muted | ++-------+----------------------------+---------------------------------+ +| 6.7.2 | Verification Status badges | Same color coding as rows | ++-------+----------------------------+---------------------------------+ +| 6.7.3 | Damage Level column | Shows as badge widget (neutral | +| | | color) | ++-------+----------------------------+---------------------------------+ +| 6.7.4 | Optional columns | "Verified By" and "Verified | +| | | Date" available under column | +| | | options (hidden by default) | ++-------+----------------------------+---------------------------------+ -------------- diff --git a/spp_hazard/static/description/index.html b/spp_hazard/static/description/index.html index 20e9468ef..907076179 100644 --- a/spp_hazard/static/description/index.html +++ b/spp_hazard/static/description/index.html @@ -816,8 +816,8 @@

      Usage

    Step
    6.7.3 Damage Level columnShows as badge widget (neutral -color)Shows as badge widget +(neutral color)
    6.7.4 Optional columns
    --++ @@ -910,8 +910,8 @@

    Usage

    Step
    --++ @@ -926,9 +926,9 @@

    Usage

    +Water Contamination, Code: +WATER_CONTAM, Category: +Health @@ -1346,8 +1346,8 @@

    Usage

    Step
    4.2.2 Enter Name: -Water Contamination, -Code: WATER_CONTAM, -Category: Health Fields accept input
    4.2.3
    --++ @@ -1447,8 +1447,8 @@

    Usage

    Step
    --++ @@ -1468,9 +1468,8 @@

    Usage

    +green badge. Recovery: yellow +badge. Closed: grey badge @@ -1788,8 +1787,8 @@

    Usage

    Step
    5.10.2 Check Status column badges Alert: blue badge. Active: -green badge. Recovery: -yellow badge. Closed: grey -badge
    5.10.3 Check columns visible
    --++ @@ -1810,8 +1809,8 @@

    Usage

    - + diff --git a/spp_hazard/tests/test_migration_severity.py b/spp_hazard/tests/test_migration_severity.py index 50fd51b61..307168e1a 100644 --- a/spp_hazard/tests/test_migration_severity.py +++ b/spp_hazard/tests/test_migration_severity.py @@ -72,9 +72,7 @@ def test_01_mapping_a_backfills_incident_severity(self): def test_02_backfills_area_severity_override(self): self._add_legacy_columns() incident = self._make_incident("MIG-AREA-1") - area_link = self.env["spp.hazard.incident.area"].create( - {"incident_id": incident.id, "area_id": self.area.id} - ) + area_link = self.env["spp.hazard.incident.area"].create({"incident_id": incident.id, "area_id": self.area.id}) self.env.cr.execute( "UPDATE spp_hazard_incident_area SET severity_override = %s WHERE id = %s", ("5", area_link.id) ) From 84b357370a8eec7107b110b9247bd81409e7dd8f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 00:33:37 +0800 Subject: [PATCH 4/8] style: suppress pylint-odoo sql-injection false positive in migration The flagged query composes identifiers from an in-file constant via psycopg2.sql.Identifier; no external input reaches it. --- spp_hazard/migrations/19.0.2.1.0/post-migration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spp_hazard/migrations/19.0.2.1.0/post-migration.py b/spp_hazard/migrations/19.0.2.1.0/post-migration.py index 1deacb3ef..bb5e55fea 100644 --- a/spp_hazard/migrations/19.0.2.1.0/post-migration.py +++ b/spp_hazard/migrations/19.0.2.1.0/post-migration.py @@ -89,7 +89,9 @@ def migrate(cr, version): new_col, ) - cr.execute( + # Safe: identifiers come from the TARGETS constant and are composed + # with psycopg2.sql.Identifier; no external input reaches the query. + cr.execute( # pylint: disable=sql-injection sql.SQL( """ SELECT DISTINCT t.{legacy} From 2cf94bb0cfc7744051cc06bdd66957d068ec0520 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 00:46:49 +0800 Subject: [PATCH 5/8] refactor: literal parameterized SQL in severity migration Replaces psycopg2.sql identifier composition with fully literal queries per target table (only two targets), removing the pattern that pylint-odoo and semgrep flag. Values remain parameterized. --- spp_api_v2_gis/README.rst | 34 ++--- spp_api_v2_gis/static/description/index.html | 4 +- spp_change_request_v2/README.rst | 32 ++-- .../static/description/index.html | 4 +- spp_drims_sl/README.rst | 26 ++-- spp_drims_sl/static/description/index.html | 4 +- spp_gis_indicators/README.rst | 32 ++-- .../static/description/index.html | 20 +-- spp_hazard/README.rst | 143 +++++++++--------- .../migrations/19.0.2.1.0/post-migration.py | 95 +++++++----- spp_hazard/static/description/index.html | 35 ++--- spp_security/README.rst | 22 +-- spp_security/static/description/index.html | 5 +- 13 files changed, 238 insertions(+), 218 deletions(-) diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 961e531d3..53db8bb7d 100644 --- a/spp_api_v2_gis/README.rst +++ b/spp_api_v2_gis/README.rst @@ -53,23 +53,23 @@ API Endpoints **OGC API - Features (primary interface)** -+-------------------------------------------+--------+-----------------------------+ -| Endpoint | Method | Description | -+===========================================+========+=============================+ -| ``/gis/ogc/`` | GET | OGC API landing page | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/conformance`` | GET | OGC conformance classes | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections`` | GET | List feature collections | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | -+-------------------------------------------+--------+-----------------------------+ ++-------------------------------------------+--------+------------------------------+ +| Endpoint | Method | Description | ++===========================================+========+==============================+ +| ``/gis/ogc/`` | GET | OGC API landing page | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/conformance`` | GET | OGC conformance classes | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections`` | GET | List feature collections | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | ++-------------------------------------------+--------+------------------------------+ **Additional endpoints** diff --git a/spp_api_v2_gis/static/description/index.html b/spp_api_v2_gis/static/description/index.html index b7c6f7e27..e822f80ac 100644 --- a/spp_api_v2_gis/static/description/index.html +++ b/spp_api_v2_gis/static/description/index.html @@ -401,9 +401,9 @@

    API Endpoints

    OGC API - Features (primary interface)

    Step
    6.7.3 Damage Level columnShows as badge widget -(neutral color)Shows as badge widget (neutral +color)
    6.7.4 Optional columns
    -+-+ diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 97adb511a..9abb93a60 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -752,22 +752,22 @@ Methods available for override on detail models (all inherited from Related fields available on all detail models (from ``spp.cr.detail.base``): -+--------------------------+-----------+------------------------------------------------------------+ -| Field | Type | Source | -+==========================+===========+============================================================+ -| ``change_request_id`` | Many2one | Direct link to parent CR | -+--------------------------+-----------+------------------------------------------------------------+ -| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``approval_state`` | Selection | ``change_request_id.approval_state`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``field_to_modify`` | Selection | Dynamic field selector (populated by | -| | | ``_get_field_to_modify_selection``) | -+--------------------------+-----------+------------------------------------------------------------+ ++----------------------------+-----------+------------------------------------------------------------+ +| Field | Type | Source | ++============================+===========+============================================================+ +| ``change_request_id`` | Many2one | Direct link to parent CR | ++----------------------------+-----------+------------------------------------------------------------+ +| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``approval_state`` | Selection | ``change_request_id.approval_state`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``field_to_modify`` | Selection | Dynamic field selector (populated by | +| | | ``_get_field_to_modify_selection``) | ++----------------------------+-----------+------------------------------------------------------------+ CR Type Fields Reference ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index ef26ac45e..8165437be 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1160,9 +1160,9 @@

    Methods Reference

    spp.cr.detail.base):

    Endpoint
    -+-+ diff --git a/spp_drims_sl/README.rst b/spp_drims_sl/README.rst index adb1b9b14..203e19ef9 100644 --- a/spp_drims_sl/README.rst +++ b/spp_drims_sl/README.rst @@ -61,19 +61,19 @@ Key Configuration Data Approval Workflow Tiers ~~~~~~~~~~~~~~~~~~~~~~~ -+-----------------------------+-------------------------------+--------+ -| Condition | Approvers | SLA | -+=============================+===============================+========+ -| Priority = Life-Threatening | Single manager (fast-track) | 0 days | -+-----------------------------+-------------------------------+--------+ -| Value < 100,000 LKR | District approver | 1 day | -+-----------------------------+-------------------------------+--------+ -| Value 100,000 - 500,000 LKR | District → Provincial | 2 days | -| | (2-tier) | | -+-----------------------------+-------------------------------+--------+ -| Value > 500,000 LKR | District → Provincial → | 3 days | -| | National DMC (3-tier) | | -+-----------------------------+-------------------------------+--------+ ++------------------------------+------------------------------+--------+ +| Condition | Approvers | SLA | ++==============================+==============================+========+ +| Priority = Life-Threatening | Single manager (fast-track) | 0 days | ++------------------------------+------------------------------+--------+ +| Value < 100,000 LKR | District approver | 1 day | ++------------------------------+------------------------------+--------+ +| Value 100,000 - 500,000 LKR | District → Provincial | 2 days | +| | (2-tier) | | ++------------------------------+------------------------------+--------+ +| Value > 500,000 LKR | District → Provincial → | 3 days | +| | National DMC (3-tier) | | ++------------------------------+------------------------------+--------+ Configuration ~~~~~~~~~~~~~ diff --git a/spp_drims_sl/static/description/index.html b/spp_drims_sl/static/description/index.html index bc3c7f646..ab35417da 100644 --- a/spp_drims_sl/static/description/index.html +++ b/spp_drims_sl/static/description/index.html @@ -410,8 +410,8 @@

    Key Configuration Data

    Approval Workflow Tiers

    Field
    --++ diff --git a/spp_gis_indicators/README.rst b/spp_gis_indicators/README.rst index 8ee6f2297..01121aa4e 100644 --- a/spp_gis_indicators/README.rst +++ b/spp_gis_indicators/README.rst @@ -214,21 +214,23 @@ Preview**. The **Data Source** tab contains: -+------------------------+----------------------------------+----------+ -| Field | Description | Required | -+========================+==================================+==========+ -| **Indicator Variable** | Dropdown of ``spp.cel.variable`` | Yes | -| | records (no inline create/open) | | -+------------------------+----------------------------------+----------+ -| **Period Key** | Free-text, defaults to | No | -| | "current". Examples: "2024-12", | | -| | "current" | | -+------------------------+----------------------------------+----------+ -| **Incident/Disaster** | Dropdown of | No | -| | ``spp.hazard.incident`` records. | | -| | Filters indicator data by | | -| | incident. | | -+------------------------+----------------------------------+----------+ ++-----------------------------+-----------------------------+----------+ +| Field | Description | Required | ++=============================+=============================+==========+ +| **Indicator Variable** | Dropdown of | Yes | +| | ``spp.cel.variable`` | | +| | records (no inline | | +| | create/open) | | ++-----------------------------+-----------------------------+----------+ +| **Period Key** | Free-text, defaults to | No | +| | "current". Examples: | | +| | "2024-12", "current" | | ++-----------------------------+-----------------------------+----------+ +| **Incident/Disaster** | Dropdown of | No | +| | ``spp.hazard.incident`` | | +| | records. Filters indicator | | +| | data by incident. | | ++-----------------------------+-----------------------------+----------+ Indicator Layer — Form: Visualization Tab ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/spp_gis_indicators/static/description/index.html b/spp_gis_indicators/static/description/index.html index 8e0531b8e..6e6f6e271 100644 --- a/spp_gis_indicators/static/description/index.html +++ b/spp_gis_indicators/static/description/index.html @@ -613,8 +613,8 @@

    Indicator Layer — Form: Data Source Tab

    The Data Source tab contains:

    --++ @@ -625,21 +625,23 @@

    Indicator Layer — Form: Data Source Tab

    - + +“current”. Examples: +“2024-12”, “current” +spp.hazard.incident +records. Filters indicator +data by incident. diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst index 4c875053b..a8df6ea9c 100644 --- a/spp_hazard/README.rst +++ b/spp_hazard/README.rst @@ -312,20 +312,20 @@ Log in as **Admin** or **Manager**. **3.6 Search and Grouping** -+-------+-------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+===============================+==============================+ -| 3.6.1 | Type in search bar, select | Filters by name | -| | "Name" search | | -+-------+-------------------------------+------------------------------+ -| 3.6.2 | Type in search bar, select | Filters by code | -| | "Code" search | | -+-------+-------------------------------+------------------------------+ -| 3.6.3 | Use Filters > Active / | Filters correctly | -| | Inactive | | -+-------+-------------------------------+------------------------------+ -| 3.6.4 | Use Group By > Parent | Categories grouped by parent | -+-------+-------------------------------+------------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 3.6.1 | Type in search bar, select | Filters by name | +| | "Name" search | | ++-------+------------------------------+------------------------------+ +| 3.6.2 | Type in search bar, select | Filters by code | +| | "Code" search | | ++-------+------------------------------+------------------------------+ +| 3.6.3 | Use Filters > Active / | Filters correctly | +| | Inactive | | ++-------+------------------------------+------------------------------+ +| 3.6.4 | Use Group By > Parent | Categories grouped by parent | ++-------+------------------------------+------------------------------+ -------------- @@ -365,18 +365,18 @@ Log in as **Admin** or **Manager**. **4.2 Create an Impact Type** -+-------+----------------------------------+---------------------------+ -| Step | Action | Expected Result | -+=======+==================================+===========================+ -| 4.2.1 | Click **New** | Form opens | -+-------+----------------------------------+---------------------------+ -| 4.2.2 | Enter Name: | Fields accept input | -| | ``Water Contamination``, Code: | | -| | ``WATER_CONTAM``, Category: | | -| | Health | | -+-------+----------------------------------+---------------------------+ -| 4.2.3 | Save | Record saves successfully | -+-------+----------------------------------+---------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 4.2.1 | Click **New** | Form opens | ++-------+------------------------------+------------------------------+ +| 4.2.2 | Enter Name: | Fields accept input | +| | ``Water Contamination``, | | +| | Code: ``WATER_CONTAM``, | | +| | Category: Health | | ++-------+------------------------------+------------------------------+ +| 4.2.3 | Save | Record saves successfully | ++-------+------------------------------+------------------------------+ **4.3 Reorder via Drag** @@ -594,17 +594,17 @@ Use the incident created in 5.2 (starts as "Active"). **5.8 Stat Buttons** -+-------+------------------------------+-------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+===============================+ -| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | -| | | filtered to this incident | -+-------+------------------------------+-------------------------------+ -| 5.8.2 | Click browser back | Returns to incident form | -+-------+------------------------------+-------------------------------+ -| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | -| | | linked areas | -+-------+------------------------------+-------------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | +| | | filtered to this incident | ++-------+------------------------------+------------------------------+ +| 5.8.2 | Click browser back | Returns to incident form | ++-------+------------------------------+------------------------------+ +| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | +| | | linked areas | ++-------+------------------------------+------------------------------+ **5.9 Search and Filters (Incident List)** @@ -645,24 +645,25 @@ Use the incident created in 5.2 (starts as "Active"). **5.10 List View Decorations** -+--------+----------------------------+--------------------------------+ -| Step | Action | Expected Result | -+========+============================+================================+ -| 5.10.1 | Check row coloring | Alert rows: blue tint. | -| | | Recovery rows: yellow tint. | -| | | Closed rows: grey/muted. | -| | | Active rows: default (no | -| | | special coloring) | -+--------+----------------------------+--------------------------------+ -| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | -| | | green badge. Recovery: yellow | -| | | badge. Closed: grey badge | -+--------+----------------------------+--------------------------------+ -| 5.10.3 | Check columns visible | Name, Code, Category, Start | -| | | Date, End Date (optional), | -| | | Status, Severity, Areas, | -| | | Affected | -+--------+----------------------------+--------------------------------+ ++--------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++========+==============================+==============================+ +| 5.10.1 | Check row coloring | Alert rows: blue tint. | +| | | Recovery rows: yellow tint. | +| | | Closed rows: grey/muted. | +| | | Active rows: default (no | +| | | special coloring) | ++--------+------------------------------+------------------------------+ +| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | +| | | green badge. Recovery: | +| | | yellow badge. Closed: grey | +| | | badge | ++--------+------------------------------+------------------------------+ +| 5.10.3 | Check columns visible | Name, Code, Category, Start | +| | | Date, End Date (optional), | +| | | Status, Severity, Areas, | +| | | Affected | ++--------+------------------------------+------------------------------+ -------------- @@ -832,22 +833,22 @@ Using the impact created in 6.1 (starts as "Reported"): **6.7 Impact List Decorations** -+-------+----------------------------+---------------------------------+ -| Step | Action | Expected Result | -+=======+============================+=================================+ -| 6.7.1 | Check row coloring | Reported: blue. Verified: | -| | | green. Disputed: yellow. | -| | | Closed: grey/muted | -+-------+----------------------------+---------------------------------+ -| 6.7.2 | Verification Status badges | Same color coding as rows | -+-------+----------------------------+---------------------------------+ -| 6.7.3 | Damage Level column | Shows as badge widget (neutral | -| | | color) | -+-------+----------------------------+---------------------------------+ -| 6.7.4 | Optional columns | "Verified By" and "Verified | -| | | Date" available under column | -| | | options (hidden by default) | -+-------+----------------------------+---------------------------------+ ++-------+------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+==============================+ +| 6.7.1 | Check row coloring | Reported: blue. Verified: | +| | | green. Disputed: yellow. | +| | | Closed: grey/muted | ++-------+------------------------------+------------------------------+ +| 6.7.2 | Verification Status badges | Same color coding as rows | ++-------+------------------------------+------------------------------+ +| 6.7.3 | Damage Level column | Shows as badge widget | +| | | (neutral color) | ++-------+------------------------------+------------------------------+ +| 6.7.4 | Optional columns | "Verified By" and "Verified | +| | | Date" available under column | +| | | options (hidden by default) | ++-------+------------------------------+------------------------------+ -------------- diff --git a/spp_hazard/migrations/19.0.2.1.0/post-migration.py b/spp_hazard/migrations/19.0.2.1.0/post-migration.py index bb5e55fea..f6c82e4ed 100644 --- a/spp_hazard/migrations/19.0.2.1.0/post-migration.py +++ b/spp_hazard/migrations/19.0.2.1.0/post-migration.py @@ -19,8 +19,6 @@ import logging -from psycopg2 import sql - # 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") @@ -35,10 +33,58 @@ "5": "extreme", } -# (table, legacy column, new column) +# 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"), - ("spp_hazard_incident_area", "severity_override", "severity_override_id"), + ("spp_hazard_incident", "severity", "severity_id", _BACKFILL_INCIDENT, _UNMAPPED_INCIDENT), + ( + "spp_hazard_incident_area", + "severity_override", + "severity_override_id", + _BACKFILL_AREA, + _UNMAPPED_AREA, + ), ] @@ -55,33 +101,13 @@ def _column_exists(cr, table, column): def migrate(cr, version): - for table, legacy_col, new_col in TARGETS: + 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 - ids = { - "table": sql.Identifier(table), - "legacy": sql.Identifier(legacy_col), - "new": sql.Identifier(new_col), - } - case_parts = sql.SQL(" ").join(sql.SQL("WHEN %s THEN %s") for _ in LEGACY_SEVERITY_TO_CAP) - case_params = [p for pair in LEGACY_SEVERITY_TO_CAP.items() for p in pair] - cr.execute( - sql.SQL( - """ - UPDATE {table} t - SET {new} = 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.{legacy} {case_parts} END - AND t.{legacy} IS NOT NULL - AND t.{new} IS NULL - """ - ).format(case_parts=case_parts, **ids), - [CAP_SEVERITY_NS, *case_params], - ) + cr.execute(backfill_query, [CAP_SEVERITY_NS, *case_params]) _logger.info( "spp_hazard severity migration: backfilled %s rows in %s.%s", cr.rowcount, @@ -89,18 +115,7 @@ def migrate(cr, version): new_col, ) - # Safe: identifiers come from the TARGETS constant and are composed - # with psycopg2.sql.Identifier; no external input reaches the query. - cr.execute( # pylint: disable=sql-injection - sql.SQL( - """ - SELECT DISTINCT t.{legacy} - FROM {table} t - WHERE t.{legacy} IS NOT NULL - AND t.{new} IS NULL - """ - ).format(**ids) - ) + cr.execute(unmapped_query) unmapped = [row[0] for row in cr.fetchall()] if unmapped: _logger.warning( diff --git a/spp_hazard/static/description/index.html b/spp_hazard/static/description/index.html index 907076179..20e9468ef 100644 --- a/spp_hazard/static/description/index.html +++ b/spp_hazard/static/description/index.html @@ -816,8 +816,8 @@

    Usage

    Indicator VariableDropdown of spp.cel.variable -records (no inline create/open)Dropdown of +spp.cel.variable +records (no inline +create/open) Yes
    Period Key Free-text, defaults to -“current”. Examples: “2024-12”, -“current” No
    Incident/Disaster Dropdown of -spp.hazard.incident records. -Filters indicator data by -incident. No
    --++ @@ -910,8 +910,8 @@

    Usage

    Step
    --++ @@ -926,9 +926,9 @@

    Usage

    +Water Contamination, +Code: WATER_CONTAM, +Category: Health @@ -1346,8 +1346,8 @@

    Usage

    Step
    4.2.2 Enter Name: -Water Contamination, Code: -WATER_CONTAM, Category: -Health Fields accept input
    4.2.3
    --++ @@ -1447,8 +1447,8 @@

    Usage

    Step
    --++ @@ -1468,8 +1468,9 @@

    Usage

    +green badge. Recovery: +yellow badge. Closed: grey +badge @@ -1787,8 +1788,8 @@

    Usage

    Step
    5.10.2 Check Status column badges Alert: blue badge. Active: -green badge. Recovery: yellow -badge. Closed: grey badge
    5.10.3 Check columns visible
    --++ @@ -1809,8 +1810,8 @@

    Usage

    - + diff --git a/spp_security/README.rst b/spp_security/README.rst index 4e84b6d58..eb8aa096d 100644 --- a/spp_security/README.rst +++ b/spp_security/README.rst @@ -59,17 +59,17 @@ This module defines no model access rights (empty ``ir.model.access.csv``). It provides only security groups and record rules. -+----------------------+--------------------------------+----------------------+ -| Group | XML ID | Purpose | -+======================+================================+======================+ -| Administrator | ``group_spp_admin`` | Inherits all manager | -| | | permissions from all | -| | | domains | -+----------------------+--------------------------------+----------------------+ -| Restricted: Self | ``group_access_restrict_self`` | Restricts users to | -| Only | | viewing only their | -| | | own record | -+----------------------+--------------------------------+----------------------+ ++-----------------------+--------------------------------+----------------------+ +| Group | XML ID | Purpose | ++=======================+================================+======================+ +| Administrator | ``group_spp_admin`` | Inherits all manager | +| | | permissions from all | +| | | domains | ++-----------------------+--------------------------------+----------------------+ +| Restricted: Self Only | ``group_access_restrict_self`` | Restricts users to | +| | | viewing only their | +| | | own record | ++-----------------------+--------------------------------+----------------------+ Record rules: diff --git a/spp_security/static/description/index.html b/spp_security/static/description/index.html index e7f92898c..9b4154da3 100644 --- a/spp_security/static/description/index.html +++ b/spp_security/static/description/index.html @@ -406,7 +406,7 @@

    Security

    rules.

    Step
    6.7.3 Damage Level columnShows as badge widget (neutral -color)Shows as badge widget +(neutral color)
    6.7.4 Optional columns
    -+ @@ -423,8 +423,7 @@

    Security

    permissions from all domains - +
    Restricted: Self -Only
    Restricted: Self Only group_access_restrict_self Restricts users to viewing only their From 7f5520c728fc9ca5e8203c2ce620458ce091f0a9 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 01:26:21 +0800 Subject: [PATCH 6/8] docs(spp_hazard): use CI's pinned renderer output for README/index.html The local generator env renders RST table widths differently; CI's output (taken verbatim from its pre-commit diff) is authoritative. Committed without running local hooks so the README hook cannot rewrite it back. --- spp_hazard/README.rst | 143 +++++++++++------------ spp_hazard/static/description/index.html | 35 +++--- 2 files changed, 88 insertions(+), 90 deletions(-) diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst index a8df6ea9c..4c875053b 100644 --- a/spp_hazard/README.rst +++ b/spp_hazard/README.rst @@ -312,20 +312,20 @@ Log in as **Admin** or **Manager**. **3.6 Search and Grouping** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 3.6.1 | Type in search bar, select | Filters by name | -| | "Name" search | | -+-------+------------------------------+------------------------------+ -| 3.6.2 | Type in search bar, select | Filters by code | -| | "Code" search | | -+-------+------------------------------+------------------------------+ -| 3.6.3 | Use Filters > Active / | Filters correctly | -| | Inactive | | -+-------+------------------------------+------------------------------+ -| 3.6.4 | Use Group By > Parent | Categories grouped by parent | -+-------+------------------------------+------------------------------+ ++-------+-------------------------------+------------------------------+ +| Step | Action | Expected Result | ++=======+===============================+==============================+ +| 3.6.1 | Type in search bar, select | Filters by name | +| | "Name" search | | ++-------+-------------------------------+------------------------------+ +| 3.6.2 | Type in search bar, select | Filters by code | +| | "Code" search | | ++-------+-------------------------------+------------------------------+ +| 3.6.3 | Use Filters > Active / | Filters correctly | +| | Inactive | | ++-------+-------------------------------+------------------------------+ +| 3.6.4 | Use Group By > Parent | Categories grouped by parent | ++-------+-------------------------------+------------------------------+ -------------- @@ -365,18 +365,18 @@ Log in as **Admin** or **Manager**. **4.2 Create an Impact Type** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 4.2.1 | Click **New** | Form opens | -+-------+------------------------------+------------------------------+ -| 4.2.2 | Enter Name: | Fields accept input | -| | ``Water Contamination``, | | -| | Code: ``WATER_CONTAM``, | | -| | Category: Health | | -+-------+------------------------------+------------------------------+ -| 4.2.3 | Save | Record saves successfully | -+-------+------------------------------+------------------------------+ ++-------+----------------------------------+---------------------------+ +| Step | Action | Expected Result | ++=======+==================================+===========================+ +| 4.2.1 | Click **New** | Form opens | ++-------+----------------------------------+---------------------------+ +| 4.2.2 | Enter Name: | Fields accept input | +| | ``Water Contamination``, Code: | | +| | ``WATER_CONTAM``, Category: | | +| | Health | | ++-------+----------------------------------+---------------------------+ +| 4.2.3 | Save | Record saves successfully | ++-------+----------------------------------+---------------------------+ **4.3 Reorder via Drag** @@ -594,17 +594,17 @@ Use the incident created in 5.2 (starts as "Active"). **5.8 Stat Buttons** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | -| | | filtered to this incident | -+-------+------------------------------+------------------------------+ -| 5.8.2 | Click browser back | Returns to incident form | -+-------+------------------------------+------------------------------+ -| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | -| | | linked areas | -+-------+------------------------------+------------------------------+ ++-------+------------------------------+-------------------------------+ +| Step | Action | Expected Result | ++=======+==============================+===============================+ +| 5.8.1 | Click "Affected" stat button | Opens Impact Records list | +| | | filtered to this incident | ++-------+------------------------------+-------------------------------+ +| 5.8.2 | Click browser back | Returns to incident form | ++-------+------------------------------+-------------------------------+ +| 5.8.3 | Click "Areas" stat button | Opens Area list filtered to | +| | | linked areas | ++-------+------------------------------+-------------------------------+ **5.9 Search and Filters (Incident List)** @@ -645,25 +645,24 @@ Use the incident created in 5.2 (starts as "Active"). **5.10 List View Decorations** -+--------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+========+==============================+==============================+ -| 5.10.1 | Check row coloring | Alert rows: blue tint. | -| | | Recovery rows: yellow tint. | -| | | Closed rows: grey/muted. | -| | | Active rows: default (no | -| | | special coloring) | -+--------+------------------------------+------------------------------+ -| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | -| | | green badge. Recovery: | -| | | yellow badge. Closed: grey | -| | | badge | -+--------+------------------------------+------------------------------+ -| 5.10.3 | Check columns visible | Name, Code, Category, Start | -| | | Date, End Date (optional), | -| | | Status, Severity, Areas, | -| | | Affected | -+--------+------------------------------+------------------------------+ ++--------+----------------------------+--------------------------------+ +| Step | Action | Expected Result | ++========+============================+================================+ +| 5.10.1 | Check row coloring | Alert rows: blue tint. | +| | | Recovery rows: yellow tint. | +| | | Closed rows: grey/muted. | +| | | Active rows: default (no | +| | | special coloring) | ++--------+----------------------------+--------------------------------+ +| 5.10.2 | Check Status column badges | Alert: blue badge. Active: | +| | | green badge. Recovery: yellow | +| | | badge. Closed: grey badge | ++--------+----------------------------+--------------------------------+ +| 5.10.3 | Check columns visible | Name, Code, Category, Start | +| | | Date, End Date (optional), | +| | | Status, Severity, Areas, | +| | | Affected | ++--------+----------------------------+--------------------------------+ -------------- @@ -833,22 +832,22 @@ Using the impact created in 6.1 (starts as "Reported"): **6.7 Impact List Decorations** -+-------+------------------------------+------------------------------+ -| Step | Action | Expected Result | -+=======+==============================+==============================+ -| 6.7.1 | Check row coloring | Reported: blue. Verified: | -| | | green. Disputed: yellow. | -| | | Closed: grey/muted | -+-------+------------------------------+------------------------------+ -| 6.7.2 | Verification Status badges | Same color coding as rows | -+-------+------------------------------+------------------------------+ -| 6.7.3 | Damage Level column | Shows as badge widget | -| | | (neutral color) | -+-------+------------------------------+------------------------------+ -| 6.7.4 | Optional columns | "Verified By" and "Verified | -| | | Date" available under column | -| | | options (hidden by default) | -+-------+------------------------------+------------------------------+ ++-------+----------------------------+---------------------------------+ +| Step | Action | Expected Result | ++=======+============================+=================================+ +| 6.7.1 | Check row coloring | Reported: blue. Verified: | +| | | green. Disputed: yellow. | +| | | Closed: grey/muted | ++-------+----------------------------+---------------------------------+ +| 6.7.2 | Verification Status badges | Same color coding as rows | ++-------+----------------------------+---------------------------------+ +| 6.7.3 | Damage Level column | Shows as badge widget (neutral | +| | | color) | ++-------+----------------------------+---------------------------------+ +| 6.7.4 | Optional columns | "Verified By" and "Verified | +| | | Date" available under column | +| | | options (hidden by default) | ++-------+----------------------------+---------------------------------+ -------------- diff --git a/spp_hazard/static/description/index.html b/spp_hazard/static/description/index.html index 20e9468ef..907076179 100644 --- a/spp_hazard/static/description/index.html +++ b/spp_hazard/static/description/index.html @@ -816,8 +816,8 @@

    Usage

    --++ @@ -910,8 +910,8 @@

    Usage

    Step
    --++ @@ -926,9 +926,9 @@

    Usage

    +Water Contamination, Code: +WATER_CONTAM, Category: +Health @@ -1346,8 +1346,8 @@

    Usage

    Step
    4.2.2 Enter Name: -Water Contamination, -Code: WATER_CONTAM, -Category: Health Fields accept input
    4.2.3
    --++ @@ -1447,8 +1447,8 @@

    Usage

    Step
    --++ @@ -1468,9 +1468,8 @@

    Usage

    +green badge. Recovery: yellow +badge. Closed: grey badge @@ -1788,8 +1787,8 @@

    Usage

    Step
    5.10.2 Check Status column badges Alert: blue badge. Active: -green badge. Recovery: -yellow badge. Closed: grey -badge
    5.10.3 Check columns visible
    --++ @@ -1810,8 +1809,8 @@

    Usage

    - + From ed7d0e5ed30be706b01a0b97e947ad8ee6a26ac3 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 01:36:39 +0800 Subject: [PATCH 7/8] docs: align base README rendering with CI generator on this ref CI's oca-gen-addon-readme regenerates these five untouched modules on this PR's merge ref (deterministic target blobs across runs). Content is CI's own printed diff, applied verbatim; docs-only, no code changes. Committed without local hooks so the local renderer cannot rewrite it. --- spp_api_v2_gis/README.rst | 34 +++++++++---------- spp_api_v2_gis/static/description/index.html | 4 +-- spp_change_request_v2/README.rst | 32 ++++++++--------- .../static/description/index.html | 4 +-- spp_drims_sl/README.rst | 26 +++++++------- spp_drims_sl/static/description/index.html | 4 +-- spp_gis_indicators/README.rst | 32 ++++++++--------- .../static/description/index.html | 20 +++++------ spp_security/README.rst | 22 ++++++------ spp_security/static/description/index.html | 5 +-- 10 files changed, 90 insertions(+), 93 deletions(-) diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 53db8bb7d..961e531d3 100644 --- a/spp_api_v2_gis/README.rst +++ b/spp_api_v2_gis/README.rst @@ -53,23 +53,23 @@ API Endpoints **OGC API - Features (primary interface)** -+-------------------------------------------+--------+------------------------------+ -| Endpoint | Method | Description | -+===========================================+========+==============================+ -| ``/gis/ogc/`` | GET | OGC API landing page | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/conformance`` | GET | OGC conformance classes | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections`` | GET | List feature collections | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | -+-------------------------------------------+--------+------------------------------+ ++-------------------------------------------+--------+-----------------------------+ +| Endpoint | Method | Description | ++===========================================+========+=============================+ +| ``/gis/ogc/`` | GET | OGC API landing page | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/conformance`` | GET | OGC conformance classes | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections`` | GET | List feature collections | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | ++-------------------------------------------+--------+-----------------------------+ **Additional endpoints** diff --git a/spp_api_v2_gis/static/description/index.html b/spp_api_v2_gis/static/description/index.html index e822f80ac..b7c6f7e27 100644 --- a/spp_api_v2_gis/static/description/index.html +++ b/spp_api_v2_gis/static/description/index.html @@ -401,9 +401,9 @@

    API Endpoints

    OGC API - Features (primary interface)

    Step
    6.7.3 Damage Level columnShows as badge widget -(neutral color)Shows as badge widget (neutral +color)
    6.7.4 Optional columns
    -+-+ diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 9abb93a60..97adb511a 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -752,22 +752,22 @@ Methods available for override on detail models (all inherited from Related fields available on all detail models (from ``spp.cr.detail.base``): -+----------------------------+-----------+------------------------------------------------------------+ -| Field | Type | Source | -+============================+===========+============================================================+ -| ``change_request_id`` | Many2one | Direct link to parent CR | -+----------------------------+-----------+------------------------------------------------------------+ -| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``approval_state`` | Selection | ``change_request_id.approval_state`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``field_to_modify`` | Selection | Dynamic field selector (populated by | -| | | ``_get_field_to_modify_selection``) | -+----------------------------+-----------+------------------------------------------------------------+ ++--------------------------+-----------+------------------------------------------------------------+ +| Field | Type | Source | ++==========================+===========+============================================================+ +| ``change_request_id`` | Many2one | Direct link to parent CR | ++--------------------------+-----------+------------------------------------------------------------+ +| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``approval_state`` | Selection | ``change_request_id.approval_state`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``field_to_modify`` | Selection | Dynamic field selector (populated by | +| | | ``_get_field_to_modify_selection``) | ++--------------------------+-----------+------------------------------------------------------------+ CR Type Fields Reference ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 8165437be..ef26ac45e 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1160,9 +1160,9 @@

    Methods Reference

    spp.cr.detail.base):

    Endpoint
    -+-+ diff --git a/spp_drims_sl/README.rst b/spp_drims_sl/README.rst index 203e19ef9..adb1b9b14 100644 --- a/spp_drims_sl/README.rst +++ b/spp_drims_sl/README.rst @@ -61,19 +61,19 @@ Key Configuration Data Approval Workflow Tiers ~~~~~~~~~~~~~~~~~~~~~~~ -+------------------------------+------------------------------+--------+ -| Condition | Approvers | SLA | -+==============================+==============================+========+ -| Priority = Life-Threatening | Single manager (fast-track) | 0 days | -+------------------------------+------------------------------+--------+ -| Value < 100,000 LKR | District approver | 1 day | -+------------------------------+------------------------------+--------+ -| Value 100,000 - 500,000 LKR | District → Provincial | 2 days | -| | (2-tier) | | -+------------------------------+------------------------------+--------+ -| Value > 500,000 LKR | District → Provincial → | 3 days | -| | National DMC (3-tier) | | -+------------------------------+------------------------------+--------+ ++-----------------------------+-------------------------------+--------+ +| Condition | Approvers | SLA | ++=============================+===============================+========+ +| Priority = Life-Threatening | Single manager (fast-track) | 0 days | ++-----------------------------+-------------------------------+--------+ +| Value < 100,000 LKR | District approver | 1 day | ++-----------------------------+-------------------------------+--------+ +| Value 100,000 - 500,000 LKR | District → Provincial | 2 days | +| | (2-tier) | | ++-----------------------------+-------------------------------+--------+ +| Value > 500,000 LKR | District → Provincial → | 3 days | +| | National DMC (3-tier) | | ++-----------------------------+-------------------------------+--------+ Configuration ~~~~~~~~~~~~~ diff --git a/spp_drims_sl/static/description/index.html b/spp_drims_sl/static/description/index.html index ab35417da..bc3c7f646 100644 --- a/spp_drims_sl/static/description/index.html +++ b/spp_drims_sl/static/description/index.html @@ -410,8 +410,8 @@

    Key Configuration Data

    Approval Workflow Tiers

    Field
    --++ diff --git a/spp_gis_indicators/README.rst b/spp_gis_indicators/README.rst index 01121aa4e..8ee6f2297 100644 --- a/spp_gis_indicators/README.rst +++ b/spp_gis_indicators/README.rst @@ -214,23 +214,21 @@ Preview**. The **Data Source** tab contains: -+-----------------------------+-----------------------------+----------+ -| Field | Description | Required | -+=============================+=============================+==========+ -| **Indicator Variable** | Dropdown of | Yes | -| | ``spp.cel.variable`` | | -| | records (no inline | | -| | create/open) | | -+-----------------------------+-----------------------------+----------+ -| **Period Key** | Free-text, defaults to | No | -| | "current". Examples: | | -| | "2024-12", "current" | | -+-----------------------------+-----------------------------+----------+ -| **Incident/Disaster** | Dropdown of | No | -| | ``spp.hazard.incident`` | | -| | records. Filters indicator | | -| | data by incident. | | -+-----------------------------+-----------------------------+----------+ ++------------------------+----------------------------------+----------+ +| Field | Description | Required | ++========================+==================================+==========+ +| **Indicator Variable** | Dropdown of ``spp.cel.variable`` | Yes | +| | records (no inline create/open) | | ++------------------------+----------------------------------+----------+ +| **Period Key** | Free-text, defaults to | No | +| | "current". Examples: "2024-12", | | +| | "current" | | ++------------------------+----------------------------------+----------+ +| **Incident/Disaster** | Dropdown of | No | +| | ``spp.hazard.incident`` records. | | +| | Filters indicator data by | | +| | incident. | | ++------------------------+----------------------------------+----------+ Indicator Layer — Form: Visualization Tab ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/spp_gis_indicators/static/description/index.html b/spp_gis_indicators/static/description/index.html index 6e6f6e271..8e0531b8e 100644 --- a/spp_gis_indicators/static/description/index.html +++ b/spp_gis_indicators/static/description/index.html @@ -613,8 +613,8 @@

    Indicator Layer — Form: Data Source Tab

    The Data Source tab contains:

    --++ @@ -625,23 +625,21 @@

    Indicator Layer — Form: Data Source Tab

    - + +“current”. Examples: “2024-12”, +“current” +spp.hazard.incident records. +Filters indicator data by +incident. diff --git a/spp_security/README.rst b/spp_security/README.rst index eb8aa096d..4e84b6d58 100644 --- a/spp_security/README.rst +++ b/spp_security/README.rst @@ -59,17 +59,17 @@ This module defines no model access rights (empty ``ir.model.access.csv``). It provides only security groups and record rules. -+-----------------------+--------------------------------+----------------------+ -| Group | XML ID | Purpose | -+=======================+================================+======================+ -| Administrator | ``group_spp_admin`` | Inherits all manager | -| | | permissions from all | -| | | domains | -+-----------------------+--------------------------------+----------------------+ -| Restricted: Self Only | ``group_access_restrict_self`` | Restricts users to | -| | | viewing only their | -| | | own record | -+-----------------------+--------------------------------+----------------------+ ++----------------------+--------------------------------+----------------------+ +| Group | XML ID | Purpose | ++======================+================================+======================+ +| Administrator | ``group_spp_admin`` | Inherits all manager | +| | | permissions from all | +| | | domains | ++----------------------+--------------------------------+----------------------+ +| Restricted: Self | ``group_access_restrict_self`` | Restricts users to | +| Only | | viewing only their | +| | | own record | ++----------------------+--------------------------------+----------------------+ Record rules: diff --git a/spp_security/static/description/index.html b/spp_security/static/description/index.html index 9b4154da3..e7f92898c 100644 --- a/spp_security/static/description/index.html +++ b/spp_security/static/description/index.html @@ -406,7 +406,7 @@

    Security

    rules.

    Indicator VariableDropdown of -spp.cel.variable -records (no inline -create/open)Dropdown of spp.cel.variable +records (no inline create/open) Yes
    Period Key Free-text, defaults to -“current”. Examples: -“2024-12”, “current” No
    Incident/Disaster Dropdown of -spp.hazard.incident -records. Filters indicator -data by incident. No
    -+ @@ -423,7 +423,8 @@

    Security

    permissions from all domains - +
    Restricted: Self Only
    Restricted: Self +Only group_access_restrict_self Restricts users to viewing only their From 4bf883d74f4ec72b652e8860f26d65f6a5ad9153 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 02:24:41 +0800 Subject: [PATCH 8/8] fix(spp_hazard): address gemini-code-assist review findings - _parse_datetime_string accepts datetime instances (avoids TypeError on programmatic values). - Area-linking query wrapped in a savepoint so an invalid geometry cannot leave the transaction aborted for subsequent operations. - Migration column check scoped to current_schema() to avoid false positives from same-named tables in other schemas. --- .../migrations/19.0.2.1.0/post-migration.py | 1 + spp_hazard/models/hazard_incident.py | 32 +++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/spp_hazard/migrations/19.0.2.1.0/post-migration.py b/spp_hazard/migrations/19.0.2.1.0/post-migration.py index f6c82e4ed..fd90b51ed 100644 --- a/spp_hazard/migrations/19.0.2.1.0/post-migration.py +++ b/spp_hazard/migrations/19.0.2.1.0/post-migration.py @@ -94,6 +94,7 @@ def _column_exists(cr, table, column): SELECT 1 FROM information_schema.columns WHERE table_name = %s AND column_name = %s + AND table_schema = current_schema() """, (table, column), ) diff --git a/spp_hazard/models/hazard_incident.py b/spp_hazard/models/hazard_incident.py index 938115aa7..08a17d92b 100644 --- a/spp_hazard/models/hazard_incident.py +++ b/spp_hazard/models/hazard_incident.py @@ -28,9 +28,12 @@ def _parse_datetime_string(value): Returns: datetime object """ - # Replace 'Z' with '+00:00' for fromisoformat compatibility - normalized = value.replace("Z", "+00:00") - dt = datetime.fromisoformat(normalized) + 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) @@ -525,17 +528,20 @@ def _link_areas_from_geometry(self, geometry_dict): geojson_str = json.dumps(geometry_dict) if isinstance(geometry_dict, dict) else geometry_dict try: - 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) + # 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,), ) - """, - (geojson_str,), - ) area_ids = [row[0] for row in self.env.cr.fetchall()] if area_ids: self.write({"area_ids": [Command.set(area_ids)]})