# -*- 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 website language for translations
# Try to get current website, fallback to default website or system language
website = self.env['website'].get_current_website()
if website:
lang = website.default_lang_id.code if website.default_lang_id else 'en_US'
else:
# Fallback: try to get default website
try:
default_website = self.env.ref('website.default_website', raise_if_not_found=False)
if default_website and default_website.default_lang_id:
lang = default_website.default_lang_id.code
else:
lang = self.env.context.get('lang', 'en_US')
except Exception:
lang = self.env.context.get('lang', 'en_US')
# Create environment with website language for translations
env_lang = self.env(context=dict(self.env.context, lang=lang))
# 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, env_lang=env_lang)
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'''
{rows_html}
'''
# 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, env_lang=None):
"""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)
env_lang: Environment with language context for translations
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, env_lang=env_lang)
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, env_lang=None):
"""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)
env_lang: Environment with language context for translations
Returns:
tuple: (field_element, updated_counter)
"""
field = template_field.field_id
field_name = field.name
field_type = field.ttype
# Use environment with language context if provided, otherwise use self.env
env = env_lang if env_lang else self.env
# Use custom label if provided, otherwise use field's default label with language context
if template_field.label_custom:
field_label = template_field.label_custom
else:
# Get field description in the correct language using the model's field
model_name = field.model_id.model
try:
model = env[model_name]
model_field = model._fields.get(field_name)
if model_field:
# Use get_description() method which returns translated string
# This method respects the language context
field_desc = model_field.get_description(env)
field_label = field_desc.get('string', '') if isinstance(field_desc, dict) else (field_desc or field.field_description or field.name)
if not field_label:
field_label = field.field_description or field.name
else:
field_label = field.field_description or field.name
except Exception:
# Fallback to field description or name
field_label = 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 == 'binary':
# File upload
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 == 'one2many' and field.relation == 'ir.attachment':
# Multiple file upload for attachment_ids
input_el = etree.SubElement(input_div, 'input', {
'type': 'file',
'class': 'form-control s_website_form_input',
'name': field_name,
'id': field_id,
'multiple': 'true'
})
if required:
input_el.set('required', '1')
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 with language context
model_name = field.model_id.model
model = 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'):
# Use _description_selection method which handles translation correctly
try:
if hasattr(model_field, '_description_selection'):
# This method returns translated options when env has lang context
selection = model_field._description_selection(env)
if isinstance(selection, (list, tuple)):
options_list = selection
else:
# Fallback: use get_field_selection from ir.model.fields
options_list = env['ir.model.fields'].get_field_selection(model_name, field_name)
except Exception:
# Fallback: get selection directly
selection = model_field.selection
if callable(selection):
selection = selection(model)
if isinstance(selection, (list, tuple)):
options_list = selection
elif field.selection:
try:
# Evaluate selection string if it's stored as string
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) with language context
if selection_options:
options_list = selection_options
else:
model_name = field.model_id.model
model = 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 - translate using website language context
default_option = etree.SubElement(input_el, 'option', {
'value': ''
})
# Get translation using the environment's language context
# Load translations explicitly and get translated text
lang = env.lang or 'en_US'
try:
from odoo.tools.translate import get_translation, code_translations
# Force load translations by accessing them (this triggers _load_python_translations)
translations = code_translations.get_python_translations('helpdesk_extras', lang)
translated_text = get_translation('helpdesk_extras', lang, '-- Select --', ())
# If translation is the same as source, it means translation not found or not loaded
if translated_text == '-- Select --' and lang != 'en_US':
# Check if translation exists in loaded translations
translated_text = translations.get('-- Select --', '-- Select --')
default_option.text = translated_text
except Exception:
# Fallback: use direct translation mapping based on language
translations_map = {
'es_MX': '-- Seleccionar --',
'es_ES': '-- Seleccionar --',
'es': '-- Seleccionar --',
}
default_option.text = translations_map.get(lang, '-- 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 with language context
model_name = field.model_id.model
model = env[model_name]
if hasattr(model, field_name):
model_field = model._fields.get(field_name)
if model_field and hasattr(model_field, 'selection'):
# Use _description_selection method which handles translation correctly
try:
if hasattr(model_field, '_description_selection'):
# This method returns translated options when env has lang context
selection = model_field._description_selection(env)
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')
else:
# Fallback: use get_field_selection from ir.model.fields
selection = env['ir.model.fields'].get_field_selection(model_name, field_name)
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:
# Fallback: get selection directly
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:
# Evaluate selection string if it's stored as string
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 with language context
relation = field.relation
if relation and relation != 'ir.attachment':
try:
records = 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 = 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 - translate using website language context
default_option = etree.SubElement(input_el, 'option', {'value': ''})
# Get translation using the environment's language context
# Load translations explicitly and get translated text
lang = env.lang or 'en_US'
try:
from odoo.tools.translate import get_translation, code_translations
# Force load translations by accessing them (this triggers _load_python_translations)
translations = code_translations.get_python_translations('helpdesk_extras', lang)
translated_text = get_translation('helpdesk_extras', lang, '-- Select --', ())
# If translation is the same as source, it means translation not found or not loaded
if translated_text == '-- Select --' and lang != 'en_US':
# Check if translation exists in loaded translations
translated_text = translations.get('-- Select --', '-- Select --')
default_option.text = translated_text
except Exception:
# Fallback: use direct translation mapping based on language
translations_map = {
'es_MX': '-- Seleccionar --',
'es_ES': '-- Seleccionar --',
'es': '-- Seleccionar --',
}
default_option.text = translations_map.get(lang, '-- Select --')
# Load records dynamically from relation with language context
relation = field.relation
if relation and relation != 'ir.attachment':
try:
# Try to get records from the relation model with language context
records = 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 with language context
if field_name == 'request_type_id':
request_types = 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 = env['helpdesk.affected.module'].sudo().search([
('active', '=', True)
], order='name')
for module in modules:
option = etree.SubElement(input_el, 'option', {
'value': str(module.id)
})
option.text = 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 with language context
relation = field.relation
if relation:
try:
records = 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 , 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'{template_field.help_text}
')
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,
}
}