Skip to content

feat(spp_hazard): CAP vocabulary severity and alert ingestion, with upgrade migration (re-land from #76)#274

Open
gonzalesedwin1123 wants to merge 2 commits into
19.0from
reland/hazard-cap
Open

feat(spp_hazard): CAP vocabulary severity and alert ingestion, with upgrade migration (re-land from #76)#274
gonzalesedwin1123 wants to merge 2 commits into
19.0from
reland/hazard-cap

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Re-lands the spp_hazard portion of reverted PR #76 (revert: #271), together with the dependent adaptations and the two fixes whose absence triggered the revert discussion.

Summary

  • spp.hazard.incident.severity (1-5 Selection) becomes severity_id, a spp.vocabulary.code Many2one on the CAP v1.2 severity vocabulary; severity_override on incident areas becomes severity_override_id likewise.
  • New CAP fields: urgency, certainty, message type, event, effective/expires; alert ingestion via create_incident_from_alert (creates hazard-zone geofences, auto-links intersecting areas); incident uuid.
  • CAP vocabulary data (severity, urgency, certainty, msg-type).
  • Dependent adaptations shipped together because they reference the new fields directly: spp_drims (effective_severity_id, numeric choropleth severity), spp_drims_sl_demo (scenario severity resolution), spp_hazard_programs (program view).

Added vs the original PR

  • Upgrade migration (spp_hazard 19.0.2.1.0): backfills severity_id/severity_override_id from the legacy Selection columns for databases upgrading from v19.0.2.0.x (release Biliran ships the old schema). Label-faithful mapping: 1→minor, 2→moderate, 3→severe (CAP defines severe as 'Significant threat'), 4→severe, 5→extreme. Idempotent; never overwrites values; unmapped values logged and left empty; no-op on fresh installs. Regression tests included.
  • Demo data fix: four incident-area records still wrote the removed severity_override field, breaking fresh installs with demo data; they now use severity_override_id vocabulary refs.

Verification

  • ./spp t spp_hazard: 87 passed, 0 failed (includes 5 new migration tests)
  • ./spp t spp_drims: 250/0, spp_drims_sl_demo: 8/0, spp_hazard_programs: 24/0

Comment thread spp_hazard/migrations/19.0.2.1.0/post-migration.py Fixed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates hazard incident severity tracking from a hardcoded 1-5 Selection to a CAP v1.2 vocabulary code, introducing new CAP-aligned fields (urgency, certainty, message type, and event) and robust alert ingestion methods that automatically create or update incidents and geofences from GeoJSON alerts. It also includes a post-migration script to backfill legacy severity values and updates related tests, views, and demo generators across dependent modules. The review feedback highlights three important improvements: making the _parse_datetime_string helper robust against already-parsed datetime objects, wrapping the PostGIS intersection query in a savepoint to prevent transaction aborts on database errors, and restricting the migration's column existence check to the active schema to avoid false positives.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +22 to +37
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The helper function _parse_datetime_string assumes that value is always a string. However, in Odoo, datetime fields or values passed programmatically (e.g., in properties or context) can sometimes already be datetime objects. Calling .replace("Z", "+00:00") on a datetime object will raise a TypeError.\n\nTo make this helper robust and prevent potential runtime crashes, check if value is already a datetime instance before attempting string manipulation.

Suggested change
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
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). Also handles datetime objects.
Returns:
datetime object
"""
if isinstance(value, datetime):
dt = value
else:
# Replace 'Z' with '+00:00' for fromisoformat compatibility
normalized = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(normalized)
# Odoo requires naive (UTC) datetimes
if dt.tzinfo is not None:
dt = dt.astimezone(UTC).replace(tzinfo=None)
return dt

Comment on lines +528 to +553
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,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In PostgreSQL/psycopg2, any error occurring during a query execution (such as an invalid GeoJSON format passed to ST_GeomFromGeoJSON) will abort the current transaction. Simply catching psycopg2.Error and logging a warning is insufficient because the transaction remains in an aborted state, causing all subsequent database operations in the same request to fail with InFailedSqlTransaction.\n\nTo safely handle potential database errors during geometry intersection and allow the transaction to continue, wrap the database execution inside a savepoint using with self.env.cr.savepoint():.

        try:
            with self.env.cr.savepoint():
                self.env.cr.execute(
                    """
                    SELECT id FROM spp_area
                    WHERE geo_polygon IS NOT NULL
                    AND ST_Intersects(
                        geo_polygon::geometry,
                        ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326)
                    )
                    """,
                    (geojson_str,),
                )
                area_ids = [row[0] for row in self.env.cr.fetchall()]
            if area_ids:
                self.write({"area_ids": [Command.set(area_ids)]})
            else:
                _logger.info(
                    "No intersecting areas found for incident %s",
                    self.code,
                )
        except psycopg2.Error as e:
            _logger.warning(
                "Failed to link areas from geometry for incident %s: %s",
                self.code,
                e,
            )

Comment on lines +43 to +52
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When querying information_schema.columns, filtering only by table_name and column_name can lead to false positives if the same table name exists in other schemas (such as pg_catalog or other custom schemas in the database).\n\nTo ensure the check is isolated to the active schema, add AND table_schema = current_schema() to the query.

Suggested change
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 _column_exists(cr, table, column):
cr.execute(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
AND table_schema = current_schema()
""",
(table, column),
)
return bool(cr.fetchone())

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 69.01%. Comparing base (bf61488) to head (8a74908).
⚠️ Report is 1 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_drims_sl_demo/wizard/drims_demo_generator.py 85.71% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #274      +/-   ##
==========================================
- Coverage   74.86%   69.01%   -5.85%     
==========================================
  Files        1093      130     -963     
  Lines       63718    11295   -52423     
==========================================
- Hits        47701     7795   -39906     
+ Misses      16017     3500   -12517     
Flag Coverage Δ
endpoint_route_handler ?
fastapi ?
spp_aggregation ?
spp_alerts ?
spp_analytics ?
spp_api_v2 ?
spp_api_v2_change_request ?
spp_api_v2_cycles ?
spp_api_v2_data ?
spp_api_v2_entitlements ?
spp_api_v2_gis ?
spp_api_v2_products ?
spp_api_v2_programs ?
spp_api_v2_service_points ?
spp_api_v2_simulation 71.12% <ø> (ø)
spp_api_v2_vocabulary ?
spp_approval ?
spp_area ?
spp_area_hdx ?
spp_attachment_av_scan ?
spp_audit ?
spp_audit_programs ?
spp_banking ?
spp_base_common ?
spp_base_setting ?
spp_case_base ?
spp_case_cel ?
spp_case_demo ?
spp_case_entitlements ?
spp_case_graduation ?
spp_case_programs ?
spp_case_registry ?
spp_case_session ?
spp_cel_domain ?
spp_cel_event ?
spp_cel_registry_search ?
spp_cel_vocabulary ?
spp_change_request_v2 ?
spp_claim_169 ?
spp_cr_type_assign_program ?
spp_cr_types_advanced 0.00% <ø> (ø)
spp_cr_types_base ?
spp_dci ?
spp_dci_client ?
spp_dci_client_dr ?
spp_dci_client_ibr ?
spp_dci_client_sr ?
spp_dci_compliance ?
spp_dci_demo ?
spp_dci_indicators ?
spp_dci_server ?
spp_dci_server_social ?
spp_demo ?
spp_demo_phl_luzon ?
spp_disability_registry ?
spp_drims 81.01% <100.00%> (+0.18%) ⬆️
spp_drims_sl ?
spp_drims_sl_demo 66.95% <85.71%> (-1.54%) ⬇️
spp_encryption ?
spp_farmer_registry ?
spp_farmer_registry_cr ?
spp_farmer_registry_demo ?
spp_farmer_registry_vocabularies ?
spp_gis ?
spp_gis_indicators ?
spp_gis_report ?
spp_graduation ?
spp_grm ?
spp_grm_case_link ?
spp_grm_demo ?
spp_hazard ?
spp_hazard_programs ?
spp_hxl_area ?
spp_import_match ?
spp_indicator ?
spp_irrigation ?
spp_land_record ?
spp_metric ?
spp_metric_service ?
spp_metrics_core ?
spp_metrics_services ?
spp_mis_demo_v2 ?
spp_oauth ?
spp_program_geofence ?
spp_programs 65.27% <ø> (ø)
spp_registrant_gis ?
spp_registry ?
spp_registry_group_hierarchy ?
spp_scoring ?
spp_scoring_programs ?
spp_security ?
spp_service_points ?
spp_simulation ?
spp_starter_disability_registry ?
spp_starter_farmer_registry ?
spp_starter_social_registry ?
spp_starter_sp_mis ?
spp_statistic ?
spp_storage_backend ?
spp_studio ?
spp_studio_change_requests ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_drims/__manifest__.py 0.00% <ø> (ø)
spp_drims/models/hazard_incident_area.py 100.00% <100.00%> (+21.05%) ⬆️
spp_drims_sl_demo/__manifest__.py 0.00% <ø> (ø)
spp_drims_sl_demo/wizard/drims_demo_generator.py 66.81% <85.71%> (-1.56%) ⬇️

... and 963 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment on lines +92 to +101
cr.execute(
sql.SQL(
"""
SELECT DISTINCT t.{legacy}
FROM {table} t
WHERE t.{legacy} IS NOT NULL
AND t.{new} IS NULL
"""
).format(**ids)
)
…ion (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).
…enerate READMEs

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants