helpdesk_team.py 68 KB

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