# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import math from odoo import fields, models, api from odoo.osv import expression class HelpdeskSLAStatus(models.Model): _name = 'helpdesk.sla.status' _description = "Ticket SLA Status" _table = 'helpdesk_sla_status' _order = 'deadline ASC, sla_stage_id' _rec_name = 'sla_id' ticket_id = fields.Many2one('helpdesk.ticket', string='Ticket', required=True, ondelete='cascade', index=True) sla_id = fields.Many2one('helpdesk.sla', required=True, ondelete='cascade') sla_stage_id = fields.Many2one('helpdesk.stage', related='sla_id.stage_id', store=True, export_string_translation=False) # need to be stored for the search in `_sla_reach` deadline = fields.Datetime("Deadline", compute='_compute_deadline', compute_sudo=True, store=True) reached_datetime = fields.Datetime("Reached Date", help="Datetime at which the SLA stage was reached for the first time") status = fields.Selection([('failed', 'Failed'), ('reached', 'Reached'), ('ongoing', 'Ongoing')], string="Status", compute='_compute_status', compute_sudo=True, search='_search_status') color = fields.Integer("Color Index", compute='_compute_color') exceeded_hours = fields.Float("Exceeded Working Hours", compute='_compute_exceeded_hours', compute_sudo=True, store=True, help="Working hours exceeded for reached SLAs compared with deadline. Positive number means the SLA was reached after the deadline.") @api.depends('ticket_id.create_date', 'sla_id', 'ticket_id.stage_id') def _compute_deadline(self): for status in self: if (status.deadline and status.reached_datetime) or (status.deadline and not status.sla_id.exclude_stage_ids) or (status.status == 'failed'): continue deadline = status.ticket_id.create_date working_calendar = status.ticket_id.team_id.resource_calendar_id if not working_calendar: # Normally, having a working_calendar is mandatory status.deadline = deadline continue if status.sla_id.exclude_stage_ids: if status.ticket_id.stage_id in status.sla_id.exclude_stage_ids: # We are in the freezed time stage: No deadline status.deadline = False continue avg_hour = working_calendar.hours_per_day or 8 # default to 8 working hours/day time_days = math.floor(status.sla_id.time / avg_hour) if time_days > 0: deadline = working_calendar.plan_days(time_days + 1, deadline, compute_leaves=True) # We should also depend on ticket creation time, otherwise for 1 day SLA, all tickets # created on monday will have their deadline filled with tuesday 8:00 create_dt = working_calendar.plan_hours(0, status.ticket_id.create_date) deadline = deadline and deadline.replace(hour=create_dt.hour, minute=create_dt.minute, second=create_dt.second, microsecond=create_dt.microsecond) sla_hours = status.sla_id.time % avg_hour if status.sla_id.exclude_stage_ids: sla_hours += status._get_freezed_hours(working_calendar) # Except if ticket creation time is later than the end time of the working day deadline_for_working_cal = working_calendar.plan_hours(0, deadline) if deadline_for_working_cal and deadline.day < deadline_for_working_cal.day and time_days > 0: deadline = deadline.replace(hour=0, minute=0, second=0, microsecond=0) # We should execute the function plan_hours in any case because, in a 1 day SLA environment, # if I create a ticket knowing that I'm not working the day after at the same time, ticket # deadline will be set at time I don't work (ticket creation time might not be in working calendar). status.deadline = deadline and working_calendar.plan_hours(sla_hours, deadline, compute_leaves=True) @api.depends('deadline', 'reached_datetime') def _compute_status(self): for status in self: if status.reached_datetime and status.deadline: # if reached_datetime, SLA is finished: either failed or succeeded status.status = 'reached' if status.reached_datetime < status.deadline else 'failed' else: # if not finished, deadline should be compared to now() status.status = 'ongoing' if not status.deadline or status.deadline > fields.Datetime.now() else 'failed' @api.model def _search_status(self, operator, value): """ Supported operators: '=', 'in' and their negative form. """ # constants datetime_now = fields.Datetime.now() positive_domain = { 'failed': ['|', '&', ('reached_datetime', '=', True), ('deadline', '<=', 'reached_datetime'), '&', ('reached_datetime', '=', False), ('deadline', '<=', fields.Datetime.to_string(datetime_now))], 'reached': ['&', ('reached_datetime', '=', True), ('reached_datetime', '<', 'deadline')], 'ongoing': ['|', ('deadline', '=', False), '&', ('reached_datetime', '=', False), ('deadline', '>', fields.Datetime.to_string(datetime_now))] } # in/not in case: we treat value as a list of selection item if not isinstance(value, list): value = [value] # transform domains if operator in expression.NEGATIVE_TERM_OPERATORS: # "('status', 'not in', [A, B])" tranformed into "('status', '=', C) OR ('status', '=', D)" domains_to_keep = [dom for key, dom in positive_domain if key not in value] return expression.OR(domains_to_keep) else: return expression.OR(positive_domain[value_item] for value_item in value) @api.depends('status') def _compute_color(self): for status in self: if status.status == 'failed': status.color = 1 elif status.status == 'reached': status.color = 10 else: status.color = 0 @api.depends('deadline', 'reached_datetime') def _compute_exceeded_hours(self): for status in self: if status.deadline and status.ticket_id.team_id.resource_calendar_id: reached_datetime = status.reached_datetime or fields.Datetime.now() if reached_datetime <= status.deadline: start_dt = reached_datetime end_dt = status.deadline factor = -1 else: start_dt = status.deadline end_dt = reached_datetime factor = 1 duration_data = status.ticket_id.team_id.resource_calendar_id.get_work_duration_data(start_dt, end_dt, compute_leaves=True) status.exceeded_hours = duration_data['hours'] * factor else: status.exceeded_hours = False def _get_freezed_hours(self, working_calendar): self.ensure_one() hours_freezed = 0 field_stage = self.env['ir.model.fields']._get(self.ticket_id._name, "stage_id") freeze_stages = self.sla_id.exclude_stage_ids.ids tracking_lines = self.ticket_id.message_ids.tracking_value_ids.filtered(lambda tv: tv.field_id == field_stage).sorted(key="create_date") if not tracking_lines: return 0 old_time = self.ticket_id.create_date for tracking_line in tracking_lines: if tracking_line.old_value_integer in freeze_stages: # We must use get_work_hours_count to compute real waiting hours (as the deadline computation is also based on calendar) hours_freezed += working_calendar.get_work_hours_count(old_time, tracking_line.create_date) old_time = tracking_line.create_date if tracking_lines[-1].new_value_integer in freeze_stages: # the last tracking line is not yet created hours_freezed += working_calendar.get_work_hours_count(old_time, fields.Datetime.now()) return hours_freezed