From 085c119804ea0f1ffc8b322abe155c59f36510cc Mon Sep 17 00:00:00 2001
From: emjay0921
Date: Fri, 24 Jul 2026 16:26:26 +0800
Subject: [PATCH 06/13] fix(drims): refresh incident stock value KPI when
warehouse incident_ids changes (#1094)
---
spp_drims/models/stock_warehouse.py | 42 +++++++++++++++++++++++++++++
spp_drims/tests/test_incident.py | 32 ++++++++++++++++++++++
2 files changed, 74 insertions(+)
diff --git a/spp_drims/models/stock_warehouse.py b/spp_drims/models/stock_warehouse.py
index 0553f1651..36a5e02ff 100644
--- a/spp_drims/models/stock_warehouse.py
+++ b/spp_drims/models/stock_warehouse.py
@@ -78,6 +78,48 @@ class StockWarehouse(models.Model):
compute="_compute_drims_stock_health",
)
+ def write(self, vals):
+ """OP#1094: refresh incident stock KPIs when warehouse ↔ incident links
+ change.
+
+ The incident stock KPI (``drims_stock_value``) is a cached, stored
+ compute keyed off the warehouses linked to the incident, but its
+ dependencies don't include ``stock.warehouse.incident_ids`` — so
+ linking/unlinking a warehouse (Active Incidents) after stock already
+ exists left the KPI stale (e.g. showing 0). Invalidate the cache and
+ recompute for every incident affected before and after the change.
+ """
+ affected = set()
+ if "incident_ids" in vals:
+ for wh in self:
+ affected.update(wh.incident_ids.ids) # incidents linked before
+ result = super().write(vals)
+ if "incident_ids" in vals:
+ for wh in self:
+ affected.update(wh.incident_ids.ids) # incidents linked after
+ if affected:
+ self._drims_refresh_incident_stock_kpis(list(affected))
+ return result
+
+ def _drims_refresh_incident_stock_kpis(self, incident_ids):
+ """Drop the cached ``drims_stock_value`` for the given incidents and
+ recompute their stock KPIs (OP#1094).
+
+ Recompute is explicit because the KPI's ``@api.depends`` does not cover
+ ``stock.warehouse.incident_ids``, so a stored value would otherwise stay
+ stale even after the cache is cleared.
+ """
+ self.env["spp.data.value"].search(
+ [
+ ("variable_name", "=", "drims_stock_value"),
+ ("subject_model", "=", "spp.hazard.incident"),
+ ("subject_id", "in", incident_ids),
+ ]
+ ).unlink()
+ incidents = self.env["spp.hazard.incident"].browse(incident_ids).exists()
+ if incidents:
+ incidents._compute_drims_stock_kpis()
+
def _compute_drims_counts(self):
Donation = self.env["spp.drims.donation"]
Picking = self.env["stock.picking"]
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index 9c081d010..092dc8834 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -169,6 +169,38 @@ def test_1157_flag_as_alert(self):
self.incident.action_set_alert()
self.assertEqual(self.incident.status, "alert")
+ def test_1094_stock_kpi_refreshes_on_warehouse_link(self):
+ """OP#1094: linking a warehouse to an incident refreshes the stock KPI,
+ even when the stock existed before the link (cache was populated with 0)."""
+ wh = self.env["stock.warehouse"].create({"name": "KPI WH 1094", "code": "K1094", "is_drims_warehouse": True})
+ self.product.standard_price = 25.0
+ self.env["stock.quant"].create(
+ {"product_id": self.product.id, "location_id": wh.lot_stock_id.id, "quantity": 100}
+ )
+ incident = self.env["spp.hazard.incident"].create(
+ {
+ "name": "KPI Incident 1094",
+ "code": "KPI-1094",
+ "category_id": self.hazard_category.id,
+ "start_date": "2024-01-01",
+ "status": "active",
+ }
+ )
+ # Not linked yet -> stock KPI is 0 (and this stores 0).
+ self.assertEqual(incident.drims_stock_value, 0.0)
+
+ # Link the warehouse AFTER stock already exists.
+ wh.write({"incident_ids": [(4, incident.id)]})
+ self.env.flush_all()
+ incident.invalidate_recordset()
+ self.assertEqual(incident.drims_stock_value, 2500.0) # 100 * 25
+
+ # Unlinking refreshes back to 0.
+ wh.write({"incident_ids": [(3, incident.id)]})
+ self.env.flush_all()
+ incident.invalidate_recordset()
+ self.assertEqual(incident.drims_stock_value, 0.0)
+
def test_incident_stock_value_initially_zero(self):
"""Test stock value is zero when no warehouse linked."""
self.incident.invalidate_recordset()
From 3dd2dbedf8daaaa483961b743f5ca29e9998f4be Mon Sep 17 00:00:00 2001
From: emjay0921
Date: Wed, 29 Jul 2026 11:08:18 +0800
Subject: [PATCH 07/13] feat(drims): define incident warehouses bidirectionally
+ filter donation/request pickers (#1164)
---
spp_drims/data/config_defaults.xml | 11 +++++++
spp_drims/models/donation.py | 22 +++++++++++++
spp_drims/models/hazard_incident.py | 18 +++++++++++
spp_drims/models/request.py | 25 +++++++++++++--
spp_drims/models/res_config_settings.py | 18 +++++++++++
spp_drims/tests/test_incident.py | 42 +++++++++++++++++++++++++
spp_drims/views/dashboard_views.xml | 9 ++++++
spp_drims/views/donation_views.xml | 2 ++
spp_drims/views/request_views.xml | 2 ++
9 files changed, 147 insertions(+), 2 deletions(-)
diff --git a/spp_drims/data/config_defaults.xml b/spp_drims/data/config_defaults.xml
index f355d013e..e190706cd 100644
--- a/spp_drims/data/config_defaults.xml
+++ b/spp_drims/data/config_defaults.xml
@@ -90,4 +90,15 @@
drims.performance.kpi_cache_ttl_minutes30
+
+
+
+
+
+
+ drims.warehouse.filter_by_incident
+ True
+
diff --git a/spp_drims/models/donation.py b/spp_drims/models/donation.py
index f7ed9920e..049954b7b 100644
--- a/spp_drims/models/donation.py
+++ b/spp_drims/models/donation.py
@@ -96,6 +96,15 @@ class DrimsDonation(models.Model):
string="Receiving Warehouse",
required=True,
tracking=True,
+ domain="[('id', 'in', allowed_warehouse_ids)]",
+ )
+ # OP#1164: warehouses selectable for this donation — the incident's linked
+ # warehouses when incident-filtering is enabled, otherwise all DRIMS
+ # warehouses. Used to domain the Receiving Warehouse field.
+ allowed_warehouse_ids = fields.Many2many(
+ "stock.warehouse",
+ compute="_compute_allowed_warehouse_ids",
+ string="Allowed Warehouses",
)
# Dates
@@ -245,6 +254,19 @@ def _compute_has_acceptable_items(self):
for line in rec.line_ids
)
+ @api.depends("incident_id", "incident_id.drims_warehouse_ids")
+ def _compute_allowed_warehouse_ids(self):
+ """OP#1164: selectable warehouses = the incident's warehouses when
+ incident-filtering is on (and the incident has any), else all DRIMS
+ warehouses. The fallback avoids locking out donation creation when an
+ incident has no warehouses linked yet."""
+ Warehouse = self.env["stock.warehouse"]
+ filter_on = self.env["res.config.settings"].is_warehouse_filter_by_incident_enabled()
+ all_drims = Warehouse.search([("is_drims_warehouse", "=", True)])
+ for rec in self:
+ incident_whs = rec.incident_id.drims_warehouse_ids
+ rec.allowed_warehouse_ids = incident_whs if (filter_on and incident_whs) else all_drims
+
@api.depends("picking_ids")
def _compute_picking_count(self):
for rec in self:
diff --git a/spp_drims/models/hazard_incident.py b/spp_drims/models/hazard_incident.py
index 49ddee55b..386ab50ed 100644
--- a/spp_drims/models/hazard_incident.py
+++ b/spp_drims/models/hazard_incident.py
@@ -114,6 +114,9 @@ class HazardIncident(models.Model):
"stock.warehouse",
string="DRIMS Warehouses",
compute="_compute_drims_warehouses",
+ inverse="_inverse_drims_warehouses",
+ domain="[('is_drims_warehouse', '=', True)]",
+ help="Warehouses responding to this incident. Editable here or from a warehouse's Active Incidents.",
)
drims_picking_ids = fields.One2many(
"stock.picking",
@@ -172,6 +175,21 @@ def _compute_drims_warehouses(self):
]
)
+ def _inverse_drims_warehouses(self):
+ """OP#1164: allow defining an incident's warehouses from the incident.
+
+ Writes back to the ``stock.warehouse.incident_ids`` many2many (the same
+ link editable from the warehouse's "Active Incidents"), so both sides
+ stay in sync. Writing through ``stock.warehouse.write`` also lets the
+ OP#1094 stock-KPI cache invalidation fire for the affected incidents.
+ """
+ Warehouse = self.env["stock.warehouse"]
+ for rec in self:
+ current = Warehouse.search([("incident_ids", "in", rec.id), ("is_drims_warehouse", "=", True)])
+ target = rec.drims_warehouse_ids
+ (target - current).write({"incident_ids": [(4, rec.id)]})
+ (current - target).write({"incident_ids": [(3, rec.id)]})
+
@api.depends(
"drims_donation_ids",
"drims_donation_ids.total_value",
diff --git a/spp_drims/models/request.py b/spp_drims/models/request.py
index cec17c35b..9b6b56844 100644
--- a/spp_drims/models/request.py
+++ b/spp_drims/models/request.py
@@ -120,7 +120,7 @@ class DrimsRequest(models.Model):
source_warehouse_id = fields.Many2one(
"stock.warehouse",
string="Source Warehouse",
- domain="[('is_drims_warehouse', '=', True)]",
+ domain="[('id', 'in', allowed_warehouse_ids)]",
tracking=True,
help="Warehouse to fulfill request from",
)
@@ -128,10 +128,18 @@ class DrimsRequest(models.Model):
destination_warehouse_id = fields.Many2one(
"stock.warehouse",
string="Destination Warehouse",
- domain="[('is_drims_warehouse', '=', True), ('area_id', '=', destination_area_id)]",
+ domain="[('id', 'in', allowed_warehouse_ids), ('area_id', '=', destination_area_id)]",
tracking=True,
help="Warehouse in the destination area to receive the dispatch",
)
+ # OP#1164: warehouses selectable for this request — the incident's linked
+ # warehouses when incident-filtering is enabled, otherwise all DRIMS
+ # warehouses. Used to domain the source/destination warehouse fields.
+ allowed_warehouse_ids = fields.Many2many(
+ "stock.warehouse",
+ compute="_compute_allowed_warehouse_ids",
+ string="Allowed Warehouses",
+ )
# Contact
contact_name = fields.Char(string="Contact Name")
@@ -299,6 +307,19 @@ def _compute_fulfillment_progress(self):
rec.allocation_pct = 0
rec.fulfillment_pct = 0
+ @api.depends("incident_id", "incident_id.drims_warehouse_ids")
+ def _compute_allowed_warehouse_ids(self):
+ """OP#1164: selectable warehouses = the incident's warehouses when
+ incident-filtering is on (and the incident has any), else all DRIMS
+ warehouses. The fallback avoids locking out allocation/dispatch when an
+ incident has no warehouses linked yet."""
+ Warehouse = self.env["stock.warehouse"]
+ filter_on = self.env["res.config.settings"].is_warehouse_filter_by_incident_enabled()
+ all_drims = Warehouse.search([("is_drims_warehouse", "=", True)])
+ for rec in self:
+ incident_whs = rec.incident_id.drims_warehouse_ids
+ rec.allowed_warehouse_ids = incident_whs if (filter_on and incident_whs) else all_drims
+
@api.depends("picking_ids")
def _compute_picking_count(self):
for rec in self:
diff --git a/spp_drims/models/res_config_settings.py b/spp_drims/models/res_config_settings.py
index ba1233e47..5957b550d 100644
--- a/spp_drims/models/res_config_settings.py
+++ b/spp_drims/models/res_config_settings.py
@@ -181,3 +181,21 @@ def get_kpi_cache_ttl_minutes(self):
# nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access
ICP = self.env["ir.config_parameter"].sudo()
return int(ICP.get_param("drims.performance.kpi_cache_ttl_minutes", 30))
+
+ # =========================================================================
+ # WAREHOUSE HELPERS
+ # =========================================================================
+
+ @api.model
+ def is_warehouse_filter_by_incident_enabled(self):
+ """Whether donation/request warehouse pickers are restricted to the
+ incident's linked warehouses (OP#1164).
+
+ Returns:
+ bool: True if filtering is enabled (default True). Set the
+ ``drims.warehouse.filter_by_incident`` parameter to False to allow
+ selecting from any DRIMS warehouse.
+ """
+ # nosemgrep: odoo-sudo-without-context — standard Odoo pattern for system parameter access
+ ICP = self.env["ir.config_parameter"].sudo()
+ return ICP.get_param("drims.warehouse.filter_by_incident", "True") == "True"
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index 092dc8834..9cfc28287 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -163,6 +163,48 @@ def test_incident_drims_warehouses_computed(self):
self.incident.invalidate_recordset()
self.assertIn(self.warehouse, self.incident.drims_warehouse_ids)
+ def test_1164_link_warehouses_from_incident_side(self):
+ """OP#1164: editing drims_warehouse_ids on the incident links/unlinks the
+ warehouse both ways (via stock.warehouse.incident_ids)."""
+ self.warehouse.is_drims_warehouse = True
+ # Link from the incident side.
+ self.incident.drims_warehouse_ids = [(4, self.warehouse.id)]
+ self.assertIn(self.incident, self.warehouse.incident_ids)
+ self.incident.invalidate_recordset()
+ self.assertIn(self.warehouse, self.incident.drims_warehouse_ids)
+ # Unlink from the incident side.
+ self.incident.drims_warehouse_ids = [(3, self.warehouse.id)]
+ self.assertNotIn(self.incident, self.warehouse.incident_ids)
+
+ def test_1164_donation_allowed_warehouses_respects_filter(self):
+ """OP#1164: donation warehouse choices = the incident's warehouses when
+ filtering is on; all DRIMS warehouses when off."""
+ self.warehouse.is_drims_warehouse = True
+ other = self.env["stock.warehouse"].create(
+ {"name": "Other WH 1164", "code": "O1164", "is_drims_warehouse": True}
+ )
+ self.incident.drims_warehouse_ids = [(6, 0, [self.warehouse.id])]
+ donation = self.env["spp.drims.donation"].create(
+ {"incident_id": self.incident.id, "warehouse_id": self.warehouse.id, "donor_name": "D"}
+ )
+ # Filter ON (default): only the incident's warehouse.
+ self.assertIn(self.warehouse, donation.allowed_warehouse_ids)
+ self.assertNotIn(other, donation.allowed_warehouse_ids)
+ # Filter OFF: all DRIMS warehouses.
+ self.env["ir.config_parameter"].sudo().set_param("drims.warehouse.filter_by_incident", "False")
+ donation.invalidate_recordset(["allowed_warehouse_ids"])
+ self.assertIn(other, donation.allowed_warehouse_ids)
+
+ def test_1164_allowed_warehouses_fallback_when_incident_has_none(self):
+ """OP#1164: with filtering on but no warehouses linked, fall back to all
+ DRIMS warehouses (so donation/request creation isn't locked out)."""
+ self.warehouse.is_drims_warehouse = True
+ self.incident.drims_warehouse_ids = [(5, 0, 0)] # ensure none linked
+ donation = self.env["spp.drims.donation"].create(
+ {"incident_id": self.incident.id, "warehouse_id": self.warehouse.id, "donor_name": "D"}
+ )
+ self.assertIn(self.warehouse, donation.allowed_warehouse_ids)
+
def test_1157_flag_as_alert(self):
"""OP#1157: action_set_alert moves the incident into the Alert state."""
self.assertNotEqual(self.incident.status, "alert")
diff --git a/spp_drims/views/dashboard_views.xml b/spp_drims/views/dashboard_views.xml
index 1909b8b8e..1459c94e6 100644
--- a/spp_drims/views/dashboard_views.xml
+++ b/spp_drims/views/dashboard_views.xml
@@ -378,6 +378,15 @@
options="{'no_create': True, 'no_open': True}"
readonly="status == 'closed'"
/>
+
+
diff --git a/spp_drims/views/donation_views.xml b/spp_drims/views/donation_views.xml
index b419fe085..380c21b7c 100644
--- a/spp_drims/views/donation_views.xml
+++ b/spp_drims/views/donation_views.xml
@@ -132,6 +132,8 @@
/>
+
+
diff --git a/spp_drims/views/request_views.xml b/spp_drims/views/request_views.xml
index 32b999080..db7b95de6 100644
--- a/spp_drims/views/request_views.xml
+++ b/spp_drims/views/request_views.xml
@@ -304,6 +304,8 @@
invisible="approval_state != 'approved'"
>
+
+
Date: Mon, 10 Aug 2026 09:19:08 +0800
Subject: [PATCH 08/13] fix(hazard): raise incidents in Alert and lead with
Flag As Alert
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
QA round 1 on OP#1157 returned three findings and a question.
Flag As Alert was inserted before the statusbar, which rendered it last.
It is now anchored on the first header button so the order reads Flag As
Alert, Start Recovery, Close Incident — the order the workflow runs in.
Anchored by button name rather than position, so a reordering of the base
view fails loudly at upgrade instead of silently drifting back.
New incidents landed straight in Active, skipping the triage step the
Alert state exists for. The status default becomes "alert", making the
lifecycle Alert -> Active -> Recovery -> Closed. Changed on spp_hazard,
where the state machine lives, so every consumer behaves the same rather
than the same model behaving differently depending on which modules are
installed.
Close Incident was already correct in effect — with four states,
"not closed" and "alert, active or recovery" are the same set — but it is
now stated positively so it stays right if a state is ever added.
QA also asked when Recovery can be set: only from Active, since Start
Recovery is hidden otherwise. With Alert as the entry state a new incident
is confirmed Active before Recovery is offered. A test pins that so the
answer does not quietly change.
Four spp_hazard tests asserted the old default; they now confirm Active
explicitly rather than assuming it.
Not changed, and flagged for QA instead: DRIMS low-stock alerting and the
request-from-template picker both filter on status = "active", so an
incident sitting in Alert reaches neither until it is confirmed. Whether an
alert-state incident should drive stock alerting is a product decision.
Verified across every spp_hazard dependent — spp_hazard, spp_drims,
spp_hazard_programs, spp_api_v2_gis, spp_gis_indicators, spp_drims_sl_demo
— and by hand on a fresh database.
OP#1157
---
spp_drims/tests/test_incident.py | 65 ++++++++++++++++++++++
spp_drims/views/hazard_incident_views.xml | 11 +++-
spp_hazard/models/hazard_incident.py | 8 ++-
spp_hazard/tests/test_hazard_incident.py | 44 ++++++++++++++-
spp_hazard/tests/test_registrant.py | 2 +
spp_hazard/views/hazard_incident_views.xml | 5 +-
6 files changed, 128 insertions(+), 7 deletions(-)
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index 9cfc28287..2c8b7f8b3 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -1,8 +1,11 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta
+from lxml import etree
+
from odoo.exceptions import UserError
from odoo.tests import tagged
+from odoo.tools.safe_eval import safe_eval
from .common import DrimsTestCommon
@@ -443,6 +446,68 @@ def test_incident_picking_ids_relation(self):
)
self.assertIn(picking, self.incident.drims_picking_ids)
+ # ── OP#1157 QA round 1: header button order and Alert entry state ──
+
+ def _incident_form_buttons(self):
+ """Header action buttons of the incident form, in rendered order."""
+ view = self.env["spp.hazard.incident"].get_view(self.env.ref("spp_hazard.view_hazard_incident_form").id, "form")
+ tree = etree.fromstring(view["arch"])
+ return [b.get("name") for b in tree.xpath("//header/button[@name]")]
+
+ def test_flag_as_alert_is_the_leftmost_header_button(self):
+ """QA asked for Flag As Alert · Start Recovery · Close Incident.
+
+ It was inserted before the statusbar, which put it last. Anchoring it
+ on the first base button puts it where the workflow starts.
+ """
+ buttons = self._incident_form_buttons()
+
+ self.assertIn("action_set_alert", buttons, "Flag As Alert is missing from the header")
+ self.assertEqual(
+ buttons[0],
+ "action_set_alert",
+ f"Flag As Alert should lead the header, got {buttons}",
+ )
+ # The rest keep their base order behind it.
+ self.assertLess(buttons.index("action_set_recovery"), buttons.index("action_close"))
+
+ def test_close_incident_hidden_only_outside_the_open_states(self):
+ """QA asked to confirm Close shows only for alert / active / recovery."""
+ view = self.env["spp.hazard.incident"].get_view(self.env.ref("spp_hazard.view_hazard_incident_form").id, "form")
+ tree = etree.fromstring(view["arch"])
+ close = tree.xpath("//header/button[@name='action_close']")[0]
+ condition = close.get("invisible")
+
+ for status in ("alert", "active", "recovery"):
+ self.assertFalse(
+ safe_eval(condition, {"status": status}),
+ f"Close Incident should be offered while {status}",
+ )
+ self.assertTrue(safe_eval(condition, {"status": "closed"}))
+
+ def test_new_drims_incident_starts_in_alert(self):
+ """The entry state, seen from DRIMS rather than the base module."""
+ incident = self.env["spp.hazard.incident"].create(
+ {
+ "name": "DRIMS Fresh Incident",
+ "code": "DRIMS-ALERT-1157",
+ "category_id": self.hazard_category.id,
+ "start_date": "2026-08-01",
+ }
+ )
+ self.assertEqual(incident.status, "alert")
+
+ def test_flag_as_alert_returns_an_incident_to_alert(self):
+ """The button itself still does its job from active and recovery."""
+ self.incident.action_set_active()
+ self.incident.action_set_alert()
+ self.assertEqual(self.incident.status, "alert")
+
+ self.incident.action_set_active()
+ self.incident.action_set_recovery()
+ self.incident.action_set_alert()
+ self.assertEqual(self.incident.status, "alert")
+
@tagged("post_install", "-at_install")
class TestDrimsIncidentClosedGuards(DrimsTestCommon):
diff --git a/spp_drims/views/hazard_incident_views.xml b/spp_drims/views/hazard_incident_views.xml
index cb749cbde..db6512a0e 100644
--- a/spp_drims/views/hazard_incident_views.xml
+++ b/spp_drims/views/hazard_incident_views.xml
@@ -18,8 +18,15 @@
-
+ the incident is active/recovery (not already alert, not closed).
+
+ Inserted before the first header button rather than before the
+ statusbar, so it reads leftmost — Flag As Alert, Start Recovery,
+ Close Incident — which is the order the workflow runs in.
+ Anchored on the base view's first button; if that is ever
+ renamed this xpath fails loudly at upgrade rather than silently
+ reordering. -->
+
From 987e8412918e814b64fe31d52809862269053055 Mon Sep 17 00:00:00 2001
From: emjay0921
Date: Tue, 11 Aug 2026 14:38:38 +0800
Subject: [PATCH 10/13] test(drims): allocate through allocation rows in the
incident KPI tests
OP#1079 made the request line's quantity_allocated a stored compute over
per-warehouse allocation rows, so the helper's direct write no longer
registered and the Units KPI counted stock that had in fact been allocated.
---
spp_drims/tests/test_incident.py | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index 2c8b7f8b3..5b9c55125 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -347,7 +347,7 @@ def _stock_in_receipt(self, qty):
return picking
def _request_with_allocation(self, requested, allocated):
- return self.env["spp.drims.request"].create(
+ request = self.env["spp.drims.request"].create(
{
"incident_id": self.incident.id,
"destination_area_id": self.area.id,
@@ -359,13 +359,23 @@ def _request_with_allocation(self, requested, allocated):
{
"product_id": self.product.id,
"quantity_requested": requested,
- "quantity_allocated": allocated,
"uom_id": self.product.uom_id.id,
},
)
],
}
)
+ # OP#1079 made the line's quantity_allocated a stored compute over
+ # per-warehouse allocation rows, so allocating means recording a row —
+ # writing the total directly no longer registers at all.
+ self.env["spp.drims.request.allocation"].create(
+ {
+ "request_line_id": request.line_ids[0].id,
+ "warehouse_id": self.warehouse.id,
+ "quantity_allocated": allocated,
+ }
+ )
+ return request
def test_incident_units_net_of_allocation(self):
"""Units/Products = stocked-in from this incident's donations minus what
From a866e12c8afb573a0a326151aaccbb4a7430b3c3 Mon Sep 17 00:00:00 2001
From: emjay0921
Date: Wed, 12 Aug 2026 09:45:16 +0800
Subject: [PATCH 11/13] feat(hazard): enter incidents as drafts and classify
them explicitly
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round 2 made Alert the entry state. QA has since changed the requirement: an
incident should be entered as a draft, and the person recording it says what
it is — Flag As Alert for something being watched, Set Active for a response
already under way. Neither is assumed on their behalf.
Adds a draft state at the head of the selection and makes it the default.
Set Active is now offered from Draft as well, so a response already under way
does not have to be flagged as an alert first. Start Recovery and Close
Incident stay hidden there, which is what leaves a draft showing exactly the
two buttons asked for.
The list's default filter needed Draft adding too. It pre-selects Alert,
Active and Recovery, so with Draft as the entry state a newly created
incident would have disappeared from the list it was created in and read as
having failed to save. A test asserts both the filter and its default,
because nothing at the model level would notice.
A draft is deliberately not a live incident: it is excluded from is_ongoing,
from the affected-registrant check, and from the Active-only consumers. It
starts counting once classified. A draft also cannot be closed — a mistaken
one is deleted rather than closed.
Six tests asserting the round-2 lifecycle are rewritten, not dropped.
---
spp_drims/tests/test_incident.py | 27 ++++++++-
spp_hazard/models/hazard_incident.py | 18 ++++--
spp_hazard/tests/test_hazard_incident.py | 66 +++++++++++++++++-----
spp_hazard/views/hazard_incident_views.xml | 15 ++++-
4 files changed, 102 insertions(+), 24 deletions(-)
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index 5b9c55125..fd8002b8f 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -495,7 +495,7 @@ def test_close_incident_hidden_only_outside_the_open_states(self):
)
self.assertTrue(safe_eval(condition, {"status": "closed"}))
- def test_new_drims_incident_starts_in_alert(self):
+ def test_new_drims_incident_starts_in_draft(self):
"""The entry state, seen from DRIMS rather than the base module."""
incident = self.env["spp.hazard.incident"].create(
{
@@ -505,7 +505,30 @@ def test_new_drims_incident_starts_in_alert(self):
"start_date": "2026-08-01",
}
)
- self.assertEqual(incident.status, "alert")
+ self.assertEqual(incident.status, "draft")
+
+ def test_draft_offers_exactly_flag_as_alert_and_set_active(self):
+ """OP#1157 round 3: a draft presents those two choices and no others.
+
+ Evaluated off the form arch because button visibility is a view
+ concern; the buttons themselves are contributed by two modules, which
+ is precisely why the combined header is worth asserting.
+ """
+ arch = etree.fromstring(
+ self.env["spp.hazard.incident"].get_view(self.env.ref("spp_hazard.view_hazard_incident_form").id, "form")[
+ "arch"
+ ]
+ )
+ shown = [
+ b.get("name")
+ for b in arch.xpath("//header/button")
+ if not safe_eval(b.get("invisible", "False"), {"status": "draft"})
+ ]
+ self.assertEqual(
+ shown,
+ ["action_set_alert", "action_set_active"],
+ f"a draft should offer Flag As Alert then Set Active, got {shown}",
+ )
def test_flag_as_alert_returns_an_incident_to_alert(self):
"""The button itself still does its job from active and recovery."""
diff --git a/spp_hazard/models/hazard_incident.py b/spp_hazard/models/hazard_incident.py
index 3125d3a20..af1c00adf 100644
--- a/spp_hazard/models/hazard_incident.py
+++ b/spp_hazard/models/hazard_incident.py
@@ -55,19 +55,25 @@ class HazardIncident(models.Model):
)
status = fields.Selection(
[
+ ("draft", "Draft"),
("alert", "Alert"),
("active", "Active"),
("recovery", "Recovery"),
("closed", "Closed"),
],
- # OP#1157: an incident is raised as an alert and only becomes active
- # once it has been confirmed, so Alert is the entry state rather than
- # something you have to step back into. The lifecycle is
- # Alert -> Active -> Recovery -> Closed.
- default="alert",
+ # OP#1157 round 3: an incident is entered as a draft and the person
+ # recording it then says what it is — Flag As Alert for something being
+ # watched, Set Active for a response already under way. Neither is
+ # assumed for them, which is why Draft rather than Alert is the entry
+ # state. The lifecycle is Draft -> Alert or Active -> Recovery ->
+ # Closed; Alert can still be raised later from Active or Recovery.
+ default="draft",
required=True,
tracking=True,
- help="Current status of the incident. A new incident starts in Alert and is confirmed with Set Active.",
+ help=(
+ "Current status of the incident. A new incident starts in Draft and is "
+ "moved on with Flag As Alert or Set Active."
+ ),
)
severity = fields.Selection(
[
diff --git a/spp_hazard/tests/test_hazard_incident.py b/spp_hazard/tests/test_hazard_incident.py
index 90f7b1e0a..98691232c 100644
--- a/spp_hazard/tests/test_hazard_incident.py
+++ b/spp_hazard/tests/test_hazard_incident.py
@@ -6,6 +6,7 @@
from odoo import Command
from odoo.exceptions import ValidationError
from odoo.tests import mute_logger
+from odoo.tools.safe_eval import safe_eval
from .common import HazardTestCase
@@ -31,14 +32,16 @@ def test_01_incident_creation(self):
"""Test basic incident creation."""
self.assertTrue(self.incident)
self.assertEqual(self.incident.name, "Test Typhoon Incident")
- self.assertEqual(self.incident.status, "alert") # OP#1157: incidents are raised as alerts
- self.assertTrue(self.incident.is_ongoing)
+ self.assertEqual(self.incident.status, "draft") # OP#1157: incidents are entered as drafts
+ # A draft is not under way yet, so it does not count as ongoing.
+ self.assertFalse(self.incident.is_ongoing)
- def test_new_incident_starts_in_alert(self):
- """OP#1157: an incident is raised as an alert, then confirmed.
+ def test_new_incident_starts_in_draft(self):
+ """OP#1157: an incident is entered as a draft, then classified.
- QA round 1 found new incidents landing straight in Active, which skips
- the triage step the Alert state exists for.
+ The person recording it says what it is — Flag As Alert for something
+ being watched, Set Active for a response already under way. Neither is
+ assumed on their behalf, so the entry state is Draft.
"""
incident = self.env["spp.hazard.incident"].create(
{
@@ -48,7 +51,42 @@ def test_new_incident_starts_in_alert(self):
"start_date": "2024-02-01",
}
)
- self.assertEqual(incident.status, "alert")
+ self.assertEqual(incident.status, "draft")
+
+ def test_draft_is_not_filtered_out_of_the_incident_list(self):
+ """Draft is the entry state, so the default filter has to include it.
+
+ The action pre-selects status filters. Without Draft among them a newly
+ recorded incident would disappear from the very list it was created in,
+ which would read as the record not having been saved.
+ """
+ action = self.env.ref("spp_hazard.action_hazard_incident")
+ context = safe_eval(action.context or "{}")
+ self.assertTrue(
+ context.get("search_default_draft"),
+ f"Draft is missing from the incident list's default filter: {context}",
+ )
+
+ search = etree.fromstring(self.env.ref("spp_hazard.view_hazard_incident_search").arch)
+ self.assertTrue(
+ search.xpath("//filter[@name='draft']"),
+ "the search view offers no Draft filter to switch back on",
+ )
+
+ def test_draft_can_go_straight_to_active(self):
+ """A response already under way should not have to be flagged first."""
+ incident = self.env["spp.hazard.incident"].create(
+ {
+ "name": "Already Responding",
+ "code": "TEST-INC-DIRECT",
+ "category_id": self.category_typhoon.id,
+ "start_date": "2024-02-01",
+ }
+ )
+ self.assertEqual(incident.status, "draft")
+
+ incident.action_set_active()
+ self.assertEqual(incident.status, "active")
def test_alert_is_reachable_and_reversible(self):
"""Alert is a state you can return to, not only start in."""
@@ -64,10 +102,10 @@ def test_alert_is_reachable_and_reversible(self):
def test_recovery_is_reached_from_active_only(self):
"""Answers QA's question about when Recovery is available.
- Start Recovery is offered only from Active, so a newly raised incident
- goes Alert -> Active -> Recovery rather than jumping straight in.
+ Start Recovery is offered only from Active, so a newly entered incident
+ goes Draft -> Active -> Recovery rather than jumping straight in.
"""
- self.assertEqual(self.incident.status, "alert")
+ self.assertEqual(self.incident.status, "draft")
self.incident.action_set_active()
self.incident.action_set_recovery()
self.assertEqual(self.incident.status, "recovery")
@@ -99,7 +137,9 @@ def test_03_date_validation(self):
def test_04_is_ongoing_computation(self):
"""Test is_ongoing computed field."""
- # Active incident with no end date should be ongoing
+ # A draft is not under way; confirming it into Active makes it ongoing.
+ self.assertFalse(self.incident.is_ongoing)
+ self.incident.action_set_active()
self.assertTrue(self.incident.is_ongoing)
# Set end date - should no longer be ongoing
@@ -112,8 +152,8 @@ def test_04_is_ongoing_computation(self):
def test_05_status_transitions(self):
"""Test status transition actions."""
- # OP#1157: a new incident starts in alert and is confirmed into active.
- self.assertEqual(self.incident.status, "alert")
+ # OP#1157: a new incident starts in draft and is classified from there.
+ self.assertEqual(self.incident.status, "draft")
self.incident.action_set_active()
self.assertEqual(self.incident.status, "active")
diff --git a/spp_hazard/views/hazard_incident_views.xml b/spp_hazard/views/hazard_incident_views.xml
index f1ecb8b49..90b0ede2e 100644
--- a/spp_hazard/views/hazard_incident_views.xml
+++ b/spp_hazard/views/hazard_incident_views.xml
@@ -44,11 +44,15 @@
{'search_default_alert': 1, 'search_default_active': 1, 'search_default_recovery': 1}
+ >{'search_default_draft': 1, 'search_default_alert': 1, 'search_default_active': 1, 'search_default_recovery': 1}
Record your first hazard incident
From c433ad828c153d053755e71889c50d2de4da1d4b Mon Sep 17 00:00:00 2001
From: emjay0921
Date: Wed, 19 Aug 2026 10:58:16 +0800
Subject: [PATCH 12/13] test(spp_drims): give the OP#1164 donation fixtures a
line
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both warehouse-choice tests create a donation with no items. The donations
review on fix/1076-drims-donations-review adds _check_has_lines, which forbids
exactly that — so whichever of the two branches merges second turns 19.0 red,
and with no textual conflict between them nothing warns either author first.
The line is incidental to what these tests assert; it is there to keep the
donation valid.
Not done here: dropping this branch's create-time closed-incident guard in
favour of that review's _check_incident_not_closed constraint, which is
strictly broader because it also catches an existing donation re-pointed at a
closed incident. The constraint does not exist on this branch, so removing the
guard now would leave test_1158_donation_blocked_when_closed with nothing to
raise. It should follow once the donations PR has merged.
---
spp_drims/tests/test_incident.py | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index fd8002b8f..4fb686446 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -188,7 +188,20 @@ def test_1164_donation_allowed_warehouses_respects_filter(self):
)
self.incident.drims_warehouse_ids = [(6, 0, [self.warehouse.id])]
donation = self.env["spp.drims.donation"].create(
- {"incident_id": self.incident.id, "warehouse_id": self.warehouse.id, "donor_name": "D"}
+ {
+ "incident_id": self.incident.id,
+ "warehouse_id": self.warehouse.id,
+ "donor_name": "D",
+ # A donation needs at least one item (_check_has_lines, added by
+ # the donations review on fix/1076-drims-donations-review). This
+ # test is about warehouse choices, so the line is only there to
+ # make the donation valid — without it, whichever of the two
+ # branches merges second turns 19.0 red with no textual conflict
+ # to warn either author.
+ "line_ids": [
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 100, "uom_id": self.product.uom_id.id})
+ ],
+ }
)
# Filter ON (default): only the incident's warehouse.
self.assertIn(self.warehouse, donation.allowed_warehouse_ids)
@@ -204,7 +217,15 @@ def test_1164_allowed_warehouses_fallback_when_incident_has_none(self):
self.warehouse.is_drims_warehouse = True
self.incident.drims_warehouse_ids = [(5, 0, 0)] # ensure none linked
donation = self.env["spp.drims.donation"].create(
- {"incident_id": self.incident.id, "warehouse_id": self.warehouse.id, "donor_name": "D"}
+ {
+ "incident_id": self.incident.id,
+ "warehouse_id": self.warehouse.id,
+ "donor_name": "D",
+ # See the note above: a donation must carry at least one item.
+ "line_ids": [
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 100, "uom_id": self.product.uom_id.id})
+ ],
+ }
)
self.assertIn(self.warehouse, donation.allowed_warehouse_ids)
From f55922900e8d121919079b468b5538892746d0bc Mon Sep 17 00:00:00 2001
From: emjay0921
Date: Wed, 19 Aug 2026 11:33:08 +0800
Subject: [PATCH 13/13] =?UTF-8?q?fix(spp=5Fdrims):=20address=20the=20incid?=
=?UTF-8?q?ent=20management=20review=20=E2=80=94=20upgrade=20path=20and=20?=
=?UTF-8?q?lifecycle=20guards?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Version bumps on spp_drims and spp_hazard, and a migration for the part a bump
alone does not fix. Three stored computes changed meaning rather than being
added: incident stock units and item count now count incident-related stock net
of allocations, and distributed value is net of confirmed returns. Odoo only
computes a stored field for existing rows when its column is new, so an upgraded
database would keep the old numbers until some dependency happened to change —
and for closed incidents never, since the refresh cron skips them. The migration
drops the stale spp.data.value cache rows the computes would otherwise prefer,
then recomputes all three for every incident, closed ones included.
The incident's own lifecycle is now enforced on the server, not only hidden in
the form header. A draft cannot be closed — QA's rule is that a mistakenly
entered incident is deleted — and a closed incident cannot be moved back to
alert, active or recovery, which over RPC would have sidestepped every guard
that keys off `closed`. Alert goes through the same gate.
Field edits on a closed incident stay a UI-level rule, deliberately: a write()
guard would have to allowlist its way around the stored KPI computes, which
legitimately write to closed incidents. The rule that protects data is on the
DRIMS operations, and those are already guarded server-side.
Three spp_hazard tests closed an incident straight after creating it, which the
draft entry state makes illegal; they now take it through the lifecycle first.
Nits from the review: _compute_allowed_warehouse_ids was duplicated verbatim in
donation.py and request.py and now calls one helper on the incident, the cron
comment listing status values was missing draft, and a test comment still said
incidents start in alert.
---
spp_drims/README.rst | 16 +++++
spp_drims/__manifest__.py | 2 +-
.../migrations/19.0.3.1.0/post-migration.py | 61 +++++++++++++++++++
spp_drims/models/donation.py | 6 +-
spp_drims/models/hazard_incident.py | 29 ++++++++-
spp_drims/models/request.py | 6 +-
spp_drims/readme/HISTORY.md | 5 ++
spp_drims/static/description/index.html | 19 +++++-
spp_drims/tests/test_incident.py | 56 +++++++++++++++++
spp_hazard/README.rst | 9 +++
spp_hazard/__manifest__.py | 2 +-
spp_hazard/models/hazard_incident.py | 39 +++++++++++-
spp_hazard/readme/HISTORY.md | 4 ++
spp_hazard/static/description/index.html | 14 ++++-
spp_hazard/tests/test_hazard_incident.py | 6 ++
spp_hazard/tests/test_registrant.py | 2 +-
16 files changed, 258 insertions(+), 18 deletions(-)
create mode 100644 spp_drims/migrations/19.0.3.1.0/post-migration.py
diff --git a/spp_drims/README.rst b/spp_drims/README.rst
index 35393c233..5650addfa 100644
--- a/spp_drims/README.rst
+++ b/spp_drims/README.rst
@@ -179,6 +179,22 @@ Dependencies
Changelog
=========
+19.0.3.1.0
+~~~~~~~~~~
+
+- feat(drims): Incident Management review — incidents are entered as a
+ **Draft** and then flagged Alert or set Active, a closed incident
+ refuses DRIMS operations and no longer accepts lifecycle changes,
+ dashboard KPI cards no longer open the record when a box is clicked,
+ warehouses can be linked to an incident and drive the warehouse
+ choices on donations and requests, and the Impact tab is hidden where
+ it does not apply (#1094, #1123, #1157, #1158, #1159, #1160, #1164)
+- fix(drims): incident stock KPIs now count incident-related stock net
+ of allocations, and distributed value is net of confirmed returns.
+ Both are stored computes whose meaning changed, so upgrading
+ recomputes them for every incident — including closed ones, which the
+ refresh cron skips (#1100)
+
19.0.3.0.0
~~~~~~~~~~
diff --git a/spp_drims/__manifest__.py b/spp_drims/__manifest__.py
index e775182ab..2d42ed0b8 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.3.0.0",
+ "version": "19.0.3.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_drims/migrations/19.0.3.1.0/post-migration.py b/spp_drims/migrations/19.0.3.1.0/post-migration.py
new file mode 100644
index 000000000..32428bf33
--- /dev/null
+++ b/spp_drims/migrations/19.0.3.1.0/post-migration.py
@@ -0,0 +1,61 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Recompute the incident KPIs whose meaning changed in this version (OP#1100).
+
+Three stored computes were redefined rather than added:
+
+* ``drims_total_stock_units`` and ``drims_stock_item_count`` went from
+ "everything physically in the warehouse" to "incident-related stock net of
+ what has been allocated out".
+* ``drims_distributed_value`` is now net of confirmed returns.
+
+Odoo only computes a stored field for existing rows when its column is newly
+created, so an upgraded database keeps the old numbers until something happens
+to touch a dependency. For a quiet incident that may be never — and closed
+incidents are excluded from ``_cron_refresh_drims_kpis``, so nothing would ever
+correct them.
+
+The cached ``spp.data.value`` rows behind the value KPIs hold pre-change
+numbers for the same reason, and the compute prefers a live cache entry over
+recomputing, so they are dropped first.
+"""
+
+import logging
+
+from odoo import SUPERUSER_ID, api
+
+_logger = logging.getLogger(__name__)
+
+STALE_CACHE_VARIABLES = ("drims_distributed_value", "drims_stock_value")
+
+RECOMPUTED_FIELDS = (
+ "drims_total_stock_units",
+ "drims_stock_item_count",
+ "drims_distributed_value",
+)
+
+
+def migrate(cr, version):
+ if not version:
+ return
+
+ env = api.Environment(cr, SUPERUSER_ID, {})
+
+ data_value = env.get("spp.data.value")
+ if data_value is not None:
+ for variable in STALE_CACHE_VARIABLES:
+ data_value.invalidate(variable_name=variable)
+ _logger.info("Dropped cached values for %s", ", ".join(STALE_CACHE_VARIABLES))
+
+ # Every incident, not just the open ones: the cron that would otherwise
+ # heal these skips closed incidents, which is exactly where a stale number
+ # would sit unnoticed.
+ incidents = env["spp.hazard.incident"].search([])
+ if not incidents:
+ return
+
+ for field_name in RECOMPUTED_FIELDS:
+ field = incidents._fields.get(field_name)
+ if field is not None:
+ env.add_to_compute(field, incidents)
+ env.flush_all()
+ _logger.info("Recomputed %s for %d incident(s)", ", ".join(RECOMPUTED_FIELDS), len(incidents))
diff --git a/spp_drims/models/donation.py b/spp_drims/models/donation.py
index 049954b7b..8e2c9cdd9 100644
--- a/spp_drims/models/donation.py
+++ b/spp_drims/models/donation.py
@@ -260,12 +260,8 @@ def _compute_allowed_warehouse_ids(self):
incident-filtering is on (and the incident has any), else all DRIMS
warehouses. The fallback avoids locking out donation creation when an
incident has no warehouses linked yet."""
- Warehouse = self.env["stock.warehouse"]
- filter_on = self.env["res.config.settings"].is_warehouse_filter_by_incident_enabled()
- all_drims = Warehouse.search([("is_drims_warehouse", "=", True)])
for rec in self:
- incident_whs = rec.incident_id.drims_warehouse_ids
- rec.allowed_warehouse_ids = incident_whs if (filter_on and incident_whs) else all_drims
+ rec.allowed_warehouse_ids = rec.incident_id._drims_allowed_warehouses()
@api.depends("picking_ids")
def _compute_picking_count(self):
diff --git a/spp_drims/models/hazard_incident.py b/spp_drims/models/hazard_incident.py
index 386ab50ed..7e868194a 100644
--- a/spp_drims/models/hazard_incident.py
+++ b/spp_drims/models/hazard_incident.py
@@ -385,9 +385,36 @@ def action_set_alert(self):
The 'alert' status exists in the base state machine but had no setter;
this exposes it via the "Flag As Alert" header button.
+
+ Goes through the base gate so a closed incident cannot be flagged back
+ into alert over RPC, which would sidestep every OP#1158 guard that keys
+ off `closed` (OP#1100 review).
"""
+ self._ensure_status_change_allowed("alert")
self.write({"status": "alert"})
+ def _drims_allowed_warehouses(self):
+ """OP#1164: the warehouses selectable for work on this incident.
+
+ The incident's own warehouses when incident-filtering is on and it has
+ any, otherwise every DRIMS warehouse. The fallback is what stops an
+ incident with nothing linked yet from locking out donation and request
+ creation.
+
+ Lives here rather than in donation.py and request.py, which each held a
+ verbatim copy, so the policy has one home (OP#1100 review).
+
+ Callable on an empty incident — a donation or request that has not been
+ pointed at one yet — which then gets the full DRIMS list. The warehouse
+ search only runs on the fallback path, so the common case (filtering on,
+ incident has warehouses) costs no query per row when a list computes
+ this field.
+ """
+ filter_on = self.env["res.config.settings"].is_warehouse_filter_by_incident_enabled()
+ if filter_on and self.drims_warehouse_ids:
+ return self.drims_warehouse_ids
+ return self.env["stock.warehouse"].search([("is_drims_warehouse", "=", True)])
+
def _drims_ensure_open(self, action):
"""OP#1158: block DRIMS operations once an incident is closed.
@@ -649,7 +676,7 @@ def _cron_refresh_drims_kpis(self):
Called by scheduled action (recommended frequency: every 30 minutes).
"""
# Find active incidents (not closed)
- # status field values: alert, active, recovery, closed
+ # status field values: draft, alert, active, recovery, closed
active_incidents = self.search(
[
("status", "!=", "closed"),
diff --git a/spp_drims/models/request.py b/spp_drims/models/request.py
index 5ef43b504..6c7bf3a93 100644
--- a/spp_drims/models/request.py
+++ b/spp_drims/models/request.py
@@ -366,12 +366,8 @@ def _compute_allowed_warehouse_ids(self):
incident-filtering is on (and the incident has any), else all DRIMS
warehouses. The fallback avoids locking out allocation/dispatch when an
incident has no warehouses linked yet."""
- Warehouse = self.env["stock.warehouse"]
- filter_on = self.env["res.config.settings"].is_warehouse_filter_by_incident_enabled()
- all_drims = Warehouse.search([("is_drims_warehouse", "=", True)])
for rec in self:
- incident_whs = rec.incident_id.drims_warehouse_ids
- rec.allowed_warehouse_ids = incident_whs if (filter_on and incident_whs) else all_drims
+ rec.allowed_warehouse_ids = rec.incident_id._drims_allowed_warehouses()
@api.depends("picking_ids")
def _compute_picking_count(self):
diff --git a/spp_drims/readme/HISTORY.md b/spp_drims/readme/HISTORY.md
index 41a4b4ea2..29dc582b4 100644
--- a/spp_drims/readme/HISTORY.md
+++ b/spp_drims/readme/HISTORY.md
@@ -1,3 +1,8 @@
+### 19.0.3.1.0
+
+- feat(drims): Incident Management review — incidents are entered as a **Draft** and then flagged Alert or set Active, a closed incident refuses DRIMS operations and no longer accepts lifecycle changes, dashboard KPI cards no longer open the record when a box is clicked, warehouses can be linked to an incident and drive the warehouse choices on donations and requests, and the Impact tab is hidden where it does not apply (#1094, #1123, #1157, #1158, #1159, #1160, #1164)
+- fix(drims): incident stock KPIs now count incident-related stock net of allocations, and distributed value is net of confirmed returns. Both are stored computes whose meaning changed, so upgrading recomputes them for every incident — including closed ones, which the refresh cron skips (#1100)
+
### 19.0.3.0.0
- feat(drims): allocate stock per source warehouse. The Allocate Stock wizard now auto-splits each requested line across the DRIMS warehouses that hold stock (e.g. 70 → 50 @ WH1 + 20 @ WH2) with editable rows; the split is captured on a new per-warehouse allocation record, shown on the request's Allocations tab and summarised in a "Source Warehouse(s)" column on the Requests list; dispatch creates one picking per source warehouse. The single "Source Warehouse" field on the request has been removed — the warehouse(s) are chosen in the wizard. The allocation wizard distinguishes no-stock, stock-shortfall and deliberate partial-allocation cases with clear messages, and the request line's Fulfillment % tracks allocated ÷ requested so the bar reflects allocation progress (#1079)
diff --git a/spp_drims/static/description/index.html b/spp_drims/static/description/index.html
index f64fb3c62..f159157f1 100644
--- a/spp_drims/static/description/index.html
+++ b/spp_drims/static/description/index.html
@@ -565,6 +565,23 @@
feat(drims): Incident Management review — incidents are entered as a
+Draft and then flagged Alert or set Active, a closed incident
+refuses DRIMS operations and no longer accepts lifecycle changes,
+dashboard KPI cards no longer open the record when a box is clicked,
+warehouses can be linked to an incident and drive the warehouse
+choices on donations and requests, and the Impact tab is hidden where
+it does not apply (#1094, #1123, #1157, #1158, #1159, #1160, #1164)
+
fix(drims): incident stock KPIs now count incident-related stock net
+of allocations, and distributed value is net of confirmed returns.
+Both are stored computes whose meaning changed, so upgrading
+recomputes them for every incident — including closed ones, which the
+refresh cron skips (#1100)
+
+
+
19.0.3.0.0
feat(drims): allocate stock per source warehouse. The Allocate Stock
@@ -584,7 +601,7 @@
19.0.3.0.0
destination-type selector (#1075)
-
+
19.0.2.0.0
Initial migration to OpenSPP2
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index 4fb686446..9ba7581c2 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -642,6 +642,62 @@ def test_1158_donation_allowed_when_open(self):
)
self.assertTrue(donation.exists())
+ # ── lifecycle: the state machine is enforced on the server, not just hidden ──
+ def test_1100_closed_incident_cannot_be_reopened(self):
+ """The header hides these buttons; hiding is not enforcement.
+
+ Over RPC, an import or the shell, flipping a closed incident back to
+ active or alert would sidestep every guard above, all of which key off
+ `closed` (OP#1100 review).
+ """
+ for action in ("action_set_active", "action_set_alert", "action_set_recovery"):
+ with self.subTest(action=action):
+ with self.assertRaises(UserError):
+ getattr(self.closed_incident, action)()
+
+ self.assertEqual(self.closed_incident.status, "closed")
+
+ def test_1100_closed_incident_cannot_be_closed_again(self):
+ with self.assertRaises(UserError):
+ self.closed_incident.action_close()
+
+ def test_1100_a_draft_is_not_closed_but_deleted(self):
+ """QA's rule: a mistakenly entered incident is deleted, not closed."""
+ draft = self.env["spp.hazard.incident"].create(
+ {
+ "name": "Draft Incident 1100",
+ "code": "DRAFT-1100",
+ "category_id": self.hazard_category.id,
+ "start_date": "2024-01-01",
+ }
+ )
+ self.assertEqual(draft.status, "draft")
+
+ with self.assertRaises(UserError):
+ draft.action_close()
+
+ draft.unlink()
+
+ def test_1100_an_open_incident_still_moves_through_its_lifecycle(self):
+ """The guard must not block the transitions that are the point of it."""
+ incident = self.env["spp.hazard.incident"].create(
+ {
+ "name": "Lifecycle Incident 1100",
+ "code": "LIFE-1100",
+ "category_id": self.hazard_category.id,
+ "start_date": "2024-01-01",
+ }
+ )
+
+ incident.action_set_alert()
+ self.assertEqual(incident.status, "alert")
+ incident.action_set_active()
+ self.assertEqual(incident.status, "active")
+ incident.action_set_recovery()
+ self.assertEqual(incident.status, "recovery")
+ incident.action_close()
+ self.assertEqual(incident.status, "closed")
+
# ── personnel: cannot deploy to a closed incident ──
def test_1158_personnel_blocked_when_closed(self):
with self.assertRaises(UserError):
diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst
index 4634a07c0..a77de2406 100644
--- a/spp_hazard/README.rst
+++ b/spp_hazard/README.rst
@@ -1186,6 +1186,15 @@ encounter unexpected behavior, please report it as a new issue.
Changelog
=========
+19.0.2.1.0
+~~~~~~~~~~
+
+- feat(hazard): incidents start as a **Draft** and reach Alert or Active
+ deliberately, rather than being assumed active on entry. Lifecycle
+ moves are now refused server-side as well as hidden in the form: a
+ draft cannot be closed (delete it instead) and a closed incident
+ cannot be reopened (#1157, #1158)
+
19.0.2.0.2
~~~~~~~~~~
diff --git a/spp_hazard/__manifest__.py b/spp_hazard/__manifest__.py
index 336923eb1..5e960c7d4 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",
diff --git a/spp_hazard/models/hazard_incident.py b/spp_hazard/models/hazard_incident.py
index af1c00adf..642d52928 100644
--- a/spp_hazard/models/hazard_incident.py
+++ b/spp_hazard/models/hazard_incident.py
@@ -3,7 +3,7 @@
import logging
from odoo import _, api, fields, models
-from odoo.exceptions import ValidationError
+from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
@@ -190,16 +190,53 @@ def _compute_affected_registrant_count(self):
for rec in self:
rec.affected_registrant_count = mapped.get(rec.id, 0)
+ def _ensure_status_change_allowed(self, target):
+ """Refuse a lifecycle move the state machine does not allow (OP#1100).
+
+ The header buttons already hide the moves that make no sense, but a
+ view attribute is not enforcement: over RPC, an import or the shell,
+ a draft could be closed and a closed incident flipped back to active,
+ which would sidestep every guard that keys off `closed`.
+
+ Two rules, both from the lifecycle described on `status`:
+
+ * a draft is not closed — a mistakenly entered one is deleted;
+ * a closed incident does not reopen, so nothing moves out of it.
+
+ Field edits on a closed incident stay a UI-level rule. A blanket
+ `write()` guard would have to allowlist its way around the stored KPI
+ computes, which legitimately write to closed incidents; the guarding
+ that matters is on the DRIMS operations, which have their own
+ server-side checks.
+ """
+ for rec in self:
+ if rec.status == "closed":
+ raise UserError(
+ _("Incident '%(name)s' is closed and cannot be moved to %(target)s.")
+ % {"name": rec.display_name, "target": target}
+ )
+ if target == "closed" and rec.status == "draft":
+ raise UserError(
+ _(
+ "Incident '%(name)s' is still a draft, so there is nothing to close. "
+ "Delete it instead, or set it Active or Alert first."
+ )
+ % {"name": rec.display_name}
+ )
+
def action_set_active(self):
"""Set incident status to active."""
+ self._ensure_status_change_allowed("active")
self.write({"status": "active"})
def action_set_recovery(self):
"""Set incident status to recovery."""
+ self._ensure_status_change_allowed("recovery")
self.write({"status": "recovery"})
def action_close(self):
"""Close the incident."""
+ self._ensure_status_change_allowed("closed")
for rec in self:
rec.write(
{
diff --git a/spp_hazard/readme/HISTORY.md b/spp_hazard/readme/HISTORY.md
index c02593c5b..0496cdc96 100644
--- a/spp_hazard/readme/HISTORY.md
+++ b/spp_hazard/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.2.1.0
+
+- feat(hazard): incidents start as a **Draft** and reach Alert or Active deliberately, rather than being assumed active on entry. Lifecycle moves are now refused server-side as well as hidden in the form: a draft cannot be closed (delete it instead) and a closed incident cannot be reopened (#1157, #1158)
+
### 19.0.2.0.2
- fix(security): grant `group_hazard_viewer` to spp_user_roles roles (Registry Viewer, Program Manager, Global/Local Registrar) that the OP#951 menu audit identifies as needing read-only Hazard menu access. Other affected roles defined outside this module (program/CR/farm roles) are wired in their own modules.
diff --git a/spp_hazard/static/description/index.html b/spp_hazard/static/description/index.html
index 9df1ed8e5..06b97519e 100644
--- a/spp_hazard/static/description/index.html
+++ b/spp_hazard/static/description/index.html
@@ -2454,6 +2454,16 @@
feat(hazard): incidents start as a Draft and reach Alert or Active
+deliberately, rather than being assumed active on entry. Lifecycle
+moves are now refused server-side as well as hidden in the form: a
+draft cannot be closed (delete it instead) and a closed incident
+cannot be reopened (#1157, #1158)
+
+
+
19.0.2.0.2
fix(security): grant group_hazard_viewer to spp_user_roles roles
@@ -2469,7 +2479,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 +2487,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/tests/test_hazard_incident.py b/spp_hazard/tests/test_hazard_incident.py
index 98691232c..b16f1e2c9 100644
--- a/spp_hazard/tests/test_hazard_incident.py
+++ b/spp_hazard/tests/test_hazard_incident.py
@@ -286,6 +286,8 @@ def test_15_close_sets_end_date(self):
}
)
self.assertFalse(incident.end_date)
+ # OP#1100: a draft is deleted, not closed — take it through the lifecycle first.
+ incident.action_set_active()
incident.action_close()
self.assertTrue(incident.end_date)
self.assertEqual(incident.status, "closed")
@@ -301,6 +303,8 @@ def test_16_close_preserves_existing_end_date(self):
"end_date": "2024-02-01",
}
)
+ # OP#1100: a draft is deleted, not closed — take it through the lifecycle first.
+ incident.action_set_active()
incident.action_close()
self.assertEqual(str(incident.end_date), "2024-02-01")
self.assertEqual(incident.status, "closed")
@@ -415,6 +419,8 @@ def test_21_multi_record_close(self):
"end_date": "2024-04-01",
}
)
+ # OP#1100: a draft is deleted, not closed — take it through the lifecycle first.
+ (inc1 | inc2).action_set_active()
(inc1 | inc2).action_close()
self.assertEqual(inc1.status, "closed")
self.assertEqual(inc2.status, "closed")
diff --git a/spp_hazard/tests/test_registrant.py b/spp_hazard/tests/test_registrant.py
index 95c1cb7ad..1bf7c003b 100644
--- a/spp_hazard/tests/test_registrant.py
+++ b/spp_hazard/tests/test_registrant.py
@@ -52,7 +52,7 @@ def test_hazard_impact_count(self):
def test_has_active_impact_with_active_incident(self):
"""Test flag is True when incident is active."""
- # OP#1157: incidents now start in alert, so confirm it first.
+ # OP#1157: incidents now start in draft, so set it active first.
self.incident.action_set_active()
self.assertEqual(self.incident.status, "active")
self.assertTrue(self.registrant.has_active_impact)