Files

1077 lines
54 KiB
Python

# 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 {<helpdesk.ticket>: <helpdesk.sla>}
"""
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."))