helpdesk_team.py 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import json
  4. import logging
  5. from lxml import etree, html
  6. from odoo import api, fields, models, Command, _
  7. from odoo.osv import expression
  8. _logger = logging.getLogger(__name__)
  9. class HelpdeskTeamExtras(models.Model):
  10. _inherit = "helpdesk.team"
  11. collaborator_ids = fields.One2many(
  12. "helpdesk.team.collaborator",
  13. "team_id",
  14. string="Collaborators",
  15. copy=False,
  16. export_string_translation=False,
  17. help="Partners with access to this helpdesk team",
  18. )
  19. template_id = fields.Many2one(
  20. 'helpdesk.template',
  21. string='Template',
  22. help="Template to use for tickets in this team. If set, template fields will be shown in ticket form."
  23. )
  24. workflow_template_id = fields.Many2one(
  25. 'helpdesk.workflow.template',
  26. string='Workflow Template',
  27. help="Workflow template with stages and SLA policies. Use 'Apply Template' button to create stages and SLAs."
  28. )
  29. @api.model_create_multi
  30. def create(self, vals_list):
  31. """Override create to regenerate form XML if template is set"""
  32. teams = super().create(vals_list)
  33. # After create, if template is set and form view exists, regenerate
  34. # This handles the case when team is created with template_id already set
  35. for team in teams.filtered(lambda t: t.use_website_helpdesk_form and t.template_id and t.website_form_view_id):
  36. team._regenerate_form_from_template()
  37. return teams
  38. def _ensure_submit_form_view(self):
  39. """Override to regenerate form from template after creating view"""
  40. result = super()._ensure_submit_form_view()
  41. # After view is created, if template is set, regenerate form
  42. # Note: super() may have created views, so we need to refresh to get updated website_form_view_id
  43. for team in self.filtered(lambda t: t.use_website_helpdesk_form and t.template_id):
  44. # Refresh to get updated website_form_view_id after super() created it
  45. team.invalidate_recordset(['website_form_view_id'])
  46. if team.website_form_view_id:
  47. team._regenerate_form_from_template()
  48. return result
  49. def write(self, vals):
  50. """Override write to regenerate form XML when template changes"""
  51. result = super().write(vals)
  52. if 'template_id' in vals:
  53. # Regenerate form XML when template is assigned/changed
  54. # After super().write(), refresh teams to get updated values
  55. teams_to_process = self.browse(self.ids).filtered('use_website_helpdesk_form')
  56. for team in teams_to_process:
  57. # Ensure website_form_view_id exists before regenerating
  58. # This handles the case when template is assigned but view doesn't exist yet
  59. if not team.website_form_view_id:
  60. # Call _ensure_submit_form_view which will create the view if needed
  61. # This method already handles template regeneration if template_id is set
  62. team._ensure_submit_form_view()
  63. else:
  64. # View exists, regenerate or restore form based on template
  65. if team.template_id:
  66. team._regenerate_form_from_template()
  67. else:
  68. # If template is removed, restore default form
  69. team._restore_default_form()
  70. return result
  71. # New computed fields for hours stats in backend view
  72. hours_total_available = fields.Float(
  73. compute="_compute_hours_stats",
  74. string="Total Available Hours",
  75. store=False
  76. )
  77. hours_total_used = fields.Float(
  78. compute="_compute_hours_stats",
  79. string="Total Used Hours",
  80. store=False
  81. )
  82. hours_percentage_used = fields.Float(
  83. compute="_compute_hours_stats",
  84. string="Percentage Used Hours",
  85. store=False
  86. )
  87. has_hours_stats = fields.Boolean(
  88. compute="_compute_hours_stats",
  89. string="Has Hours Stats",
  90. store=False
  91. )
  92. def _compute_hours_stats(self):
  93. """Compute hours stats for the team based on collaborators"""
  94. # Check if sale_timesheet is installed
  95. has_sale_timesheet = "sale_timesheet" in self.env.registry._init_modules
  96. # Get UoM hour reference once for all teams
  97. try:
  98. uom_hour = self.env.ref("uom.product_uom_hour")
  99. except Exception:
  100. uom_hour = False
  101. if not uom_hour:
  102. for team in self:
  103. team.hours_total_available = 0.0
  104. team.hours_total_used = 0.0
  105. team.hours_percentage_used = 0.0
  106. team.has_hours_stats = False
  107. return
  108. SaleOrderLine = self.env["sale.order.line"].sudo()
  109. for team in self:
  110. # Default values
  111. total_available = 0.0
  112. total_used = 0.0
  113. has_stats = False
  114. # If team has collaborators, calculate their hours
  115. if not team.collaborator_ids:
  116. team.hours_total_available = 0.0
  117. team.hours_total_used = 0.0
  118. team.hours_percentage_used = 0.0
  119. team.has_hours_stats = False
  120. continue
  121. # Get unique commercial partners (optimize: avoid duplicates)
  122. partners = team.collaborator_ids.partner_id.commercial_partner_id
  123. unique_partners = partners.filtered(lambda p: p.active).ids
  124. if not unique_partners:
  125. team.hours_total_available = 0.0
  126. team.hours_total_used = 0.0
  127. team.hours_percentage_used = 0.0
  128. team.has_hours_stats = False
  129. continue
  130. # Build service domain once (reused for both queries)
  131. base_service_domain = []
  132. if has_sale_timesheet:
  133. try:
  134. base_service_domain = SaleOrderLine._domain_sale_line_service(
  135. check_state=False
  136. )
  137. except Exception:
  138. base_service_domain = [
  139. ("product_id.type", "=", "service"),
  140. ("product_id.service_policy", "=", "ordered_prepaid"),
  141. ("remaining_hours_available", "=", True),
  142. ]
  143. # Optimize: Get all prepaid lines for all partners in one query
  144. prepaid_domain = expression.AND([
  145. [
  146. ("company_id", "=", team.company_id.id),
  147. ("order_partner_id", "child_of", unique_partners),
  148. ("state", "in", ["sale", "done"]),
  149. ("remaining_hours", ">", 0),
  150. ],
  151. base_service_domain,
  152. ])
  153. all_prepaid_lines = SaleOrderLine.search(prepaid_domain)
  154. # Optimize: Get all lines for hours used calculation in one query
  155. hours_used_domain = expression.AND([
  156. [
  157. ("company_id", "=", team.company_id.id),
  158. ("order_partner_id", "child_of", unique_partners),
  159. ("state", "in", ["sale", "done"]),
  160. ],
  161. base_service_domain,
  162. ])
  163. all_lines = SaleOrderLine.search(hours_used_domain)
  164. # Cache order payment status to avoid multiple checks
  165. order_paid_cache = {}
  166. orders_to_check = (all_prepaid_lines | all_lines).mapped("order_id")
  167. for order in orders_to_check:
  168. order_paid_cache[order.id] = self._is_order_paid(order)
  169. # Group lines by commercial partner for calculation
  170. partner_lines = {}
  171. for line in all_prepaid_lines:
  172. partner_id = line.order_partner_id.commercial_partner_id.id
  173. if partner_id not in partner_lines:
  174. partner_lines[partner_id] = {"prepaid": [], "all": []}
  175. partner_lines[partner_id]["prepaid"].append(line)
  176. for line in all_lines:
  177. partner_id = line.order_partner_id.commercial_partner_id.id
  178. if partner_id not in partner_lines:
  179. partner_lines[partner_id] = {"prepaid": [], "all": []}
  180. partner_lines[partner_id]["all"].append(line)
  181. # Calculate stats per partner
  182. for partner_id, lines_dict in partner_lines.items():
  183. partner = self.env["res.partner"].browse(partner_id)
  184. if not partner.exists():
  185. continue
  186. prepaid_lines = lines_dict["prepaid"]
  187. all_partner_lines = lines_dict["all"]
  188. # 1. Calculate prepaid hours and highest price
  189. highest_price = 0.0
  190. prepaid_hours = 0.0
  191. for line in prepaid_lines:
  192. order_id = line.order_id.id
  193. if order_paid_cache.get(order_id, False):
  194. prepaid_hours += max(0.0, line.remaining_hours or 0.0)
  195. # Track highest price from all lines (for credit calculation)
  196. if line.price_unit > highest_price:
  197. highest_price = line.price_unit
  198. # 2. Calculate credit hours
  199. credit_hours = 0.0
  200. if (
  201. team.company_id.account_use_credit_limit
  202. and partner.credit_limit > 0
  203. ):
  204. credit_used = partner.credit or 0.0
  205. credit_avail = max(0.0, partner.credit_limit - credit_used)
  206. if highest_price > 0:
  207. credit_hours = credit_avail / highest_price
  208. total_available += prepaid_hours + credit_hours
  209. # 3. Calculate hours used
  210. for line in all_partner_lines:
  211. order_id = line.order_id.id
  212. if order_paid_cache.get(order_id, False):
  213. qty_delivered = line.qty_delivered or 0.0
  214. if qty_delivered > 0:
  215. qty_hours = (
  216. line.product_uom._compute_quantity(
  217. qty_delivered, uom_hour, raise_if_failure=False
  218. )
  219. or 0.0
  220. )
  221. total_used += qty_hours
  222. has_stats = total_available > 0 or total_used > 0
  223. team.hours_total_available = total_available
  224. team.hours_total_used = total_used
  225. team.has_hours_stats = has_stats
  226. # Calculate percentage
  227. if has_stats:
  228. grand_total = total_used + total_available
  229. if grand_total > 0:
  230. team.hours_percentage_used = (total_used / grand_total) * 100
  231. else:
  232. team.hours_percentage_used = 0.0
  233. else:
  234. team.hours_percentage_used = 0.0
  235. def _check_helpdesk_team_sharing_access(self):
  236. """Check if current user has access to this helpdesk team through sharing"""
  237. self.ensure_one()
  238. if self.env.user._is_portal():
  239. collaborator = self.env["helpdesk.team.collaborator"].search(
  240. [
  241. ("team_id", "=", self.id),
  242. ("partner_id", "=", self.env.user.partner_id.id),
  243. ],
  244. limit=1,
  245. )
  246. return collaborator
  247. return self.env.user._is_internal()
  248. def _get_new_collaborators(self, partners):
  249. """Get new collaborators that can be added to the team"""
  250. self.ensure_one()
  251. return partners.filtered(
  252. lambda partner: partner not in self.collaborator_ids.partner_id
  253. and partner.partner_share
  254. )
  255. def _add_collaborators(self, partners, access_mode="user_own"):
  256. """Add collaborators to the team"""
  257. self.ensure_one()
  258. new_collaborators = self._get_new_collaborators(partners)
  259. if not new_collaborators:
  260. return
  261. self.write(
  262. {
  263. "collaborator_ids": [
  264. Command.create(
  265. {
  266. "partner_id": collaborator.id,
  267. "access_mode": access_mode,
  268. }
  269. )
  270. for collaborator in new_collaborators
  271. ]
  272. }
  273. )
  274. # Subscribe partners as followers
  275. self.message_subscribe(partner_ids=new_collaborators.ids)
  276. def action_open_share_team_wizard(self):
  277. """Open the share team wizard"""
  278. self.ensure_one()
  279. action = self.env["ir.actions.actions"]._for_xml_id(
  280. "helpdesk_extras.helpdesk_team_share_wizard_action"
  281. )
  282. action["context"] = {
  283. "active_id": self.id,
  284. "active_model": "helpdesk.team",
  285. "default_res_model": "helpdesk.team",
  286. "default_res_id": self.id,
  287. }
  288. return action
  289. @api.model
  290. def _is_order_paid(self, order):
  291. """
  292. Check if a sale order has received payment through its invoices.
  293. Only considers orders with at least one invoice that is posted and fully paid.
  294. This method can be used both in frontend and backend.
  295. Args:
  296. order: sale.order record
  297. Returns:
  298. bool: True if order has at least one paid invoice, False otherwise
  299. """
  300. if not order:
  301. return False
  302. # Use sudo to ensure access to invoice fields
  303. order_sudo = order.sudo()
  304. # Check if order has invoices
  305. if not order_sudo.invoice_ids:
  306. return False
  307. # Check if at least one invoice is fully paid
  308. # payment_state values: 'not_paid', 'partial', 'paid', 'in_payment', 'reversed', 'invoicing_legacy'
  309. # We only consider invoices that are:
  310. # - posted (state = 'posted')
  311. # - fully paid (payment_state = 'paid')
  312. paid_invoices = order_sudo.invoice_ids.filtered(
  313. lambda inv: inv.state == "posted" and inv.payment_state == "paid"
  314. )
  315. # Debug: Log invoice states for troubleshooting
  316. if order_sudo.invoice_ids:
  317. invoice_states = []
  318. for inv in order_sudo.invoice_ids:
  319. try:
  320. invoice_states.append(
  321. f"Invoice {inv.id}: state={inv.state}, payment_state={getattr(inv, 'payment_state', 'N/A')}"
  322. )
  323. except Exception:
  324. invoice_states.append(f"Invoice {inv.id}: error reading state")
  325. # self.env['ir.logging'].sudo().create({
  326. # 'name': 'helpdesk_extras',
  327. # 'type': 'server',
  328. # 'level': 'info',
  329. # 'message': f'Order {order.id} - Invoice states: {"; ".join(invoice_states)} - Paid: {bool(paid_invoices)}',
  330. # 'path': 'helpdesk.team',
  331. # 'func': '_is_order_paid',
  332. # 'line': '1',
  333. # })
  334. # Return True ONLY if at least one invoice is fully paid
  335. # This is critical: we must have at least one invoice with payment_state == 'paid'
  336. result = bool(paid_invoices)
  337. # Extra verification: ensure we're really getting paid invoices
  338. if result:
  339. # Double-check that we have at least one invoice that is actually paid
  340. verified_paid = any(
  341. inv.state == "posted" and getattr(inv, "payment_state", "") == "paid"
  342. for inv in order_sudo.invoice_ids
  343. )
  344. if not verified_paid:
  345. result = False
  346. return result
  347. def _regenerate_form_from_template(self):
  348. """Regenerate the website form XML based on the template"""
  349. self.ensure_one()
  350. if not self.template_id or not self.website_form_view_id:
  351. return
  352. # Get base form structure (from default template)
  353. # We use the default template arch to ensure we start with a clean base
  354. default_form = self.env.ref('website_helpdesk.ticket_submit_form', raise_if_not_found=False)
  355. if not default_form:
  356. return
  357. # Get template fields sorted by sequence
  358. template_fields = self.template_id.field_ids.sorted('sequence')
  359. # Log template fields for debugging
  360. _logger.info(f"Regenerating form for team {self.id}, template {self.template_id.id} with {len(template_fields)} fields")
  361. for tf in template_fields:
  362. _logger.info(f" - Field: {tf.field_id.name if tf.field_id else 'None'} (type: {tf.field_id.ttype if tf.field_id else 'None'})")
  363. # Whitelistear campos del template antes de construir el formulario
  364. field_names = [tf.field_id.name for tf in template_fields
  365. if tf.field_id and not tf.field_id.website_form_blacklisted]
  366. if field_names:
  367. try:
  368. self.env['ir.model.fields'].formbuilder_whitelist('helpdesk.ticket', field_names)
  369. _logger.info(f"Whitelisted fields: {field_names}")
  370. except Exception as e:
  371. _logger.warning(f"Could not whitelist fields {field_names}: {e}")
  372. # Parse current arch to get existing description, team_id and submit button
  373. root = etree.fromstring(self.website_form_view_id.arch.encode('utf-8'))
  374. rows_el = root.xpath('.//div[contains(@class, "s_website_form_rows")]')
  375. if not rows_el:
  376. _logger.error(f"Could not find s_website_form_rows container in view {self.website_form_view_id.id}")
  377. return
  378. rows_el = rows_el[0]
  379. # Get template field names to know which ones are already in template
  380. template_field_names = set(tf.field_id.name for tf in template_fields if tf.field_id)
  381. # Get existing description, team_id and submit button HTML (to preserve them)
  382. # BUT: only preserve description if it's NOT in the template
  383. description_html = None
  384. team_id_html = None
  385. submit_button_html = None
  386. for child in list(rows_el):
  387. classes = child.get('class', '')
  388. if 's_website_form_submit' in classes:
  389. submit_button_html = etree.tostring(child, encoding='unicode', pretty_print=True)
  390. continue
  391. if 's_website_form_field' not in classes:
  392. continue
  393. field_input = child.xpath('.//input[@name] | .//textarea[@name] | .//select[@name]')
  394. if not field_input:
  395. continue
  396. field_name = field_input[0].get('name')
  397. if field_name == 'description':
  398. # Only preserve description if it's NOT in the template
  399. if 'description' not in template_field_names:
  400. description_html = etree.tostring(child, encoding='unicode', pretty_print=True)
  401. elif field_name == 'team_id':
  402. # Always preserve team_id (it's always needed, hidden)
  403. team_id_html = etree.tostring(child, encoding='unicode', pretty_print=True)
  404. # Build HTML for template fields
  405. field_id_counter = 0
  406. template_fields_html = []
  407. for tf in template_fields:
  408. try:
  409. field_html, field_id_counter = self._build_template_field_html(tf, field_id_counter)
  410. if field_html:
  411. template_fields_html.append(field_html)
  412. _logger.info(f"Built HTML for field {tf.field_id.name if tf.field_id else 'Unknown'}")
  413. except Exception as e:
  414. _logger.error(f"Error building HTML for field {tf.field_id.name if tf.field_id else 'Unknown'}: {e}", exc_info=True)
  415. # Build complete rows container HTML
  416. # Order: template fields -> description (if not in template) -> team_id -> submit button
  417. rows_html_parts = []
  418. # Add template fields first (this includes description if it's in the template)
  419. rows_html_parts.extend(template_fields_html)
  420. # Add description only if it exists AND is NOT in template
  421. if description_html:
  422. rows_html_parts.append(description_html)
  423. # Add team_id (always needed, hidden)
  424. if team_id_html:
  425. rows_html_parts.append(team_id_html)
  426. # Add submit button (if exists)
  427. if submit_button_html:
  428. rows_html_parts.append(submit_button_html)
  429. # Join all parts - each field HTML already has proper formatting
  430. # We need to indent each field to match Odoo's formatting (32 spaces)
  431. indented_parts = []
  432. for part in rows_html_parts:
  433. # Split by lines and indent each line
  434. lines = part.split('\n')
  435. indented_lines = []
  436. for line in lines:
  437. if line.strip(): # Only indent non-empty lines
  438. indented_lines.append(' ' + line)
  439. else:
  440. indented_lines.append('')
  441. indented_parts.append('\n'.join(indented_lines))
  442. rows_html = '\n'.join(indented_parts)
  443. # Wrap in the rows container div
  444. rows_container_html = f'''<div class="s_website_form_rows row s_col_no_bgcolor">
  445. {rows_html}
  446. </div>'''
  447. # Use the same save method as form builder
  448. try:
  449. self.website_form_view_id.sudo().save(
  450. rows_container_html,
  451. xpath='.//div[contains(@class, "s_website_form_rows")]'
  452. )
  453. _logger.info(f"Successfully saved form using view.save() for team {self.id}, view {self.website_form_view_id.id}")
  454. except Exception as e:
  455. _logger.error(f"Error saving form with view.save(): {e}", exc_info=True)
  456. raise
  457. def _restore_default_form(self):
  458. """Restore the default form when template is removed"""
  459. self.ensure_one()
  460. if not self.website_form_view_id:
  461. return
  462. # Get default form structure
  463. default_form = self.env.ref('website_helpdesk.ticket_submit_form', raise_if_not_found=False)
  464. if not default_form:
  465. return
  466. # Restore default arch
  467. self.website_form_view_id.sudo().arch = default_form.arch
  468. def _build_template_field_html(self, template_field, field_id_counter=0):
  469. """Build HTML string for a template field exactly as Odoo's form builder does
  470. Args:
  471. template_field: helpdesk.template.field record
  472. field_id_counter: int, counter for generating unique field IDs (incremented and returned)
  473. Returns:
  474. tuple: (html_string, updated_counter)
  475. """
  476. # Build the XML element first using existing method
  477. field_el, field_id_counter = self._build_template_field_xml(template_field, field_id_counter)
  478. if field_el is None:
  479. return None, field_id_counter
  480. # Convert to HTML string with proper formatting
  481. html_str = etree.tostring(field_el, encoding='unicode', pretty_print=True)
  482. return html_str, field_id_counter
  483. def _build_template_field_xml(self, template_field, field_id_counter=0):
  484. """Build XML element for a template field exactly as Odoo's form builder does
  485. Args:
  486. template_field: helpdesk.template.field record
  487. field_id_counter: int, counter for generating unique field IDs (incremented and returned)
  488. Returns:
  489. tuple: (field_element, updated_counter)
  490. """
  491. field = template_field.field_id
  492. field_name = field.name
  493. field_type = field.ttype
  494. # Use custom label if provided, otherwise use field's default label
  495. field_label = template_field.label_custom or field.field_description or field.name
  496. required = template_field.required
  497. sequence = template_field.sequence
  498. # Generate unique ID - use counter to avoid collisions
  499. field_id_counter += 1
  500. field_id = f'helpdesk_{field_id_counter}_{abs(hash(field_name)) % 10000}'
  501. # Build classes (exactly as form builder does) - CORREGIDO: mb-3 en lugar de mb-0 py-2
  502. classes = ['mb-3', 's_website_form_field', 'col-12']
  503. if required:
  504. classes.append('s_website_form_required')
  505. # Add visibility classes if configured (form builder uses these)
  506. visibility_classes = []
  507. if template_field.visibility_dependency:
  508. visibility_classes.append('s_website_form_field_hidden_if')
  509. visibility_classes.append('d-none')
  510. # Create field container div (exactly as form builder does)
  511. all_classes = classes + visibility_classes
  512. field_div = etree.Element('div', {
  513. 'class': ' '.join(all_classes),
  514. 'data-type': field_type,
  515. 'data-name': 'Field'
  516. })
  517. # Add visibility attributes if configured (form builder uses these)
  518. if template_field.visibility_dependency:
  519. field_div.set('data-visibility-dependency', template_field.visibility_dependency.name)
  520. if template_field.visibility_condition:
  521. field_div.set('data-visibility-condition', template_field.visibility_condition)
  522. if template_field.visibility_comparator:
  523. field_div.set('data-visibility-comparator', template_field.visibility_comparator)
  524. # Add visibility_between for range comparators (between/!between)
  525. if template_field.visibility_comparator in ['between', '!between'] and template_field.visibility_between:
  526. field_div.set('data-visibility-between', template_field.visibility_between)
  527. # Create inner row (exactly as form builder does)
  528. row_div = etree.SubElement(field_div, 'div', {
  529. 'class': 'row s_col_no_resize s_col_no_bgcolor'
  530. })
  531. # Create label (exactly as form builder does)
  532. label = etree.SubElement(row_div, 'label', {
  533. 'class': 'col-form-label col-sm-auto s_website_form_label',
  534. 'style': 'width: 200px',
  535. 'for': field_id
  536. })
  537. label_content = etree.SubElement(label, 'span', {
  538. 'class': 's_website_form_label_content'
  539. })
  540. label_content.text = field_label
  541. if required:
  542. mark = etree.SubElement(label, 'span', {
  543. 'class': 's_website_form_mark'
  544. })
  545. mark.text = ' *'
  546. # Create input container
  547. input_div = etree.SubElement(row_div, 'div', {
  548. 'class': 'col-sm'
  549. })
  550. # Build input based on field type
  551. input_el = None
  552. if field_type == 'boolean':
  553. # Checkbox - CORREGIDO: value debe ser 'Yes' no '1'
  554. form_check = etree.SubElement(input_div, 'div', {
  555. 'class': 'form-check'
  556. })
  557. input_el = etree.SubElement(form_check, 'input', {
  558. 'type': 'checkbox',
  559. 'class': 's_website_form_input form-check-input',
  560. 'name': field_name,
  561. 'id': field_id,
  562. 'value': 'Yes'
  563. })
  564. if required:
  565. input_el.set('required', '1')
  566. # Set checked if default_value is 'Yes' or '1' or 'True'
  567. if template_field.default_value and template_field.default_value.lower() in ('yes', '1', 'true'):
  568. input_el.set('checked', 'checked')
  569. elif field_type in ('text', 'html'):
  570. # Textarea - CORREGIDO: eliminar atributo type (no existe en textarea)
  571. input_el = etree.SubElement(input_div, 'textarea', {
  572. 'class': 'form-control s_website_form_input',
  573. 'name': field_name,
  574. 'id': field_id,
  575. 'rows': '3'
  576. })
  577. if template_field.placeholder:
  578. input_el.set('placeholder', template_field.placeholder)
  579. if required:
  580. input_el.set('required', '1')
  581. # Set default value as text content
  582. if template_field.default_value:
  583. input_el.text = template_field.default_value
  584. elif field_type == 'binary':
  585. # File upload
  586. input_el = etree.SubElement(input_div, 'input', {
  587. 'type': 'file',
  588. 'class': 'form-control s_website_form_input',
  589. 'name': field_name,
  590. 'id': field_id
  591. })
  592. if required:
  593. input_el.set('required', '1')
  594. elif field_type == 'one2many' and field.relation == 'ir.attachment':
  595. # Multiple file upload for attachment_ids
  596. input_el = etree.SubElement(input_div, 'input', {
  597. 'type': 'file',
  598. 'class': 'form-control s_website_form_input',
  599. 'name': field_name,
  600. 'id': field_id,
  601. 'multiple': 'true'
  602. })
  603. if required:
  604. input_el.set('required', '1')
  605. elif field_type == 'selection':
  606. # Check if custom selection options are provided (for non-relation selection fields)
  607. selection_options = None
  608. if template_field.selection_options and not field.relation:
  609. try:
  610. selection_options = json.loads(template_field.selection_options)
  611. if not isinstance(selection_options, list):
  612. selection_options = None
  613. except (json.JSONDecodeError, ValueError):
  614. _logger.warning(f"Invalid JSON in selection_options for field {field_name}: {template_field.selection_options}")
  615. selection_options = None
  616. # Determine widget type
  617. widget_type = template_field.widget or 'default'
  618. # Check if this is a relation field (many2one stored as selection)
  619. is_relation = bool(field.relation)
  620. if widget_type == 'radio' and not is_relation:
  621. # Radio buttons for selection (non-relation)
  622. radio_wrapper = etree.SubElement(input_div, 'div', {
  623. 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
  624. 'data-name': field_name,
  625. 'data-display': 'horizontal'
  626. })
  627. # Get selection options
  628. if selection_options:
  629. options_list = selection_options
  630. else:
  631. # Get from model field definition
  632. model_name = field.model_id.model
  633. model = self.env[model_name]
  634. options_list = []
  635. if hasattr(model, field_name):
  636. model_field = model._fields.get(field_name)
  637. if model_field and hasattr(model_field, 'selection'):
  638. selection = model_field.selection
  639. if callable(selection):
  640. selection = selection(model)
  641. if isinstance(selection, (list, tuple)):
  642. options_list = selection
  643. elif field.selection:
  644. try:
  645. selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
  646. if isinstance(selection, (list, tuple)):
  647. options_list = selection
  648. except Exception:
  649. pass
  650. # Create radio buttons
  651. for option_value, option_label in options_list:
  652. radio_div = etree.SubElement(radio_wrapper, 'div', {
  653. 'class': 'radio col-12 col-lg-4 col-md-6'
  654. })
  655. form_check = etree.SubElement(radio_div, 'div', {
  656. 'class': 'form-check'
  657. })
  658. radio_input = etree.SubElement(form_check, 'input', {
  659. 'type': 'radio',
  660. 'class': 's_website_form_input form-check-input',
  661. 'name': field_name,
  662. 'id': f'{field_id}_{abs(hash(str(option_value))) % 10000}',
  663. 'value': str(option_value)
  664. })
  665. if required:
  666. radio_input.set('required', '1')
  667. if template_field.default_value and str(template_field.default_value) == str(option_value):
  668. radio_input.set('checked', 'checked')
  669. radio_label = etree.SubElement(form_check, 'label', {
  670. 'class': 'form-check-label',
  671. 'for': radio_input.get('id')
  672. })
  673. radio_label.text = option_label
  674. input_el = radio_wrapper # For consistency, but not used
  675. elif widget_type == 'checkbox' and not is_relation:
  676. # Checkboxes for selection (non-relation) - multiple selection
  677. checkbox_wrapper = etree.SubElement(input_div, 'div', {
  678. 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
  679. 'data-name': field_name,
  680. 'data-display': 'horizontal'
  681. })
  682. # Get selection options (same as radio)
  683. if selection_options:
  684. options_list = selection_options
  685. else:
  686. model_name = field.model_id.model
  687. model = self.env[model_name]
  688. options_list = []
  689. if hasattr(model, field_name):
  690. model_field = model._fields.get(field_name)
  691. if model_field and hasattr(model_field, 'selection'):
  692. selection = model_field.selection
  693. if callable(selection):
  694. selection = selection(model)
  695. if isinstance(selection, (list, tuple)):
  696. options_list = selection
  697. elif field.selection:
  698. try:
  699. selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
  700. if isinstance(selection, (list, tuple)):
  701. options_list = selection
  702. except Exception:
  703. pass
  704. # Create checkboxes
  705. default_values = template_field.default_value.split(',') if template_field.default_value else []
  706. for option_value, option_label in options_list:
  707. checkbox_div = etree.SubElement(checkbox_wrapper, 'div', {
  708. 'class': 'checkbox col-12 col-lg-4 col-md-6'
  709. })
  710. form_check = etree.SubElement(checkbox_div, 'div', {
  711. 'class': 'form-check'
  712. })
  713. checkbox_input = etree.SubElement(form_check, 'input', {
  714. 'type': 'checkbox',
  715. 'class': 's_website_form_input form-check-input',
  716. 'name': field_name,
  717. 'id': f'{field_id}_{abs(hash(str(option_value))) % 10000}',
  718. 'value': str(option_value)
  719. })
  720. if required:
  721. checkbox_input.set('required', '1')
  722. if str(option_value) in [v.strip() for v in default_values]:
  723. checkbox_input.set('checked', 'checked')
  724. checkbox_label = etree.SubElement(form_check, 'label', {
  725. 'class': 'form-check-label s_website_form_check_label',
  726. 'for': checkbox_input.get('id')
  727. })
  728. checkbox_label.text = option_label
  729. input_el = checkbox_wrapper # For consistency, but not used
  730. else:
  731. # Default: Select dropdown
  732. input_el = etree.SubElement(input_div, 'select', {
  733. 'class': 'form-select s_website_form_input',
  734. 'name': field_name,
  735. 'id': field_id
  736. })
  737. if template_field.placeholder:
  738. input_el.set('placeholder', template_field.placeholder)
  739. if required:
  740. input_el.set('required', '1')
  741. # Add default option
  742. default_option = etree.SubElement(input_el, 'option', {
  743. 'value': ''
  744. })
  745. default_option.text = '-- Select --'
  746. # Populate selection options
  747. if selection_options:
  748. # Use custom selection options
  749. for option_value, option_label in selection_options:
  750. option = etree.SubElement(input_el, 'option', {
  751. 'value': str(option_value)
  752. })
  753. option.text = option_label
  754. if template_field.default_value and str(template_field.default_value) == str(option_value):
  755. option.set('selected', 'selected')
  756. else:
  757. # Get from model field definition
  758. model_name = field.model_id.model
  759. model = self.env[model_name]
  760. if hasattr(model, field_name):
  761. model_field = model._fields.get(field_name)
  762. if model_field and hasattr(model_field, 'selection'):
  763. selection = model_field.selection
  764. if callable(selection):
  765. selection = selection(model)
  766. if isinstance(selection, (list, tuple)):
  767. for option_value, option_label in selection:
  768. option = etree.SubElement(input_el, 'option', {
  769. 'value': str(option_value)
  770. })
  771. option.text = option_label
  772. if template_field.default_value and str(template_field.default_value) == str(option_value):
  773. option.set('selected', 'selected')
  774. elif field.selection:
  775. try:
  776. selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
  777. if isinstance(selection, (list, tuple)):
  778. for option_value, option_label in selection:
  779. option = etree.SubElement(input_el, 'option', {
  780. 'value': str(option_value)
  781. })
  782. option.text = option_label
  783. if template_field.default_value and str(template_field.default_value) == str(option_value):
  784. option.set('selected', 'selected')
  785. except Exception:
  786. pass # If selection can't be evaluated, just leave default option
  787. elif field_type in ('integer', 'float'):
  788. # Number input (exactly as form builder does)
  789. input_type = 'number'
  790. input_el = etree.SubElement(input_div, 'input', {
  791. 'type': input_type,
  792. 'class': 'form-control s_website_form_input',
  793. 'name': field_name,
  794. 'id': field_id
  795. })
  796. if template_field.placeholder:
  797. input_el.set('placeholder', template_field.placeholder)
  798. if template_field.default_value:
  799. input_el.set('value', template_field.default_value)
  800. if field_type == 'integer':
  801. input_el.set('step', '1')
  802. else:
  803. input_el.set('step', 'any')
  804. if required:
  805. input_el.set('required', '1')
  806. elif field_type == 'many2one':
  807. # Determine widget type for many2one
  808. widget_type = template_field.widget or 'default'
  809. if widget_type == 'radio':
  810. # Radio buttons for many2one
  811. radio_wrapper = etree.SubElement(input_div, 'div', {
  812. 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
  813. 'data-name': field_name,
  814. 'data-display': 'horizontal'
  815. })
  816. # Load records from relation
  817. relation = field.relation
  818. if relation and relation != 'ir.attachment':
  819. try:
  820. records = self.env[relation].sudo().search_read(
  821. [], ['display_name'], limit=1000
  822. )
  823. for record in records:
  824. radio_div = etree.SubElement(radio_wrapper, 'div', {
  825. 'class': 'radio col-12 col-lg-4 col-md-6'
  826. })
  827. form_check = etree.SubElement(radio_div, 'div', {
  828. 'class': 'form-check'
  829. })
  830. radio_input = etree.SubElement(form_check, 'input', {
  831. 'type': 'radio',
  832. 'class': 's_website_form_input form-check-input',
  833. 'name': field_name,
  834. 'id': f'{field_id}_{record["id"]}',
  835. 'value': str(record['id'])
  836. })
  837. if required:
  838. radio_input.set('required', '1')
  839. if template_field.default_value and str(template_field.default_value) == str(record['id']):
  840. radio_input.set('checked', 'checked')
  841. radio_label = etree.SubElement(form_check, 'label', {
  842. 'class': 'form-check-label',
  843. 'for': radio_input.get('id')
  844. })
  845. radio_label.text = record['display_name']
  846. except Exception:
  847. pass
  848. input_el = radio_wrapper
  849. elif widget_type == 'checkbox':
  850. # Checkboxes for many2one (multiple selection - unusual but supported)
  851. checkbox_wrapper = etree.SubElement(input_div, 'div', {
  852. 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
  853. 'data-name': field_name,
  854. 'data-display': 'horizontal'
  855. })
  856. relation = field.relation
  857. if relation and relation != 'ir.attachment':
  858. try:
  859. records = self.env[relation].sudo().search_read(
  860. [], ['display_name'], limit=1000
  861. )
  862. default_values = template_field.default_value.split(',') if template_field.default_value else []
  863. for record in records:
  864. checkbox_div = etree.SubElement(checkbox_wrapper, 'div', {
  865. 'class': 'checkbox col-12 col-lg-4 col-md-6'
  866. })
  867. form_check = etree.SubElement(checkbox_div, 'div', {
  868. 'class': 'form-check'
  869. })
  870. checkbox_input = etree.SubElement(form_check, 'input', {
  871. 'type': 'checkbox',
  872. 'class': 's_website_form_input form-check-input',
  873. 'name': field_name,
  874. 'id': f'{field_id}_{record["id"]}',
  875. 'value': str(record['id'])
  876. })
  877. if required:
  878. checkbox_input.set('required', '1')
  879. if str(record['id']) in [v.strip() for v in default_values]:
  880. checkbox_input.set('checked', 'checked')
  881. checkbox_label = etree.SubElement(form_check, 'label', {
  882. 'class': 'form-check-label s_website_form_check_label',
  883. 'for': checkbox_input.get('id')
  884. })
  885. checkbox_label.text = record['display_name']
  886. except Exception:
  887. pass
  888. input_el = checkbox_wrapper
  889. else:
  890. # Default: Select dropdown for many2one
  891. input_el = etree.SubElement(input_div, 'select', {
  892. 'class': 'form-select s_website_form_input',
  893. 'name': field_name,
  894. 'id': field_id
  895. })
  896. if template_field.placeholder:
  897. input_el.set('placeholder', template_field.placeholder)
  898. if required:
  899. input_el.set('required', '1')
  900. # Add default option
  901. default_option = etree.SubElement(input_el, 'option', {'value': ''})
  902. default_option.text = '-- Select --'
  903. # Load records dynamically from relation
  904. relation = field.relation
  905. if relation and relation != 'ir.attachment':
  906. try:
  907. # Try to get records from the relation model
  908. records = self.env[relation].sudo().search_read(
  909. [], ['display_name'], limit=1000
  910. )
  911. for record in records:
  912. option = etree.SubElement(input_el, 'option', {
  913. 'value': str(record['id'])
  914. })
  915. option.text = record['display_name']
  916. if template_field.default_value and str(template_field.default_value) == str(record['id']):
  917. option.set('selected', 'selected')
  918. except Exception:
  919. # If relation doesn't exist or access denied, try specific cases
  920. if field_name == 'request_type_id':
  921. request_types = self.env['helpdesk.request.type'].sudo().search([('active', '=', True)])
  922. for req_type in request_types:
  923. option = etree.SubElement(input_el, 'option', {
  924. 'value': str(req_type.id)
  925. })
  926. option.text = req_type.name
  927. elif field_name == 'affected_module_id':
  928. modules = self.env['helpdesk.affected.module'].sudo().search([
  929. ('active', '=', True)
  930. ], order='name')
  931. for module in modules:
  932. option = etree.SubElement(input_el, 'option', {
  933. 'value': str(module.id)
  934. })
  935. option.text = module.name
  936. elif field_type in ('date', 'datetime'):
  937. # Date/Datetime field - NUEVO: soporte para fechas
  938. date_wrapper = etree.SubElement(input_div, 'div', {
  939. 'class': f's_website_form_{field_type} input-group date'
  940. })
  941. input_el = etree.SubElement(date_wrapper, 'input', {
  942. 'type': 'text',
  943. 'class': 'form-control datetimepicker-input s_website_form_input',
  944. 'name': field_name,
  945. 'id': field_id
  946. })
  947. if template_field.placeholder:
  948. input_el.set('placeholder', template_field.placeholder)
  949. if template_field.default_value:
  950. input_el.set('value', template_field.default_value)
  951. if required:
  952. input_el.set('required', '1')
  953. # Add calendar icon
  954. icon_div = etree.SubElement(date_wrapper, 'div', {
  955. 'class': 'input-group-text o_input_group_date_icon'
  956. })
  957. icon = etree.SubElement(icon_div, 'i', {'class': 'fa fa-calendar'})
  958. elif field_type == 'binary':
  959. # Binary field (file upload) - NUEVO: soporte para archivos
  960. input_el = etree.SubElement(input_div, 'input', {
  961. 'type': 'file',
  962. 'class': 'form-control s_website_form_input',
  963. 'name': field_name,
  964. 'id': field_id
  965. })
  966. if required:
  967. input_el.set('required', '1')
  968. elif field_type in ('one2many', 'many2many'):
  969. # One2many/Many2many fields - NUEVO: soporte para checkboxes múltiples
  970. if field.relation == 'ir.attachment':
  971. # Binary one2many (file upload multiple)
  972. input_el = etree.SubElement(input_div, 'input', {
  973. 'type': 'file',
  974. 'class': 'form-control s_website_form_input',
  975. 'name': field_name,
  976. 'id': field_id,
  977. 'multiple': ''
  978. })
  979. if required:
  980. input_el.set('required', '1')
  981. else:
  982. # Generic one2many/many2many as checkboxes
  983. multiple_div = etree.SubElement(input_div, 'div', {
  984. 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
  985. 'data-name': field_name,
  986. 'data-display': 'horizontal'
  987. })
  988. # Try to load records from relation
  989. relation = field.relation
  990. if relation:
  991. try:
  992. records = self.env[relation].sudo().search_read(
  993. [], ['display_name'], limit=100
  994. )
  995. for record in records:
  996. checkbox_div = etree.SubElement(multiple_div, 'div', {
  997. 'class': 'checkbox col-12 col-lg-4 col-md-6'
  998. })
  999. form_check = etree.SubElement(checkbox_div, 'div', {
  1000. 'class': 'form-check'
  1001. })
  1002. checkbox_input = etree.SubElement(form_check, 'input', {
  1003. 'type': 'checkbox',
  1004. 'class': 's_website_form_input form-check-input',
  1005. 'name': field_name,
  1006. 'id': f'{field_id}_{record["id"]}',
  1007. 'value': str(record['id'])
  1008. })
  1009. checkbox_label = etree.SubElement(form_check, 'label', {
  1010. 'class': 'form-check-label s_website_form_check_label',
  1011. 'for': f'{field_id}_{record["id"]}'
  1012. })
  1013. checkbox_label.text = record['display_name']
  1014. except Exception:
  1015. pass # If relation doesn't exist or access denied
  1016. elif field_type == 'monetary':
  1017. # Monetary field - NUEVO: soporte para montos
  1018. input_el = etree.SubElement(input_div, 'input', {
  1019. 'type': 'number',
  1020. 'class': 'form-control s_website_form_input',
  1021. 'name': field_name,
  1022. 'id': field_id,
  1023. 'step': 'any'
  1024. })
  1025. if required:
  1026. input_el.set('required', '1')
  1027. else:
  1028. # Default: text input (char) - exactly as form builder does
  1029. input_el = etree.SubElement(input_div, 'input', {
  1030. 'type': 'text',
  1031. 'class': 'form-control s_website_form_input',
  1032. 'name': field_name,
  1033. 'id': field_id
  1034. })
  1035. if template_field.placeholder:
  1036. input_el.set('placeholder', template_field.placeholder)
  1037. if template_field.default_value:
  1038. input_el.set('value', template_field.default_value)
  1039. if required:
  1040. input_el.set('required', '1')
  1041. # Add help text description if provided (exactly as form builder does)
  1042. if template_field.help_text:
  1043. help_text_div = etree.SubElement(input_div, 'div', {
  1044. 'class': 's_website_form_field_description small form-text text-muted'
  1045. })
  1046. # Parse HTML help text and add as content
  1047. try:
  1048. # html.fromstring may wrap content in <html><body>, so we need to handle that
  1049. help_html = html.fragment_fromstring(template_field.help_text, create_parent='div')
  1050. # Copy all children and text from the parsed HTML
  1051. if help_html is not None:
  1052. # If fragment_fromstring created a wrapper div, get its children
  1053. if len(help_html) > 0:
  1054. for child in help_html:
  1055. help_text_div.append(child)
  1056. elif help_html.text:
  1057. help_text_div.text = help_html.text
  1058. else:
  1059. # Fallback: use text content
  1060. help_text_div.text = help_html.text_content() or template_field.help_text
  1061. else:
  1062. help_text_div.text = template_field.help_text
  1063. except Exception as e:
  1064. # Fallback: use plain text or raw HTML
  1065. _logger.warning(f"Error parsing help_text HTML for field {field_name}: {e}")
  1066. # Try to set as raw HTML (Odoo's HTML fields are sanitized, so this should be safe)
  1067. try:
  1068. # Use etree to parse and append raw HTML
  1069. raw_html = etree.fromstring(f'<div>{template_field.help_text}</div>')
  1070. for child in raw_html:
  1071. help_text_div.append(child)
  1072. if not len(help_text_div):
  1073. help_text_div.text = template_field.help_text
  1074. except Exception:
  1075. # Final fallback: plain text
  1076. help_text_div.text = template_field.help_text
  1077. return field_div, field_id_counter
  1078. def apply_workflow_template(self):
  1079. """Apply workflow template to create stages and SLAs for this team
  1080. This method creates real helpdesk.stage and helpdesk.sla records
  1081. based on the workflow template configuration.
  1082. """
  1083. self.ensure_one()
  1084. if not self.workflow_template_id:
  1085. raise ValueError(_("No workflow template selected"))
  1086. template = self.workflow_template_id
  1087. if not template.active:
  1088. raise ValueError(_("The selected workflow template is not active"))
  1089. # Mapping: stage_template_id -> real_stage_id
  1090. stage_mapping = {}
  1091. # 1. Create real stages from template stages
  1092. for stage_template in template.stage_template_ids.sorted('sequence'):
  1093. stage_vals = {
  1094. 'name': stage_template.name,
  1095. 'sequence': stage_template.sequence,
  1096. 'fold': stage_template.fold,
  1097. 'description': stage_template.description or False,
  1098. 'template_id': stage_template.template_id_email.id if stage_template.template_id_email else False,
  1099. 'legend_blocked': stage_template.legend_blocked,
  1100. 'legend_done': stage_template.legend_done,
  1101. 'legend_normal': stage_template.legend_normal,
  1102. 'team_ids': [(4, self.id)],
  1103. }
  1104. real_stage = self.env['helpdesk.stage'].create(stage_vals)
  1105. stage_mapping[stage_template.id] = real_stage.id
  1106. # 2. Create real SLAs from template SLAs
  1107. for sla_template in template.sla_template_ids.sorted('sequence'):
  1108. # Get real stage ID from mapping
  1109. real_stage_id = stage_mapping.get(sla_template.stage_template_id.id)
  1110. if not real_stage_id:
  1111. _logger.warning(
  1112. f"Skipping SLA template '{sla_template.name}': "
  1113. f"stage template {sla_template.stage_template_id.id} not found in mapping"
  1114. )
  1115. continue
  1116. # Get real exclude stage IDs - map template stages to real stages
  1117. exclude_stage_ids = []
  1118. for exclude_template_stage in sla_template.exclude_stage_template_ids:
  1119. if exclude_template_stage.id in stage_mapping:
  1120. exclude_stage_ids.append(stage_mapping[exclude_template_stage.id])
  1121. else:
  1122. _logger.warning(
  1123. f"SLA template '{sla_template.name}': "
  1124. f"exclude stage template {exclude_template_stage.id} ({exclude_template_stage.name}) "
  1125. f"not found in stage mapping. Skipping."
  1126. )
  1127. sla_vals = {
  1128. 'name': sla_template.name,
  1129. 'description': sla_template.description or False,
  1130. 'team_id': self.id,
  1131. 'stage_id': real_stage_id,
  1132. 'time': sla_template.time,
  1133. 'priority': sla_template.priority,
  1134. 'tag_ids': [(6, 0, sla_template.tag_ids.ids)],
  1135. 'exclude_stage_ids': [(6, 0, exclude_stage_ids)],
  1136. 'active': True,
  1137. }
  1138. created_sla = self.env['helpdesk.sla'].create(sla_vals)
  1139. _logger.info(
  1140. f"Created SLA '{created_sla.name}' with {len(exclude_stage_ids)} excluded stage(s): "
  1141. f"{[self.env['helpdesk.stage'].browse(sid).name for sid in exclude_stage_ids]}"
  1142. )
  1143. # 3. Ensure team has use_sla enabled if template has SLAs
  1144. if template.sla_template_ids and not self.use_sla:
  1145. self.use_sla = True
  1146. return {
  1147. 'type': 'ir.actions.client',
  1148. 'tag': 'display_notification',
  1149. 'params': {
  1150. 'title': _('Workflow Template Applied'),
  1151. 'message': _(
  1152. 'Successfully created %d stage(s) and %d SLA policy(ies) from template "%s".',
  1153. len(stage_mapping),
  1154. len(template.sla_template_ids),
  1155. template.name
  1156. ),
  1157. 'type': 'success',
  1158. 'sticky': False,
  1159. }
  1160. }