Skip to content

Commit bef946c

Browse files
Merge pull request #102 from OpenSPP/fix/spp-alerts-description
fix: harden spp_alerts module for public release
2 parents 4475c68 + 85d5a26 commit bef946c

File tree

15 files changed

+2808
-528
lines changed

15 files changed

+2808
-528
lines changed

spp_alerts/README.rst

Lines changed: 699 additions & 35 deletions
Large diffs are not rendered by default.

spp_alerts/__manifest__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
"views/alert_rule_views.xml",
3333
"views/menus.xml",
3434
],
35-
"assets": {},
3635
"application": False,
3736
"installable": True,
3837
"auto_install": False,

spp_alerts/models/alert.py

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
_logger = logging.getLogger(__name__)
88

9+
PRIORITY_SEQUENCE = {
10+
"low": 1,
11+
"medium": 2,
12+
"high": 3,
13+
"critical": 4,
14+
}
15+
916

1017
class Alert(models.Model):
1118
"""Generic alert model for monitoring thresholds, expiries, and deadlines.
@@ -25,8 +32,9 @@ class Alert(models.Model):
2532
_name = "spp.alert"
2633
_description = "Alert"
2734
_inherit = ["mail.thread"]
28-
_order = "priority desc, create_date desc"
35+
_order = "priority_sequence desc, create_date desc"
2936
_rec_name = "reference"
37+
_check_company_auto = True
3038

3139
reference = fields.Char(
3240
string="Reference",
@@ -47,6 +55,8 @@ class Alert(models.Model):
4755
help="Type of alert from alert types vocabulary",
4856
)
4957

58+
# Named `alert_type` (not `alert_type_code`) for concise domain filtering.
59+
# Use `alert_type_id` for the vocabulary record, `alert_type` for the code string.
5060
alert_type = fields.Char(
5161
related="alert_type_id.code",
5262
store=True,
@@ -69,6 +79,16 @@ class Alert(models.Model):
6979
help="Priority level of the alert",
7080
)
7181

82+
priority_sequence = fields.Integer(
83+
string="Priority Sequence",
84+
compute="_compute_priority_sequence",
85+
store=True,
86+
help="Numeric ordering of priority for consistent sorting (low=1, medium=2, high=3, critical=4)",
87+
)
88+
89+
# Alert states differ from the standard approval workflow states (draft/pending/approved)
90+
# because the alert lifecycle is fundamentally different: alerts are raised, acknowledged,
91+
# and resolved — there is no approval decision involved.
7292
state = fields.Selection(
7393
[
7494
("active", "Active"),
@@ -102,6 +122,7 @@ class Alert(models.Model):
102122
index=True,
103123
readonly=True,
104124
ondelete="set null",
125+
check_company=True,
105126
help="Alert rule that generated this alert (empty for manually created alerts)",
106127
)
107128

@@ -120,6 +141,12 @@ class Alert(models.Model):
120141
help="Record that triggered this alert",
121142
)
122143

144+
res_name = fields.Char(
145+
string="Source Record Name",
146+
compute="_compute_res_name",
147+
help="Display name of the record that triggered this alert",
148+
)
149+
123150
# Metrics for threshold and deadline tracking
124151
current_value = fields.Float(
125152
string="Current Value",
@@ -164,6 +191,28 @@ class Alert(models.Model):
164191
help="Company this alert belongs to",
165192
)
166193

194+
@api.depends("priority")
195+
def _compute_priority_sequence(self):
196+
"""Compute numeric priority sequence for consistent ordering."""
197+
for record in self:
198+
record.priority_sequence = PRIORITY_SEQUENCE.get(record.priority, 0)
199+
200+
def _compute_res_name(self):
201+
"""Compute the display name of the source record.
202+
203+
Handles missing models or deleted records gracefully by returning an
204+
empty string rather than raising an error.
205+
"""
206+
for record in self:
207+
if not record.res_model or not record.res_id:
208+
record.res_name = ""
209+
continue
210+
try:
211+
source = self.env[record.res_model].browse(record.res_id)
212+
record.res_name = source.display_name if source.exists() else ""
213+
except (KeyError, AttributeError):
214+
record.res_name = ""
215+
167216
@api.model_create_multi
168217
def create(self, vals_list):
169218
"""Override create to auto-generate reference from sequence."""
@@ -180,7 +229,13 @@ def action_acknowledge(self):
180229
181230
This is typically the first step in addressing an alert - acknowledging
182231
that it has been seen and is being investigated.
232+
233+
Raises:
234+
UserError: If the alert is not in the active state.
183235
"""
236+
for record in self:
237+
if record.state != "active":
238+
raise UserError(_("Only active alerts can be acknowledged."))
184239
self.write({"state": "acknowledged"})
185240
return True
186241

@@ -194,9 +249,13 @@ def action_resolve(self, notes=None):
194249
bool: True on success
195250
196251
Raises:
197-
UserError: If resolution notes are not provided.
252+
UserError: If the alert is already resolved or resolution notes are missing.
198253
"""
254+
# Fail-fast: if any record in the batch lacks resolution notes, none are resolved.
255+
# This prevents partial resolution of related alert batches.
199256
for record in self:
257+
if record.state == "resolved":
258+
raise UserError(_("Alert '%s' is already resolved.", record.reference))
200259
resolution = notes or record.resolution_notes
201260
if not resolution:
202261
raise UserError(_("Please provide resolution notes before resolving the alert."))
@@ -210,3 +269,38 @@ def action_resolve(self, notes=None):
210269
vals["resolution_notes"] = notes
211270
self.write(vals)
212271
return True
272+
273+
def action_view_related_alerts(self):
274+
"""Return an action to view other alerts from the same rule.
275+
276+
Returns:
277+
dict: An ir.actions.act_window action dict, or False if no rule is set.
278+
"""
279+
self.ensure_one()
280+
if not self.rule_id:
281+
return False
282+
return {
283+
"type": "ir.actions.act_window",
284+
"name": _("Related Alerts"),
285+
"res_model": "spp.alert",
286+
"view_mode": "list,form",
287+
"domain": [("rule_id", "=", self.rule_id.id), ("id", "!=", self.id)],
288+
"context": {"search_default_filter_active": 1},
289+
}
290+
291+
def action_view_source(self):
292+
"""Return an action to open the source record in a form view.
293+
294+
Returns:
295+
dict: An ir.actions.act_window action dict, or False if no source is set.
296+
"""
297+
self.ensure_one()
298+
if not self.res_model or not self.res_id:
299+
return False
300+
return {
301+
"type": "ir.actions.act_window",
302+
"res_model": self.res_model,
303+
"res_id": self.res_id,
304+
"view_mode": "form",
305+
"target": "current",
306+
}

0 commit comments

Comments
 (0)