helpdesk_team.py 70 KB

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