# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import ast from dateutil.relativedelta import relativedelta from odoo import api, Command, fields, models, tools, _ from odoo.exceptions import AccessError from odoo.osv import expression from odoo.addons.web.controllers.utils import clean_action 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' _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(HelpdeskTicket, self).default_get(fields) if result.get('team_id') and fields: team = self.env['helpdesk.team'].browse(result['team_id']) if 'user_id' in fields and 'user_id' not in result: # if no user given, deduce it from the team result['user_id'] = team._determine_user_to_assign()[team.id].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 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) name = fields.Char(string='Subject', required=True, index=True, tracking=True) team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', default=_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: [('groups_id', 'in', self.env.ref('helpdesk.group_helpdesk_user').id)]) 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)]") 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) 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_product_returns = fields.Boolean(related='team_id.use_product_returns', 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'])], export_string_translation=False) first_response_hours = fields.Float("Hours to First Response", aggregator="avg") avg_response_hours = fields.Float("Average Hours to Respond", aggregator="avg") 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") 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').users.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(HelpdeskTicket, self)._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( [('exceeded_hours', '<', 0), ('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: 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): datetime_now = fields.Datetime.now() if (value and operator in expression.NEGATIVE_TERM_OPERATORS) or (not value and operator not in expression.NEGATIVE_TERM_OPERATORS): # is not failed return ['&', ('sla_reached_late', '=', False), '|', ('sla_deadline', '=', False), ('sla_deadline', '>=', datetime_now)] return ['|', ('sla_reached_late', '=', True), ('sla_deadline', '<', datetime_now)] # is failed @api.depends('sla_deadline', 'sla_reached_late') def _compute_sla_success(self): now = fields.Datetime.now() for ticket in self: ticket.sla_success = (ticket.sla_deadline and ticket.sla_deadline > now) @api.model def _search_sla_success(self, operator, value): datetime_now = fields.Datetime.now() if (value and operator in expression.NEGATIVE_TERM_OPERATORS) or (not value and operator not in expression.NEGATIVE_TERM_OPERATORS): # is failed return [('sla_status_ids.reached_datetime', '>', datetime_now), ('sla_reached_late', '!=', False), '|', ('sla_deadline', '!=', False), ('sla_deadline', '<', datetime_now)] return [('sla_status_ids.reached_datetime', '<', datetime_now), ('sla_reached', '=', True), ('sla_reached_late', '=', False), '|', ('sla_deadline', '=', False), ('sla_deadline', '>=', datetime_now)] # is success @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.id] 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): 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): 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): 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([("partner_id", "child_of", ticket.partner_id.commercial_partner_id.id)]) 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 partner_tickets.fetch(['stage_id']) # prevent over-fetching fields, leading to potential out-of-memory error open_ticket = partner_tickets.filtered(lambda ticket: not ticket.stage_id.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: 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): dt = fields.Datetime.now() - relativedelta(hours=value) d1, d2 = False, False if operator in ['<', '<=', '>', '>=']: d1 = ['&', ('close_date', '=', False), ('create_date', expression.TERM_OPERATORS_NEGATION[operator], dt)] d2 = ['&', ('close_date', '!=', False), ('close_hours', operator, value)] elif operator in ['=', '!=']: subdomain = ['&', ('create_date', '>=', dt.replace(minute=0, second=0, microsecond=0)), ('create_date', '<=', dt.replace(minute=59, second=59, microsecond=99))] if operator in expression.NEGATIVE_TERM_OPERATORS: subdomain = expression.distribute_not(subdomain) d1 = expression.AND([[('close_date', '=', False)], subdomain]) d2 = ['&', ('close_date', '!=', False), ('close_hours', operator, value)] return expression.OR([d1, d2]) 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_email_normalized = tools.email_normalize(self.partner_id.email) or self.partner_id.email or False return ticket_email_normalized != partner_email_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 customer 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._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(HelpdeskTicket, self).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 _find_or_create_partner(self, partner_name, partner_email, company=False): parsed_name, parsed_email_normalized = tools.parse_contact_from_email(partner_email) if not parsed_name: parsed_name = partner_name return self.env['res.partner'].with_context(default_company_id=company).find_or_create( tools.formataddr((parsed_name, parsed_email_normalized)) ) @api.model_create_multi def create(self, list_value): now = fields.Datetime.now() # determine user_id and stage_id if not given. Done in batch. teams = self.env['helpdesk.team'].browse([vals['team_id'] for vals in list_value if vals.get('team_id')]) team_default_map = dict.fromkeys(teams.ids, dict()) for team in teams: team_default_map[team.id] = { 'stage_id': team._determine_stage()[team.id].id, 'user_id': team._determine_user_to_assign()[team.id].id } # 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 list_value: partner_id = vals.get('partner_id', False) partner_name = vals.get('partner_name', False) partner_email = vals.get('partner_email', False) if partner_name and partner_email and not partner_id: company = False if vals.get('team_id'): team = self.env['helpdesk.team'].browse(vals.get('team_id')) company = team.company_id.id vals['partner_id'] = self._find_or_create_partner(partner_name, partner_email, company).id # determine partner email for ticket with partner but no email given partners = self.env['res.partner'].browse([vals['partner_id'] for vals in list_value 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 list_value: 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 vals.get('team_id'): team_default = team_default_map[vals['team_id']] if 'stage_id' not in vals: vals['stage_id'] = team_default['stage_id'] # Note: this will break the randomly distributed user assignment. Indeed, it will be too difficult to # equally assigned user when creating ticket in batch, as it requires to search after the last assigned # after every ticket creation, which is not very performant. We decided to not cover this user case. if 'user_id' not in vals: vals['user_id'] = team_default['user_id'] 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(HelpdeskTicket, self).create(list_value) all_partner_emails = [] for ticket in tickets: all_partner_emails += tools.email_split(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_split(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' 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_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) return res def copy_data(self, default=None): vals_list = super().copy_data(default=default) return [dict(vals, name=self.env._("%s (copy)", ticket.name)) for ticket, vals in zip(self, vals_list)] def _unsubscribe_portal_users(self): self.message_unsubscribe(partner_ids=self.message_partner_ids.filtered('user_ids.share').ids) # ------------------------------------------------------------ # 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) @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] = expression.AND([[ ('team_id', '=', ticket.team_id.id), ('priority', '=', ticket.priority), ('stage_id.sequence', '>=', ticket.stage_id.sequence), ], expression.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 """ status_to_keep = dict.fromkeys(self.ids, list()) # generate the map of status to keep by ticket only if requested if keep_reached: for ticket in self: for status in ticket.sla_status_ids: if status.reached_datetime: status_to_keep[ticket.id].append(status.sla_id.id) # create the list of value, and maybe exclude the existing ones result = [] for ticket in self: for sla in slas: if not (keep_reached and sla.id in status_to_keep[ticket.id]): 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 # ------------------------------------------------------------ #DVE FIXME: if partner gets created when sending the message it should be set as partner_id of the ticket. def _message_get_suggested_recipients(self): recipients = super()._message_get_suggested_recipients() try: if self.partner_id and self.partner_id.email: self._message_add_suggested_recipient(recipients, partner=self.partner_id, reason=_('Customer')) elif self.partner_email: self._message_add_suggested_recipient(recipients, email=self.partner_email, reason=_('Customer Email')) except AccessError: # no read access rights -> just ignore suggested recipients because this implies modifying followers pass return recipients def _get_customer_information(self): email_normalized_to_values = super()._get_customer_information() Partner = self.env['res.partner'] for record in self.filtered('partner_email'): email_normalized = tools.email_normalize(record.partner_email) if not email_normalized: continue values = email_normalized_to_values.setdefault(email_normalized, {}) values.update({ 'name': record.partner_name or tools.parse_contact_from_email(record.partner_email)[0] or record.partner_email, 'phone': record.partner_phone, }) return email_normalized_to_values def _ticket_email_split(self, msg): email_list = tools.email_split((msg.get('to') or '') + ',' + (msg.get('cc') or '')) # check left-part is not already an alias return [ x for x in email_list if x.split('@')[0] not in self.mapped('team_id.alias_name') ] @api.model def message_new(self, msg, custom_values=None): values = dict(custom_values or {}, partner_email=msg.get('from'), partner_name=msg.get('from'), partner_id=msg.get('author_id')) ticket = super(HelpdeskTicket, self.with_context(mail_notify_author=True)).message_new(msg, custom_values=values) thread_context = self.env['mail.thread'] if ticket.company_id: thread_context = thread_context.with_context(default_company_id=ticket.company_id) partner_ids = [x.id for x in thread_context._mail_find_partner_from_emails(ticket._ticket_email_split(msg), records=ticket, force_create=True) if x] customer_ids = [p.id for p in thread_context._mail_find_partner_from_emails(tools.email_split(values['partner_email']), records=ticket, force_create=True) if p] 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, update_vals=None): partner_ids = [x.id for x in self.env['mail.thread']._mail_find_partner_from_emails(self._ticket_email_split(msg), records=self) if x] if partner_ids: self.message_subscribe(partner_ids) return super(HelpdeskTicket, self).message_update(msg, 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 tools.email_normalize(self.partner_email) == tools.email_normalize(message.email_from): self.description = message.body return super(HelpdeskTicket, self)._message_post_after_hook(message, msg_vals) def _send_email_notify_to_cc(self, partners_to_notify): 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, record_name=self.display_name, email_layout_xmlid='mail.mail_notification_layout', model_description=ticket_model_description, mail_auto_delete=True, ) def _track_template(self, changes): res = super(HelpdeskTicket, self)._track_template(changes) ticket = self[0] if 'stage_id' in changes and ticket.stage_id.template_id and ticket.partner_email and ( not self.env.user.partner_id or not ticket.partner_id or ticket.partner_id != self.env.user.partner_id or self.env.user._is_portal() or ticket._context.get('mail_notify_author') ): 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(HelpdeskTicket, self)._track_subtype(init_values) def _notify_get_recipients_groups(self, message, model_description, msg_vals=None): """ Give access button to portal and portal customers. If they are notified they should probably have access to the document. """ return super()._notify_get_recipients_groups( message, model_description, msg_vals=msg_vals ) def _notify_get_reply_to(self, default=None): """ Override to set alias of tickets to their team if any. """ aliases = self.mapped('team_id').sudo()._notify_get_reply_to(default=default) 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)) 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_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