| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269 |
- # -*- 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 == '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
- 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['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
- 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,
- }
- }
|