Coverage for ingadhoc-odoo-saas-adhoc / saas_provider_upgrade / models / saas_upgrade_line_request_run.py: 56%
158 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
1import logging
3from markupsafe import Markup
4from odoo import _, api, fields, models
5from odoo.exceptions import UserError
6from odoo.fields import Domain
7from odoo.models import BaseModel
8from odoo.tools.misc import format_duration
10from ..constants import RUN_PRIORITY_MAP
12_logger = logging.getLogger(__name__)
15class SaasUpgradeLineRequestRun(models.Model):
16 _name = "saas.upgrade.line.request.run"
17 _description = "Upgrade line request run"
18 _inherit = ["saas.provider.upgrade.util", "base.bg"]
19 _order = "aim asc, create_date desc"
21 upgrade_line_id = fields.Many2one(
22 "saas.upgrade.line",
23 ondelete="cascade",
24 index=True,
25 )
26 type = fields.Selection(
27 related="upgrade_line_id.type",
28 store=True,
29 )
30 request_id = fields.Many2one(
31 "helpdesk.ticket.upgrade.request",
32 ondelete="cascade",
33 help="Request that triggered this run",
34 index=True,
35 )
36 ticket_id = fields.Many2one(
37 "helpdesk.ticket",
38 related="request_id.ticket_id",
39 store=True,
40 )
41 start_date = fields.Datetime()
42 end_date = fields.Datetime()
43 duration = fields.Float(
44 compute="_compute_total_duration",
45 store=True,
46 )
47 state = fields.Selection(
48 [
49 ("done", "Done"),
50 ("running", "Running"),
51 ("not_applicable", "N/A"),
52 ("error", "Error"),
53 ("pending", "Pending"),
54 ("waiting", "Waiting"),
55 ("canceled", "Canceled"),
56 ],
57 )
58 script_id = fields.Many2one(
59 "saas.upgrade.line.script",
60 help="The script that was executed for this run",
61 )
62 placeholders = fields.Json()
63 next_run_id = fields.Many2one(
64 "saas.upgrade.line.request.run",
65 help="Next run in the chain of runs",
66 )
67 callback = fields.Char(
68 help="Callback method to call when all the chained runs are done.",
69 )
70 in_chain = fields.Boolean()
71 aim = fields.Selection(
72 related="request_id.aim",
73 store=True,
74 )
75 bg_job_id = fields.Many2one(
76 "bg.job",
77 readonly=True,
78 string="Background Job",
79 help="Background job associated with this run",
80 )
81 retries = fields.Integer(
82 related="bg_job_id.retry_count",
83 depends=["bg_job_id.retry_count"],
84 readonly=True,
85 store=True,
86 string="Retries",
87 help="Number of times this run has been retried in background",
88 )
90 _unique_line_request = models.Constraint(
91 "unique(upgrade_line_id, request_id)",
92 "The upgrade_line_id and the request_id must be unique per run",
93 )
95 @api.depends("start_date", "end_date")
96 def _compute_total_duration(self):
97 for rec in self:
98 if rec.start_date and rec.end_date:
99 rec.duration = max(fields.Float.round((rec.end_date - rec.start_date).total_seconds() / 60, 2), 0.0)
100 else:
101 rec.duration = 0.0
103 @api.depends("state", "duration")
104 def _compute_display_name(self):
105 state_values = dict(self._fields["state"].selection)
106 for rec in self:
107 state = state_values.get(rec.state)
108 if rec.state == "done":
109 rec.display_name = "%s (%s)" % (state, format_duration(rec.duration))
110 else:
111 rec.display_name = state
113 def start(self, script_version: BaseModel):
114 """
115 Marks the run as started, setting the start date and the script version used.
117 :param script_version: The script version that is being executed.
118 """
119 if not self:
120 return
121 self.ensure_one()
122 self.write(
123 {"state": "running", "start_date": fields.Datetime.now(), "end_date": None, "script_id": script_version}
124 )
126 def enqueue(self, in_chain: bool = False, max_retries: int = 3) -> dict | None:
127 """
128 Enqueue the run to be processed in background.
130 :return: The action that indicates that the process is running in background.
131 """
132 if not self: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 return None
134 self.ensure_one()
135 priority = RUN_PRIORITY_MAP.get(self.aim, 10)
136 self.state = "pending"
137 self.in_chain = in_chain
138 res, job = self.bg_enqueue(
139 "run" if self.upgrade_line_id else "_finish_in_chain", priority=priority, max_retries=max_retries
140 )
141 self.bg_job_id = job.id
142 return res
144 def finish(self, result: str | None = None) -> Markup | None:
145 """
146 Finishes the run, setting the end date and changing the state to 'done'.
147 If the run is part of a chain, it will trigger the next run in the chain and call the callback.
148 If the run is not part of a chain, it will call the callback to notify the user about the result.
150 :param result: The result of the run, to be included in the notification message.
151 :return: The message to notify the user about the result of the run if applies, otherwise None.
152 """
153 if not self:
154 return None
155 self.ensure_one()
156 if self.state != "running": 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 _logger.info(
158 "[UL_FINISH] UL: %s | Request: %s | RunID: %s marked as done but was in unexpected state '%s'",
159 self.upgrade_line_id.id,
160 self.request_id.id,
161 self.id,
162 self.state,
163 )
164 raise UserError(_("Running status: Unexpected state '%s' when finishing run") % self.state)
166 self.write(
167 {
168 "state": "done",
169 "end_date": fields.Datetime.now(),
170 }
171 )
172 self._register_script_version_in_request()
173 return self._finish_in_chain() if self.in_chain else self._finish_manual(result)
175 def _finish_in_chain(self):
176 """
177 Handles the finish of a run that is part of a chain.
178 """
179 next_run = self.next_run_id
180 callback = self.callback
181 if next_run:
182 next_run.enqueue(in_chain=True)
183 if callback:
184 getattr(self.request_id, callback)()
186 def _finish_manual(self, result: str | None) -> Markup:
187 """
188 Handles the finish of a run that is not part of a chain,
189 generating the message to notify the user about the result.
191 :param result: The result of the run, to be included in the notification message.
192 :return: The message to notify the user about the result of the run.
193 """
194 upgrade_line = self.upgrade_line_id
195 approve = self.env.context.get("approve", False)
196 if approve and not result: 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 upgrade_line.action_approve_script()
198 message = self.env._("Script approved")
199 elif approve and result: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 message = self.env._("Script not approved because of the result: <blockquote>%s</blockquote>") % result
201 else:
202 message = result and ("\u2713 <blockquote>Result: %s</blockquote>" % result) or "\u2713"
203 return self._create_notification_message(message)
205 def set_error(self, e: Exception | Markup | str):
206 """
207 Marks the run as errored, setting the end date and changing the state to 'error'.
208 """
209 if not self or not self.request_id:
210 return
211 self.ensure_one()
212 self.write(
213 {
214 "state": "error",
215 "end_date": fields.Datetime.now(),
216 }
217 )
218 if self.in_chain:
219 self.request_id._set_error_and_notify(e)
220 else:
221 message = self._create_notification_message(str(e))
222 self.bg_job_id._notify_user(message)
224 def set_not_applicable(self):
225 """
226 Marks the run as not applicable, resetting dates.
227 """
228 if not self:
229 return
230 self.ensure_one()
231 self.write(
232 {
233 "state": "not_applicable",
234 "start_date": None,
235 "end_date": None,
236 }
237 )
239 def set_canceled(self):
240 """
241 Marks the run as canceled, setting the end date and changing the state to 'canceled'.
242 """
243 if not self: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 return
245 self.ensure_one()
246 self.write(
247 {
248 "state": "canceled",
249 "end_date": fields.Datetime.now(),
250 }
251 )
253 def _cancel(self):
254 """
255 Marks the run as errored when the upgrade request is cancelled.
256 """
257 for rec in self:
258 rec.set_canceled()
259 if rec.bg_job_id and rec.bg_job_id.state not in ("canceled", "done"): 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 rec.bg_job_id.cancel("Upgrade line request run cancelled.")
262 def run(self) -> Markup | None:
263 """
264 Method to run the upgrade line request run.
266 :return: The message to notify the user about the result of the run if applies, otherwise None.
267 """
268 self.ensure_one()
269 upgrade_line = self.upgrade_line_id
270 return upgrade_line.run(self.request_id)
272 def _register_script_version_in_request(self):
273 """
274 Function used to add the upgrade line version used to the 'test' request
275 The map has the following structure:
276 {
277 <upgrade_line_id>: <version>
278 }
279 """
280 # Do not need to save this if we are in production, or the UL type is in UL_TYPES_FREEZE_IGNORES
281 upgrade_line = self.upgrade_line_id
282 request = self.request_id
283 script_version = self.script_id
284 ul_type = upgrade_line.type
285 if (
286 request.aim == "production"
287 or upgrade_line.run_type == "test"
288 or self._production_freeze_ignore(ul_type)
289 or not script_version
290 ):
291 return
292 # Save also when 'active_production_freeze' is False. If we activate it we need the data.
293 upgrade_line_versions_used = request.upgrade_line_versions_used or {}
294 upgrade_line_versions_used[str(upgrade_line.id)] = script_version.version
295 request.upgrade_line_versions_used = upgrade_line_versions_used
297 def _create_notification_message(self, message: str) -> Markup:
298 """
299 Generates the message to notify the user about the result of the run,
300 including links to the upgrade line and the request.
302 :param message: The message to include in the notification.
303 :return: The formatted message with links to the upgrade line and the request.
304 """
305 request = self.request_id
306 upgrade_line = self.upgrade_line_id
307 test_dev = self.env.context.get("test_dev", False)
308 return Markup("<b>UL %s %s on Request %s</b>: <i>%s</i>") % (
309 upgrade_line._get_html_link(title=upgrade_line.name),
310 "(Dev)" if test_dev else "",
311 request._get_html_link(title=request.id),
312 Markup(message),
313 )
315 @api.ondelete(at_uninstall=False)
316 def _unlink_chained_runs(self):
317 """
318 Deletes all the chained runs that are linked to the runs being unlinked,
319 """
320 if self.env.context.get("bypass_unlink_chained_runs"):
321 return
323 runs = self.filtered(lambda r: r.state in ("pending", "waiting", "error") and r.next_run_id)
324 if not runs:
325 return
327 to_delete = self.env[self._name]
328 seen_ids = set()
329 for rec in runs:
330 current = rec.next_run_id
331 while current and current.id not in seen_ids:
332 seen_ids.add(current.id)
333 to_delete |= current
334 current = current.next_run_id
336 # Avoid deleting the records being unlinked
337 to_delete -= self
338 if to_delete:
339 to_delete.with_context(bypass_unlink_chained_runs=True).unlink()
341 @api.autovacuum
342 def _gc_delete_runs(self, batch_size: int = 100):
343 """
344 Autovacuum to permanently delete upgrade line request runs:
346 1. That are older than 1 day and are not linked to any upgrade line or request or
347 2. That are in 'not_applicable' state and linked to requests whose tickets are already upgraded or
348 3. That are linked to archived upgrade lines.
349 """
350 domain = (
351 (
352 Domain("create_date", "<", "now -1d")
353 & (Domain("upgrade_line_id", "=", False) | Domain("request_id", "=", False))
354 )
355 | (
356 Domain("state", "=", "not_applicable")
357 & Domain("request_id.ticket_id.ticket_upgrade_status", "=", "upgraded")
358 )
359 | Domain("upgrade_line_id.active", "=", False)
360 )
361 runs_to_delete = self.search(domain)
362 if runs_to_delete:
363 _logger.info("Autovacuum: Deleting %d upgrade line request runs", len(runs_to_delete))
364 for start in range(0, len(runs_to_delete), batch_size):
365 batch = runs_to_delete[start : start + batch_size]
366 batch.sudo().with_context(bypass_unlink_chained_runs=True).unlink()
367 self.env.cr.commit() # pylint: disable=invalid-commit