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