| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450 |
- # -*- 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 or workflow_template_id already set
- for team in teams.filtered(lambda t: t.use_website_helpdesk_form and t.website_form_view_id and (t.template_id or (t.workflow_template_id and t.workflow_template_id.field_ids))):
- 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 or (t.workflow_template_id and t.workflow_template_id.field_ids))):
- # 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 or 'workflow_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 handles template regeneration for both template_id and workflow_template_id
- team._ensure_submit_form_view()
- else:
- # View exists, regenerate or restore form based on template
- has_template = team.template_id or (team.workflow_template_id and team.workflow_template_id.field_ids)
- if has_template:
- 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 (supports both legacy template_id and workflow_template_id)"""
- self.ensure_one()
-
- # Get template fields from either legacy template or workflow template
- template_fields = self.env['helpdesk.template.field']
- template_source = None
-
- # Priority: workflow_template_id over template_id (legacy)
- if self.workflow_template_id and self.workflow_template_id.field_ids:
- template_fields = self.workflow_template_id.field_ids.sorted('sequence')
- template_source = f"workflow_template {self.workflow_template_id.id}"
- elif self.template_id and self.template_id.field_ids:
- template_fields = self.template_id.field_ids.sorted('sequence')
- template_source = f"template {self.template_id.id}"
-
- if not template_fields or not self.website_form_view_id:
- return
- # Get base form structure (from default template)
- # We use the default template arch to ensure we start with a clean base
- default_form = self.env.ref('website_helpdesk.ticket_submit_form', raise_if_not_found=False)
- if not default_form:
- return
- # Get website language for translations
- # Try to get current website, fallback to default website or system language
- website = self.env['website'].get_current_website()
- if website:
- lang = website.default_lang_id.code if website.default_lang_id else 'en_US'
- else:
- # Fallback: try to get default website
- try:
- default_website = self.env.ref('website.default_website', raise_if_not_found=False)
- if default_website and default_website.default_lang_id:
- lang = default_website.default_lang_id.code
- else:
- lang = self.env.context.get('lang', 'en_US')
- except Exception:
- lang = self.env.context.get('lang', 'en_US')
-
- # Create environment with website language for translations
- env_lang = self.env(context=dict(self.env.context, lang=lang))
-
- # Log template fields for debugging
- _logger.info(f"Regenerating form for team {self.id}, {template_source} 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,
- }
- }
|