Coverage for ingadhoc-odoo-saas-adhoc / saas_provider_upgrade / models / helpdesk_ticket_customer_note.py: 75%

165 statements  

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

1############################################################################## 

2# For copyright and license notices, see __manifest__.py file in module root 

3# directory 

4############################################################################## 

5 

6import hashlib 

7import logging 

8import re 

9from datetime import timedelta 

10from typing import TYPE_CHECKING 

11 

12from lxml import etree 

13from markupsafe import Markup 

14from odoo import SUPERUSER_ID, api, fields, models 

15from odoo.addons.html_editor.tools import handle_history_divergence 

16from odoo.exceptions import UserError 

17from odoo.fields import Domain 

18from odoo.models import BaseModel 

19from odoo.tools import is_html_empty, safe_eval 

20 

21if TYPE_CHECKING: 

22 from ..models.helpdesk_ticket_upgrade_request import HelpdeskTicketUpgradeRequest as UpgradeRequest 

23 from ..models.saas_upgrade_line import SaasUpgradeLine as SaasUpgradeLine 

24 

25_logger = logging.getLogger(__name__) 

26 

27 

28class HelpdeskTicketCustomerNote(models.Model): 

29 _name = "helpdesk.ticket.customer_note" 

30 _description = "Helpdesk Ticket Customer Notes" 

31 _rec_name = "title" 

32 _order = "priority desc, sequence, adhoc_product_id" 

33 _inherit = [ 

34 "mail.thread", 

35 "mail.activity.mixin", 

36 "portal.mixin", 

37 "saas.provider.upgrade.util", 

38 "html.field.history.mixin", 

39 ] 

40 

41 def _get_versioned_fields(self): 

42 return [HelpdeskTicketCustomerNote.description.name] 

43 

44 sequence = fields.Integer() 

45 adhoc_product_id = fields.Many2one( 

46 "adhoc.product", 

47 domain=[ 

48 ("parent_id", "!=", False), 

49 ("type", "=", "horizontal"), 

50 ], 

51 ) 

52 title = fields.Char( 

53 required=True, 

54 tracking=True, 

55 ) 

56 description = fields.Html( 

57 copy=False, 

58 ) 

59 html_field_history = fields.Json(copy=False) 

60 notes = fields.Text() 

61 status = fields.Selection( 

62 [ 

63 ("ongoing", "In Progress"), 

64 ("done", "Completed"), 

65 ], 

66 default="ongoing", 

67 required=True, 

68 tracking=True, 

69 help="* Ongoing: represents notes that are being worked on.\n" 

70 "* Done: represents notes that have already been analyzed and require no further action.", 

71 ) 

72 responsible = fields.Char(tracking=True) 

73 ticket_id = fields.Many2one( 

74 "helpdesk.ticket", 

75 required=True, 

76 ondelete="cascade", 

77 ) 

78 upgrade_type_id = fields.Many2one( 

79 "saas.upgrade.type", 

80 readonly=True, 

81 help="Type to know to which upgrade jump this customer note belongs.", 

82 ) 

83 upgrade_line_id = fields.Many2one( 

84 "saas.upgrade.line", 

85 ondelete="cascade", 

86 readonly=True, 

87 ) 

88 post_production = fields.Boolean() 

89 code_validation = fields.Boolean( 

90 related="upgrade_line_id.customer_note_validation", 

91 depends=["upgrade_line_id.customer_note_validation"], 

92 store=True, 

93 ) 

94 upload_changes = fields.Boolean( 

95 related="upgrade_line_id.customer_note_upload_changes", 

96 store=True, 

97 ) 

98 parent_adhoc_product_id = fields.Many2one( 

99 related="adhoc_product_id.parent_id", 

100 store=True, 

101 ) 

102 active = fields.Boolean( 

103 default=True, 

104 tracking=True, 

105 ) 

106 priority = fields.Selection( 

107 related="upgrade_line_id.priority", 

108 depends=["upgrade_line_id.priority"], 

109 store=True, 

110 ) 

111 used_placeholders_hash = fields.Char() 

112 client_config_line_ids = fields.One2many( 

113 "saas.upgrade.client.config.line", 

114 "customer_note_id", 

115 string="Client Configurations", 

116 ) 

117 

118 def action_open_client_config_lines(self) -> dict: 

119 self.ensure_one() 

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

121 "saas_provider_upgrade.action_saas_upgrade_client_config_line" 

122 ) 

123 action["domain"] = [["customer_note_id", "=", self.id]] 

124 action["context"] = {"default_customer_note_id": self.id} 

125 return action 

126 

127 def action_create_ticket(self): 

128 self.ensure_one() 

129 description = self.env._(""" 

130 <p>Ticket generated from Customer Note: %s</p> 

131 <h2>Description</h2> 

132 %s 

133 <h2>Notes</h2> 

134 %s 

135 """) 

136 new_rec = self.env["helpdesk.ticket"].create( 

137 { 

138 "project_id": self.ticket_id.project_id.id, 

139 "name": self.title, 

140 "ticket_description": description % ("%s (#%s)" % (self.title, self.id), self.description, self.notes), 

141 "adhoc_product_id": self.adhoc_product_id.id, 

142 "parent_id": self.ticket_id.id, 

143 } 

144 ) 

145 msg = self.env._( 

146 "Follow-up in ticket: <a target='_blank' href='%s'>%s</a>" 

147 % ( 

148 new_rec.access_url, 

149 new_rec.display_name, 

150 ) 

151 ) 

152 self.message_post(body=Markup(msg)) 

153 self.notes = (self.notes + "<br/>" if self.notes else "") + msg 

154 

155 def action_create_task(self): 

156 self.ensure_one() 

157 description = self.env._(""" 

158 <p>Task generated from Customer Note: %s</p> 

159 <h2>Description</h2> 

160 %s 

161 <h2>Notes</h2> 

162 %s 

163 """) 

164 new_rec = self.env["project.task"].create( 

165 { 

166 "project_id": self.ticket_id.project_id.id, 

167 "name": self.title, 

168 "description": description % ("%s (#%s)" % (self.title, self.id), self.description, self.notes), 

169 "adhoc_product_id": self.adhoc_product_id.id, 

170 "ticket_ids": [(4, self.ticket_id.id, 0)], 

171 } 

172 ) 

173 msg = self.env._( 

174 "Follow-up in ticket: <a target='_blank' href='%s'>%s</a>" 

175 % ( 

176 new_rec.access_url, 

177 new_rec.display_name, 

178 ) 

179 ) 

180 self.message_post(body=Markup(msg)) 

181 self.notes = (self.notes + "<br/>" if self.notes else "") + msg 

182 

183 def action_toggle_status(self): 

184 """ 

185 Function to change the status of the customer note. 

186 Called from 'customer_note.js'. 

187 

188 :param self: Customer Note recordset 

189 """ 

190 next_status = { 

191 "ongoing": "done", 

192 "done": "ongoing", 

193 } 

194 for rec in self: 

195 if rec.code_validation and rec.status == "ongoing": 195 ↛ 199line 195 didn't jump to line 199 because the condition on line 195 was always true

196 action = rec._run_customer_note_validation() 

197 if action and action["params"]["type"] in ["warning", "danger"]: 

198 return action 

199 rec.status = next_status[rec.status] 

200 

201 def write(self, vals): 

202 if "description" in vals: 

203 if len(self) == 1: 203 ↛ 205line 203 didn't jump to line 205 because the condition on line 203 was always true

204 handle_history_divergence(self, "description", vals) 

205 return super().write(vals) 

206 

207 @api.model 

208 def generate(self, upgrade_line: "SaasUpgradeLine", request: "UpgradeRequest", placeholders: dict) -> None: 

209 """ 

210 Generates a Customer Note using data from 'upgrade_line_id' and 'placeholders'. 

211 

212 :param upgrade_line_id: The object containing the data to generate the Customer Note 

213 :param request: The Upgrade Request object related to the Customer Note 

214 :param placeholders: Placeholders to replace in the 'message' defined in the Upgrade Line. 

215 The rendered message becomes the final Customer Note content. 

216 """ 

217 ticket = request.ticket_id 

218 domain = Domain("ticket_id", "=", ticket.id) & Domain("upgrade_line_id", "=", upgrade_line.id) 

219 existing_customer_note = ( 

220 self.env["helpdesk.ticket.customer_note"].with_context(active_test=False).search(domain, limit=1) 

221 ) 

222 layout_message = upgrade_line.message 

223 # Sometimes the PO creates the message in the dev message field, so we don't have the 'productive' 

224 # message yet. In that case, we don't generate a Customer Note. 

225 if not layout_message: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 return 

227 

228 # Try to render the message with placeholders 

229 try: 

230 rendered_message, used_placeholders = self.render(layout_message, placeholders) 

231 except Exception as e: 

232 upgrade_line.run_info_id.set_error(e) 

233 raise UserError( 

234 self.env._("Error rendering Customer Note message for UL %s: %s") % (upgrade_line.name, str(e)) 

235 ) 

236 

237 # Check if the rendered message is empty 

238 if is_html_empty(rendered_message): 

239 if existing_customer_note and existing_customer_note.active: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 existing_customer_note.active = False 

241 existing_customer_note.message_post( 

242 body=self.env._("This Customer Note has been deactivated because the message is empty.") 

243 ) 

244 return 

245 

246 values = { 

247 "title": upgrade_line.title, 

248 "description": rendered_message, 

249 "sequence": upgrade_line.customer_note_sequence, 

250 "adhoc_product_id": upgrade_line.adhoc_product_id.id, 

251 } 

252 customer_note = existing_customer_note 

253 new_used_placeholders_hash = hashlib.md5(str(used_placeholders).encode()).hexdigest() 

254 create = not customer_note or ( 

255 customer_note.used_placeholders_hash != new_used_placeholders_hash 

256 and customer_note.upgrade_type_id != request.upgrade_type_id 

257 ) 

258 if create: 

259 values.update( 

260 { 

261 "status": "ongoing", 

262 "upgrade_line_id": upgrade_line.id, 

263 "ticket_id": ticket.id, 

264 "upgrade_type_id": request.upgrade_type_id.id, 

265 } 

266 ) 

267 customer_note = self.create(values) 

268 else: 

269 customer_note.upgrade_type_id = request.upgrade_type_id 

270 if customer_note.used_placeholders_hash != new_used_placeholders_hash: 270 ↛ 279line 270 didn't jump to line 279 because the condition on line 270 was always true

271 status = customer_note.status 

272 values.update( 

273 { 

274 "used_placeholders_hash": new_used_placeholders_hash, 

275 "status": "ongoing" if upgrade_line.force_reopen_customer_note else status, 

276 } 

277 ) 

278 customer_note.write(values) 

279 customer_note.action_unarchive() 

280 

281 customer_note._link_client_config_lines(upgrade_line, ticket) 

282 

283 @api.model 

284 def render(self, html: str, placeholders: dict) -> tuple[str | bool, dict]: 

285 """ 

286 Renders the 'html' with the variables in the 'placeholders' dict. 

287 

288 :param html: An HTML structure to render, defined in the UL's 'message' variable 

289 :param placeholders: Dictionary with the placeholder values 

290 :return: A tuple (rendered_message, used_placeholders) 

291 """ 

292 uses_qweb = bool(self.extract_qweb_variables(html)) 

293 if uses_qweb and not placeholders: 

294 # Return empty message if the Customer Note uses qweb placeholders but no placeholders are provided. 

295 return "", {} 

296 

297 html = self._sanitize_html(html) 

298 used_placeholders = self._sanitize_placeholders(placeholders) 

299 return self.env["ir.qweb"]._render(etree.XML(html), used_placeholders), used_placeholders 

300 

301 @api.model 

302 def _sanitize_html(self, html: str) -> str: 

303 """ 

304 Sanitizes the HTML structure to be rendered in the Customer Note. 

305 

306 :param html: The HTML structure to sanitize 

307 :return: Sanitized HTML structure 

308 """ 

309 to_replace = [ 

310 (r"&nbsp;", r"&#160;"), 

311 (r"<br>", r"<br/>"), 

312 (r"<p><br></p>", r"<br/>"), 

313 (r"<hr>", r"<hr/>"), 

314 (r"(<img[^>]*)(?<!/)>", r"\1 />"), 

315 ] 

316 for old, new in to_replace: 

317 html = re.sub(old, new, html) 

318 return f"<html>{html}</html>" 

319 

320 @api.model 

321 def _sanitize_placeholders(self, placeholders: dict) -> dict: 

322 """ 

323 Sanitizes placeholders before rendering. 

324 Currently wraps HTML values with Markup(). 

325 

326 :param placeholders: Dictionary with the placeholder values 

327 :return: Sanitized placeholders dictionary 

328 """ 

329 for key, value in placeholders.items(): 

330 if type(value) is str and bool(re.search(r"<.*?>", value)): 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true

331 placeholders[key] = Markup(value) 

332 return placeholders 

333 

334 def _link_client_config_lines(self, upgrade_line: "SaasUpgradeLine", ticket: BaseModel) -> None: 

335 """ 

336 Links client configuration lines to the Customer Note. 

337 

338 :param upgrade_line: Upgrade Line record 

339 :param ticket: Helpdesk Ticket record 

340 """ 

341 self.ensure_one() 

342 config_lines = self.env["saas.upgrade.client.config.line"].search( 

343 [ 

344 ("upgrade_line_id", "=", upgrade_line.id), 

345 ("ticket_id", "=", ticket.id), 

346 ] 

347 ) 

348 config_lines.write({"customer_note_id": self.id}) 

349 

350 def _run_customer_note_validation(self) -> dict | None: 

351 """ 

352 Runs the validation script for the Customer Note. 

353 Returns a display notification if validation fails. 

354 

355 :return: Action dictionary for display notification or None 

356 """ 

357 self.ensure_one() 

358 request = self.ticket_id.last_test_request_line_id 

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

360 if self.ticket_id.all_request_line_ids.filtered(lambda x: x.aim == "test" and x.automatic): 

361 message = self.env._( 

362 "There is already a test request being generated for this Customer Note. " 

363 "Please wait until it finishes before closing this Customer Note." 

364 ) 

365 else: 

366 self.env["helpdesk.ticket.upgrade.request"].with_user(SUPERUSER_ID)._create_request( 

367 self.ticket_id.id, "test", automatic=True, bypass=True 

368 ) 

369 message = self.env._( 

370 "A test request is required to close this Customer Note. " 

371 "One has been generated and will be available in a few hours." 

372 ) 

373 return self.build_display_notification_action( 

374 self.env._("Cannot close Customer Note"), 

375 message, 

376 "danger", 

377 ) 

378 

379 upgrade_line_sudo = self.sudo().upgrade_line_id 

380 validation_script = upgrade_line_sudo.customer_note_validation_script 

381 no_new_client = "new_client" not in validation_script 

382 no_database = "old_client" not in validation_script and no_new_client 

383 eval_context: dict = {} 

384 with ( 

385 request.sudo() 

386 .with_context(no_new_client=no_new_client, no_database=no_database) 

387 ._get_eval_context(eval_context) 

388 ): 

389 # Add customer_note to the evaluation context for validation scripts 

390 eval_context["customer_note"] = self 

391 if validation_script: 391 ↛ 400line 391 didn't jump to line 400

392 try: 

393 safe_eval.safe_eval(validation_script, eval_context, mode="exec") 

394 except Exception as e: 

395 body = self.env._( 

396 f"Error running validation script (CN: {self.id}): <blockquote>{str(e)}</blockquote>" 

397 ) 

398 request.message_post(body=Markup(body)) 

399 eval_context["result"] = self.env._("Validation failed. Please contact the upgrade team.") 

400 result = eval_context.pop("result", False) 

401 if result: 

402 return self.build_display_notification_action(self.env._("Cannot close Customer Note"), result, "warning") 

403 return None 

404 

405 @api.autovacuum 

406 def _gc_archived_customer_notes(self): 

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

408 archived_customer_notes = self.search( 

409 [ 

410 ("create_date", "<", cutoff_date), 

411 ("active", "=", False), 

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

413 ] 

414 ) 

415 if archived_customer_notes: 

416 _logger.info("Autovacuum: Deleting %d archived customer notes", len(archived_customer_notes)) 

417 archived_customer_notes.unlink() 

418 else: 

419 _logger.info("Autovacuum: No archived customer notes to delete")