# Part of Odoo. See LICENSE file for full copyright and licensing details. import ast from collections import defaultdict from dateutil.relativedelta import relativedelta from lxml import html from odoo import api, fields, models, tools, _ from odoo.fields import Command, Domain from odoo.tools import html_sanitize, LazyTranslate from odoo.addons.web.controllers.utils import clean_action from odoo.exceptions import UserError _lt = LazyTranslate(__name__) TICKET_PRIORITY = [ ('0', 'Low priority'), ('1', 'Medium priority'), ('2', 'High priority'), ('3', 'Urgent'), ] class HelpdeskTicket(models.Model): _name = 'helpdesk.ticket' _description = 'Helpdesk Ticket' _order = 'priority desc, id desc' _mail_defaults_to_email = True _mail_thread_customer = True _primary_email = 'partner_email' _inherit = [ 'portal.mixin', 'mail.thread.cc', 'utm.mixin', 'rating.mixin', 'mail.activity.mixin', 'mail.tracking.duration.mixin', ] _track_duration_field = 'stage_id' @api.model def default_get(self, fields): result = super().default_get(fields) if result.get('team_id') and fields: team = self.env['helpdesk.team'].browse(result['team_id']) if 'stage_id' in fields and 'stage_id' not in result: # if no stage given, deduce it from the team result['stage_id'] = team._determine_stage()[team.id].id if 'user_id' in fields and 'user_id' not in result and ('stage_id' in fields and not self.env['helpdesk.stage'].browse(result['stage_id']).fold): # if no user given, deduce it from the team result['user_id'] = team._determine_user_to_assign({team: 1})[team.id][0] return result def _default_team_id(self): team_id = self.env['helpdesk.team'].search([('member_ids', 'in', self.env.uid)], limit=1).id if not team_id: team_id = self.env['helpdesk.team'].search([], limit=1).id return team_id @api.model def _read_group_stage_ids(self, stages, domain): # write the domain # - ('id', 'in', stages.ids): add columns that should be present # - OR ('team_ids', '=', team_id) if team_id: add team columns search_domain = [('id', 'in', stages.ids)] if self.env.context.get('default_team_id'): search_domain = ['|', ('team_ids', 'in', self.env.context['default_team_id'])] + search_domain return stages.search(search_domain) @api.model def _search(self, domain, offset=0, limit=None, order=None, *, active_test=True, bypass_access=False): """ Override _search to have a more intuitive result when ordering by `ticket_ref`, which is a Char field. """ if order and order.startswith('ticket_ref '): order = order.replace('ticket_ref ', 'id ', 1) return super()._search(domain, offset, limit, order, active_test=active_test, bypass_access=bypass_access) name = fields.Char(string='Subject', required=True, index=True, tracking=True) team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', default=lambda self: self._default_team_id(), index=True, tracking=True) use_sla = fields.Boolean(related='team_id.use_sla') team_privacy_visibility = fields.Selection(related='team_id.privacy_visibility', export_string_translation=False) description = fields.Html(sanitize_attributes=False) active = fields.Boolean(default=True) tag_ids = fields.Many2many('helpdesk.tag', string='Tags') company_id = fields.Many2one(related='team_id.company_id', string='Company', store=True, readonly=True) color = fields.Integer(string='Color Index') kanban_state = fields.Selection([ ('normal', 'In progress'), ('done', 'Ready'), ('blocked', 'Blocked')], string='Kanban State', copy=False, default='normal', required=True) kanban_state_label = fields.Char(compute='_compute_kanban_state_label', string='Kanban State Label', tracking=True) legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False) legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False) legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False) domain_user_ids = fields.Many2many('res.users', compute='_compute_domain_user_ids', export_string_translation=False) user_id = fields.Many2one( 'res.users', string='Assigned to', compute='_compute_user_and_stage_ids', store=True, readonly=False, tracking=True, domain=lambda self: [('all_group_ids', 'in', self.env.ref('helpdesk.group_helpdesk_user').id)], falsy_value_label=_lt("👤 Unassigned")) properties = fields.Properties( 'Properties', definition='team_id.ticket_properties', copy=True) partner_id = fields.Many2one('res.partner', string='Customer', tracking=True, index=True) partner_ticket_ids = fields.Many2many('helpdesk.ticket', compute='_compute_partner_ticket_count', string="Partner Tickets") partner_ticket_count = fields.Integer('Number of other tickets from the same partner', compute='_compute_partner_ticket_count') partner_open_ticket_count = fields.Integer('Number of other open tickets from the same partner', compute='_compute_partner_ticket_count') # Used to submit tickets from a contact form partner_name = fields.Char(string='Customer Name', compute='_compute_partner_name', store=True, readonly=False) partner_email = fields.Char(string='Customer Email', compute='_compute_partner_email', inverse="_inverse_partner_email", store=True, readonly=False) partner_phone = fields.Char(string='Customer Phone', compute='_compute_partner_phone', inverse="_inverse_partner_phone", store=True, readonly=False) commercial_partner_id = fields.Many2one(related="partner_id.commercial_partner_id") closed_by_partner = fields.Boolean('Closed by Partner', readonly=True) priority = fields.Selection(TICKET_PRIORITY, string='Priority', default='0', tracking=True) stage_id = fields.Many2one( 'helpdesk.stage', string='Stage', compute='_compute_user_and_stage_ids', store=True, readonly=False, ondelete='restrict', tracking=1, group_expand='_read_group_stage_ids', copy=False, index=True, domain="[('team_ids', '=', team_id)]") stage_id_color = fields.Integer(string='Stage Color', related="stage_id.color", export_string_translation=False) fold = fields.Boolean(related="stage_id.fold", export_string_translation=False) date_last_stage_update = fields.Datetime("Last Stage Update", copy=False, readonly=True) ticket_ref = fields.Char(string='Ticket IDs Sequence', copy=False, readonly=True, index=True) # next 4 fields are computed in write (or create) assign_date = fields.Datetime("First assignment date") assign_hours = fields.Float("Time to first assignment (hours)", compute='_compute_assign_hours', store=True, aggregator="avg") close_date = fields.Datetime("Close date", copy=False) close_hours = fields.Float("Time to close (hours)", compute='_compute_close_hours', store=True, aggregator="avg") open_hours = fields.Integer("Open Time (hours)", compute='_compute_open_hours', search='_search_open_hours', aggregator="avg") # SLA relative sla_ids = fields.Many2many('helpdesk.sla', 'helpdesk_sla_status', 'ticket_id', 'sla_id', string="SLAs", copy=False) sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status") sla_reached_late = fields.Boolean("Has SLA reached late", compute='_compute_sla_reached_late', compute_sudo=True, store=True) sla_reached = fields.Boolean("Has SLA reached", compute='_compute_sla_reached', compute_sudo=True, store=True) sla_deadline = fields.Datetime("SLA Deadline", compute='_compute_sla_deadline', compute_sudo=True, store=True, falsy_value_label=_lt("Deadline reached")) sla_deadline_hours = fields.Float("Working Hours until SLA Deadline", compute='_compute_sla_deadline', compute_sudo=True, store=True, aggregator="avg") sla_fail = fields.Boolean("Failed SLA Policy", compute='_compute_sla_fail', search='_search_sla_fail') sla_success = fields.Boolean("Success SLA Policy", compute='_compute_sla_success', search='_search_sla_success') use_credit_notes = fields.Boolean(related='team_id.use_credit_notes', export_string_translation=False) use_coupons = fields.Boolean(related='team_id.use_coupons', string='Use Coupons') use_giftcards = fields.Boolean(related='team_id.use_giftcards', string='Use Gift Cards') use_product_returns = fields.Boolean(related='team_id.use_product_returns', export_string_translation=False) use_product_replacements = fields.Boolean(related='team_id.use_product_replacements', export_string_translation=False) use_product_repairs = fields.Boolean(related='team_id.use_product_repairs', export_string_translation=False) use_rating = fields.Boolean(related='team_id.use_rating', export_string_translation=False) is_partner_email_update = fields.Boolean(compute='_compute_is_partner_email_update', export_string_translation=False) is_partner_phone_update = fields.Boolean(compute='_compute_is_partner_phone_update', export_string_translation=False) # customer portal: include comment and (incoming/outgoing) emails in communication history website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment', 'email_outgoing', 'auto_comment'])], export_string_translation=False) first_response_hours = fields.Float("Hours to First Response", aggregator="avg", copy=False) avg_response_hours = fields.Float("Average Hours to Respond", aggregator="avg", copy=False) oldest_unanswered_customer_message_date = fields.Datetime("Oldest Unanswered Customer Message Date", export_string_translation=False) answered_customer_message_count = fields.Integer('# Exchanges', aggregator="avg") total_response_hours = fields.Float("Total Exchange Time in Hours", aggregator="avg", copy=False) display_extra_info = fields.Boolean(compute="_compute_display_extra_info", export_string_translation=False) @api.depends('stage_id', 'kanban_state') def _compute_kanban_state_label(self): for ticket in self: if ticket.kanban_state == 'normal': ticket.kanban_state_label = ticket.legend_normal elif ticket.kanban_state == 'blocked': ticket.kanban_state_label = ticket.legend_blocked else: ticket.kanban_state_label = ticket.legend_done @api.depends('team_id') def _compute_domain_user_ids(self): user_ids = self.env.ref('helpdesk.group_helpdesk_user').all_user_ids.ids for ticket in self: ticket_user_ids = [] ticket_sudo = ticket.sudo() if ticket_sudo.team_id and ticket_sudo.team_id.privacy_visibility == 'invited_internal': ticket_user_ids = ticket_sudo.team_id.message_partner_ids.user_ids.ids ticket.domain_user_ids = [Command.set(user_ids + ticket_user_ids)] def _compute_access_url(self): super()._compute_access_url() for ticket in self: ticket.access_url = '/my/ticket/%s' % ticket.id @api.depends('sla_status_ids.deadline', 'sla_status_ids.reached_datetime') def _compute_sla_reached_late(self): """ Required to do it in SQL since we need to compare 2 columns value """ mapping = {} if self.ids: self.env.cr.execute(""" SELECT ticket_id, COUNT(id) AS reached_late_count FROM helpdesk_sla_status WHERE ticket_id IN %s AND (deadline < reached_datetime OR (deadline < %s AND reached_datetime IS NULL)) GROUP BY ticket_id """, (tuple(self.ids), fields.Datetime.now())) mapping = dict(self.env.cr.fetchall()) for ticket in self: ticket.sla_reached_late = mapping.get(ticket.id, 0) > 0 @api.depends('sla_status_ids.deadline', 'sla_status_ids.reached_datetime') def _compute_sla_reached(self): sla_status_read_group = self.env['helpdesk.sla.status']._read_group( [('reached_datetime', '!=', False), ('ticket_id', 'in', self.ids)], ['ticket_id'], ) sla_status_ids_per_ticket = {ticket.id for [ticket] in sla_status_read_group} for ticket in self: ticket.sla_reached = ticket.id in sla_status_ids_per_ticket @api.depends('sla_status_ids.deadline', 'sla_status_ids.reached_datetime') def _compute_sla_deadline(self): """ Keep the deadline for the last stage (closed one), so a closed ticket can have a status failed. Note: a ticket in a closed stage will probably have no deadline """ now = fields.Datetime.now() for ticket in self: # the current team is invalid, no need to compute new values since the transaction will be rolled back anyway. if not (ticket.team_id and ticket.team_id.use_sla): continue min_deadline = False for status in ticket.sla_status_ids: if status.reached_datetime or not status.deadline: continue if not min_deadline or status.deadline < min_deadline: min_deadline = status.deadline ticket.update({ 'sla_deadline': min_deadline, 'sla_deadline_hours': ticket.team_id.resource_calendar_id.get_work_duration_data\ (now, min_deadline, compute_leaves=True)['hours'] if min_deadline else 0.0, }) @api.depends('sla_deadline', 'sla_reached_late') def _compute_sla_fail(self): now = fields.Datetime.now() for ticket in self: if ticket.sla_deadline: ticket.sla_fail = (ticket.sla_deadline < now) or ticket.sla_reached_late else: ticket.sla_fail = ticket.sla_reached_late @api.depends('partner_email', 'partner_id') def _compute_is_partner_email_update(self): for ticket in self: ticket.is_partner_email_update = ticket._get_partner_email_update() @api.depends('partner_phone', 'partner_id') def _compute_is_partner_phone_update(self): for ticket in self: ticket.is_partner_phone_update = ticket._get_partner_phone_update() @api.model def _search_sla_fail(self, operator, value): if operator != 'in': return NotImplemented datetime_now = fields.Datetime.now() return ['|', ('sla_reached_late', '=', True), ('sla_deadline', '<', datetime_now)] @api.depends('sla_deadline', 'sla_reached', 'sla_reached_late') def _compute_sla_success(self): now = fields.Datetime.now() for ticket in self: ticket.sla_success = (ticket.sla_reached and not ticket.sla_reached_late and (not ticket.sla_deadline or ticket.sla_deadline > now)) @api.model def _search_sla_success(self, operator, value): if operator != 'in': return NotImplemented datetime_now = fields.Datetime.now() return [('sla_reached', '=', True), ('sla_reached_late', '=', False), '|', ('sla_deadline', '=', False), ('sla_deadline', '>', datetime_now)] @api.depends('team_id') def _compute_user_and_stage_ids(self): for ticket in self.filtered(lambda ticket: ticket.team_id): if not ticket.user_id: ticket.user_id = ticket.team_id._determine_user_to_assign({ticket.team_id: 1})[ticket.team_id.id][0] if not ticket.stage_id or ticket.stage_id not in ticket.team_id.stage_ids: ticket.stage_id = ticket.team_id._determine_stage()[ticket.team_id.id] @api.depends('partner_id') def _compute_partner_name(self): self.fetch(['partner_id']) for ticket in self: if ticket.partner_id: ticket.partner_name = ticket.partner_id.name @api.depends('partner_id.email') def _compute_partner_email(self): self.fetch(['partner_id']) for ticket in self: if ticket.partner_id: ticket.partner_email = ticket.partner_id.email def _inverse_partner_email(self): for ticket in self: if ticket._get_partner_email_update(): ticket.partner_id.email = ticket.partner_email @api.depends('partner_id.phone') def _compute_partner_phone(self): self.fetch(['partner_id']) for ticket in self: if ticket.partner_id: ticket.partner_phone = ticket.partner_id.phone def _inverse_partner_phone(self): for ticket in self: if (ticket._get_partner_phone_update() or not ticket.partner_id.phone) and ticket.partner_phone: ticket = ticket.sudo() ticket.partner_id.phone = ticket.partner_phone @api.depends('partner_id', 'partner_email', 'partner_phone') def _compute_partner_ticket_count(self): for ticket in self: partner_tickets = self.search_fetch([("partner_id", "child_of", ticket.partner_id.commercial_partner_id.id)], ['fold']) if ticket.partner_id else ticket ticket.partner_ticket_ids = partner_tickets partner_tickets = partner_tickets - ticket._origin ticket.partner_ticket_count = len(partner_tickets) if partner_tickets else 0 open_ticket = partner_tickets.filtered(lambda ticket: not ticket.fold) ticket.partner_open_ticket_count = len(open_ticket) @api.depends('assign_date') def _compute_assign_hours(self): for ticket in self: create_date = fields.Datetime.from_string(ticket.create_date) if create_date and ticket.assign_date and ticket.team_id.resource_calendar_id: duration_data = ticket.team_id.resource_calendar_id.get_work_duration_data(create_date, fields.Datetime.from_string(ticket.assign_date), compute_leaves=True) ticket.assign_hours = duration_data['hours'] else: ticket.assign_hours = False @api.depends('create_date', 'close_date') def _compute_close_hours(self): for ticket in self: create_date = fields.Datetime.from_string(ticket.create_date) if create_date and ticket.close_date and ticket.team_id.resource_calendar_id: duration_data = ticket.team_id.resource_calendar_id.get_work_duration_data(create_date, fields.Datetime.from_string(ticket.close_date), compute_leaves=True) ticket.close_hours = duration_data['hours'] else: ticket.close_hours = False @api.depends('close_hours') def _compute_open_hours(self): for ticket in self: if ticket.create_date: # fix from https://github.com/odoo/enterprise/commit/928fbd1a16e9837190e9c172fa50828fae2a44f7 if ticket.close_date: time_difference = ticket.close_date - fields.Datetime.from_string(ticket.create_date) else: time_difference = fields.Datetime.now() - fields.Datetime.from_string(ticket.create_date) ticket.open_hours = (time_difference.seconds) / 3600 + time_difference.days * 24 else: ticket.open_hours = 0 def _compute_display_extra_info(self): self.display_extra_info = self.env.user.has_group('base.group_multi_company') @api.model def _search_open_hours(self, operator, value): if operator == 'in': return Domain.OR(self._search_open_hours('=', v) for v in value) if operator == 'not in': return Domain.AND(self._search_open_hours('!=', v) for v in value) dt = fields.Datetime.now() - relativedelta(hours=value) domain_closed = Domain('close_hours', operator, value) if operator in ['<', '<=', '>', '>=']: domain_unclosed = ~Domain('create_date', operator, dt) elif operator in ['=', '!=']: dt = dt.replace(minute=0, second=0, microsecond=0) domain_unclosed = ( Domain('create_date', '>=', dt) & Domain('create_date', '<', dt + relativedelta(hours=1)) ) if operator == '!=': domain_unclosed = ~domain_unclosed else: return NotImplemented return ( (Domain('close_date', '=', False) & domain_unclosed) | (Domain('close_date', '!=', False) & domain_closed) ) def _get_rotting_depends_fields(self): return super()._get_rotting_depends_fields() + ['close_date', 'kanban_state'] def _get_rotting_domain(self): return super()._get_rotting_domain() & Domain('close_date', '=', False) def _get_partner_email_update(self): self.ensure_one() if self.partner_id.email and self.partner_email and self.partner_email != self.partner_id.email: ticket_email_normalized = tools.email_normalize(self.partner_email) or self.partner_email or False partner_emails_normalized = tools.email_normalize_all(self.partner_id.email) or self.partner_id.email or False return ticket_email_normalized not in partner_emails_normalized return False def _get_partner_phone_update(self): self.ensure_one() if self.partner_id.phone and self.partner_phone and self.partner_phone != self.partner_id.phone: ticket_phone_formatted = self.partner_phone or False partner_phone_formatted = self.partner_id.phone or False return ticket_phone_formatted != partner_phone_formatted return False def action_customer_preview(self): self.ensure_one() if self.team_privacy_visibility != 'portal' or not self.partner_id: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'type': 'danger', 'message': _('At this time, there is no preview available to show. The current ticket cannot be accessed by the customer, as it belongs to a helpdesk team that is not publicly available, or there is no customer associated with the ticket.'), } } return { 'type': 'ir.actions.act_url', 'url': self.get_portal_url(), 'target': 'self', } # ------------------------------------------------------------ # ORM overrides # ------------------------------------------------------------ @api.depends('ticket_ref', 'partner_name') @api.depends_context('with_partner') def _compute_display_name(self): display_partner_name = self.env.context.get('with_partner', False) ticket_with_name = self.filtered('name') for ticket in ticket_with_name: name = ticket.name if ticket.ticket_ref: name += f' (#{ticket.ticket_ref})' if display_partner_name and ticket.partner_name: name += f' - {ticket.partner_name}' ticket.display_name = name return super(HelpdeskTicket, self - ticket_with_name)._compute_display_name() @api.model def get_empty_list_help(self, help_message): self = self.with_context( empty_list_help_id=self.env.context.get('default_team_id'), empty_list_help_model='helpdesk.team', empty_list_help_document_name=_("tickets"), ) return super().get_empty_list_help(help_message) def create_action(self, action_ref, title, search_view_ref): action = self.env["ir.actions.actions"]._for_xml_id(action_ref) action = clean_action(action, self.env) if title: action['display_name'] = title if search_view_ref: action['search_view_id'] = self.env.ref(search_view_ref).read()[0] if 'views' not in action: action['views'] = [(False, view) for view in action['view_mode'].split(",")] return action @api.model def _assign_vals_by_tags(self, vals_list): """ This method is used to automatically assign unassigned tickets based on added tags. Given a list of vals with the associated team and added tags, it updates the vals with the user_id of the user to assign the ticket to. This is a side effect, and the vals are intended to then be used in a create/write method. :param vals_list: A list of tuples with the following structure: [(helpdesk_team, tag_ids, vals_dict), ...] Each tuple consists of: - `helpdesk_team_id`: The id of the `helpdesk.team` of the ticket. - `tag_ids`: A list of ids corresponding to the `helpdesk.tag` being added to the ticket. - `vals_dict`: A dictionary of values that are intended to be later used to create or write on the ticket. Note that this dictionary will be modified **in place** (side effect) """ if not vals_list: return domain = Domain.OR([('team_id', '=', team_id), ('tag_id', 'in', tag_ids)] for team_id, tag_ids, _dummy in vals_list) tag_assignment_res = self.env['helpdesk.tag.assignment'].sudo()._read_group( domain, ['team_id', 'tag_id', 'user_ids'], ) # Dict of all the users that could be assigned from a tag in a team users_per_team_and_tag = defaultdict(set) all_possible_users_ids = set() for team, tag, user in tag_assignment_res: users_per_team_and_tag[(team.id, tag.id)].add(user.id) all_possible_users_ids.add(user.id) all_possible_users = self.env['res.users'].browse(all_possible_users_ids) all_team_ids = {vals[0] for vals in vals_list} users_per_working_days = all_possible_users._get_working_users_per_first_working_day() # Used to keep track of how many open tickets users have in each team tickets_per_user_per_team = defaultdict(dict) for team, _dummy, user in tag_assignment_res: tickets_per_user_per_team[team.id][user.id] = 0 # Add the open tickets to the previously created dict ticket_count_data = self.env['helpdesk.ticket']._read_group( [('stage_id.fold', '=', False), ('user_id', 'in', all_possible_users.ids), ('team_id', 'in', list(all_team_ids))], ['user_id', 'team_id'], ['__count'], ) for user, team, open_tickets in ticket_count_data: tickets_per_user_per_team[team.id][user.id] = open_tickets for team_id, tag_ids, vals in vals_list: # get all users matching at least one of the tags... possible_user_ids = { user_id for tag_id in tag_ids for user_id in users_per_team_and_tag.get((team_id, tag_id), []) } if possible_user_ids: for user_ids in users_per_working_days: # ...restrict them to the earliest available users ... if available_user_ids := set(user_ids) & possible_user_ids: # ... and select the one with the fewest open tickets count_per_user = filter(lambda item: item[0] in available_user_ids, tickets_per_user_per_team[team_id].items()) chosen_user_id = min(count_per_user, key=lambda count: count[1])[0] vals['user_id'] = chosen_user_id tickets_per_user_per_team[team_id][chosen_user_id] += 1 break @api.model_create_multi def create(self, vals_list): now = fields.Datetime.now() # Determine user_id if not given for teams with automatic assignment. We have two cases: # - If the assignment method is 'randomly' or 'balanced', we determine the next users to assign for each team. # - If the assignment method is 'tags', it's a bit more complex as it depends on the added tags for each individual ticket. # In that case, we directly modify the vals, as it's simpler than computing the user ids then map them to the vals they originate from. team_ids = { vals['team_id'] for vals in vals_list if vals.get('team_id') } tickets_to_assign_by_tags = [] teams = self.env['helpdesk.team'].browse(team_ids) # determine stage_id if not given. Done in batch. default_stage_per_team_id = teams._determine_stage() # Determine assignees if auto assignement is enabled # In case of assignment by tags, we add the user_ids to the values below. For the other methods, they are added later team_per_team_id = dict(zip(team_ids, teams)) ticket_amount_per_team = defaultdict(int) for vals in vals_list: if not (team_id := vals.get('team_id')): continue stage = self.env['helpdesk.stage'].browse(vals['stage_id']) if 'stage_id' in vals else default_stage_per_team_id[team_id] team = team_per_team_id[team_id] if stage.fold or vals.get('user_id') or not team.auto_assignment: continue if team.assign_method == 'tags': if tag_ids := vals.get('tag_ids'): tag_ids = self._fields['tag_ids'].convert_to_cache(tag_ids, self) tickets_to_assign_by_tags.append((team.id, tag_ids, vals)) else: ticket_amount_per_team[team] += 1 self._assign_vals_by_tags(tickets_to_assign_by_tags) assignees_per_team_id = self.env['helpdesk.team']._determine_user_to_assign(ticket_amount_per_team) # Manually create a partner now since '_generate_template_recipients' doesn't keep the name. This is # to avoid intrusive changes in the 'mail' module # TDE TODO: to extract and clean in mail thread for vals in vals_list: partner_id = vals.get('partner_id', False) partner_name = vals.get('partner_name', False) partner_email = vals.get('partner_email', False) if partner_email and not partner_id: company_id = self.env['helpdesk.team'].browse(vals['team_id']).company_id.id if vals.get('team_id') else False suggested_name = tools.parse_contact_from_email(partner_name)[0] or tools.parse_contact_from_email(partner_email)[0] vals['partner_id'] = self.env['mail.thread']._partner_find_from_emails_single( [partner_email], additional_values={tools.mail.email_normalize(partner_email) or partner_email: {'name': suggested_name, 'company_id': company_id}}, filter_found=lambda partner: not partner.company_id or partner.company_id.id == company_id, ).id # determine partner email for ticket with partner but no email given partners = self.env['res.partner'].browse([vals['partner_id'] for vals in vals_list if 'partner_id' in vals and vals.get('partner_id') and 'partner_email' not in vals]) partner_email_map = {partner.id: partner.email for partner in partners} partner_name_map = {partner.id: partner.name for partner in partners} company_per_team_id = {t.id: t.company_id for t in teams} for vals in vals_list: company = company_per_team_id.get(vals.get('team_id', False)) vals['ticket_ref'] = self.env['ir.sequence'].with_company(company).sudo().next_by_code('helpdesk.ticket') if team_id := vals.get('team_id'): if 'stage_id' not in vals: vals['stage_id'] = default_stage_per_team_id[team_id].id if self.env['helpdesk.stage'].browse(vals['stage_id']).fold: vals['close_date'] = now if 'user_id' not in vals and team_id in assignees_per_team_id: vals['user_id'] = assignees_per_team_id[team_id].pop() if vals.get('user_id'): # if a user is finally assigned, force ticket assign_date and reset assign_hours vals['assign_date'] = fields.Datetime.now() vals['assign_hours'] = 0 # set partner email if in map of not given if vals.get('partner_id') in partner_email_map: vals['partner_email'] = partner_email_map.get(vals['partner_id']) # set partner name if in map of not given if vals.get('partner_id') in partner_name_map: vals['partner_name'] = partner_name_map.get(vals['partner_id']) if vals.get('stage_id'): vals['date_last_stage_update'] = now vals['oldest_unanswered_customer_message_date'] = now # context: no_log, because subtype already handle this tickets = super().create(vals_list) all_partner_emails = [] for ticket in tickets: all_partner_emails += tools.email_normalize_all(ticket.email_cc) partners = self.env['res.partner'].search([('email', 'in', all_partner_emails)]) partner_per_email = { partner.email: partner for partner in partners if not all(u.share for u in partner.user_ids) } # make customer follower for ticket in tickets: partner_ids = [] if ticket.partner_id: partner_ids = ticket.partner_id.ids if ticket.email_cc: partners_with_internal_user = self.env['res.partner'] for email in tools.email_normalize_all(ticket.email_cc): new_partner = partner_per_email.get(email) if new_partner: partners_with_internal_user |= new_partner if partners_with_internal_user: ticket._send_email_notify_to_cc(partners_with_internal_user) partner_ids += partners_with_internal_user.ids if partner_ids: ticket.message_subscribe(partner_ids) ticket._portal_ensure_token() # apply SLA tickets.sudo()._sla_apply() return tickets def write(self, vals): # we set the assignation date (assign_date) to now for tickets that are being assigned for the first time # same thing for the closing date assigned_tickets = closed_tickets = self.browse() if vals.get('user_id'): assigned_tickets = self.filtered(lambda ticket: not ticket.assign_date) if vals.get('stage_id'): if self.env['helpdesk.stage'].browse(vals.get('stage_id')).fold: closed_tickets = self.filtered(lambda ticket: not ticket.close_date) else: # auto reset the 'closed_by_partner' flag vals['closed_by_partner'] = False vals['close_date'] = False now = fields.Datetime.now() # update last stage date when changing stage if 'stage_id' in vals: vals['date_last_stage_update'] = now if 'kanban_state' not in vals: vals['kanban_state'] = 'normal' if 'kanban_state' in vals: vals['date_last_stage_update'] = now old_tag_ids_per_ticket_id = {} if 'tag_ids' in vals: old_tag_ids_per_ticket_id = {t.id: set(t.tag_ids.ids) for t in self} res = super(HelpdeskTicket, self - assigned_tickets - closed_tickets).write(vals) res &= super(HelpdeskTicket, assigned_tickets - closed_tickets).write(dict(vals, **{ 'assign_date': now, })) res &= super(HelpdeskTicket, closed_tickets - assigned_tickets).write(dict(vals, **{ 'close_date': now, 'oldest_unanswered_customer_message_date': False, })) res &= super(HelpdeskTicket, assigned_tickets & closed_tickets).write(dict(vals, **{ 'assign_date': now, 'close_date': now, })) if vals.get('partner_id'): self.message_subscribe([vals['partner_id']]) # SLA business sla_triggers = self._sla_reset_trigger() if any(field_name in sla_triggers for field_name in vals.keys()): self.sudo()._sla_apply(keep_reached=True) if 'stage_id' in vals: self.sudo()._sla_reach(vals['stage_id']) if stage_fold := ('stage_id' in vals and self.env['helpdesk.stage'].browse(vals['stage_id']).fold): odoobot_partner_id = self.env['ir.model.data']._xmlid_to_res_id('base.partner_root') for ticket in self: exceeded_hours = ticket.sla_status_ids.mapped('exceeded_hours') if exceeded_hours: min_hours = min([hours for hours in exceeded_hours if hours > 0], default=min(exceeded_hours)) message = _("This ticket was successfully closed %s hours before its SLA deadline.", round(abs(min_hours))) if min_hours < 0 \ else _("This ticket was closed %s hours after its SLA deadline.", round(min_hours)) ticket.message_post(body=message, subtype_xmlid="mail.mt_note", author_id=odoobot_partner_id) elif old_tag_ids_per_ticket_id: unassigned_tickets_to_assign = self.filtered( lambda t: not t.user_id and t.team_id.auto_assignment and t.team_id.assign_method == 'tags' and not t.stage_id.fold ) if unassigned_tickets_to_assign: vals_list = [ (ticket.team_id.id, list(added_tags), {}) for ticket in unassigned_tickets_to_assign if (added_tags := set(ticket.tag_ids.ids) - old_tag_ids_per_ticket_id[ticket.id]) ] if vals_list: self._assign_vals_by_tags(vals_list) for ticket, vals_dict in zip(unassigned_tickets_to_assign, list(zip(*vals_list))[2]): if vals_dict: ticket.write(vals_dict) return res def copy_data(self, default=None): vals_list = super().copy_data(default=default) has_default_user = default and 'user_id' in default active_users = self.env['res.users'] if not has_default_user: active_users = self.user_id.filtered('active') for ticket, vals in zip(self, vals_list): vals['name'] = self.env._("%s (copy)", ticket.name) if not has_default_user and ticket.user_id and ticket.user_id not in active_users: vals['user_id'] = False return vals_list def _unsubscribe_portal_users(self): self.message_unsubscribe(partner_ids=self.message_partner_ids.filtered('user_ids.share').ids) def website_form_input_filter(self, request, values): if 'partner_id' in values: values.pop('partner_name', None) values.pop('partner_email', None) return values # ------------------------------------------------------------ # Actions and Business methods # ------------------------------------------------------------ @api.model def _sla_reset_trigger(self): """ Get the list of field for which we have to reset the SLAs (regenerate) """ return ['team_id', 'priority', 'tag_ids', 'partner_id'] def _sla_apply(self, keep_reached=False): """ Apply SLA to current tickets: erase the current SLAs, then find and link the new SLAs to each ticket. Note: transferring ticket to a team "not using SLA" (but with SLAs defined), SLA status of the ticket will be erased but nothing will be recreated. :returns recordset of new helpdesk.sla.status applied on current tickets """ # get SLA to apply sla_per_tickets = self._sla_find() # generate values of new sla status sla_status_value_list = [] for tickets, slas in sla_per_tickets.items(): sla_status_value_list += tickets._sla_generate_status_values(slas, keep_reached=keep_reached) sla_status_to_remove = self.mapped('sla_status_ids') if keep_reached: # keep only the reached one to avoid losing reached_date info sla_status_to_remove = sla_status_to_remove.filtered(lambda status: not status.reached_datetime) # unlink status and create the new ones in 2 operations sla_status_to_remove.unlink() return self.env['helpdesk.sla.status'].create(sla_status_value_list) def _sla_find_domain(self): """ Get domain to find matching SLAs. This function aims to be inherited by submodules :return: domain :rtype : list [tuple] """ return [ ('team_id', '=', self.team_id.id), ('priority', '=', self.priority), ('stage_id.sequence', '>=', self.stage_id.sequence), ] @api.model def _sla_find_false_domain(self): return [('partner_ids', '=', False)] def _sla_find_extra_domain(self): self.ensure_one() return [ '|', ('partner_ids', 'parent_of', self.partner_id.ids), ('partner_ids', 'child_of', self.partner_id.ids), ] def _sla_find(self): """ Find the SLA to apply on the current tickets :returns a map with the tickets linked to the SLA to apply on them :rtype : dict {: } """ tickets_map = {} sla_domain_map = {} def _generate_key(ticket): """ Return a tuple identifying the combinaison of field determining the SLA to apply on the ticket """ fields_list = self._sla_reset_trigger() key = list() for field_name in fields_list: if ticket._fields[field_name].type == 'many2one': key.append(ticket[field_name].id) else: key.append(ticket[field_name]) return tuple(key) for ticket in self: if ticket.team_id.use_sla: # limit to the team using SLA key = _generate_key(ticket) # group the ticket per key tickets_map.setdefault(key, self.env['helpdesk.ticket']) tickets_map[key] |= ticket # group the SLA to apply, by key if key not in sla_domain_map: sla_domain_map[key] = Domain.AND([ ticket._sla_find_domain(), Domain.OR([ ticket._sla_find_extra_domain(), self._sla_find_false_domain(), ]), ]) result = {} for key, tickets in tickets_map.items(): # only one search per ticket group domain = sla_domain_map[key] slas = self.env['helpdesk.sla'].search(domain) result[tickets] = slas.filtered(lambda s: not s.tag_ids or (tickets.tag_ids & s.tag_ids)) # SLA to apply on ticket subset return result def _sla_generate_status_values(self, slas, keep_reached=False): """ Return the list of values for given SLA to be applied on current ticket """ exclude_slas_per_ticket = {} # generate the map of status to keep by ticket only if requested if keep_reached: exclude_slas_per_ticket = dict(self.env['helpdesk.sla.status']._read_group( domain=[('reached_datetime', '!=', False), ('ticket_id', 'in', self.ids)], groupby=['ticket_id'], aggregates=['sla_id:recordset'], )) result = [] for ticket in self: exclude_slas = exclude_slas_per_ticket.get(ticket, self.env['helpdesk.sla']) for sla in slas - exclude_slas: result.append({ 'ticket_id': ticket.id, 'sla_id': sla.id, 'reached_datetime': fields.Datetime.now() if ticket.stage_id == sla.stage_id else False # in case of SLA on first stage }) return result def _sla_reach(self, stage_id): """ Flag the SLA status of current ticket for the given stage_id as reached, and even the unreached SLA applied on stage having a sequence lower than the given one. """ stage = self.env['helpdesk.stage'].browse(stage_id) stages = self.env['helpdesk.stage'].search([('sequence', '<=', stage.sequence), ('team_ids', 'in', self.mapped('team_id').ids)]) # take previous stages sla_status = self.env['helpdesk.sla.status'].search([('ticket_id', 'in', self.ids)]) sla_not_reached = sla_status.filtered(lambda sla: not sla.reached_datetime and sla.sla_stage_id in stages) sla_not_reached.write({'reached_datetime': fields.Datetime.now()}) (sla_status - sla_not_reached).filtered(lambda x: x.sla_stage_id not in stages).write({'reached_datetime': False}) def action_open_helpdesk_ticket(self): self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id("helpdesk.helpdesk_ticket_action_main_tree") action.update({ 'domain': [('id', '!=', self.id), ('id', 'in', self.partner_ticket_ids.ids)], 'context': { **ast.literal_eval(action.get('context', {})), 'create': False, }, }) return action def action_open_ratings(self): self.ensure_one() action = self.env['ir.actions.act_window']._for_xml_id('helpdesk.rating_rating_action_helpdesk') if self.rating_count == 1: action.update({ 'view_mode': 'form', 'res_id': self.rating_ids[0].id, 'views': [(view_id, view_type) for view_id, view_type in action['views'] if view_type == 'form'], }) return action # ------------------------------------------------------------ # Messaging API # ------------------------------------------------------------ def _get_customer_information(self): email_keys_to_values = super()._get_customer_information() for ticket in self: email_key = tools.email_normalize(ticket.partner_email) or ticket.partner_email # do not fill Falsy with random data, unless monorecord (= always correct) if not email_key and len(self) > 1: continue email_keys_to_values.setdefault(email_key, {}).update({ 'company_id': ticket.company_id.id, 'name': ticket.partner_name or tools.parse_contact_from_email(ticket.partner_email)[0] or ticket.partner_email, 'phone': ticket.partner_phone, }) return email_keys_to_values @api.model def message_new(self, msg_dict, custom_values=None): values = dict(custom_values or {}, partner_email=msg_dict.get('from'), partner_name=msg_dict.get('from'), partner_id=msg_dict.get('author_id')) partner = self.env['res.partner'].browse(values.get('partner_id')) team = self.env['helpdesk.team'].browse(values.get('team_id')) if team and partner.company_id and partner.company_id != team.company_id: values.pop('partner_id') ticket = super(HelpdeskTicket, self.with_context(mail_notify_author=True)).message_new(msg_dict, custom_values=values) partner_ids = ticket._partner_find_from_emails_single(tools.email_split((msg_dict.get('to') or '') + ',' + (msg_dict.get('cc') or ''))).ids customer_ids = ticket._partner_find_from_emails_single(tools.email_split(values['partner_email'])).ids partner_ids += customer_ids if customer_ids and not values.get('partner_id'): ticket.partner_id = customer_ids[0] if partner_ids: ticket.message_subscribe(partner_ids) return ticket def message_update(self, msg_dict, update_vals=None): for ticket in self: if partners := ticket._partner_find_from_emails_single(tools.email_split((msg_dict.get('to') or '') + ',' + (msg_dict.get('cc') or '')), no_create=True): self.message_subscribe(partners.ids) return super().message_update(msg_dict, update_vals=update_vals) def _message_compute_subject(self): """ Override the display name by the actual name field for communication.""" self.ensure_one() return self.name def _message_post_after_hook(self, message, msg_vals): if not self.partner_email: return super()._message_post_after_hook(message, msg_vals) if self.partner_id and not self.partner_id.email: self.partner_id.email = self.partner_email if not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. email_normalized = tools.email_normalize(self.partner_email) new_partner = message.partner_ids.filtered( lambda partner: partner.email == self.partner_email or (email_normalized and partner.email_normalized == email_normalized) ) if new_partner: if new_partner[0].email_normalized: email_domain = ('partner_email', 'in', [new_partner[0].email, new_partner[0].email_normalized]) else: email_domain = ('partner_email', '=', new_partner[0].email) self.search([ ('partner_id', '=', False), email_domain, ]).write({'partner_id': new_partner[0].id}) # use the sanitized body of the email from the message thread to populate the ticket's description if ( not self.description and message.subtype_id == self._creation_subtype() and msg_vals.get('message_type') == 'email' and tools.email_normalize(self.partner_email) == tools.email_normalize(message.email_from) and msg_vals.get('body') ): # Remove the signature from the email body source_html = msg_vals.get('body') doc = html.fromstring(source_html) signature_xpath = ( '//*[@id="Signature"] | ' '//*[@data-smartmail="gmail_signature"] | ' '//span[normalize-space(.) = "--"]' ) for element in doc.xpath(signature_xpath): element.getparent().remove(element) cleaned_html = html.tostring(doc, encoding='unicode').strip() self.description = html_sanitize(cleaned_html) return super()._message_post_after_hook(message, msg_vals) def _send_email_notify_to_cc(self, partners_to_notify): # TDE TODO: this should be removed with email-like recipients management self.ensure_one() template_id = self.env['ir.model.data']._xmlid_to_res_id('helpdesk.ticket_invitation_follower', raise_if_not_found=False) if not template_id: return ticket_model_description = self.env['ir.model']._get(self._name).display_name values = { 'object': self, } for partner in partners_to_notify: values['partner_name'] = partner.name assignation_msg = self.env['ir.qweb']._render('helpdesk.ticket_invitation_follower', values, minimal_qcontext=True) self.message_notify( subject=_('You have been invited to follow %s', self.display_name), body=assignation_msg, partner_ids=partner.ids, email_layout_xmlid='mail.mail_notification_layout', model_description=ticket_model_description, mail_auto_delete=True, ) def _track_template(self, changes): res = super()._track_template(changes) ticket = self[0] if 'stage_id' in changes and ticket.stage_id.template_id and ticket.partner_email: res['stage_id'] = (ticket.stage_id.template_id, { 'auto_delete_keep_log': False, 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), 'email_layout_xmlid': 'mail.mail_notification_light' } ) return res def _creation_subtype(self): return self.env.ref('helpdesk.mt_ticket_new') def _track_subtype(self, init_values): self.ensure_one() if 'stage_id' in init_values: return self.env.ref('helpdesk.mt_ticket_stage') return super()._track_subtype(init_values) def _notify_get_reply_to(self, default=None, author_id=False): """ Override to set alias of tickets to their team if any. """ aliases = self.mapped('team_id').sudo()._notify_get_reply_to(default=default, author_id=author_id) res = {ticket.id: aliases.get(ticket.team_id.id) for ticket in self} leftover = self.filtered(lambda rec: not rec.team_id) if leftover: res.update(super(HelpdeskTicket, leftover)._notify_get_reply_to(default=default, author_id=author_id)) return res # ------------------------------------------------------------ # Rating Mixin # ------------------------------------------------------------ def _rating_apply_get_default_subtype_id(self): return self.env['ir.model.data']._xmlid_to_res_id("helpdesk.mt_ticket_rated") def _rating_get_parent_field_name(self): return 'team_id' # --------------------------------------------------- # Mail gateway # --------------------------------------------------- def _mail_get_message_subtypes(self): res = super()._mail_get_message_subtypes() if len(self) == 1 and self.team_id: team = self.team_id optional_subtypes = [('use_credit_notes', self.env.ref('helpdesk.mt_ticket_refund_status')), ('use_product_returns', self.env.ref('helpdesk.mt_ticket_return_status')), ('use_product_replacements', self.env.ref('helpdesk.mt_ticket_delivery_status')), ('use_product_repairs', self.env.ref('helpdesk.mt_ticket_repair_status'))] for field, subtype in optional_subtypes: if not team[field] and subtype in res: res -= subtype return res @api.constrains('company_id', 'partner_id') def _check_partner_id_has_the_same_company(self): for ticket in self: if ticket.partner_id.company_id and ticket.partner_id.company_id != ticket.company_id: raise UserError(_("The customer cannot belong to a different company than the ticket."))