Coverage for ingadhoc-odoo-saas-adhoc / saas_provider_upgrade / models / helpdesk_ticket_upgrade_request.py: 42%

966 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:05 +0000

1import base64 

2import logging 

3from ast import literal_eval 

4from collections.abc import Callable 

5from contextlib import contextmanager 

6from datetime import ( 

7 datetime as dt, 

8 timedelta, 

9) 

10from functools import partial 

11from textwrap import dedent 

12from typing import TYPE_CHECKING, Any, Literal, Optional 

13 

14import pandas as pd 

15from markupsafe import Markup 

16from odoo import _, api, fields, models 

17from odoo.exceptions import UserError, ValidationError 

18from odoo.tools.safe_eval import datetime, dateutil, json, pytz, safe_eval, time, wrap_module 

19 

20from .. import UpgradeScript 

21from ..constants import MAP_NEXT_STATES 

22 

23if TYPE_CHECKING: 

24 from odooly import Client 

25 

26 from saas_provider_upgrade.models.saas_database import SaasDatabase 

27 from saas_provider_upgrade.models.saas_upgrade_line import SaasUpgradeLine 

28 

29_logger = logging.getLogger(__name__) 

30 

31SHELL_COMMAND_TEMPLATE = """ 

32request = env['helpdesk.ticket.upgrade.request'].browse({}) 

33eval_context = dict() 

34with request.with_context(no_new_client={}, no_close_cursor=True)._get_eval_context(eval_context): 

35 pass 

36for key, value in eval_context.items(): 

37 globals()[key] = value 

38""" 

39 

40 

41class HelpdeskTicketUpgradeRequest(models.Model): 

42 _name = "helpdesk.ticket.upgrade.request" 

43 _inherit = ["mail.thread", "mail.activity.mixin", "saas.provider.upgrade.util"] 

44 _description = "Helpdesk Ticket Upgrade Request" 

45 _rec_name = "id" 

46 

47 _upgrade_request_states = [ 

48 ("on_create", "On Create"), 

49 ("draft", "Draft"), 

50 ("validated", "Validated"), 

51 ("requested", "Requested"), 

52 ("odoo_done", "Odoo Done"), 

53 ("restored", "Restored"), 

54 ("upgrading", "Upgrading"), 

55 ("upgraded", "Upgraded"), 

56 ("running_tests", "Running Tests"), 

57 ("done", "Done"), 

58 ("cancel", "Cancelled"), 

59 ] 

60 

61 @api.model 

62 def _get_stop_at_states(self): 

63 """ 

64 Returns valid states for stop_at field. 

65 Excludes initial states (on_create, draft) and cancelled state. 

66 """ 

67 excluded = ["on_create", "draft", "cancel"] 

68 return [(key, label) for key, label in self._upgrade_request_states if key not in excluded] 

69 

70 description = fields.Char() 

71 original_database_id = fields.Many2one( 

72 "saas.database", 

73 string="Old Database", 

74 domain=[("state", "=", "active")], 

75 tracking=True, 

76 ) 

77 original_database_state = fields.Selection( 

78 string="Old Database State", 

79 related="original_database_id.state", 

80 store=True, 

81 tracking=False, 

82 ) 

83 original_main_hostname = fields.Char( 

84 string="Old Main Hostname", 

85 related="original_database_id.main_hostname", 

86 ) 

87 upgraded_database_id = fields.Many2one( 

88 "saas.database", 

89 string="New Database", 

90 tracking=True, 

91 ) 

92 upgraded_database_state = fields.Selection( 

93 string="New Database State", 

94 related="upgraded_database_id.state", 

95 store=True, 

96 tracking=False, 

97 ) 

98 upgraded_main_hostname = fields.Char( 

99 string="New Main Hostname", 

100 related="upgraded_database_id.main_hostname", 

101 ) 

102 state = fields.Selection( 

103 _upgrade_request_states, 

104 required=True, 

105 readonly=True, 

106 default="on_create", 

107 tracking=True, 

108 ) 

109 ticket_id = fields.Many2one( 

110 "helpdesk.ticket", 

111 required=True, 

112 readonly=True, 

113 ondelete="cascade", 

114 string="Upgrade", 

115 ) 

116 upgrade_type_id = fields.Many2one( 

117 "saas.upgrade.type", 

118 string="Upgrade Type", 

119 readonly=True, 

120 ) 

121 upgrade_target = fields.Char( 

122 related="upgrade_type_id.target", 

123 ) 

124 analytic_account_id = fields.Many2one( 

125 related="ticket_id.main_database_id.analytic_account_id", 

126 ) 

127 hide_from_portal = fields.Boolean( 

128 "Hide from Portal", 

129 help="If enabled, this request will not be visible in the customer portal.", 

130 default=False, 

131 tracking=True, 

132 ) 

133 odoo_request_id = fields.Char( 

134 readonly=True, 

135 ) 

136 odoo_request_token = fields.Char() 

137 odoo_host_uri = fields.Char( 

138 readonly=True, 

139 ) 

140 script_ids = fields.Many2many( 

141 "saas.upgrade.line", 

142 compute="_compute_scripts", 

143 string="Upgrade Scripts", 

144 ) 

145 deactivated_script_ids = fields.Many2many( 

146 "saas.upgrade.line", 

147 "deactivated_upgrade_line_request_rel", 

148 "request_id", 

149 "upgrade_line_id", 

150 string="Deactivated Upgrade Lines", 

151 help="Upgrade lines that were deactivated in this request.", 

152 ) 

153 automatic = fields.Boolean( 

154 tracking=True, 

155 help="Indicates whether this request was created automatically.", 

156 ) 

157 planned_start = fields.Datetime( 

158 readonly=True, 

159 default=fields.Datetime.now, 

160 ) 

161 end_date = fields.Datetime( 

162 readonly=True, 

163 ) 

164 total_time = fields.Float( 

165 compute="_compute_total_time", 

166 store=True, 

167 help="Total time of the request, excluding error periods.", 

168 ) 

169 cum_total_time = fields.Float( 

170 compute="_compute_total_time", 

171 store=True, 

172 string="Accumulated Total Time", 

173 help="Cumulative total time of the request and its predecessors.", 

174 ) 

175 real_total_time = fields.Float( 

176 compute="_compute_real_total_time", 

177 store=True, 

178 help="Total elapsed time from start to end, including error periods.", 

179 ) 

180 cum_real_total_time = fields.Float( 

181 compute="_compute_real_total_time", 

182 store=True, 

183 string="Accumulated Real Total Time", 

184 help="Cumulative real total time of the request and its predecessors.", 

185 ) 

186 aim = fields.Selection( 

187 [("test", "Test"), ("production", "Production")], 

188 tracking=True, 

189 default="test", 

190 help="If 'Test' is selected, a copy of the production database will be used " 

191 "without shutting it down. If 'Production' is selected, the production " 

192 "database will be switched and the current one will be used as a backup.", 

193 ) 

194 odoo_aim = fields.Selection( 

195 [("test", "Test"), ("production", "Production")], 

196 default="production", 

197 help="The type of upgrade requested from Odoo. Default is 'Production' to prevent " 

198 "Odoo from running neutralization scripts.", 

199 ) 

200 stop_at = fields.Selection( 

201 selection=_get_stop_at_states, 

202 help="When set, the automatic request will stop at this state and notify the assigned technician. " 

203 "Only applicable for automatic requests.", 

204 tracking=True, 

205 ) 

206 stop_at_user_id = fields.Many2one( 

207 "res.users", 

208 string="Stop At User", 

209 help="User who set the stop_at value and will be notified when the request reaches that state.", 

210 tracking=True, 

211 ) 

212 upgrade_line_request_run_ids = fields.One2many( 

213 "saas.upgrade.line.request.run", 

214 "request_id", 

215 string="Upgrade Line Runs", 

216 tracking=False, 

217 ) 

218 upgrade_line_request_log_entry_ids = fields.One2many( 

219 "saas.upgrade.line.request.log.entry", 

220 "request_id", 

221 string="Upgrade Line Log Entries", 

222 tracking=False, 

223 ) 

224 request_info = fields.Text( 

225 default="{}", 

226 help="Internal JSON data used to store additional request metadata.", 

227 ) 

228 event_upgrade_id = fields.Many2one( 

229 "calendar.event", 

230 string="Upgrade Event", 

231 help="Calendar event linked to this upgrade request.", 

232 ) 

233 status = fields.Selection( 

234 [ 

235 ("on_hold", "On Hold"), 

236 ("running", "Running"), 

237 ("error", "Error"), 

238 ], 

239 default="on_hold", 

240 help="Current execution status of the upgrade request.", 

241 ) 

242 upgrade_alerts = fields.Json( 

243 compute="_compute_upgrade_alerts", 

244 help="Contains system alerts or validation warnings related to the upgrade.", 

245 ) 

246 with_error_log = fields.Boolean( 

247 help="Indicates whether this request contains logged errors.", 

248 ) 

249 time_running = fields.Json( 

250 compute="_compute_total_time", 

251 store=True, 

252 help="Tracks total time the request spent running by state.", 

253 ) 

254 time_on_hold = fields.Json( 

255 compute="_compute_total_time", 

256 store=True, 

257 help="Tracks total time the request was on hold by state.", 

258 ) 

259 with_original_database = fields.Boolean( 

260 compute="_compute_with_database", 

261 store=True, 

262 help="Indicates whether there is an active related original database.", 

263 ) 

264 with_upgraded_database = fields.Boolean( 

265 compute="_compute_with_database", 

266 store=True, 

267 help="Indicates whether there is an active related upgraded database.", 

268 ) 

269 with_first_original_database = fields.Boolean( 

270 compute="_compute_with_database", 

271 store=True, 

272 help="Indicates whether there is an active related first original database.", 

273 ) 

274 upgrade_line_versions_used = fields.Json( 

275 help="Tracks which upgrade line versions were used in this request.", 

276 ) 

277 active_production_freeze = fields.Boolean( 

278 related="ticket_id.active_production_freeze", 

279 help="Indicates whether the production environment is currently frozen.", 

280 ) 

281 request_completion = fields.Float( 

282 compute="_compute_request_completion", 

283 recursive=True, 

284 help="Percentage of completion of the upgrade request.", 

285 ) 

286 odoo_upgrade_log_attachment_id = fields.Many2one( 

287 "ir.attachment", 

288 string="Odoo Upgrade Log", 

289 ondelete="set null", 

290 help="Attachment containing raw logs from the Odoo upgrade process.", 

291 ) 

292 upgrade_log_attachment_id = fields.Many2one( 

293 "ir.attachment", 

294 string="Upgrade Log", 

295 ondelete="set null", 

296 help="Attachment containing raw logs of the upgrade process.", 

297 ) 

298 upgrade_report_html = fields.Html( 

299 string="Upgrade Report", 

300 help="Detailed HTML report of the executed upgrade.", 

301 ) 

302 on_by_pass = fields.Boolean( 

303 compute="_compute_on_by_pass", 

304 store=True, 

305 precompute=True, 

306 help="Indicates whether this request bypasses standard upgrade validation steps.", 

307 ) 

308 confirm_production_sent = fields.Boolean( 

309 related="ticket_id.confirm_production_sent", 

310 help="Indicates whether the production confirmation has been sent.", 

311 ) 

312 deadline_upgrade = fields.Date( 

313 related="ticket_id.deadline_upgrade", 

314 store=True, 

315 help="Deadline for completing the upgrade.", 

316 ) 

317 shell_command = fields.Text( 

318 compute="_compute_shell_command", 

319 help="Shell command to access the upgraded database environment via Odoo shell.", 

320 ) 

321 first_original_database_id = fields.Many2one( 

322 "saas.database", 

323 string="First Original Database", 

324 compute="_compute_first_original_database", 

325 store=True, 

326 readonly=True, 

327 recursive=True, 

328 help="Reference to the first original database in a series of upgrade requests.", 

329 ) 

330 next_request_ids = fields.One2many( 

331 "helpdesk.ticket.upgrade.request", 

332 "prev_request_id", 

333 string="Next Upgrade Requests", 

334 ) 

335 next_request_id = fields.Many2one( 

336 "helpdesk.ticket.upgrade.request", 

337 string="Next Upgrade Request", 

338 compute="_compute_next_request_id", 

339 inverse="_inverse_next_request_id", 

340 store=True, 

341 recursive=True, 

342 index=True, 

343 help="Reference to the next upgrade request in sequence.", 

344 ) 

345 prev_request_id = fields.Many2one( 

346 "helpdesk.ticket.upgrade.request", 

347 string="Previous Upgrade Request", 

348 index=True, 

349 help="Reference to the previous upgrade request in sequence.", 

350 ) 

351 prod_backup_id = fields.Many2one( 

352 "saas.cnpg.snapshot", 

353 string="Production Backup", 

354 help="Reference to the production backup taken before starting the upgrade.", 

355 ) 

356 int_backup_id = fields.Many2one( 

357 "saas.cnpg.snapshot", 

358 string="Intermediate Backup", 

359 help="Reference to the intermediate backup taken during the upgrade process.", 

360 ) 

361 

362 @api.model 

363 def default_get(self, fields_list): 

364 defaults = super().default_get(fields_list) 

365 # only if the context has a default_state we set the state otherwise let the original default 

366 default_state = self.env.context.get("default_state", False) 

367 if default_state and "state" in fields_list: 

368 defaults["state"] = default_state 

369 return defaults 

370 

371 @api.onchange("stop_at") 

372 def _onchange_stop_at(self): 

373 """Update stop_at_user_id when stop_at is modified from UI.""" 

374 if self.stop_at: 

375 self.stop_at_user_id = self.env.user 

376 else: 

377 self.stop_at_user_id = False 

378 

379 @api.depends("aim") 

380 @api.depends_context("force_by_pass") 

381 def _compute_on_by_pass(self): 

382 for rec in self: 

383 force_by_pass = self.env.context.get("force_by_pass", False) 

384 rec.on_by_pass = force_by_pass if force_by_pass else rec.aim == "production" 

385 

386 @api.depends("status", "state") 

387 def _compute_upgrade_alerts(self): 

388 for rec in self: 

389 upgrade_alerts = {} 

390 if rec.state in ["done", "cancel"]: 

391 rec.upgrade_alerts = upgrade_alerts 

392 continue 

393 

394 has_prev_request = bool(rec.prev_request_id) 

395 if has_prev_request and rec.prev_request_id.state != "done": 

396 message = _("Previous request #%s is not completed.") % rec.prev_request_id.id 

397 action_text = _("Go to previous request") 

398 action = rec.prev_request_id.get_formview_action() 

399 level = "info" 

400 else: 

401 if rec.status == "error": 

402 action = False 

403 action_text = False 

404 last_error_log = rec.upgrade_line_request_log_entry_ids.filtered( 

405 lambda log: log.type == "error" and not log.solved 

406 )[:1] 

407 if rec.with_error_log and last_error_log: 

408 action_text = _("Check the log errors here") 

409 action = last_error_log._get_records_action(name=_("Error Log")) 

410 message = _("This request has an error!") 

411 level = "danger" 

412 elif rec.status == "running" and rec.automatic: 

413 message = _("This request is being processed.") 

414 action_text = False 

415 action = False 

416 level = "warning" 

417 elif rec.status == "on_hold" and rec.automatic: 

418 message = _("This request is waiting to be processed.") 

419 action_text = False 

420 action = False 

421 level = "info" 

422 else: 

423 rec.upgrade_alerts = upgrade_alerts 

424 continue 

425 

426 upgrade_alerts["request_status"] = { 

427 "message": message, 

428 "action_text": action_text, 

429 "action": action, 

430 "level": level, 

431 } 

432 rec.upgrade_alerts = upgrade_alerts 

433 

434 @api.depends( 

435 "state", 

436 "ticket_id.custom_script_ids", 

437 "upgrade_type_id.upgrade_line_ids", 

438 "active_production_freeze", 

439 "deactivated_script_ids", 

440 ) 

441 def _compute_scripts(self): 

442 state_ul_type_map = { 

443 "on_create": "0_on_create", 

444 "draft": "1_evaluation", 

445 "validated": "2_pre", 

446 "restored": "3_pre-adhoc", 

447 "upgraded": "4_post", 

448 "running_tests": "5_test", 

449 "done": "6_after_done", 

450 } 

451 

452 ul_model = self.env["saas.upgrade.line"] 

453 for rec in self: 

454 ul_type = state_ul_type_map.get(rec.state) 

455 if not ul_type: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

456 rec.script_ids = ul_model 

457 continue 

458 original_database_version = rec.original_database_id.odoo_version_group_id 

459 domain = fields.Domain( 

460 [ 

461 ("type", "=", ul_type), 

462 ("state", "=", "approved"), 

463 ("run_type", "in", [rec.aim, False]), 

464 ("from_major_version_ids", "in", original_database_version.ids + [False]), 

465 ] 

466 ) 

467 

468 if ( 

469 rec.active_production_freeze 

470 and rec.aim == "production" 

471 and not ul_model._production_freeze_ignore(ul_type) 

472 ): 

473 # We use the upgrade_lines in the map, then in the run function we filter by version. 

474 # The map is a flat mapping of upgrade_line_id -> version. 

475 try: 

476 upgrade_line_versions_to_use = rec._get_upgrade_line_versions_to_use() 

477 freezed_upgrade_line_ids = ( 

478 upgrade_line_versions_to_use and [int(id) for id in upgrade_line_versions_to_use.keys()] or [] 

479 ) 

480 except Exception as e: 

481 _logger.exception("Error obtaining freezed upgrade lines for request %s: %s", rec.id, str(e)) 

482 freezed_upgrade_line_ids = [] 

483 

484 # If 'active_production_freeze' is True and request aim is 'production' and ul_type is not ignored in freeze 

485 # we restrict the 'script_ids' to run 

486 domain &= fields.Domain.OR( 

487 [ 

488 [("id", "in", freezed_upgrade_line_ids)], 

489 [("run_type", "=", "production")], 

490 ] 

491 ) 

492 

493 # Check for installed modules in the original database 

494 installed_modules = set(rec.original_database_id.module_ids.mapped("name")) 

495 

496 def _script_matches_modules(script: "SaasUpgradeLine") -> bool: 

497 if not script.module_ids: 497 ↛ 499line 497 didn't jump to line 499 because the condition on line 497 was always true

498 return True 

499 script_module_names = set(script.module_ids.mapped("name")) 

500 match script.module_check_logic: 

501 case "all": 

502 return script_module_names.issubset(installed_modules) 

503 case "any": 

504 return bool(script_module_names & installed_modules) 

505 return True 

506 

507 custom_scripts = rec.ticket_id.custom_script_ids.filtered( 

508 lambda ul: not ul.upgrade_type_ids or rec.upgrade_type_id in ul.upgrade_type_ids 

509 ) 

510 upgrade_type_scripts = rec.upgrade_type_id.upgrade_line_ids.filtered(lambda ul: not ul.upgrade_ticket_ids) 

511 scripts = ( 

512 (custom_scripts + upgrade_type_scripts) 

513 .filtered(domain) 

514 .filtered(lambda ul: _script_matches_modules(ul)) 

515 ) 

516 rec.script_ids = (scripts - rec.deactivated_script_ids).sorted() 

517 

518 @api.depends("original_database_id.state", "upgraded_database_id.state", "first_original_database_id.state") 

519 def _compute_with_database(self): 

520 valid_states = {"active", "inactive", "processing", "editing"} 

521 for rec in self: 

522 rec.with_original_database = rec.original_database_id.state in valid_states 

523 rec.with_upgraded_database = rec.upgraded_database_id.state in valid_states 

524 rec.with_first_original_database = rec.first_original_database_id.state in valid_states 

525 

526 @api.depends("end_date", "planned_start", "prev_request_id", "prev_request_id.end_date") 

527 def _compute_real_total_time(self): 

528 for rec in self: 

529 if rec.end_date and rec.planned_start: 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true

530 rec.real_total_time = ((rec.end_date - rec.planned_start).total_seconds()) / 3600.0 

531 prev_requests = rec._get_previous_requests() 

532 rec.cum_real_total_time = rec.real_total_time + sum(prev_requests.mapped("real_total_time")) 

533 

534 @api.depends("state", "prev_request_id", "prev_request_id.end_date") 

535 def _compute_total_time(self): 

536 states = [x[0] for x in self._fields["state"].selection] 

537 for rec in self.filtered(lambda r: not r.end_date): 

538 # Singular total time logic 

539 state_index = states.index(rec.state) 

540 next_states = states[state_index:] 

541 time_on_hold: dict = rec.time_on_hold or {} 

542 time_running: dict = rec.time_running or {} 

543 time_on_hold_keys = set(time_on_hold.keys()) if time_on_hold else set() 

544 time_running_keys = set(time_running.keys()) if time_running else set() 

545 for key in time_on_hold_keys | time_running_keys: 545 ↛ 546line 545 didn't jump to line 546 because the loop on line 545 never started

546 if key not in next_states: 

547 continue 

548 time_on_hold[key] = 0 

549 time_running[key] = 0 

550 

551 rec.time_on_hold = time_on_hold 

552 rec.time_running = time_running 

553 time = 0 

554 if rec.time_on_hold: 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true

555 time += sum(rec.time_on_hold.values()) 

556 if rec.time_running: 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true

557 time += sum(rec.time_running.values()) 

558 rec.total_time = time / 3600.0 

559 

560 # Accumulated total time logic 

561 prev_requests = rec._get_previous_requests() 

562 rec.cum_total_time = rec.total_time + sum(prev_requests.mapped("total_time")) 

563 

564 @api.depends("state", "prev_request_id", "prev_request_id.request_completion") 

565 def _compute_request_completion(self): 

566 states = [x[0] for x in self._upgrade_request_states if x[0] not in ["on_create", "cancel"]] 

567 states_len = len(states) 

568 for rec in self: 

569 if rec.state in ["on_create", "cancel"]: 

570 rec.request_completion = 0.0 

571 continue 

572 rec_len = states.index(rec.state) 

573 prev_requests = rec._get_previous_requests() 

574 current_completion = round(rec_len / (states_len - 1), 2) 

575 total_prev_completion = sum(prev_requests.mapped("request_completion")) 

576 rec.request_completion = (current_completion + total_prev_completion) / (len(prev_requests) + 1) 

577 

578 @api.depends("with_upgraded_database") 

579 def _compute_shell_command(self): 

580 for rec in self: 

581 no_new_client = not rec.with_upgraded_database 

582 rec.shell_command = dedent(SHELL_COMMAND_TEMPLATE.format(rec.id, no_new_client)).strip() 

583 

584 @api.depends("prev_request_id", "prev_request_id.first_original_database_id", "original_database_id") 

585 def _compute_first_original_database(self): 

586 for rec in self: 

587 if rec.prev_request_id: 

588 rec.first_original_database_id = rec.prev_request_id.first_original_database_id 

589 else: 

590 rec.first_original_database_id = rec.original_database_id 

591 

592 @api.depends("next_request_ids") 

593 def _compute_next_request_id(self): 

594 for rec in self: 

595 rec.next_request_id = rec.next_request_ids[:1] 

596 

597 def _inverse_next_request_id(self): 

598 for rec in self: 

599 if rec.next_request_id: 

600 rec.next_request_id.prev_request_id = rec 

601 

602 @api.constrains("ticket_id", "original_database_id", "upgrade_type_id") 

603 def _check_databases_version(self): 

604 for rec in self: 

605 original_database_version = rec.original_database_id.odoo_version_group_id 

606 target_version = rec.upgrade_type_id.target_odoo_version_group_id 

607 if original_database_version and (original_database_version.sequence <= target_version.sequence): 

608 # Sequence is used descending, so we check if the original version is lower than the target version 

609 # 18.0 -> sequence 1, 17.0 -> sequence 2 

610 raise ValidationError( 

611 _("The original database version must be lower than the upgraded database version.") 

612 ) 

613 

614 @api.ondelete(at_uninstall=False) 

615 def _check_aim_before_deletion(self): 

616 if self.env.context.get("bypass_delete", False): 

617 return 

618 for rec in self: 

619 if rec.aim == "production": 

620 raise UserError( 

621 _("Cannot delete 'Production' type requests. If necessary, cancel this one and schedule a new one.") 

622 ) 

623 

624 @api.model_create_multi 

625 def create(self, vals_list): 

626 from_create_request = self.env.context.get("from_create_request", False) 

627 if not from_create_request: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 raise UserError( 

629 _("Upgrade requests can only be created via the designated creation method '_create_request'.") 

630 ) 

631 

632 res = super().create(vals_list) 

633 for rec in res: 

634 rec.run_on_create_scripts() 

635 if rec.state == "on_create": 

636 rec._next_state() 

637 return res 

638 

639 def write(self, vals): 

640 state = vals.get("state", False) 

641 vals_upgraded_database_id = vals.get("upgraded_database_id", False) 

642 vals_original_database_id = vals.get("original_database_id", False) 

643 automatic = vals.get("automatic", False) 

644 for rec in self.sudo(): 

645 runs = self.env["saas.upgrade.line.request.run"] 

646 if rec.aim == "production" and state == "cancel" and rec.state != "draft": 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 raise UserError(_("Only upgrade requests in draft can be cancelled.")) 

648 if vals_original_database_id and rec.original_database_id.id != vals_original_database_id: 648 ↛ 649line 648 didn't jump to line 649 because the condition on line 648 was never true

649 runs = rec.upgrade_line_request_run_ids 

650 elif vals_upgraded_database_id and rec.upgraded_database_id.id != vals_upgraded_database_id: 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true

651 rec._delete_log_attachment("upgrade_log_attachment_id") 

652 runs |= rec.upgrade_line_request_run_ids.filtered( 

653 lambda u: u.upgrade_line_id.type in ["3_pre-adhoc", "4_post", "5_test", "6_after_done"] 

654 ) 

655 if rec.automatic and "automatic" in vals and not automatic: 

656 runs |= rec.upgrade_line_request_run_ids.filtered(lambda r: r.state in ["pending", "waiting"]) 

657 runs.write({"request_id": False, "state": False}) 

658 runs.mapped("bg_job_id").cancel(self.env._("Upgrade request is no longer valid.")) 

659 res = super().write(vals) 

660 for rec in self.sudo(): 

661 # if we cancel request execute the method to completen the process 

662 if state == "cancel" and rec.state == "cancel": 

663 rec._process_cancel_upgrade_request() 

664 if not self.env.context.get("cancel_cascade"): 

665 related_requests = rec._get_previous_requests() | rec._get_next_requests() 

666 related_requests.with_context(cancel_cascade=True).state = "cancel" 

667 return res 

668 

669 def _create_log_attachment( 

670 self, field_name: str, file_content: str | bytes, filename: str, description: str 

671 ) -> None: 

672 """ 

673 Create or update log file attachment in the chatter and link it to the given field. 

674 

675 :param field_name: Name of the Many2one field to link (e.g. 'upgrade_log_attachment_id') 

676 :param file_content: Base64 encoded file content (str or bytes) 

677 :param filename: Name of the attachment file 

678 :param description: Description for the attachment 

679 """ 

680 self.ensure_one() 

681 existing = self[field_name] 

682 if existing: 

683 existing.write({"datas": file_content, "name": filename}) 

684 else: 

685 attachment = self.env["ir.attachment"].create( 

686 { 

687 "name": filename, 

688 "datas": file_content, 

689 "res_model": self._name, 

690 "res_id": self.id, 

691 "description": description, 

692 "type": "binary", 

693 } 

694 ) 

695 self[field_name] = attachment 

696 

697 def _delete_log_attachment(self, field_name: str) -> None: 

698 """ 

699 Delete log file attachment from the chatter and clear the field. 

700 

701 :param field_name: Name of the Many2one field to clear (e.g. 'upgrade_log_attachment_id') 

702 """ 

703 self.ensure_one() 

704 if self[field_name]: 

705 self[field_name].unlink() 

706 

707 def web_read(self, specification): 

708 fields_to_read = list(specification) or ["id"] 

709 if "script_ids" in fields_to_read and "context" in specification["script_ids"]: 

710 specification["script_ids"]["context"].update({"ticket_request_id": self._ids[:1]}) 

711 if "deactivated_script_ids" in fields_to_read and "context" in specification["deactivated_script_ids"]: 

712 specification["deactivated_script_ids"]["context"].update( 

713 {"ticket_request_id": self._ids[:1], "deactivated_scripts": True} 

714 ) 

715 return super().web_read(specification) 

716 

717 def action_login_with_user(self): 

718 database = self.env.context.get("database") 

719 if database: 

720 return self[database].action_login_with_user() 

721 

722 def action_choose_user_login(self): 

723 database = self.env.context.get("database") 

724 if database: 

725 return self[database].action_choose_user_login() 

726 

727 @api.model 

728 def action_log_in(self) -> dict: 

729 database_id = self.env["saas.database"].browse(self.env.context.get("database_id")) 

730 if not database_id: 

731 title = self.env._("Access Error") 

732 message = self.env._( 

733 "An error occurred while trying to log into the database, please try again. " 

734 "If the problem persists, contact support." 

735 ) 

736 action_to_return = self.build_display_notification_action(title, message, "warning") 

737 return action_to_return 

738 user_list = ( 

739 self.env["saas.database.user_list"] 

740 .sudo() 

741 .with_context(portal=True) 

742 .create({"database_id": database_id.active_subscription_id.main_database_id.id}) 

743 ) 

744 user = user_list.user_ids.filtered(lambda x: x.saas_provider_uuid == self.env.user.saas_uuid) 

745 return { 

746 "type": "ir.actions.act_url", 

747 "target": "new", 

748 "url": database_id._get_login_url(user.login), 

749 } 

750 

751 @api.model 

752 def action_log_in_as(self) -> dict: 

753 database_id = self.env["saas.database"].browse(self.env.context.get("database_id")) 

754 if not database_id: 

755 title = self.env._("Access Error") 

756 message = self.env._( 

757 "An error occurred while trying to log into the database, please try again. " 

758 "If the problem persists, contact support." 

759 ) 

760 action_to_return = self.build_display_notification_action(title, message, "warning") 

761 return action_to_return 

762 

763 return { 

764 "type": "ir.actions.act_url", 

765 "target": "new", 

766 "url": database_id.get_portal_url(suffix="/users"), 

767 } 

768 

769 def action_open_upgrade_lines(self): 

770 self.ensure_one() 

771 action = self.env["ir.actions.act_window"]._for_xml_id("saas_provider_upgrade.action_saas_upgrade_line") 

772 action["domain"] = [["id", "in", self.script_ids.ids]] 

773 action["context"] = {"ticket_request_id": self.id} 

774 return action 

775 

776 def action_open_running_status(self): 

777 self.ensure_one() 

778 action = self.env["ir.actions.act_window"]._for_xml_id( 

779 "saas_provider_upgrade.action_saas_upgrade_line_request_run" 

780 ) 

781 action["domain"] = [["request_id", "=", self.id]] 

782 action["context"] = { 

783 "search_default_applicable": True, 

784 "search_default_group_by_type": True, 

785 "default_request_id": self.id, 

786 } 

787 action["views"] = [(False, "list"), (False, "form")] 

788 return action 

789 

790 def action_cancel(self): 

791 self.ensure_one() 

792 if self.state == "cancel": 

793 return 

794 self.state = "cancel" 

795 

796 def action_open_customer_notes(self) -> dict: 

797 self.ensure_one() 

798 action = self.env["ir.actions.act_window"]._for_xml_id( 

799 "saas_provider_upgrade.action_helpdesk_ticket_customer_note" 

800 ) 

801 action["domain"] = [["ticket_id", "=", self.ticket_id.id], ["upgrade_type_id", "=", self.upgrade_type_id.id]] 

802 return action 

803 

804 def action_set_request(self): 

805 self.ensure_one() 

806 upgrade_line_id = self.env.context.get("upgrade_line_id", False) 

807 action = None 

808 if upgrade_line_id: 

809 upgrade_line = self.env["saas.upgrade.line"].browse(upgrade_line_id) 

810 try: 

811 old_client, new_client = self._get_databases_and_clients(attempts=1, sleep=1, timeout=5)[2:] 

812 except Exception: 

813 raise UserError( 

814 _("Cannot obtain client databases. Ensure both databases are active and without errors.") 

815 ) 

816 

817 if not upgrade_line.with_context(no_raise_on_filter_error=True, ticket_request_id=self.id)._applies( 

818 old_client, new_client 

819 ): 

820 raise UserError(_("This script does not apply to this request.")) 

821 

822 action: dict = self.env["ir.actions.actions"]._for_xml_id("saas_provider_upgrade.action_saas_upgrade_line") 

823 form_view = [(self.env.ref("saas_provider_upgrade.view_saas_upgrade_line_form").id, "form")] 

824 if "views" in action: 

825 action["views"] = form_view + [(state, view) for state, view in action["views"] if view != "form"] 

826 else: 

827 action["views"] = form_view 

828 action["res_id"] = upgrade_line_id 

829 new_context: dict = literal_eval(action.get("context", "{}")) 

830 new_context.update({"ticket_request_id": self.id}) 

831 action["context"] = new_context 

832 return action 

833 

834 def action_activate(self): 

835 self.ensure_one() 

836 request = self 

837 automatic = not request.automatic 

838 request.automatic = automatic 

839 request._on_hold() 

840 if not request.automatic: 

841 # Cancel pending tasks if we pause the process 

842 if request.original_database_id: 

843 request.original_database_id.action_cancel_task() 

844 if request.upgraded_database_id: 

845 request.upgraded_database_id.action_cancel_task() 

846 

847 # Sync the change to the whole chain 

848 chained_requests = (request._get_previous_requests() | request._get_next_requests()).filtered( 

849 lambda r: r.state not in ["done", "cancel"] 

850 ) 

851 for request in chained_requests: 

852 if request.automatic == automatic: 

853 continue 

854 request.automatic = automatic 

855 request._on_hold() 

856 

857 def action_open_request(self) -> dict: 

858 self.ensure_one() 

859 return { 

860 "type": "ir.actions.act_window", 

861 "name": "Database Upgrade Request", 

862 "res_model": self._name, 

863 "view_mode": "form", 

864 "res_id": self.id, 

865 } 

866 

867 def action_open_chained_requests(self) -> dict: 

868 self.ensure_one() 

869 request = self 

870 # Go to the first request 

871 while request.prev_request_id: 

872 request = request.prev_request_id 

873 

874 # Collect all requests in the chain 

875 chained_requests = request | request._get_next_requests() 

876 return { 

877 "name": _("Chained Requests"), 

878 "type": "ir.actions.act_window", 

879 "res_model": self._name, 

880 "view_mode": "list,form", 

881 "views": [ 

882 (self.env.ref("saas_provider_upgrade.view_helpdesk_ticket_upgrade_chained_requests_list").id, "list"), 

883 (False, "form"), 

884 ], 

885 "domain": [("id", "in", chained_requests.ids)], 

886 } 

887 

888 def action_open_form_or_chained(self) -> dict: 

889 """ 

890 Action to open either the current request form or the chained requests view 

891 """ 

892 self.ensure_one() 

893 if not self.prev_request_id and not self.next_request_id: 

894 return self.action_open_request() 

895 return self.action_open_chained_requests() 

896 

897 def action_toggle_portal_visibility(self): 

898 """ 

899 Toggle the hide_from_portal field value. 

900 Allows quick toggling of portal visibility directly from the list view. 

901 """ 

902 for rec in self: 

903 rec.hide_from_portal = not rec.hide_from_portal 

904 return True 

905 

906 def action_rollback(self): 

907 """ 

908 Rollback the upgrade request using 'upgrade-rollback' task. 

909 """ 

910 self.ensure_one() 

911 if self.aim != "production": 

912 raise UserError(self.env._("Only 'Production' aim requests can be rolled back.")) 

913 

914 main_database = self.ticket_id.main_database_id 

915 if main_database.state != "inactive" or not main_database.is_production: 

916 raise UserError( 

917 self.env._("Main database must be inactive and not a production copy to perform a rollback.") 

918 ) 

919 

920 main_database._schedule_task("upgrade-rollback", helpdesk_ticket_upgrade_request_id=self.id) 

921 

922 @api.model 

923 def _create_request( 

924 self, 

925 ticket_id: int, 

926 aim: Literal["test", "production"] = "test", 

927 odoo_aim: Literal["test", "production"] = "production", 

928 date_start: bool = False, 

929 automatic: bool = False, 

930 hide_from_portal: bool = False, 

931 event_upgrade_id: int = False, 

932 bypass: bool = False, 

933 stop_at: str | bool = False, 

934 ) -> "HelpdeskTicketUpgradeRequest": 

935 """ 

936 This method is used to generate upgrade requests from wherever it is required by sending the parameters. 

937 

938 :param ticket_id: ID of the ticket 

939 :param aim: Upgrade aim (test or production) 

940 :param odoo_aim: Odoo upgrade aim (test or production) 

941 :param date_start: Start/planned date 

942 :param automatic: If the request is automatic 

943 :param hide_from_portal: If the request should be hidden from portal 

944 :param event_upgrade_id: If there is an associated calendar event 

945 :param bypass: If we bypass the validations 

946 :param stop_at: State at which to stop the automatic request 

947 :return: The last created request 

948 """ 

949 ticket = self.env["helpdesk.ticket"].browse(ticket_id) 

950 if ticket.ticket_upgrade_status == "upgraded": 950 ↛ 951line 950 didn't jump to line 951 because the condition on line 950 was never true

951 return self.env["helpdesk.ticket.upgrade.request"] 

952 from_version = ticket.main_database_id.odoo_version_group_id 

953 target_version = ticket.upgrade_type_id.target_odoo_version_group_id 

954 steps = self.env["saas.upgrade.type"].calculate_upgrade_steps(from_version, target_version) 

955 prev_request = self.env["helpdesk.ticket.upgrade.request"] 

956 for step in steps: 

957 create_vals = { 

958 "ticket_id": ticket_id, 

959 "upgrade_type_id": step.id, 

960 "aim": aim, 

961 "odoo_aim": odoo_aim, 

962 "automatic": automatic, 

963 "hide_from_portal": hide_from_portal, 

964 "event_upgrade_id": event_upgrade_id, 

965 "prev_request_id": prev_request.id, 

966 } 

967 if not prev_request.id: 

968 create_vals["planned_start"] = date_start or fields.Datetime.now() 

969 create_vals["stop_at"] = stop_at 

970 if stop_at: 

971 create_vals["stop_at_user_id"] = self.env.user.id 

972 current_request = ( 

973 self.env["helpdesk.ticket.upgrade.request"] 

974 .sudo() 

975 .with_context(force_by_pass=bypass, from_create_request=True) 

976 .create(create_vals) 

977 ) 

978 prev_request = current_request 

979 

980 conditions = [ 

981 ( 

982 aim == "production" and ticket.upgrade_pull_request_ids.filtered(lambda p: p.state == "open"), 

983 _("IMPORTANT the ticket has unmerged PRs, remember to merge them before upgrading to production."), 

984 ), 

985 ] 

986 body = "<br/>".join([message for condition, message in conditions if condition]) 

987 if body: 987 ↛ 988line 987 didn't jump to line 988 because the condition on line 987 was never true

988 ticket.message_post( 

989 body=Markup(body), 

990 partner_ids=ticket.assigned_technician_id.partner_id.ids, 

991 ) 

992 return current_request 

993 

994 def run_on_create_scripts(self): 

995 """ 

996 Runs the on_create scripts when the request is created. 

997 If we get a result we raise an error and the request is not created. 

998 """ 

999 self.ensure_one() 

1000 if self.state == "on_create": 

1001 if self.on_by_pass: 

1002 script_ids = self.script_ids.filtered(lambda s: not s.allow_bypass) 

1003 if self.aim == "production" and self.env.user.has_group( 1003 ↛ 1006line 1003 didn't jump to line 1006 because the condition on line 1003 was never true

1004 "saas_provider_upgrade.group_upgrade_advanced_user" 

1005 ): 

1006 script_ids = self.env["saas.upgrade.line"] 

1007 else: 

1008 script_ids = self.script_ids 

1009 for script in script_ids: 

1010 script.run(self) 

1011 

1012 def process_automatic_requests(self): 

1013 """ 

1014 Process automatic requests, checking the state, a condition, and running the corresponding method 

1015 """ 

1016 state_method_map: dict[str, Callable[[HelpdeskTicketUpgradeRequest], Any]] = { 

1017 "draft": lambda r: r.evaluate(), 

1018 "validated": lambda r: r.request_to_odoo(), 

1019 "requested": lambda r: r.check_odoo_upgrade_state(message_post=False), 

1020 "odoo_done": lambda r: r.restore(), 

1021 "restored": lambda r: r.run_pre_adhoc(), 

1022 "upgrading": lambda r: r.run_upgrade(), 

1023 "upgraded": lambda r: r.finish_upgrade(), 

1024 "running_tests": lambda r: r.run_upgrade_tests(), 

1025 "done": lambda r: r.run_after_done(), 

1026 } 

1027 

1028 for rec in self.sorted(key="aim", reverse=True): 

1029 try: 

1030 method = state_method_map.get(rec.state) 

1031 method(rec) 

1032 except Exception as e: 

1033 self.env.cr.rollback() 

1034 _logger.exception("Error processing upgrade request %s in state %s: %s", rec.id, rec.state, str(e)) 

1035 rec._set_error_and_notify(e) 

1036 

1037 def evaluate(self): 

1038 """ 

1039 Generates the old database from the production database and runs the Evaluation scripts on it 

1040 """ 

1041 self.ensure_one() 

1042 upgrade_type = self.upgrade_type_id 

1043 main_database = self.ticket_id.project_id.main_database_id or self.ticket_id.main_database_id 

1044 self = self.with_context(lang="es_419") 

1045 if not main_database: 

1046 self._error() 

1047 raise UserError(self.env._("Could not upgrade database, no main database found")) 

1048 

1049 aim = self.aim 

1050 if not self.prev_request_id and (not self.original_database_id or self.original_database_id.state == "deleted"): 

1051 if aim == "production": 

1052 if main_database.state == "active": 

1053 expected_time = self.ticket_id._get_estimated_production_upgrade_duration(measure="minutes") 

1054 # Here we directly call the method triggered by a task to do it synchronously, since we need this to finish before continuing with the rest. 

1055 # This implies having to send the inactive_mode and eta values ​​via task_kwargs, so that it can be retrieved and then the database state changed. 

1056 task_kwargs = main_database.task_kwargs or {} 

1057 task_kwargs.update({"inactive_mode": "upgrade", "eta": int(time.time()) + expected_time * 60}) 

1058 main_database.task_kwargs = task_kwargs 

1059 main_database._inactivate() 

1060 main_database.task_kwargs = {} 

1061 # Notify the start of the upgrade request 

1062 user = self.ticket_id.assigned_technician_id or self.ticket_id.user_id 

1063 hours, minutes = divmod(expected_time, 60) 

1064 minutes = str(minutes).zfill(2) 

1065 self.urgent_communication( 

1066 self.env._( 

1067 "The upgrade request for %s has started.\n Responsible: %s.\n Estimated time for the upgrade: %s:%s hs" 

1068 ) 

1069 % (self.ticket_id.name, user.name, hours, minutes) 

1070 ) 

1071 if main_database.state != "inactive": 

1072 return None 

1073 

1074 original_database_type = upgrade_type.original_database_type_id 

1075 context = {"bypass_check_max_dbs": self.on_by_pass} 

1076 # If the database has 'disable_duplicate' we need to restore the last backup, 

1077 # we cannot restore the productive database 

1078 # TODO remove IF after boggio migracion 

1079 if main_database.disable_duplicate and aim == "test": 

1080 backup_list = ( 

1081 self.env["saas.backup_list"] 

1082 .with_context( 

1083 analytic_account_id=main_database.analytic_account_id.id, 

1084 environment=main_database.environment_id, 

1085 odoo_version=main_database.odoo_version_id, 

1086 database_type=original_database_type, 

1087 ) 

1088 .create({}) 

1089 ) 

1090 if backup_list: 

1091 last_backup = backup_list.line_ids.sorted("date", reverse=True)[0] 

1092 self.original_database_id = last_backup.with_context(**context).action_restore()["res_id"] 

1093 else: 

1094 raise UserError( 

1095 _( 

1096 "Could not generate old database, no backups found! Remember this database has 'disable_duplicate'." 

1097 ) 

1098 ) 

1099 else: 

1100 if aim == "production": 

1101 # In production do not force neutralize 

1102 context["force_neutralize"] = False 

1103 default = {"database_type_id": original_database_type.id} 

1104 self.original_database_id = main_database.with_context(**context)._action_duplicate(default) 

1105 task_kwargs = self.original_database_id.task_kwargs or {} 

1106 task_kwargs.update( 

1107 { 

1108 "helpdesk_ticket_upgrade_request_id": self.id, 

1109 "state_on_success": "editing", 

1110 } 

1111 ) 

1112 self.original_database_id.task_kwargs = task_kwargs 

1113 

1114 if self.original_database_id.state in ["active", "inactive", "editing"]: 

1115 if not self.prev_request_id: 

1116 # Only index installed modules if this is the first request in the chain 

1117 error_msgs = self.first_original_database_id._get_installed_modules() 

1118 if error_msgs: 

1119 error = _( 

1120 "Cannot get installed modules from the original database: <blockquote>%s</blockquote>" 

1121 ) % ("<br/>".join(error_msgs.mapped("body"))) 

1122 self.message_post(body=Markup(error)) 

1123 if aim == "production": 

1124 # Inactivate the main database in the first request of a production upgrade 

1125 self.ticket_id.main_database_id._schedule_task( 

1126 "upgrade-prod-backup", 

1127 helpdesk_ticket_upgrade_request_id=self.id, 

1128 state_on_success="inactive", 

1129 ) 

1130 self._running() 

1131 return self._chain_scripts_execution(self.script_ids, callback="_next_state") 

1132 

1133 def request_to_odoo(self): 

1134 """ 

1135 Runs the Pre-Odoo scripts and send the old database to Odoo for upgrade 

1136 """ 

1137 self.ensure_one() 

1138 self._running() 

1139 if self.aim == "production" and not self.prev_request_id and not self.original_database_id.protected: 

1140 # Before sending to odoo we protect the original database 

1141 self.original_database_id.write( 

1142 { 

1143 "protect_reason": "Original database for upgrade request %s" % self.id, 

1144 "protect_till_date": self.original_database_id.drop_date, 

1145 } 

1146 ) 

1147 self._delete_log_attachment("odoo_upgrade_log_attachment_id") 

1148 return self._chain_scripts_execution(self.script_ids, callback="_request_to_odoo_callback") 

1149 

1150 def _request_to_odoo_callback(self): 

1151 """ 

1152 Callback used when we finish the pre-odoo scripts to send the request to odoo 

1153 """ 

1154 # Reset the request key every send a request to odoo 

1155 self.odoo_request_token = False 

1156 self.original_database_id._schedule_task("odooupgrade", helpdesk_ticket_upgrade_request_id=self.id) 

1157 

1158 def check_odoo_upgrade_state(self, log_lines_qty: int = 50, message_post: bool = True): 

1159 """ 

1160 Checks the upgrade state on the Odoo upgrade 

1161 

1162 :param log_lines_qty: Number of log lines to retrieve in case of failure 

1163 :param message_post: Whether to post a message in the ticket or raise an error 

1164 """ 

1165 self._running() 

1166 response = self._get_odoo_upgrade_request_status() 

1167 status = response.get("status") 

1168 log_lines: str = self._get_odoo_upgrade_request_logs() 

1169 match status: 

1170 case "failed" | "cancelled": 

1171 log_list: list = log_lines.split("\n")[-log_lines_qty:] # Take the last "log_lines_qty" from the log 

1172 log: str = "<br/>".join(log_list) 

1173 if not message_post: 

1174 raise UserError(log) 

1175 self._error() 

1176 body = self.create_message(_("Odoo Upgrade failed!"), log) 

1177 case "done": 

1178 self._next_state() 

1179 body = _("Upgrade done!") 

1180 case _: 

1181 self._on_hold() 

1182 body = _("Upgrade status: %s") % status 

1183 

1184 # Save complete logs as attachment 

1185 if status in ["failed", "cancelled", "done"]: 

1186 encoded_log = base64.b64encode(log_lines.encode("utf-8")) 

1187 log_filename = f"odoo_log_{self.odoo_request_id}.txt" 

1188 self._create_log_attachment( 

1189 "odoo_upgrade_log_attachment_id", 

1190 encoded_log, 

1191 log_filename, 

1192 "Odoo Upgrade Log", 

1193 ) 

1194 

1195 if message_post: 

1196 self.message_post(body=body) 

1197 

1198 def restore(self): 

1199 """ 

1200 Restores the new database from Odoo 

1201 """ 

1202 self.ensure_one() 

1203 self._running() 

1204 response = self._get_odoo_upgrade_request_status() 

1205 if response.get("status") not in ["done"] or response.get("archived"): 

1206 raise UserError( 

1207 _( 

1208 "Cannot proceed because the request is no longer in Odoo Done or has been archived. It must be sent to Odoo again." 

1209 ) 

1210 ) 

1211 # Create the upgraded database record, if it doesn't exist 

1212 if not self.upgraded_database_id: 

1213 analytic_account = ( 

1214 self.ticket_id.main_database_id 

1215 and self.ticket_id.main_database_id.analytic_account_id 

1216 or self.ticket_id.project_id.account_id 

1217 ) 

1218 up_type = self.upgrade_type_id 

1219 version = self.upgrade_type_id.target_odoo_version_group_id._get_latest_version(False) 

1220 version = up_type.new_version_id or version 

1221 environment = up_type.new_environment_id or False 

1222 if self.on_by_pass: 

1223 analytic_account = analytic_account.with_context(bypass_check_max_dbs=True) 

1224 

1225 is_intermediate = bool(self.next_request_id) 

1226 database_type = ( 

1227 up_type.upgraded_database_type_id if not is_intermediate else up_type.intermediate_database_type_id 

1228 ) 

1229 self.upgraded_database_id = analytic_account.create_database( 

1230 database_type=database_type, 

1231 environment=environment, 

1232 odoo_version=version, 

1233 ) 

1234 # We use isdigit() to include all PRs that do not depend on a version 

1235 ticket_pulls = self.ticket_id.upgrade_pull_request_ids.filtered( 

1236 lambda p: not p.head_branch.replace(".", "").isdigit() or p.head_branch == up_type.target 

1237 ) 

1238 self.upgraded_database_id.pull_ids = (up_type.pull_ids | ticket_pulls).filtered( 

1239 lambda r: r.state not in ["dirty"] 

1240 ) 

1241 # Schedule the restore from Odoo 

1242 state_on_success = "editing" if not is_intermediate else "active" 

1243 self.upgraded_database_id._schedule_task( 

1244 "odoorestore", helpdesk_ticket_upgrade_request_id=self.id, state_on_success=state_on_success 

1245 ) 

1246 else: 

1247 if self.upgraded_database_id.state in ["editing", "active"]: 

1248 self._next_state() 

1249 else: 

1250 raise UserError(self.env._("Database is not in a valid state for restoration.")) 

1251 

1252 def run_pre_adhoc(self): 

1253 """ 

1254 Runs Pre-Adhoc scripts 

1255 """ 

1256 self.ensure_one() 

1257 self._running() 

1258 return self._chain_scripts_execution(self.script_ids, callback="_next_state") 

1259 

1260 def run_upgrade(self): 

1261 """ 

1262 Starts the upgrade process on the database 

1263 """ 

1264 self.ensure_one() 

1265 if self.upgrade_log_attachment_id: 

1266 raise UserError( 

1267 _( 

1268 "Cannot run upgrade process again, the request already has a log file. Please check the logs and restore the database if needed." 

1269 ) 

1270 ) 

1271 self._running() 

1272 # When the tasks finishes, it will call _next_state() or _error() 

1273 state_on_success = "editing" if not self.next_request_id else self.upgraded_database_id.state 

1274 self.upgraded_database_id._schedule_task( 

1275 "obaupgrade", helpdesk_ticket_upgrade_request_id=self.id, state_on_success=state_on_success 

1276 ) 

1277 

1278 def finish_upgrade(self): 

1279 """ 

1280 Runs the Post-Adhoc scripts, fix the database and finish the upgrade 

1281 """ 

1282 self.ensure_one() 

1283 self._running() 

1284 return self._chain_scripts_execution(self.script_ids, callback="_finish_upgrade_callback") 

1285 

1286 def _finish_upgrade_callback(self): 

1287 """ 

1288 Callback used when we finish the post-adhoc scripts to fix the database and finish the upgrade 

1289 """ 

1290 if self.upgraded_database_id.environment_id.bucket_config_id: 

1291 self.upgraded_database_id._ensure_bucket() 

1292 if not self.next_request_id: 

1293 self.upgraded_database_id._schedule_task( 

1294 "postupgrade", 

1295 helpdesk_ticket_upgrade_request_id=self.id, 

1296 state_on_success=self.upgraded_database_id.state, 

1297 ) 

1298 else: 

1299 self._next_state() 

1300 

1301 def run_upgrade_tests(self): 

1302 """ 

1303 Runs the Tests scripts. 

1304 """ 

1305 self.ensure_one() 

1306 self._running() 

1307 return self._chain_scripts_execution(self.script_ids, callback="_next_state") 

1308 

1309 def run_after_done(self): 

1310 """ 

1311 Runs After-Done scripts 

1312 """ 

1313 self.ensure_one() 

1314 self._running() 

1315 callback = "_run_after_done_callback" 

1316 if self.aim == "production" and not self.next_request_id: 

1317 main_database = self.ticket_id.main_database_id 

1318 # The 'after-done' scripts are executed in the last step of the task, on the new production database 

1319 match main_database.state: 

1320 case "deleted": 

1321 self.upgraded_database_id._action_duplicate_as_prod(self, callback=callback) 

1322 return 

1323 case "inactive": 

1324 main_database._schedule_task( 

1325 "upgrade-done", 

1326 state_on_success="deleted", 

1327 helpdesk_ticket_upgrade_request_id=self.id, 

1328 callback=callback, 

1329 ) 

1330 return 

1331 case _: 

1332 _logger.warning( 

1333 "The upgrade request %s is in an inconsistent state, the main database is in state '%s' when it should be 'inactive' or 'deleted'.", 

1334 self.id, 

1335 main_database.state, 

1336 ) 

1337 pass 

1338 

1339 self._chain_scripts_execution(self.script_ids, callback=callback) 

1340 

1341 def _run_after_done_callback(self): 

1342 """ 

1343 Callback used when we finish the after-done scripts to finish the request 

1344 """ 

1345 if not self.end_date: 

1346 self.write({"end_date": fields.Datetime.now()}) 

1347 

1348 self.automatic = False 

1349 self._on_hold() # _on_hold propagates to the next requests 

1350 original_database = self.original_database_id 

1351 upgraded_database = self.upgraded_database_id 

1352 next_request = self.next_request_id 

1353 if original_database.state == "editing": 

1354 original_database.apply_update() 

1355 if upgraded_database.state == "editing" and not next_request: 

1356 upgraded_database.apply_update() 

1357 

1358 if next_request: 

1359 upgraded_database._schedule_task( 

1360 "upgrade-int-backup", 

1361 helpdesk_ticket_upgrade_request_id=self.id, 

1362 state_on_success="active", 

1363 ) 

1364 next_request.write( 

1365 { 

1366 "original_database_id": upgraded_database.id, 

1367 "planned_start": fields.Datetime.now(), 

1368 } 

1369 ) 

1370 

1371 def run_after_error(self): 

1372 """ 

1373 Runs the After-Error scripts 

1374 """ 

1375 self.ensure_one() 

1376 # Disable automatic so that cron doesn't try to run it again 

1377 self.automatic = False 

1378 self.upgrade_type_id.run_after_error_script(self) 

1379 

1380 def _chain_scripts_execution(self, scripts: "SaasUpgradeLine", callback: str): 

1381 """ 

1382 Given a request and a list of scripts, create the runs to execute them in order 

1383 

1384 :param scripts: List of scripts to create the runs for 

1385 :param callback: Method to call when the chain is finished 

1386 """ 

1387 first_run_id = self.env["saas.upgrade.line.request.run"] 

1388 last_run_id = self.env["saas.upgrade.line.request.run"] 

1389 scripts = scripts.with_context(ticket_request_id=self.id) # To compute run_info_ids 

1390 # Resolve clients once to avoid repeated get_client_cluster() calls per script 

1391 try: 

1392 old_client, new_client = self._get_databases_and_clients(timeout=5)[2:] 

1393 except Exception: 

1394 raise UserError( 

1395 self.env._( 

1396 "Cannot obtain client databases. Ensure databases are active. We cannot validate the scripts applicability." 

1397 ) 

1398 ) 

1399 for script in scripts.filtered(lambda s: not s.run_info_id or s.run_info_id.state != "done"): 

1400 script._create_run_info_id() 

1401 if not script._applies(old_client, new_client): 

1402 script.run_info_id.set_not_applicable() 

1403 customer_notes = self.ticket_id.customer_note_ids.filtered(lambda c: c.upgrade_line_id == script) 

1404 if customer_notes: 

1405 customer_notes.write({"status": "done"}) 

1406 continue 

1407 

1408 script.run_info_id.write( 

1409 { 

1410 "state": "waiting", 

1411 } 

1412 ) 

1413 if last_run_id: 

1414 last_run_id.next_run_id = script.run_info_id.id 

1415 if not first_run_id: 

1416 first_run_id = script.run_info_id 

1417 last_run_id = script.run_info_id 

1418 

1419 if not first_run_id: 

1420 getattr(self, callback)() # If no runs were created, we call the callback directly 

1421 else: 

1422 if last_run_id.upgrade_line_id.execution_mode == "odoo_shell": 

1423 # If the last run is a job, we need to create a dummy run to hold the callback 

1424 # because sometimes callbacks also call jobs, and we can't have a job calling another job 

1425 dummy_run = self.env["saas.upgrade.line.request.run"].create( 

1426 { 

1427 "request_id": self.id, 

1428 "state": "waiting", 

1429 } 

1430 ) 

1431 last_run_id.next_run_id = dummy_run.id 

1432 last_run_id = dummy_run 

1433 last_run_id.callback = callback # We set the callback to the last run 

1434 first_run_id.enqueue(in_chain=True) # We enqueue the first run to start the chain 

1435 

1436 def _process_cancel_upgrade_request(self): 

1437 """ 

1438 This process the cancel of the upgrade request to send the notification to 

1439 the customer and cancel event of upgrade if exists 

1440 """ 

1441 self.ensure_one() 

1442 user_has_portal = self.env.user.has_group("base.group_portal") 

1443 if self.aim == "production": 

1444 self = self.sudo() 

1445 if user_has_portal: 1445 ↛ 1446line 1445 didn't jump to line 1446 because the condition on line 1445 was never true

1446 partners_to_notify = ( 

1447 self.ticket_id.user_id.partner_id | self.ticket_id.assigned_technician_id.partner_id 

1448 ) 

1449 self.ticket_id.message_post( 

1450 body=_("The customer has canceled the upgrade from the portal."), 

1451 partner_ids=partners_to_notify.ids, 

1452 ) 

1453 if self.event_upgrade_id: 1453 ↛ 1458line 1453 didn't jump to line 1458 because the condition on line 1453 was always true

1454 self.event_upgrade_id.action_cancel_meeting( 

1455 partner_ids=self.event_upgrade_id.attendee_ids.mapped("partner_id").ids 

1456 ) 

1457 # Reset the flag to allow approving the new scheduler date. 

1458 self.ticket_id.confirm_production_sent = False 

1459 self.automatic = False 

1460 runs = self.upgrade_line_request_run_ids.filtered(lambda r: r.state in ["running", "pending", "waiting"]) 

1461 if runs: 

1462 runs._cancel() 

1463 return True 

1464 

1465 def _is_no_new_client(self): 

1466 """ 

1467 Checks if the new client is available in the context, to avoid unnecessary calls to get_client_cluster 

1468 which can be costly if the client is not available 

1469 :return: True if no new client is needed 

1470 """ 

1471 self.ensure_one() 

1472 states = [x[0] for x in self._fields["state"].selection] 

1473 return self.env.context.get( 

1474 "no_new_client", True if states.index(self.state) <= states.index("restored") else False 

1475 ) 

1476 

1477 def _get_databases_and_clients( 

1478 self, attempts: int = 3, sleep: int = 3, timeout: int = 0 

1479 ) -> tuple[ 

1480 "SaasDatabase" | Literal[None], 

1481 "SaasDatabase" | Literal[None], 

1482 "Client" | Literal[False], 

1483 "Client" | Literal[False], 

1484 ]: 

1485 """ 

1486 Get the databases and clients for the request, with some optimizations to avoid unnecessary calls 

1487 returns original_database, upgraded_database, original_client, upgraded_client 

1488 :return: tuple of (original_database, upgraded_database, original_client, upgraded_client) 

1489 """ 

1490 self.ensure_one() 

1491 no_new_client = self._is_no_new_client() 

1492 no_database = self.env.context.get("no_database", False) 

1493 old_database = self.original_database_id.sudo() if not no_database else None 

1494 new_database = self.upgraded_database_id.sudo() if not no_database else None 

1495 old_client = ( 

1496 (old_database and not no_database) 

1497 and old_database.get_client_cluster(attempts=attempts, sleep=sleep, timeout=timeout) 

1498 or False 

1499 ) 

1500 new_client = ( 

1501 (new_database and not (no_database or no_new_client)) 

1502 and new_database.get_client_cluster(attempts=attempts, sleep=sleep, timeout=timeout) 

1503 or False 

1504 ) 

1505 return old_database, new_database, old_client, new_client 

1506 

1507 @contextmanager 

1508 def _get_eval_context(self, eval_context: dict, upgrade_line: Optional["SaasUpgradeLine"] = None): # noqa: C901 

1509 """ 

1510 Global context for the evaluation of the scripts 

1511 

1512 :param eval_context: dict to update with the eval context 

1513 :param upgrade_line: Optional upgrade line to include local context 

1514 """ 

1515 no_close_cursor = self.env.context.get("no_close_cursor", False) 

1516 old_database, new_database, old_client, new_client = self._get_databases_and_clients() 

1517 old_cr = old_database._get_cursor() if old_database and old_database.active else None 

1518 new_cr = new_database._get_cursor() if new_database and new_database.active else None 

1519 

1520 parameters = dict(safe_eval(self.ticket_id.custom_parameters_dict or "{}")) or {} 

1521 

1522 def run_sql(database, query, params=None, fetchall=False, do_not_raise=False): 

1523 if self.upgrade_type_id.upgraded_database_type_id == database.database_type_id: 

1524 cursor = new_cr 

1525 elif self.upgrade_type_id.original_database_type_id == database.database_type_id: 

1526 cursor = old_cr 

1527 else: 

1528 cursor = database._get_cursor() 

1529 try: 

1530 res = database._run_sql(cursor, query, params, fetchall=fetchall) 

1531 return res 

1532 except Exception as e: 

1533 if do_not_raise: 

1534 return False 

1535 raise e 

1536 

1537 def run_private_method(database, model, method, ids, args=None, kw=None, context=None, get_result=True): 

1538 client = database.get_client_cluster() 

1539 return client.db.saas_run_private_method( 

1540 database.instance_password, model, method, ids, args, kw, context, get_result 

1541 ) 

1542 

1543 def run_openupgradelib_method(database, method, args=None, kw=None): 

1544 client = database.get_client_cluster() 

1545 return client.db.saas_run_openupgradelib_method(database.instance_password, method, args, kw) 

1546 

1547 def run_upgrade_util_method(database, method, args=None, kw=None, exception=True): 

1548 client = database.get_client_cluster() 

1549 return client.db.saas_run_upgrade_util_method(database.instance_password, method, args, kw, exception) 

1550 

1551 def run_multiple_method(database, model, method, ids_and_args=None, kw=None, context=None): 

1552 client = database.get_client_cluster() 

1553 return client.db.saas_run_multiple_method( 

1554 database.instance_password, model, method, ids_and_args, kw, context 

1555 ) 

1556 

1557 def ensure_parameter(parameter): 

1558 if parameter not in parameters: 

1559 raise UserError(_('No parameter found for "%s"') % parameter) 

1560 res = parameters.get(parameter) 

1561 if res is None: 

1562 raise UserError(_('Parameter value not defined for "%s"') % parameter) 

1563 return res 

1564 

1565 def add_unordered_list(input_list): 

1566 html_list = "<ul>\n" 

1567 for item in input_list: 

1568 if isinstance(item, list): 

1569 html_list += f"<li>{add_unordered_list(item)}</li>\n" 

1570 else: 

1571 html_list += f"<li>{item}</li>\n" 

1572 html_list += "</ul>" 

1573 return html_list 

1574 

1575 def add_table(list_of_lists): 

1576 headers = list_of_lists[0] 

1577 data = list_of_lists[1:] 

1578 df = pd.DataFrame(data, columns=headers) 

1579 html_table = df.to_html(index=False, justify="center", escape=False, border=0) 

1580 html_table = html_table.replace("<table ", '<table border="1" ') 

1581 html_table = html_table.replace("<tr>", '<tr style="border: 1px solid black;">') 

1582 html_table = html_table.replace("<td>", '<td align="center" style="border: 1px solid black;">') 

1583 html_table = html_table.replace("<th>", '<th align="center" style="border: 1px solid black;">') 

1584 return html_table 

1585 

1586 def get_request_info(request): 

1587 return request._get_request_info() 

1588 

1589 def update_request_info(request, dict): 

1590 return request._update_request_info(dict) 

1591 

1592 eval_context.update( 

1593 { 

1594 # Libs 

1595 "base64": wrap_module(__import__("base64"), ["b64decode", "b64encode"]), 

1596 "re": wrap_module(__import__("re"), ["findall", "search", "match"]), 

1597 "requests": wrap_module(__import__("requests"), ["get", "post"]), 

1598 "time": time, 

1599 "datetime": datetime, 

1600 "timezone": pytz.timezone, 

1601 "dateutil": dateutil, 

1602 "json": json, 

1603 # Objects 

1604 "request": self, 

1605 "parameters": parameters, 

1606 "context": dict(self.env.context), # copy context to prevent side-effects of eval 

1607 "old_client": old_client, 

1608 "new_client": new_client, 

1609 "old_database": old_database, 

1610 "new_database": new_database, 

1611 "today": datetime.datetime.today(), 

1612 "user": self.env.user, 

1613 # Functions 

1614 "run_sql": run_sql, 

1615 "run_private_method": run_private_method, 

1616 "run_openupgradelib_method": run_openupgradelib_method, 

1617 "run_upgrade_util_method": run_upgrade_util_method, 

1618 "run_multiple_method": run_multiple_method, 

1619 "ensure_parameter": ensure_parameter, 

1620 "add_unordered_list": add_unordered_list, 

1621 "add_table": add_table, 

1622 "get_request_info": get_request_info, 

1623 "update_request_info": update_request_info, 

1624 "safe_eval": safe_eval, 

1625 "urgent_communication": self.urgent_communication, 

1626 } 

1627 ) 

1628 if upgrade_line: 

1629 eval_context.update(self._get_local_eval_context(upgrade_line)) 

1630 try: 

1631 yield 

1632 except Exception as e: 

1633 _logger.error("Error occurred while processing request: %s", e) 

1634 raise UserError(_("Error occurred while processing request: %s") % e) 

1635 finally: 

1636 if not no_close_cursor: 1636 ↛ exitline 1636 didn't return from function '_get_eval_context' because the condition on line 1636 was always true

1637 if old_cr: 1637 ↛ 1638line 1637 didn't jump to line 1638 because the condition on line 1637 was never true

1638 old_cr.close() 

1639 if new_cr: 1639 ↛ 1640line 1639 didn't jump to line 1640 because the condition on line 1639 was never true

1640 new_cr.close() 

1641 

1642 def _get_local_eval_context(self, upgrade_line: "SaasUpgradeLine"): # noqa: C901 

1643 """ 

1644 Local context by Upgrade Line 

1645 

1646 :param upgrade_line: Upgrade Line record 

1647 """ 

1648 upgrade_line.ensure_one() 

1649 ticket = self.ticket_id 

1650 RequestLog = self.env["saas.upgrade.line.request.log"] 

1651 ClientConfigLine = self.env["saas.upgrade.client.config.line"] 

1652 

1653 return { 

1654 "log_message": partial(RequestLog.log_message, upgrade_line, self), 

1655 "get_config_selected_values": partial(ClientConfigLine.get_config_selected_values, upgrade_line, ticket), 

1656 "add_config_boolean": partial(ClientConfigLine.add_config_boolean, upgrade_line, ticket), 

1657 "add_config_selection": partial(ClientConfigLine.add_config_selection, upgrade_line, ticket), 

1658 "add_config_selection_multi": partial(ClientConfigLine.add_config_selection_multi, upgrade_line, ticket), 

1659 "add_config_matrix": partial(ClientConfigLine.add_config_matrix, upgrade_line, ticket), 

1660 } 

1661 

1662 def _get_odoo_upgrade_request_logs(self) -> str: 

1663 """ 

1664 Returns the logs of the upgrade request in Odoo 

1665 """ 

1666 self.ensure_one() 

1667 UpgradeScript.set_servers_info(self.odoo_host_uri) 

1668 return UpgradeScript.get_logs(self.odoo_request_token) 

1669 

1670 def _get_odoo_upgrade_request_status(self) -> dict[str, Any]: 

1671 """ 

1672 Returns the status of the upgrade request in Odoo 

1673 """ 

1674 self.ensure_one() 

1675 if not self.odoo_request_token: 

1676 raise UserError(_("No request key found. Send the database to Odoo!")) 

1677 UpgradeScript.set_servers_info(self.odoo_host_uri) 

1678 return UpgradeScript.send_json_request("upgrade/request/status", {"token": self.odoo_request_token}) 

1679 

1680 def _set_error_and_notify(self, e: Exception | Markup | str) -> None: 

1681 """ 

1682 Generic method to handle cron errors 

1683 

1684 :param e: The error 

1685 """ 

1686 self._error() 

1687 title = _("Error in state: %s!") % self.state 

1688 message = self.create_message(title, str(e)) 

1689 self.message_post(body=message) 

1690 

1691 def _next_state(self): 

1692 """ 

1693 From the current state, we look up the next state in the MAP_NEXT_STATES map and update it. 

1694 If stop_at is set and matches the next state, the request will be stopped and the assigned 

1695 technician will be notified. 

1696 """ 

1697 self.ensure_one() 

1698 next_state = self._get_next_state(self.state) 

1699 vals = {"state": next_state} 

1700 

1701 # Check if we should stop at this state 

1702 if self.stop_at and self.stop_at == next_state and self.automatic: 

1703 user_to_notify = self.stop_at_user_id or self.ticket_id.assigned_technician_id 

1704 # Reset stop_at field and disable automatic execution so the cron does not keep processing it 

1705 vals["stop_at"] = False 

1706 vals["stop_at_user_id"] = False 

1707 vals["automatic"] = False 

1708 self.write(vals) 

1709 self._on_hold() 

1710 if user_to_notify: 1710 ↛ exitline 1710 didn't return from function '_next_state' because the condition on line 1710 was always true

1711 state_label = dict(self._get_stop_at_states()).get(next_state) 

1712 message = Markup( 

1713 _( 

1714 "The upgrade request has reached the stop state: <strong>{}</strong>. " 

1715 "The automatic execution has been paused." 

1716 ).format(state_label) 

1717 ) 

1718 self.message_post( 

1719 body=message, 

1720 partner_ids=user_to_notify.partner_id.ids, 

1721 message_type="notification", 

1722 subtype_xmlid="mail.mt_note", 

1723 ) 

1724 else: 

1725 self.write(vals) 

1726 self._on_hold() 

1727 

1728 @api.model 

1729 def _get_next_state(self, state: str) -> str: 

1730 """ 

1731 Returns the next state based on the given state using the MAP_NEXT_STATES map. 

1732 

1733 :param state: Some state of the request 

1734 """ 

1735 if state not in MAP_NEXT_STATES.keys(): 1735 ↛ 1736line 1735 didn't jump to line 1736 because the condition on line 1735 was never true

1736 raise UserError(_("Error! Can't use _next_state() with %s state!") % state) 

1737 

1738 next_state = MAP_NEXT_STATES[state] 

1739 return next_state 

1740 

1741 @api.model 

1742 def _get_prev_state(self, state: str) -> str: 

1743 """ 

1744 Returns the previous state based on the given state using the MAP_NEXT_STATES map. 

1745 

1746 :param state: Some state of the request 

1747 """ 

1748 if state not in MAP_NEXT_STATES.values(): 1748 ↛ 1749line 1748 didn't jump to line 1749 because the condition on line 1748 was never true

1749 raise UserError(_("Error! Can't use _get_prev_state() with %s state!") % state) 

1750 

1751 prev_state = next(k for k, v in MAP_NEXT_STATES.items() if v == state) 

1752 return prev_state 

1753 

1754 def _on_hold(self): 

1755 """ 

1756 Set request on 'on_hold' 

1757 """ 

1758 res = dict() 

1759 # Only count time if we are not in 'draft' and we are automatic 

1760 if self.state != "draft" and self.automatic: 

1761 res = self._get_status_time("running") # Always search for "time_running" and reset the counter 

1762 res.update(self._set_init_time(fields.Datetime.now())) 

1763 # we clear with_error_log when putting the request on hold, even if it was previously in error state 

1764 res.update({"status": "on_hold", "with_error_log": False}) 

1765 self.write(res) 

1766 self._get_next_requests().write({"status": "on_hold"}) 

1767 

1768 def _running(self): 

1769 """ 

1770 Set request on 'running' 

1771 """ 

1772 res = self._get_status_time("on_hold") # Always search for "time_on_hold" and reset the counter 

1773 res.update(self._set_init_time(fields.Datetime.now())) 

1774 res.update({"status": "running"}) 

1775 self.write(res) 

1776 self._get_next_requests().write({"status": "running"}) 

1777 

1778 def _error(self): 

1779 """ 

1780 Set request on 'error' 

1781 """ 

1782 res = self._get_status_time("running") # Always search for "time_running" and reset the counter 

1783 res.update({"status": "error"}) 

1784 res.update(self._set_init_time(False)) 

1785 self.write(res) 

1786 self._get_next_requests().write({"status": "error"}) 

1787 

1788 def _get_status_time(self, status: Literal["on_hold", "running"]) -> dict: 

1789 """ 

1790 Updates the time of the request in the actual state 

1791 We take the time from two fields, time_running and time_on_hold 

1792 Fields structure: 

1793 { 

1794 <state>: <seconds>, 

1795 } 

1796 

1797 :param status: The status to look for the time (running or on_hold) 

1798 :return: A dict with the updated time field to write 

1799 """ 

1800 now = fields.Datetime.now() 

1801 res = dict() 

1802 if not self.end_date and (init_time := self._get_init_time()): 

1803 key = f"time_{status}" 

1804 value = getattr(self, key) or {} 

1805 # We must get the previous state to the current one in case of 'running' 

1806 # since the time represents the previous stage, not the current one 

1807 state = self._get_prev_state(self.state) if status == "running" else self.state 

1808 actual_time = value.get(state, 0) 

1809 value.update({state: actual_time + (now - init_time).seconds}) 

1810 res[key] = value 

1811 return res 

1812 

1813 def _get_request_info(self) -> dict: 

1814 """ 

1815 Evals the request_info field and returns it as a dict 

1816 

1817 :return: dict with the request_info field 

1818 """ 

1819 request_info = safe_eval(self.request_info or "{}", {"datetime": datetime}) 

1820 return request_info 

1821 

1822 def _update_request_info(self, dict: dict) -> dict: 

1823 """ 

1824 Updates the request_info field with the given dict 

1825 

1826 :param dict: dictionary to update the request_info field 

1827 :return: dict with the updated request_info field 

1828 """ 

1829 request_info = self._get_request_info() 

1830 request_info.update(dict) 

1831 self.request_info = str(request_info) 

1832 return {"request_info": str(request_info)} 

1833 

1834 def _set_init_time(self, val: dt) -> dict: 

1835 """ 

1836 Uses the request_info field to store the initial time of an stage of the request 

1837 

1838 :param val: datetime object that represents the initial time 

1839 :return: dict with the updated request_info field 

1840 """ 

1841 return self._update_request_info({"init_time": val}) 

1842 

1843 def _get_init_time(self) -> dt: 

1844 """ 

1845 Uses the request_info field to get the initial time of an stage of the request 

1846 """ 

1847 request_info = self._get_request_info() 

1848 init_time = request_info.get("init_time", False) 

1849 return init_time 

1850 

1851 def _get_previous_requests(self) -> "HelpdeskTicketUpgradeRequest": 

1852 """ 

1853 Returns all previous requests linked to this one 

1854 

1855 :return: A recordset of all previous requests 

1856 """ 

1857 previous_requests = self.env["helpdesk.ticket.upgrade.request"] 

1858 current_request = self 

1859 while current_request.prev_request_id: 

1860 previous_request = current_request.prev_request_id 

1861 previous_requests |= previous_request 

1862 current_request = previous_request 

1863 return previous_requests 

1864 

1865 def _get_next_requests(self) -> "HelpdeskTicketUpgradeRequest": 

1866 """ 

1867 Returns all next requests linked to this one 

1868 

1869 :return: A recordset of all next requests 

1870 """ 

1871 next_requests = self.env["helpdesk.ticket.upgrade.request"] 

1872 current_request = self 

1873 while current_request.next_request_id: 

1874 next_request = current_request.next_request_id 

1875 next_requests |= next_request 

1876 current_request = next_request 

1877 return next_requests 

1878 

1879 def _get_upgrade_line_versions_to_use(self) -> dict: 

1880 """ 

1881 Returns all upgrade lines versions to use for the request 

1882 

1883 :return: A dict of all upgrade lines versions to use 

1884 """ 

1885 last_test_requests = self.ticket_id.request_line_ids.filtered(lambda x: x.aim == "test" and x.state == "done") 

1886 last_test_request = last_test_requests[-1] if last_test_requests else None 

1887 if not last_test_request: 

1888 return {} 

1889 

1890 # Walk backwards through the chain and keep only the request for the 

1891 # same upgrade type as the current request (same upgrade jump) 

1892 chain_requests = last_test_request._get_previous_requests() | last_test_request 

1893 request = chain_requests.filtered(lambda r: r.upgrade_type_id == self.upgrade_type_id) 

1894 if not request: 1894 ↛ 1895line 1894 didn't jump to line 1895 because the condition on line 1894 was never true

1895 raise UserError( 

1896 self.env._("No previous test request found for upgrade type %s. Cannot determine upgrade lines to use.") 

1897 ) 

1898 

1899 request.ensure_one() 

1900 return request.upgrade_line_versions_used or {} 

1901 

1902 def reactivate_databases_from_portal(self): 

1903 """ 

1904 Method to reactivate the databases from the portal 

1905 """ 

1906 self.ensure_one() 

1907 if self.upgraded_database_id.state == "inactive": 

1908 self.upgraded_database_id.sudo()._schedule_task("reactivate") 

1909 if self.original_database_id.state == "inactive": 

1910 self.original_database_id.sudo()._schedule_task("reactivate") 

1911 return self.build_display_notification_action( 

1912 title=_("Activation of databases scheduled"), 

1913 message=_( 

1914 "The databases will been activated successfully in a few minutes. This reactivation lasts 24 hours." 

1915 ), 

1916 ) 

1917 

1918 @api.model 

1919 def _cron_automatic_requests(self): 

1920 requests = self.search( 

1921 [ 

1922 ("automatic", "=", True), 

1923 ("status", "=", "on_hold"), 

1924 ("state", "!=", "cancel"), 

1925 ("planned_start", "<=", "now"), 

1926 "|", 

1927 ("aim", "!=", "production"), 

1928 ("ticket_id.confirm_production_sent", "=", True), 

1929 ], 

1930 order="planned_start asc", 

1931 ) 

1932 _logger.info("Running requests %s" % requests.ids) 

1933 requests.process_automatic_requests() 

1934 

1935 @api.model 

1936 def _cron_automatic_error_requests(self, limit: int = 5): 

1937 requests = self.search( 

1938 [ 

1939 ("automatic", "=", True), 

1940 ("status", "=", "error"), 

1941 ], 

1942 limit=limit, 

1943 order="aim asc, planned_start asc", 

1944 ) 

1945 _logger.info("Running After Error script for requests %s" % requests.ids) 

1946 for rec in requests: 

1947 try: 

1948 rec.run_after_error() 

1949 except Exception as e: 

1950 title = _("Error running After Error script!") 

1951 message = self.create_message(title, e) 

1952 

1953 # Post to chatter and notify assigned technician if available 

1954 partner_ids = [] 

1955 if rec.ticket_id.assigned_technician_id: 

1956 partner_ids = [rec.ticket_id.assigned_technician_id.partner_id.id] 

1957 

1958 rec.message_post(body=message, partner_ids=partner_ids) 

1959 

1960 @api.model 

1961 def _cron_monitor_production_requests(self, min_hours: float = 1.0): 

1962 now = fields.Datetime.now() 

1963 requests = self.search( 

1964 [ 

1965 ("automatic", "=", True), 

1966 ("aim", "=", "production"), 

1967 ("planned_start", "<=", now), 

1968 ("ticket_id.confirm_production_sent", "=", True), 

1969 ] 

1970 ) 

1971 _logger.info("Monitoring production requests %s" % requests.ids) 

1972 min_seconds = min_hours * 3600 

1973 for rec in requests: 

1974 request_info = rec._get_request_info() 

1975 already_monitored = request_info.get("already_monitored", False) 

1976 if already_monitored: 

1977 continue 

1978 time_passed = (now - rec.planned_start).total_seconds() 

1979 expected_time = max(min_seconds, rec.ticket_id.last_test_request_line_id.total_time * 3600) 

1980 if expected_time and time_passed > expected_time: 

1981 rec._update_request_info({"already_monitored": True}) 

1982 rec.urgent_communication( 

1983 self.with_context(lang="es_419").env._( 

1984 "The upgrade for %s is taking longer than expected. Actual state: %s" 

1985 ) 

1986 % (rec.ticket_id.name, rec.state) 

1987 ) 

1988 

1989 @api.ondelete(at_uninstall=False) 

1990 def _unlink_from_parent_request(self): 

1991 """ 

1992 Unlink the whole chain when deleting the last request in the chain. 

1993 Prevent deleting a request that has a next request. 

1994 """ 

1995 bypass_delete = self.env.context.get("bypass_delete", False) 

1996 if bypass_delete: 

1997 return 

1998 

1999 # Check if any request has a next request that is NOT being deleted in this batch 

2000 requests_being_deleted = self.ids 

2001 for rec in self: 

2002 if rec.next_request_id and rec.next_request_id.id not in requests_being_deleted: 

2003 raise UserError( 

2004 self.env._( 

2005 "You cannot delete an upgrade request that has a next request (ID %s). " 

2006 "Delete the whole chain instead from the last request." 

2007 ) 

2008 % rec.id 

2009 ) 

2010 

2011 to_delete = self.env["helpdesk.ticket.upgrade.request"] 

2012 for rec in self: 

2013 to_delete |= rec._get_previous_requests() 

2014 

2015 to_delete = to_delete - self 

2016 if to_delete: 

2017 to_delete.with_context(bypass_delete=True).unlink() 

2018 

2019 @api.autovacuum 

2020 def _gc_upgrade_requests(self): 

2021 """ 

2022 Autovacuum to permanently delete upgrade requests according to business rules: 

2023 - Test requests: canceled ones AND ones from databases that are upgraded to new version 

2024 AND ones from canceled tickets 

2025 - Production requests: canceled ones AND ones from tickets that are already upgraded 

2026 - Requests from canceled tickets 

2027 """ 

2028 cutoff_date = fields.Datetime.now() - timedelta(days=1) 

2029 

2030 # Test requests to delete 

2031 test_requests_to_delete = self.search( 

2032 [ 

2033 ("aim", "=", "test"), 

2034 ("create_date", "<", cutoff_date), 

2035 ("state", "=", "cancel"), 

2036 ("ticket_id.ticket_upgrade_status", "=", "upgraded"), 

2037 ("ticket_id.stage_id.fold", "=", True), 

2038 ("next_request_id", "=", False), 

2039 ] 

2040 ) 

2041 

2042 # Production requests to delete 

2043 production_requests_to_delete = self.search( 

2044 [ 

2045 ("aim", "=", "production"), 

2046 ("create_date", "<", cutoff_date), 

2047 ("state", "=", "cancel"), 

2048 ("ticket_id.ticket_upgrade_status", "=", "upgraded"), 

2049 ("next_request_id", "=", False), 

2050 ] 

2051 ) 

2052 

2053 # Requests from canceled tickets 

2054 canceled_ticket_requests = self.search( 

2055 [ 

2056 ("create_date", "<", cutoff_date), 

2057 ("ticket_id.ticket_upgrade_status", "=", "no_upgraded"), 

2058 ("ticket_id.stage_id.fold", "=", True), 

2059 ("next_request_id", "=", False), 

2060 ] 

2061 ) 

2062 

2063 total_requests_to_delete = test_requests_to_delete | production_requests_to_delete | canceled_ticket_requests 

2064 

2065 if total_requests_to_delete: 

2066 _logger.info( 

2067 "Autovacuum: Deleting %d upgrade requests (test: %d, production: %d, canceled tickets: %d)", 

2068 len(total_requests_to_delete), 

2069 len(test_requests_to_delete), 

2070 len(production_requests_to_delete), 

2071 len(canceled_ticket_requests), 

2072 ) 

2073 total_requests_to_delete.with_context(bypass_delete=True).unlink() 

2074 _logger.info("Autovacuum: Successfully deleted upgrade requests") 

2075 else: 

2076 _logger.info("Autovacuum: No upgrade requests to delete")