| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249 |
- # -*- coding: utf-8 -*-
- # Part of Odoo. See LICENSE file for full copyright and licensing details.
- import json
- import logging
- from lxml import etree, html
- from odoo import api, fields, models, Command, _
- from odoo.osv import expression
- _logger = logging.getLogger(__name__)
- class HelpdeskTeamExtras(models.Model):
- _inherit = "helpdesk.team"
- collaborator_ids = fields.One2many(
- "helpdesk.team.collaborator",
- "team_id",
- string="Collaborators",
- copy=False,
- export_string_translation=False,
- help="Partners with access to this helpdesk team",
- )
- template_id = fields.Many2one(
- 'helpdesk.template',
- string='Template',
- help="Template to use for tickets in this team. If set, template fields will be shown in ticket form."
- )
- workflow_template_id = fields.Many2one(
- 'helpdesk.workflow.template',
- string='Workflow Template',
- help="Workflow template with stages and SLA policies. Use 'Apply Template' button to create stages and SLAs."
- )
- @api.model_create_multi
- def create(self, vals_list):
- """Override create to regenerate form XML if template is set"""
- teams = super().create(vals_list)
- # After create, if template is set and form view exists, regenerate
- # This handles the case when team is created with template_id already set
- for team in teams.filtered(lambda t: t.use_website_helpdesk_form and t.template_id and t.website_form_view_id):
- team._regenerate_form_from_template()
- return teams
- def _ensure_submit_form_view(self):
- """Override to regenerate form from template after creating view"""
- result = super()._ensure_submit_form_view()
- # After view is created, if template is set, regenerate form
- # Note: super() may have created views, so we need to refresh to get updated website_form_view_id
- for team in self.filtered(lambda t: t.use_website_helpdesk_form and t.template_id):
- # Refresh to get updated website_form_view_id after super() created it
- team.invalidate_recordset(['website_form_view_id'])
- if team.website_form_view_id:
- team._regenerate_form_from_template()
- return result
- def write(self, vals):
- """Override write to regenerate form XML when template changes"""
- result = super().write(vals)
- if 'template_id' in vals:
- # Regenerate form XML when template is assigned/changed
- # After super().write(), refresh teams to get updated values
- teams_to_process = self.browse(self.ids).filtered('use_website_helpdesk_form')
- for team in teams_to_process:
- # Ensure website_form_view_id exists before regenerating
- # This handles the case when template is assigned but view doesn't exist yet
- if not team.website_form_view_id:
- # Call _ensure_submit_form_view which will create the view if needed
- # This method already handles template regeneration if template_id is set
- team._ensure_submit_form_view()
- else:
- # View exists, regenerate or restore form based on template
- if team.template_id:
- team._regenerate_form_from_template()
- else:
- # If template is removed, restore default form
- team._restore_default_form()
- return result
- # New computed fields for hours stats in backend view
- hours_total_available = fields.Float(
- compute="_compute_hours_stats",
- string="Total Available Hours",
- store=False
- )
- hours_total_used = fields.Float(
- compute="_compute_hours_stats",
- string="Total Used Hours",
- store=False
- )
- hours_percentage_used = fields.Float(
- compute="_compute_hours_stats",
- string="Percentage Used Hours",
- store=False
- )
- has_hours_stats = fields.Boolean(
- compute="_compute_hours_stats",
- string="Has Hours Stats",
- store=False
- )
- def _compute_hours_stats(self):
- """Compute hours stats for the team based on collaborators"""
- # Check if sale_timesheet is installed
- has_sale_timesheet = "sale_timesheet" in self.env.registry._init_modules
- # Get UoM hour reference once for all teams
- try:
- uom_hour = self.env.ref("uom.product_uom_hour")
- except Exception:
- uom_hour = False
- if not uom_hour:
- for team in self:
- team.hours_total_available = 0.0
- team.hours_total_used = 0.0
- team.hours_percentage_used = 0.0
- team.has_hours_stats = False
- return
- SaleOrderLine = self.env["sale.order.line"].sudo()
- for team in self:
- # Default values
- total_available = 0.0
- total_used = 0.0
- has_stats = False
- # If team has collaborators, calculate their hours
- if not team.collaborator_ids:
- team.hours_total_available = 0.0
- team.hours_total_used = 0.0
- team.hours_percentage_used = 0.0
- team.has_hours_stats = False
- continue
- # Get unique commercial partners (optimize: avoid duplicates)
- partners = team.collaborator_ids.partner_id.commercial_partner_id
- unique_partners = partners.filtered(lambda p: p.active).ids
- if not unique_partners:
- team.hours_total_available = 0.0
- team.hours_total_used = 0.0
- team.hours_percentage_used = 0.0
- team.has_hours_stats = False
- continue
- # Build service domain once (reused for both queries)
- base_service_domain = []
- if has_sale_timesheet:
- try:
- base_service_domain = SaleOrderLine._domain_sale_line_service(
- check_state=False
- )
- except Exception:
- base_service_domain = [
- ("product_id.type", "=", "service"),
- ("product_id.service_policy", "=", "ordered_prepaid"),
- ("remaining_hours_available", "=", True),
- ]
- # Optimize: Get all prepaid lines for all partners in one query
- prepaid_domain = expression.AND([
- [
- ("company_id", "=", team.company_id.id),
- ("order_partner_id", "child_of", unique_partners),
- ("state", "in", ["sale", "done"]),
- ("remaining_hours", ">", 0),
- ],
- base_service_domain,
- ])
- all_prepaid_lines = SaleOrderLine.search(prepaid_domain)
- # Optimize: Get all lines for hours used calculation in one query
- hours_used_domain = expression.AND([
- [
- ("company_id", "=", team.company_id.id),
- ("order_partner_id", "child_of", unique_partners),
- ("state", "in", ["sale", "done"]),
- ],
- base_service_domain,
- ])
- all_lines = SaleOrderLine.search(hours_used_domain)
- # Cache order payment status to avoid multiple checks
- order_paid_cache = {}
- orders_to_check = (all_prepaid_lines | all_lines).mapped("order_id")
- for order in orders_to_check:
- order_paid_cache[order.id] = self._is_order_paid(order)
- # Group lines by commercial partner for calculation
- partner_lines = {}
- for line in all_prepaid_lines:
- partner_id = line.order_partner_id.commercial_partner_id.id
- if partner_id not in partner_lines:
- partner_lines[partner_id] = {"prepaid": [], "all": []}
- partner_lines[partner_id]["prepaid"].append(line)
- for line in all_lines:
- partner_id = line.order_partner_id.commercial_partner_id.id
- if partner_id not in partner_lines:
- partner_lines[partner_id] = {"prepaid": [], "all": []}
- partner_lines[partner_id]["all"].append(line)
- # Calculate stats per partner
- for partner_id, lines_dict in partner_lines.items():
- partner = self.env["res.partner"].browse(partner_id)
- if not partner.exists():
- continue
- prepaid_lines = lines_dict["prepaid"]
- all_partner_lines = lines_dict["all"]
- # 1. Calculate prepaid hours and highest price
- highest_price = 0.0
- prepaid_hours = 0.0
- for line in prepaid_lines:
- order_id = line.order_id.id
- if order_paid_cache.get(order_id, False):
- prepaid_hours += max(0.0, line.remaining_hours or 0.0)
- # Track highest price from all lines (for credit calculation)
- if line.price_unit > highest_price:
- highest_price = line.price_unit
- # 2. Calculate credit hours
- credit_hours = 0.0
- if (
- team.company_id.account_use_credit_limit
- and partner.credit_limit > 0
- ):
- credit_used = partner.credit or 0.0
- credit_avail = max(0.0, partner.credit_limit - credit_used)
- if highest_price > 0:
- credit_hours = credit_avail / highest_price
- total_available += prepaid_hours + credit_hours
- # 3. Calculate hours used
- for line in all_partner_lines:
- order_id = line.order_id.id
- if order_paid_cache.get(order_id, False):
- qty_delivered = line.qty_delivered or 0.0
- if qty_delivered > 0:
- qty_hours = (
- line.product_uom._compute_quantity(
- qty_delivered, uom_hour, raise_if_failure=False
- )
- or 0.0
- )
- total_used += qty_hours
- has_stats = total_available > 0 or total_used > 0
- team.hours_total_available = total_available
- team.hours_total_used = total_used
- team.has_hours_stats = has_stats
- # Calculate percentage
- if has_stats:
- grand_total = total_used + total_available
- if grand_total > 0:
- team.hours_percentage_used = (total_used / grand_total) * 100
- else:
- team.hours_percentage_used = 0.0
- else:
- team.hours_percentage_used = 0.0
- def _check_helpdesk_team_sharing_access(self):
- """Check if current user has access to this helpdesk team through sharing"""
- self.ensure_one()
- if self.env.user._is_portal():
- collaborator = self.env["helpdesk.team.collaborator"].search(
- [
- ("team_id", "=", self.id),
- ("partner_id", "=", self.env.user.partner_id.id),
- ],
- limit=1,
- )
- return collaborator
- return self.env.user._is_internal()
- def _get_new_collaborators(self, partners):
- """Get new collaborators that can be added to the team"""
- self.ensure_one()
- return partners.filtered(
- lambda partner: partner not in self.collaborator_ids.partner_id
- and partner.partner_share
- )
- def _add_collaborators(self, partners, access_mode="user_own"):
- """Add collaborators to the team"""
- self.ensure_one()
- new_collaborators = self._get_new_collaborators(partners)
- if not new_collaborators:
- return
- self.write(
- {
- "collaborator_ids": [
- Command.create(
- {
- "partner_id": collaborator.id,
- "access_mode": access_mode,
- }
- )
- for collaborator in new_collaborators
- ]
- }
- )
- # Subscribe partners as followers
- self.message_subscribe(partner_ids=new_collaborators.ids)
- def action_open_share_team_wizard(self):
- """Open the share team wizard"""
- self.ensure_one()
- action = self.env["ir.actions.actions"]._for_xml_id(
- "helpdesk_extras.helpdesk_team_share_wizard_action"
- )
- action["context"] = {
- "active_id": self.id,
- "active_model": "helpdesk.team",
- "default_res_model": "helpdesk.team",
- "default_res_id": self.id,
- }
- return action
- @api.model
- def _is_order_paid(self, order):
- """
- Check if a sale order has received payment through its invoices.
- Only considers orders with at least one invoice that is posted and fully paid.
- This method can be used both in frontend and backend.
- Args:
- order: sale.order record
- Returns:
- bool: True if order has at least one paid invoice, False otherwise
- """
- if not order:
- return False
- # Use sudo to ensure access to invoice fields
- order_sudo = order.sudo()
- # Check if order has invoices
- if not order_sudo.invoice_ids:
- return False
- # Check if at least one invoice is fully paid
- # payment_state values: 'not_paid', 'partial', 'paid', 'in_payment', 'reversed', 'invoicing_legacy'
- # We only consider invoices that are:
- # - posted (state = 'posted')
- # - fully paid (payment_state = 'paid')
- paid_invoices = order_sudo.invoice_ids.filtered(
- lambda inv: inv.state == "posted" and inv.payment_state == "paid"
- )
- # Debug: Log invoice states for troubleshooting
- if order_sudo.invoice_ids:
- invoice_states = []
- for inv in order_sudo.invoice_ids:
- try:
- invoice_states.append(
- f"Invoice {inv.id}: state={inv.state}, payment_state={getattr(inv, 'payment_state', 'N/A')}"
- )
- except Exception:
- invoice_states.append(f"Invoice {inv.id}: error reading state")
- # self.env['ir.logging'].sudo().create({
- # 'name': 'helpdesk_extras',
- # 'type': 'server',
- # 'level': 'info',
- # 'message': f'Order {order.id} - Invoice states: {"; ".join(invoice_states)} - Paid: {bool(paid_invoices)}',
- # 'path': 'helpdesk.team',
- # 'func': '_is_order_paid',
- # 'line': '1',
- # })
- # Return True ONLY if at least one invoice is fully paid
- # This is critical: we must have at least one invoice with payment_state == 'paid'
- result = bool(paid_invoices)
- # Extra verification: ensure we're really getting paid invoices
- if result:
- # Double-check that we have at least one invoice that is actually paid
- verified_paid = any(
- inv.state == "posted" and getattr(inv, "payment_state", "") == "paid"
- for inv in order_sudo.invoice_ids
- )
- if not verified_paid:
- result = False
- return result
- def _regenerate_form_from_template(self):
- """Regenerate the website form XML based on the template"""
- self.ensure_one()
- if not self.template_id or not self.website_form_view_id:
- return
- # Get base form structure (from default template)
- # We use the default template arch to ensure we start with a clean base
- default_form = self.env.ref('website_helpdesk.ticket_submit_form', raise_if_not_found=False)
- if not default_form:
- return
- # Get template fields sorted by sequence
- template_fields = self.template_id.field_ids.sorted('sequence')
-
- # Log template fields for debugging
- _logger.info(f"Regenerating form for team {self.id}, template {self.template_id.id} with {len(template_fields)} fields")
- for tf in template_fields:
- _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'})")
-
- # Whitelistear campos del template antes de construir el formulario
- field_names = [tf.field_id.name for tf in template_fields
- if tf.field_id and not tf.field_id.website_form_blacklisted]
- if field_names:
- try:
- self.env['ir.model.fields'].formbuilder_whitelist('helpdesk.ticket', field_names)
- _logger.info(f"Whitelisted fields: {field_names}")
- except Exception as e:
- _logger.warning(f"Could not whitelist fields {field_names}: {e}")
- # Parse current arch to get existing description, team_id and submit button
- root = etree.fromstring(self.website_form_view_id.arch.encode('utf-8'))
- rows_el = root.xpath('.//div[contains(@class, "s_website_form_rows")]')
- if not rows_el:
- _logger.error(f"Could not find s_website_form_rows container in view {self.website_form_view_id.id}")
- return
- rows_el = rows_el[0]
- # Get template field names to know which ones are already in template
- template_field_names = set(tf.field_id.name for tf in template_fields if tf.field_id)
-
- # Get existing description, team_id and submit button HTML (to preserve them)
- # BUT: only preserve description if it's NOT in the template
- description_html = None
- team_id_html = None
- submit_button_html = None
-
- for child in list(rows_el):
- classes = child.get('class', '')
- if 's_website_form_submit' in classes:
- submit_button_html = etree.tostring(child, encoding='unicode', pretty_print=True)
- continue
-
- if 's_website_form_field' not in classes:
- continue
-
- field_input = child.xpath('.//input[@name] | .//textarea[@name] | .//select[@name]')
- if not field_input:
- continue
-
- field_name = field_input[0].get('name')
- if field_name == 'description':
- # Only preserve description if it's NOT in the template
- if 'description' not in template_field_names:
- description_html = etree.tostring(child, encoding='unicode', pretty_print=True)
- elif field_name == 'team_id':
- # Always preserve team_id (it's always needed, hidden)
- team_id_html = etree.tostring(child, encoding='unicode', pretty_print=True)
- # Build HTML for template fields
- field_id_counter = 0
- template_fields_html = []
- for tf in template_fields:
- try:
- field_html, field_id_counter = self._build_template_field_html(tf, field_id_counter)
- if field_html:
- template_fields_html.append(field_html)
- _logger.info(f"Built HTML for field {tf.field_id.name if tf.field_id else 'Unknown'}")
- except Exception as e:
- _logger.error(f"Error building HTML for field {tf.field_id.name if tf.field_id else 'Unknown'}: {e}", exc_info=True)
- # Build complete rows container HTML
- # Order: template fields -> description (if not in template) -> team_id -> submit button
- rows_html_parts = []
-
- # Add template fields first (this includes description if it's in the template)
- rows_html_parts.extend(template_fields_html)
-
- # Add description only if it exists AND is NOT in template
- if description_html:
- rows_html_parts.append(description_html)
-
- # Add team_id (always needed, hidden)
- if team_id_html:
- rows_html_parts.append(team_id_html)
-
- # Add submit button (if exists)
- if submit_button_html:
- rows_html_parts.append(submit_button_html)
-
- # Join all parts - each field HTML already has proper formatting
- # We need to indent each field to match Odoo's formatting (32 spaces)
- indented_parts = []
- for part in rows_html_parts:
- # Split by lines and indent each line
- lines = part.split('\n')
- indented_lines = []
- for line in lines:
- if line.strip(): # Only indent non-empty lines
- indented_lines.append(' ' + line)
- else:
- indented_lines.append('')
- indented_parts.append('\n'.join(indented_lines))
-
- rows_html = '\n'.join(indented_parts)
-
- # Wrap in the rows container div
- rows_container_html = f'''<div class="s_website_form_rows row s_col_no_bgcolor">
- {rows_html}
- </div>'''
- # Use the same save method as form builder
- try:
- self.website_form_view_id.sudo().save(
- rows_container_html,
- xpath='.//div[contains(@class, "s_website_form_rows")]'
- )
- _logger.info(f"Successfully saved form using view.save() for team {self.id}, view {self.website_form_view_id.id}")
- except Exception as e:
- _logger.error(f"Error saving form with view.save(): {e}", exc_info=True)
- raise
- def _restore_default_form(self):
- """Restore the default form when template is removed"""
- self.ensure_one()
- if not self.website_form_view_id:
- return
- # Get default form structure
- default_form = self.env.ref('website_helpdesk.ticket_submit_form', raise_if_not_found=False)
- if not default_form:
- return
- # Restore default arch
- self.website_form_view_id.sudo().arch = default_form.arch
- def _build_template_field_html(self, template_field, field_id_counter=0):
- """Build HTML string for a template field exactly as Odoo's form builder does
-
- Args:
- template_field: helpdesk.template.field record
- field_id_counter: int, counter for generating unique field IDs (incremented and returned)
-
- Returns:
- tuple: (html_string, updated_counter)
- """
- # Build the XML element first using existing method
- field_el, field_id_counter = self._build_template_field_xml(template_field, field_id_counter)
- if field_el is None:
- return None, field_id_counter
-
- # Convert to HTML string with proper formatting
- html_str = etree.tostring(field_el, encoding='unicode', pretty_print=True)
- return html_str, field_id_counter
- def _build_template_field_xml(self, template_field, field_id_counter=0):
- """Build XML element for a template field exactly as Odoo's form builder does
-
- Args:
- template_field: helpdesk.template.field record
- field_id_counter: int, counter for generating unique field IDs (incremented and returned)
-
- Returns:
- tuple: (field_element, updated_counter)
- """
- field = template_field.field_id
- field_name = field.name
- field_type = field.ttype
- # Use custom label if provided, otherwise use field's default label
- field_label = template_field.label_custom or field.field_description or field.name
- required = template_field.required
- sequence = template_field.sequence
- # Generate unique ID - use counter to avoid collisions
- field_id_counter += 1
- field_id = f'helpdesk_{field_id_counter}_{abs(hash(field_name)) % 10000}'
- # Build classes (exactly as form builder does) - CORREGIDO: mb-3 en lugar de mb-0 py-2
- classes = ['mb-3', 's_website_form_field', 'col-12']
- if required:
- classes.append('s_website_form_required')
- # Add visibility classes if configured (form builder uses these)
- visibility_classes = []
- if template_field.visibility_dependency:
- visibility_classes.append('s_website_form_field_hidden_if')
- visibility_classes.append('d-none')
- # Create field container div (exactly as form builder does)
- all_classes = classes + visibility_classes
- field_div = etree.Element('div', {
- 'class': ' '.join(all_classes),
- 'data-type': field_type,
- 'data-name': 'Field'
- })
- # Add visibility attributes if configured (form builder uses these)
- if template_field.visibility_dependency:
- field_div.set('data-visibility-dependency', template_field.visibility_dependency.name)
- if template_field.visibility_condition:
- field_div.set('data-visibility-condition', template_field.visibility_condition)
- if template_field.visibility_comparator:
- field_div.set('data-visibility-comparator', template_field.visibility_comparator)
- # Add visibility_between for range comparators (between/!between)
- if template_field.visibility_comparator in ['between', '!between'] and template_field.visibility_between:
- field_div.set('data-visibility-between', template_field.visibility_between)
- # Create inner row (exactly as form builder does)
- row_div = etree.SubElement(field_div, 'div', {
- 'class': 'row s_col_no_resize s_col_no_bgcolor'
- })
- # Create label (exactly as form builder does)
- label = etree.SubElement(row_div, 'label', {
- 'class': 'col-form-label col-sm-auto s_website_form_label',
- 'style': 'width: 200px',
- 'for': field_id
- })
- label_content = etree.SubElement(label, 'span', {
- 'class': 's_website_form_label_content'
- })
- label_content.text = field_label
- if required:
- mark = etree.SubElement(label, 'span', {
- 'class': 's_website_form_mark'
- })
- mark.text = ' *'
- # Create input container
- input_div = etree.SubElement(row_div, 'div', {
- 'class': 'col-sm'
- })
- # Build input based on field type
- input_el = None
- if field_type == 'boolean':
- # Checkbox - CORREGIDO: value debe ser 'Yes' no '1'
- form_check = etree.SubElement(input_div, 'div', {
- 'class': 'form-check'
- })
- input_el = etree.SubElement(form_check, 'input', {
- 'type': 'checkbox',
- 'class': 's_website_form_input form-check-input',
- 'name': field_name,
- 'id': field_id,
- 'value': 'Yes'
- })
- if required:
- input_el.set('required', '1')
- # Set checked if default_value is 'Yes' or '1' or 'True'
- if template_field.default_value and template_field.default_value.lower() in ('yes', '1', 'true'):
- input_el.set('checked', 'checked')
- elif field_type in ('text', 'html'):
- # Textarea - CORREGIDO: eliminar atributo type (no existe en textarea)
- input_el = etree.SubElement(input_div, 'textarea', {
- 'class': 'form-control s_website_form_input',
- 'name': field_name,
- 'id': field_id,
- 'rows': '3'
- })
- if template_field.placeholder:
- input_el.set('placeholder', template_field.placeholder)
- if required:
- input_el.set('required', '1')
- # Set default value as text content
- if template_field.default_value:
- input_el.text = template_field.default_value
- elif field_type == 'selection':
- # Check if custom selection options are provided (for non-relation selection fields)
- selection_options = None
- if template_field.selection_options and not field.relation:
- try:
- selection_options = json.loads(template_field.selection_options)
- if not isinstance(selection_options, list):
- selection_options = None
- except (json.JSONDecodeError, ValueError):
- _logger.warning(f"Invalid JSON in selection_options for field {field_name}: {template_field.selection_options}")
- selection_options = None
-
- # Determine widget type
- widget_type = template_field.widget or 'default'
-
- # Check if this is a relation field (many2one stored as selection)
- is_relation = bool(field.relation)
-
- if widget_type == 'radio' and not is_relation:
- # Radio buttons for selection (non-relation)
- radio_wrapper = etree.SubElement(input_div, 'div', {
- 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
- 'data-name': field_name,
- 'data-display': 'horizontal'
- })
- # Get selection options
- if selection_options:
- options_list = selection_options
- else:
- # Get from model field definition
- model_name = field.model_id.model
- model = self.env[model_name]
- options_list = []
- if hasattr(model, field_name):
- model_field = model._fields.get(field_name)
- if model_field and hasattr(model_field, 'selection'):
- selection = model_field.selection
- if callable(selection):
- selection = selection(model)
- if isinstance(selection, (list, tuple)):
- options_list = selection
- elif field.selection:
- try:
- selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
- if isinstance(selection, (list, tuple)):
- options_list = selection
- except Exception:
- pass
-
- # Create radio buttons
- for option_value, option_label in options_list:
- radio_div = etree.SubElement(radio_wrapper, 'div', {
- 'class': 'radio col-12 col-lg-4 col-md-6'
- })
- form_check = etree.SubElement(radio_div, 'div', {
- 'class': 'form-check'
- })
- radio_input = etree.SubElement(form_check, 'input', {
- 'type': 'radio',
- 'class': 's_website_form_input form-check-input',
- 'name': field_name,
- 'id': f'{field_id}_{abs(hash(str(option_value))) % 10000}',
- 'value': str(option_value)
- })
- if required:
- radio_input.set('required', '1')
- if template_field.default_value and str(template_field.default_value) == str(option_value):
- radio_input.set('checked', 'checked')
- radio_label = etree.SubElement(form_check, 'label', {
- 'class': 'form-check-label',
- 'for': radio_input.get('id')
- })
- radio_label.text = option_label
- input_el = radio_wrapper # For consistency, but not used
- elif widget_type == 'checkbox' and not is_relation:
- # Checkboxes for selection (non-relation) - multiple selection
- checkbox_wrapper = etree.SubElement(input_div, 'div', {
- 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
- 'data-name': field_name,
- 'data-display': 'horizontal'
- })
- # Get selection options (same as radio)
- if selection_options:
- options_list = selection_options
- else:
- model_name = field.model_id.model
- model = self.env[model_name]
- options_list = []
- if hasattr(model, field_name):
- model_field = model._fields.get(field_name)
- if model_field and hasattr(model_field, 'selection'):
- selection = model_field.selection
- if callable(selection):
- selection = selection(model)
- if isinstance(selection, (list, tuple)):
- options_list = selection
- elif field.selection:
- try:
- selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
- if isinstance(selection, (list, tuple)):
- options_list = selection
- except Exception:
- pass
-
- # Create checkboxes
- default_values = template_field.default_value.split(',') if template_field.default_value else []
- for option_value, option_label in options_list:
- checkbox_div = etree.SubElement(checkbox_wrapper, 'div', {
- 'class': 'checkbox col-12 col-lg-4 col-md-6'
- })
- form_check = etree.SubElement(checkbox_div, 'div', {
- 'class': 'form-check'
- })
- checkbox_input = etree.SubElement(form_check, 'input', {
- 'type': 'checkbox',
- 'class': 's_website_form_input form-check-input',
- 'name': field_name,
- 'id': f'{field_id}_{abs(hash(str(option_value))) % 10000}',
- 'value': str(option_value)
- })
- if required:
- checkbox_input.set('required', '1')
- if str(option_value) in [v.strip() for v in default_values]:
- checkbox_input.set('checked', 'checked')
- checkbox_label = etree.SubElement(form_check, 'label', {
- 'class': 'form-check-label s_website_form_check_label',
- 'for': checkbox_input.get('id')
- })
- checkbox_label.text = option_label
- input_el = checkbox_wrapper # For consistency, but not used
- else:
- # Default: Select dropdown
- input_el = etree.SubElement(input_div, 'select', {
- 'class': 'form-select s_website_form_input',
- 'name': field_name,
- 'id': field_id
- })
- if template_field.placeholder:
- input_el.set('placeholder', template_field.placeholder)
- if required:
- input_el.set('required', '1')
- # Add default option
- default_option = etree.SubElement(input_el, 'option', {
- 'value': ''
- })
- default_option.text = '-- Select --'
-
- # Populate selection options
- if selection_options:
- # Use custom selection options
- for option_value, option_label in selection_options:
- option = etree.SubElement(input_el, 'option', {
- 'value': str(option_value)
- })
- option.text = option_label
- if template_field.default_value and str(template_field.default_value) == str(option_value):
- option.set('selected', 'selected')
- else:
- # Get from model field definition
- model_name = field.model_id.model
- model = self.env[model_name]
- if hasattr(model, field_name):
- model_field = model._fields.get(field_name)
- if model_field and hasattr(model_field, 'selection'):
- selection = model_field.selection
- if callable(selection):
- selection = selection(model)
- if isinstance(selection, (list, tuple)):
- for option_value, option_label in selection:
- option = etree.SubElement(input_el, 'option', {
- 'value': str(option_value)
- })
- option.text = option_label
- if template_field.default_value and str(template_field.default_value) == str(option_value):
- option.set('selected', 'selected')
- elif field.selection:
- try:
- selection = eval(field.selection) if isinstance(field.selection, str) else field.selection
- if isinstance(selection, (list, tuple)):
- for option_value, option_label in selection:
- option = etree.SubElement(input_el, 'option', {
- 'value': str(option_value)
- })
- option.text = option_label
- if template_field.default_value and str(template_field.default_value) == str(option_value):
- option.set('selected', 'selected')
- except Exception:
- pass # If selection can't be evaluated, just leave default option
- elif field_type in ('integer', 'float'):
- # Number input (exactly as form builder does)
- input_type = 'number'
- input_el = etree.SubElement(input_div, 'input', {
- 'type': input_type,
- 'class': 'form-control s_website_form_input',
- 'name': field_name,
- 'id': field_id
- })
- if template_field.placeholder:
- input_el.set('placeholder', template_field.placeholder)
- if template_field.default_value:
- input_el.set('value', template_field.default_value)
- if field_type == 'integer':
- input_el.set('step', '1')
- else:
- input_el.set('step', 'any')
- if required:
- input_el.set('required', '1')
- elif field_type == 'many2one':
- # Determine widget type for many2one
- widget_type = template_field.widget or 'default'
-
- if widget_type == 'radio':
- # Radio buttons for many2one
- radio_wrapper = etree.SubElement(input_div, 'div', {
- 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
- 'data-name': field_name,
- 'data-display': 'horizontal'
- })
- # Load records from relation
- relation = field.relation
- if relation and relation != 'ir.attachment':
- try:
- records = self.env[relation].sudo().search_read(
- [], ['display_name'], limit=1000
- )
- for record in records:
- radio_div = etree.SubElement(radio_wrapper, 'div', {
- 'class': 'radio col-12 col-lg-4 col-md-6'
- })
- form_check = etree.SubElement(radio_div, 'div', {
- 'class': 'form-check'
- })
- radio_input = etree.SubElement(form_check, 'input', {
- 'type': 'radio',
- 'class': 's_website_form_input form-check-input',
- 'name': field_name,
- 'id': f'{field_id}_{record["id"]}',
- 'value': str(record['id'])
- })
- if required:
- radio_input.set('required', '1')
- if template_field.default_value and str(template_field.default_value) == str(record['id']):
- radio_input.set('checked', 'checked')
- radio_label = etree.SubElement(form_check, 'label', {
- 'class': 'form-check-label',
- 'for': radio_input.get('id')
- })
- radio_label.text = record['display_name']
- except Exception:
- pass
- input_el = radio_wrapper
- elif widget_type == 'checkbox':
- # Checkboxes for many2one (multiple selection - unusual but supported)
- checkbox_wrapper = etree.SubElement(input_div, 'div', {
- 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
- 'data-name': field_name,
- 'data-display': 'horizontal'
- })
- relation = field.relation
- if relation and relation != 'ir.attachment':
- try:
- records = self.env[relation].sudo().search_read(
- [], ['display_name'], limit=1000
- )
- default_values = template_field.default_value.split(',') if template_field.default_value else []
- for record in records:
- checkbox_div = etree.SubElement(checkbox_wrapper, 'div', {
- 'class': 'checkbox col-12 col-lg-4 col-md-6'
- })
- form_check = etree.SubElement(checkbox_div, 'div', {
- 'class': 'form-check'
- })
- checkbox_input = etree.SubElement(form_check, 'input', {
- 'type': 'checkbox',
- 'class': 's_website_form_input form-check-input',
- 'name': field_name,
- 'id': f'{field_id}_{record["id"]}',
- 'value': str(record['id'])
- })
- if required:
- checkbox_input.set('required', '1')
- if str(record['id']) in [v.strip() for v in default_values]:
- checkbox_input.set('checked', 'checked')
- checkbox_label = etree.SubElement(form_check, 'label', {
- 'class': 'form-check-label s_website_form_check_label',
- 'for': checkbox_input.get('id')
- })
- checkbox_label.text = record['display_name']
- except Exception:
- pass
- input_el = checkbox_wrapper
- else:
- # Default: Select dropdown for many2one
- input_el = etree.SubElement(input_div, 'select', {
- 'class': 'form-select s_website_form_input',
- 'name': field_name,
- 'id': field_id
- })
- if template_field.placeholder:
- input_el.set('placeholder', template_field.placeholder)
- if required:
- input_el.set('required', '1')
-
- # Add default option
- default_option = etree.SubElement(input_el, 'option', {'value': ''})
- default_option.text = '-- Select --'
-
- # Load records dynamically from relation
- relation = field.relation
- if relation and relation != 'ir.attachment':
- try:
- # Try to get records from the relation model
- records = self.env[relation].sudo().search_read(
- [], ['display_name'], limit=1000
- )
- for record in records:
- option = etree.SubElement(input_el, 'option', {
- 'value': str(record['id'])
- })
- option.text = record['display_name']
- if template_field.default_value and str(template_field.default_value) == str(record['id']):
- option.set('selected', 'selected')
- except Exception:
- # If relation doesn't exist or access denied, try specific cases
- if field_name == 'request_type_id':
- request_types = self.env['helpdesk.request.type'].sudo().search([('active', '=', True)])
- for req_type in request_types:
- option = etree.SubElement(input_el, 'option', {
- 'value': str(req_type.id)
- })
- option.text = req_type.name
- elif field_name == 'affected_module_id':
- modules = self.env['ir.module.module'].sudo().search([
- ('state', '=', 'installed'),
- ('application', '=', True)
- ], order='shortdesc')
- for module in modules:
- option = etree.SubElement(input_el, 'option', {
- 'value': str(module.id)
- })
- option.text = module.shortdesc or module.name
- elif field_type in ('date', 'datetime'):
- # Date/Datetime field - NUEVO: soporte para fechas
- date_wrapper = etree.SubElement(input_div, 'div', {
- 'class': f's_website_form_{field_type} input-group date'
- })
- input_el = etree.SubElement(date_wrapper, 'input', {
- 'type': 'text',
- 'class': 'form-control datetimepicker-input s_website_form_input',
- 'name': field_name,
- 'id': field_id
- })
- if template_field.placeholder:
- input_el.set('placeholder', template_field.placeholder)
- if template_field.default_value:
- input_el.set('value', template_field.default_value)
- if required:
- input_el.set('required', '1')
- # Add calendar icon
- icon_div = etree.SubElement(date_wrapper, 'div', {
- 'class': 'input-group-text o_input_group_date_icon'
- })
- icon = etree.SubElement(icon_div, 'i', {'class': 'fa fa-calendar'})
- elif field_type == 'binary':
- # Binary field (file upload) - NUEVO: soporte para archivos
- input_el = etree.SubElement(input_div, 'input', {
- 'type': 'file',
- 'class': 'form-control s_website_form_input',
- 'name': field_name,
- 'id': field_id
- })
- if required:
- input_el.set('required', '1')
- elif field_type in ('one2many', 'many2many'):
- # One2many/Many2many fields - NUEVO: soporte para checkboxes múltiples
- if field.relation == 'ir.attachment':
- # Binary one2many (file upload multiple)
- input_el = etree.SubElement(input_div, 'input', {
- 'type': 'file',
- 'class': 'form-control s_website_form_input',
- 'name': field_name,
- 'id': field_id,
- 'multiple': ''
- })
- if required:
- input_el.set('required', '1')
- else:
- # Generic one2many/many2many as checkboxes
- multiple_div = etree.SubElement(input_div, 'div', {
- 'class': 'row s_col_no_resize s_col_no_bgcolor s_website_form_multiple',
- 'data-name': field_name,
- 'data-display': 'horizontal'
- })
- # Try to load records from relation
- relation = field.relation
- if relation:
- try:
- records = self.env[relation].sudo().search_read(
- [], ['display_name'], limit=100
- )
- for record in records:
- checkbox_div = etree.SubElement(multiple_div, 'div', {
- 'class': 'checkbox col-12 col-lg-4 col-md-6'
- })
- form_check = etree.SubElement(checkbox_div, 'div', {
- 'class': 'form-check'
- })
- checkbox_input = etree.SubElement(form_check, 'input', {
- 'type': 'checkbox',
- 'class': 's_website_form_input form-check-input',
- 'name': field_name,
- 'id': f'{field_id}_{record["id"]}',
- 'value': str(record['id'])
- })
- checkbox_label = etree.SubElement(form_check, 'label', {
- 'class': 'form-check-label s_website_form_check_label',
- 'for': f'{field_id}_{record["id"]}'
- })
- checkbox_label.text = record['display_name']
- except Exception:
- pass # If relation doesn't exist or access denied
- elif field_type == 'monetary':
- # Monetary field - NUEVO: soporte para montos
- input_el = etree.SubElement(input_div, 'input', {
- 'type': 'number',
- 'class': 'form-control s_website_form_input',
- 'name': field_name,
- 'id': field_id,
- 'step': 'any'
- })
- if required:
- input_el.set('required', '1')
- else:
- # Default: text input (char) - exactly as form builder does
- input_el = etree.SubElement(input_div, 'input', {
- 'type': 'text',
- 'class': 'form-control s_website_form_input',
- 'name': field_name,
- 'id': field_id
- })
- if template_field.placeholder:
- input_el.set('placeholder', template_field.placeholder)
- if template_field.default_value:
- input_el.set('value', template_field.default_value)
- if required:
- input_el.set('required', '1')
-
- # Add help text description if provided (exactly as form builder does)
- if template_field.help_text:
- help_text_div = etree.SubElement(input_div, 'div', {
- 'class': 's_website_form_field_description small form-text text-muted'
- })
- # Parse HTML help text and add as content
- try:
- # html.fromstring may wrap content in <html><body>, so we need to handle that
- help_html = html.fragment_fromstring(template_field.help_text, create_parent='div')
- # Copy all children and text from the parsed HTML
- if help_html is not None:
- # If fragment_fromstring created a wrapper div, get its children
- if len(help_html) > 0:
- for child in help_html:
- help_text_div.append(child)
- elif help_html.text:
- help_text_div.text = help_html.text
- else:
- # Fallback: use text content
- help_text_div.text = help_html.text_content() or template_field.help_text
- else:
- help_text_div.text = template_field.help_text
- except Exception as e:
- # Fallback: use plain text or raw HTML
- _logger.warning(f"Error parsing help_text HTML for field {field_name}: {e}")
- # Try to set as raw HTML (Odoo's HTML fields are sanitized, so this should be safe)
- try:
- # Use etree to parse and append raw HTML
- raw_html = etree.fromstring(f'<div>{template_field.help_text}</div>')
- for child in raw_html:
- help_text_div.append(child)
- if not len(help_text_div):
- help_text_div.text = template_field.help_text
- except Exception:
- # Final fallback: plain text
- help_text_div.text = template_field.help_text
- return field_div, field_id_counter
- def apply_workflow_template(self):
- """Apply workflow template to create stages and SLAs for this team
-
- This method creates real helpdesk.stage and helpdesk.sla records
- based on the workflow template configuration.
- """
- self.ensure_one()
- if not self.workflow_template_id:
- raise ValueError(_("No workflow template selected"))
-
- template = self.workflow_template_id
- if not template.active:
- raise ValueError(_("The selected workflow template is not active"))
-
- # Mapping: stage_template_id -> real_stage_id
- stage_mapping = {}
-
- # 1. Create real stages from template stages
- for stage_template in template.stage_template_ids.sorted('sequence'):
- stage_vals = {
- 'name': stage_template.name,
- 'sequence': stage_template.sequence,
- 'fold': stage_template.fold,
- 'description': stage_template.description or False,
- 'template_id': stage_template.template_id_email.id if stage_template.template_id_email else False,
- 'legend_blocked': stage_template.legend_blocked,
- 'legend_done': stage_template.legend_done,
- 'legend_normal': stage_template.legend_normal,
- 'team_ids': [(4, self.id)],
- }
- real_stage = self.env['helpdesk.stage'].create(stage_vals)
- stage_mapping[stage_template.id] = real_stage.id
-
- # 2. Create real SLAs from template SLAs
- for sla_template in template.sla_template_ids.sorted('sequence'):
- # Get real stage ID from mapping
- real_stage_id = stage_mapping.get(sla_template.stage_template_id.id)
- if not real_stage_id:
- _logger.warning(
- f"Skipping SLA template '{sla_template.name}': "
- f"stage template {sla_template.stage_template_id.id} not found in mapping"
- )
- continue
-
- # Get real exclude stage IDs - map template stages to real stages
- exclude_stage_ids = []
- for exclude_template_stage in sla_template.exclude_stage_template_ids:
- if exclude_template_stage.id in stage_mapping:
- exclude_stage_ids.append(stage_mapping[exclude_template_stage.id])
- else:
- _logger.warning(
- f"SLA template '{sla_template.name}': "
- f"exclude stage template {exclude_template_stage.id} ({exclude_template_stage.name}) "
- f"not found in stage mapping. Skipping."
- )
-
- sla_vals = {
- 'name': sla_template.name,
- 'description': sla_template.description or False,
- 'team_id': self.id,
- 'stage_id': real_stage_id,
- 'time': sla_template.time,
- 'priority': sla_template.priority,
- 'tag_ids': [(6, 0, sla_template.tag_ids.ids)],
- 'exclude_stage_ids': [(6, 0, exclude_stage_ids)],
- 'active': True,
- }
- created_sla = self.env['helpdesk.sla'].create(sla_vals)
- _logger.info(
- f"Created SLA '{created_sla.name}' with {len(exclude_stage_ids)} excluded stage(s): "
- f"{[self.env['helpdesk.stage'].browse(sid).name for sid in exclude_stage_ids]}"
- )
-
- # 3. Ensure team has use_sla enabled if template has SLAs
- if template.sla_template_ids and not self.use_sla:
- self.use_sla = True
-
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': _('Workflow Template Applied'),
- 'message': _(
- 'Successfully created %d stage(s) and %d SLA policy(ies) from template "%s".',
- len(stage_mapping),
- len(template.sla_template_ids),
- template.name
- ),
- 'type': 'success',
- 'sticky': False,
- }
- }
|