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

1import logging 

2 

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 

9 

10from ..constants import RUN_PRIORITY_MAP 

11 

12_logger = logging.getLogger(__name__) 

13 

14 

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" 

20 

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 ) 

89 

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 ) 

94 

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 

102 

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 

112 

113 def start(self, script_version: BaseModel): 

114 """ 

115 Marks the run as started, setting the start date and the script version used. 

116 

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 ) 

125 

126 def enqueue(self, in_chain: bool = False, max_retries: int = 3) -> dict | None: 

127 """ 

128 Enqueue the run to be processed in background. 

129 

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 

143 

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. 

149 

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) 

165 

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) 

174 

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)() 

185 

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. 

190 

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) 

204 

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) 

223 

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 ) 

238 

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 ) 

252 

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.") 

261 

262 def run(self) -> Markup | None: 

263 """ 

264 Method to run the upgrade line request run. 

265 

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) 

271 

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 

296 

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. 

301 

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 ) 

314 

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 

322 

323 runs = self.filtered(lambda r: r.state in ("pending", "waiting", "error") and r.next_run_id) 

324 if not runs: 

325 return 

326 

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 

335 

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() 

340 

341 @api.autovacuum 

342 def _gc_delete_runs(self, batch_size: int = 100): 

343 """ 

344 Autovacuum to permanently delete upgrade line request runs: 

345 

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