| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448 |
- # -*- 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/workflow fields are set"""
- teams = super().create(vals_list)
- # After create, if form fields are available (from template_id OR workflow_template_id), regenerate
- for team in teams.filtered(lambda t: t.use_website_helpdesk_form and t._has_form_fields() and t.website_form_view_id):
- team._regenerate_form_from_template()
- return teams
- def _has_form_fields(self):
- """Check if team has form fields configured (from template_id or workflow_template_id)"""
- self.ensure_one()
- # Check workflow_template_id.field_ids first (new), then template_id (legacy)
- if self.workflow_template_id and self.workflow_template_id.field_ids:
- return True
- if self.template_id and self.template_id.field_ids:
- return True
- return False
- def _get_form_fields(self):
- """Get form fields from workflow_template_id (preferred) or template_id (legacy)"""
- self.ensure_one()
- # Prefer workflow_template_id.field_ids (new), fallback to template_id (legacy)
- if self.workflow_template_id and self.workflow_template_id.field_ids:
- return self.workflow_template_id.field_ids.sorted('sequence')
- if self.template_id and self.template_id.field_ids:
- return self.template_id.field_ids.sorted('sequence')
- return self.env['helpdesk.workflow.template.field']
- 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 form fields are set, regenerate form
- for team in self.filtered(lambda t: t.use_website_helpdesk_form and t._has_form_fields()):
- 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 or workflow changes"""
- result = super().write(vals)
- # Regenerate form when template_id OR workflow_template_id changes
- if 'template_id' in vals or 'workflow_template_id' in vals:
- teams_to_process = self.browse(self.ids).filtered('use_website_helpdesk_form')
- for team in teams_to_process:
- if not team.website_form_view_id:
- team._ensure_submit_form_view()
- else:
- if team._has_form_fields():
- team._regenerate_form_from_template()
- else:
- 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 form fields (from workflow or template)"""
- self.ensure_one()
- if not self._has_form_fields() or not self.website_form_view_id:
- return
- # Get base form structure (from default template)
- 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
- website = self.env['website'].get_current_website()
- if website:
- lang = website.default_lang_id.code if website.default_lang_id else 'en_US'
- else:
- 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')
-
- env_lang = self.env(context=dict(self.env.context, lang=lang))
- # Get form fields sorted by sequence (from workflow_template or legacy template)
- template_fields = self._get_form_fields()
-
- # Determine source for logging
- source = "workflow_template" if (self.workflow_template_id and self.workflow_template_id.field_ids) else "template"
- source_id = self.workflow_template_id.id if source == "workflow_template" else (self.template_id.id if self.template_id else None)
-
- _logger.info(f"Regenerating form for team {self.id}, {source} {source_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'''<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, 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 _get_relation_domain(self, relation_model, env):
- """Get domain for relation model, filtering by active=True if the model has an active field
-
- Args:
- relation_model: Model name (string)
- env: Environment
- Returns:
- list: Domain list, e.g. [] or [('active', '=', True)]
- """
- if not relation_model:
- return []
-
- try:
- model = env[relation_model]
- # Check if model has an 'active' field
- if 'active' in model._fields:
- return [('active', '=', True)]
- except (KeyError, AttributeError):
- pass
-
- return []
-
- 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 - Use rows from template_field (default 3, same as Odoo formbuilder)
- rows_value = str(template_field.rows) if template_field.rows else '3'
- input_el = etree.SubElement(input_div, 'textarea', {
- 'class': 'form-control s_website_form_input',
- 'name': field_name,
- 'id': field_id,
- 'rows': rows_value
- })
- 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 selection type (dropdown or radio) - same as Odoo formbuilder
- selection_type = template_field.selection_type if template_field.selection_type else 'dropdown'
-
- # Check if this is a relation field (many2one stored as selection)
- is_relation = bool(field.relation)
-
- if selection_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 template_field.widget == '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 selection type (dropdown or radio) - same as Odoo formbuilder
- selection_type = template_field.selection_type if template_field.selection_type else 'dropdown'
-
- if selection_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:
- domain = self._get_relation_domain(relation, env)
- records = env[relation].sudo().search_read(
- domain, ['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
- 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
- domain = self._get_relation_domain(relation, env)
- records = env[relation].sudo().search_read(
- domain, ['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:
- domain = self._get_relation_domain(relation, env)
- records = env[relation].sudo().search_read(
- domain, ['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) - Use input_type from template_field (default 'text', same as Odoo formbuilder)
- input_type_value = template_field.input_type if template_field.input_type else 'text'
- input_el = etree.SubElement(input_div, 'input', {
- 'type': input_type_value,
- '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 = {}
- stages_created = 0
- stages_reused = 0
-
- # 1. Create or reuse real stages from template stages
- for stage_template in template.stage_template_ids.sorted('sequence'):
- # Check if a stage with the same name already exists for this team
- existing_stage = self.env['helpdesk.stage'].search([
- ('name', '=', stage_template.name),
- ('team_ids', 'in', [self.id])
- ], limit=1)
-
- if existing_stage:
- # Reuse existing stage
- stages_reused += 1
- _logger.info(
- f"Reusing existing stage '{existing_stage.name}' (ID: {existing_stage.id}) "
- f"for team '{self.name}' instead of creating duplicate"
- )
- real_stage = existing_stage
- # Update stage properties from template if needed
- update_vals = {}
- if stage_template.sequence != existing_stage.sequence:
- update_vals['sequence'] = stage_template.sequence
- if stage_template.fold != existing_stage.fold:
- update_vals['fold'] = stage_template.fold
- if stage_template.description and stage_template.description != existing_stage.description:
- update_vals['description'] = stage_template.description
- if stage_template.template_id_email and stage_template.template_id_email.id != existing_stage.template_id.id:
- update_vals['template_id'] = stage_template.template_id_email.id
- if stage_template.legend_blocked != existing_stage.legend_blocked:
- update_vals['legend_blocked'] = stage_template.legend_blocked
- if stage_template.legend_done != existing_stage.legend_done:
- update_vals['legend_done'] = stage_template.legend_done
- if stage_template.legend_normal != existing_stage.legend_normal:
- update_vals['legend_normal'] = stage_template.legend_normal
-
- if update_vals:
- existing_stage.write(update_vals)
-
- # Ensure stage is linked to this team (in case it wasn't)
- if self.id not in existing_stage.team_ids.ids:
- existing_stage.team_ids = [(4, self.id)]
- else:
- # Create new stage
- 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)
- stages_created += 1
- _logger.info(
- f"Created new stage '{real_stage.name}' (ID: {real_stage.id}) for team '{self.name}'"
- )
-
- 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
-
- # Build notification message
- if stages_reused > 0:
- message = _(
- 'Successfully applied template "%s": %d stage(s) reused, %d new stage(s) created, and %d SLA policy(ies) created.',
- template.name,
- stages_reused,
- stages_created,
- len(template.sla_template_ids)
- )
- else:
- message = _(
- 'Successfully created %d stage(s) and %d SLA policy(ies) from template "%s".',
- len(stage_mapping),
- len(template.sla_template_ids),
- template.name
- )
-
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': _('Workflow Template Applied'),
- 'message': message,
- 'type': 'success',
- 'sticky': False,
- }
- }
|