forked from Mapan/odoo17e
2326 lines
125 KiB
Python
2326 lines
125 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
from collections import defaultdict
|
|
from datetime import date, datetime, timedelta, time
|
|
from dateutil.relativedelta import relativedelta
|
|
import logging
|
|
import pytz
|
|
import uuid
|
|
from math import modf
|
|
from random import randint, shuffle
|
|
import itertools
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.addons.resource.models.utils import Intervals, sum_intervals, string_to_datetime
|
|
from odoo.addons.resource.models.resource_mixin import timezone_datetime
|
|
from odoo.exceptions import UserError, AccessError
|
|
from odoo.osv import expression
|
|
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_utils, format_datetime
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def days_span(start_datetime, end_datetime):
|
|
if not isinstance(start_datetime, datetime):
|
|
raise ValueError
|
|
if not isinstance(end_datetime, datetime):
|
|
raise ValueError
|
|
end = datetime.combine(end_datetime, datetime.min.time())
|
|
start = datetime.combine(start_datetime, datetime.min.time())
|
|
duration = end - start
|
|
return duration.days + 1
|
|
|
|
|
|
class Planning(models.Model):
|
|
_name = 'planning.slot'
|
|
_description = 'Planning Shift'
|
|
_order = 'start_datetime desc, id desc'
|
|
_rec_name = 'name'
|
|
_check_company_auto = True
|
|
|
|
def _default_start_datetime(self):
|
|
return datetime.combine(fields.Date.context_today(self), time.min)
|
|
|
|
def _default_end_datetime(self):
|
|
return datetime.combine(fields.Date.context_today(self), time.max)
|
|
|
|
name = fields.Text('Note')
|
|
resource_id = fields.Many2one('resource.resource', 'Resource', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", group_expand='_group_expand_resource_id')
|
|
resource_type = fields.Selection(related='resource_id.resource_type')
|
|
resource_color = fields.Integer(related='resource_id.color', string="Resource color")
|
|
employee_id = fields.Many2one('hr.employee', 'Employee', compute='_compute_employee_id', store=True)
|
|
work_email = fields.Char("Work Email", related='employee_id.work_email')
|
|
work_address_id = fields.Many2one(related='employee_id.address_id', store=True)
|
|
work_location_id = fields.Many2one(related='employee_id.work_location_id')
|
|
department_id = fields.Many2one(related='employee_id.department_id', store=True)
|
|
user_id = fields.Many2one('res.users', string="User", related='resource_id.user_id', store=True, readonly=True)
|
|
manager_id = fields.Many2one(related='employee_id.parent_id', store=True)
|
|
job_title = fields.Char(related='employee_id.job_title')
|
|
company_id = fields.Many2one('res.company', string="Company", required=True, compute="_compute_planning_slot_company_id", store=True, readonly=False)
|
|
role_id = fields.Many2one('planning.role', string="Role", compute="_compute_role_id", store=True, readonly=False, copy=True, group_expand='_read_group_role_id',
|
|
help="Define the roles your resources perform (e.g. Chef, Bartender, Waiter...). Create open shifts for the roles you need to complete a mission. Then, assign those open shifts to the resources that are available.")
|
|
color = fields.Integer("Color", compute='_compute_color')
|
|
was_copied = fields.Boolean("This Shift Was Copied From Previous Week", default=False, readonly=True)
|
|
access_token = fields.Char("Security Token", default=lambda self: str(uuid.uuid4()), required=True, copy=False, readonly=True)
|
|
|
|
start_datetime = fields.Datetime(
|
|
"Start Date", compute='_compute_datetime', store=True, readonly=False, required=True,
|
|
copy=True)
|
|
end_datetime = fields.Datetime(
|
|
"End Date", compute='_compute_datetime', store=True, readonly=False, required=True,
|
|
copy=True)
|
|
# UI fields and warnings
|
|
allow_self_unassign = fields.Boolean('Let Employee Unassign Themselves', related='company_id.planning_allow_self_unassign')
|
|
self_unassign_days_before = fields.Integer(
|
|
"Days before shift for unassignment",
|
|
related="company_id.planning_self_unassign_days_before"
|
|
)
|
|
unassign_deadline = fields.Datetime('Deadline for unassignment', compute="_compute_unassign_deadline")
|
|
is_unassign_deadline_passed = fields.Boolean('Is unassignement deadline not past', compute="_compute_is_unassign_deadline_passed")
|
|
is_assigned_to_me = fields.Boolean('Is This Shift Assigned To The Current User', compute='_compute_is_assigned_to_me')
|
|
conflicting_slot_ids = fields.Many2many('planning.slot', compute='_compute_overlap_slot_count')
|
|
overlap_slot_count = fields.Integer('Overlapping Slots', compute='_compute_overlap_slot_count', search='_search_overlap_slot_count')
|
|
is_past = fields.Boolean('Is This Shift In The Past?', compute='_compute_past_shift')
|
|
is_users_role = fields.Boolean('Is the shifts role one of the current user roles', compute='_compute_is_users_role', search='_search_is_users_role')
|
|
request_to_switch = fields.Boolean('Has there been a request to switch on this shift slot?', default=False, readonly=True)
|
|
|
|
# time allocation
|
|
allocation_type = fields.Selection([
|
|
('planning', 'Planning'),
|
|
('forecast', 'Forecast')
|
|
], compute='_compute_allocation_type')
|
|
allocated_hours = fields.Float("Allocated Time", compute='_compute_allocated_hours', store=True, readonly=False)
|
|
allocated_percentage = fields.Float("Allocated Time %", default=100,
|
|
compute='_compute_allocated_percentage', store=True, readonly=False,
|
|
group_operator="avg")
|
|
working_days_count = fields.Float("Working Days", compute='_compute_working_days_count', store=True)
|
|
duration = fields.Float("Duration", compute="_compute_slot_duration")
|
|
|
|
# publication and sending
|
|
publication_warning = fields.Boolean(
|
|
"Modified Since Last Publication", default=False, compute='_compute_publication_warning',
|
|
store=True, readonly=True, copy=False,
|
|
help="If checked, it means that the shift contains has changed since its last publish.")
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('published', 'Published'),
|
|
], string='Status', default='draft')
|
|
# template dummy fields (only for UI purpose)
|
|
template_creation = fields.Boolean("Save as Template", store=False, inverse='_inverse_template_creation')
|
|
template_autocomplete_ids = fields.Many2many('planning.slot.template', store=False, compute='_compute_template_autocomplete_ids')
|
|
template_id = fields.Many2one('planning.slot.template', string='Shift Templates', compute='_compute_template_id', readonly=False, store=True)
|
|
template_reset = fields.Boolean()
|
|
previous_template_id = fields.Many2one('planning.slot.template')
|
|
allow_template_creation = fields.Boolean(string='Allow Template Creation', compute='_compute_allow_template_creation')
|
|
|
|
# Recurring (`repeat_` fields are none stored, only used for UI purpose)
|
|
recurrency_id = fields.Many2one('planning.recurrency', readonly=True, index=True, ondelete="set null", copy=False)
|
|
repeat = fields.Boolean("Repeat", compute='_compute_repeat', inverse='_inverse_repeat',
|
|
help="To avoid polluting your database and performance issues, shifts are only created for the next 6 months. They are then gradually created as time passes by in order to always get shifts 6 months ahead. This value can be modified from the settings of Planning, in debug mode.")
|
|
repeat_interval = fields.Integer("Repeat every", default=1, compute='_compute_repeat_interval', inverse='_inverse_repeat')
|
|
repeat_unit = fields.Selection([
|
|
('day', 'Days'),
|
|
('week', 'Weeks'),
|
|
('month', 'Months'),
|
|
('year', 'Years'),
|
|
], default='week', compute='_compute_repeat_unit', inverse='_inverse_repeat', required=True)
|
|
repeat_type = fields.Selection([('forever', 'Forever'), ('until', 'Until'), ('x_times', 'Number of Occurrences')],
|
|
string='Repeat Type', default='forever', compute='_compute_repeat_type', inverse='_inverse_repeat')
|
|
repeat_until = fields.Date("Repeat Until", compute='_compute_repeat_until', inverse='_inverse_repeat')
|
|
repeat_number = fields.Integer("Repetitions", default=1, compute='_compute_repeat_number', inverse='_inverse_repeat')
|
|
recurrence_update = fields.Selection([
|
|
('this', 'This shift'),
|
|
('subsequent', 'This and following shifts'),
|
|
('all', 'All shifts'),
|
|
], default='this', store=False)
|
|
confirm_delete = fields.Boolean('Confirm Slots Deletion', compute='_compute_confirm_delete')
|
|
|
|
is_hatched = fields.Boolean(compute='_compute_is_hatched')
|
|
|
|
slot_properties = fields.Properties('Properties', definition='role_id.slot_properties_definition', precompute=False)
|
|
|
|
_sql_constraints = [
|
|
('check_start_date_lower_end_date', 'CHECK(end_datetime > start_datetime)', 'The end date of a shift should be after its start date.'),
|
|
('check_allocated_hours_positive', 'CHECK(allocated_hours >= 0)', 'Allocated hours and allocated time percentage cannot be negative.'),
|
|
]
|
|
|
|
@api.depends('role_id.color', 'resource_id.color')
|
|
def _compute_color(self):
|
|
for slot in self:
|
|
slot.color = slot.role_id.color or slot.resource_id.color
|
|
|
|
@api.depends('repeat_until', 'repeat_number')
|
|
def _compute_confirm_delete(self):
|
|
for slot in self:
|
|
if slot.recurrency_id and slot.repeat_until and slot.repeat_number:
|
|
recurrence_end_dt = slot.repeat_until or slot.recurrency_id._get_recurrence_last_datetime()
|
|
slot.confirm_delete = fields.Date.to_date(recurrence_end_dt) > slot.repeat_until
|
|
else:
|
|
slot.confirm_delete = False
|
|
|
|
@api.constrains('repeat_until')
|
|
def _check_repeat_until(self):
|
|
if any([slot.repeat_until and slot.repeat_until < slot.start_datetime.date() for slot in self]):
|
|
raise UserError(_("The recurrence's end date should fall after the shift's start date."))
|
|
|
|
@api.onchange('repeat_until')
|
|
def _onchange_repeat_until(self):
|
|
self._check_repeat_until()
|
|
|
|
@api.depends('resource_id.company_id')
|
|
def _compute_planning_slot_company_id(self):
|
|
for slot in self:
|
|
slot.company_id = slot.resource_id.company_id or slot.company_id or slot.env.company
|
|
|
|
@api.depends('start_datetime')
|
|
def _compute_past_shift(self):
|
|
now = fields.Datetime.now()
|
|
for slot in self:
|
|
if slot.end_datetime:
|
|
if slot.end_datetime < now:
|
|
slot.is_past = True
|
|
# We have to do this (below), for the field to be set automatically to False when the shift is in the past
|
|
if slot.request_to_switch:
|
|
slot.sudo().request_to_switch = False
|
|
else:
|
|
slot.is_past = False
|
|
else:
|
|
slot.is_past = False
|
|
|
|
@api.depends('resource_id.employee_id', 'resource_type')
|
|
def _compute_employee_id(self):
|
|
for slot in self:
|
|
slot.employee_id = slot.resource_id.with_context(active_test=False).employee_id if slot.resource_type == 'user' else False
|
|
|
|
@api.depends('employee_id', 'template_id')
|
|
def _compute_role_id(self):
|
|
for slot in self:
|
|
if not slot.role_id:
|
|
slot.role_id = slot.resource_id.default_role_id
|
|
|
|
if slot.template_id:
|
|
slot.previous_template_id = slot.template_id
|
|
if slot.template_id.role_id:
|
|
slot.role_id = slot.template_id.role_id
|
|
elif slot.previous_template_id and not slot.template_id and slot.previous_template_id.role_id == slot.role_id:
|
|
slot.role_id = False
|
|
|
|
@api.depends('state')
|
|
def _compute_is_hatched(self):
|
|
for slot in self:
|
|
slot.is_hatched = slot.state == 'draft'
|
|
|
|
@api.depends('user_id')
|
|
def _compute_is_assigned_to_me(self):
|
|
for slot in self:
|
|
slot.is_assigned_to_me = slot.user_id == self.env.user
|
|
|
|
@api.depends('role_id')
|
|
def _compute_is_users_role(self):
|
|
user_resource_roles = self.env['resource.resource'].search([('user_id', '=', self.env.user.id)]).role_ids
|
|
for slot in self:
|
|
slot.is_users_role = (slot.role_id in user_resource_roles) or not user_resource_roles or not slot.role_id
|
|
|
|
def _search_is_users_role(self, operator, value):
|
|
if operator not in ('=', '!=') or not isinstance(value, bool):
|
|
raise NotImplementedError(_("Search operation not supported"))
|
|
user_resource_roles = self.env['resource.resource'].search([('user_id', '=', self.env.user.id)]).role_ids
|
|
if not user_resource_roles:
|
|
return [(1, '=', 1)]
|
|
if (operator, value) in [('!=', True), ('=', False)]:
|
|
return [('role_id', 'not in', user_resource_roles.ids)]
|
|
return ['|', ('role_id', 'in', user_resource_roles.ids), ('role_id', '=', False)]
|
|
|
|
@api.depends('start_datetime', 'end_datetime')
|
|
def _compute_allocation_type(self):
|
|
for slot in self:
|
|
if slot.start_datetime and slot.end_datetime and slot._get_slot_duration() < 24:
|
|
slot.allocation_type = 'planning'
|
|
else:
|
|
slot.allocation_type = 'forecast'
|
|
|
|
@api.depends('start_datetime', 'end_datetime', 'employee_id.resource_calendar_id', 'allocated_hours')
|
|
def _compute_allocated_percentage(self):
|
|
# [TW:Cyclic dependency] allocated_hours,allocated_percentage
|
|
# As allocated_hours and allocated percentage have some common dependencies, and are dependant one from another, we have to make sure
|
|
# they are computed in the right order to get rid of undeterministic computation.
|
|
#
|
|
# Allocated percentage must only be recomputed if allocated_hours has been modified by the user and not in any other cases.
|
|
# If allocated hours have to be recomputed, the allocated percentage have to keep its current value.
|
|
# Hence, we stop the computation of allocated percentage if allocated hours have to be recomputed.
|
|
allocated_hours_field = self._fields['allocated_hours']
|
|
slots = self.filtered(lambda slot: not self.env.is_to_compute(allocated_hours_field, slot) and slot.start_datetime and slot.end_datetime and slot.start_datetime != slot.end_datetime)
|
|
if not slots:
|
|
return
|
|
# if there are at least one slot having start or end date, call the _get_valid_work_intervals
|
|
start_utc = pytz.utc.localize(min(slots.mapped('start_datetime')))
|
|
end_utc = pytz.utc.localize(max(slots.mapped('end_datetime')))
|
|
resource_work_intervals, calendar_work_intervals = slots.resource_id \
|
|
.filtered('calendar_id') \
|
|
._get_valid_work_intervals(start_utc, end_utc, calendars=slots.company_id.resource_calendar_id)
|
|
for slot in slots:
|
|
if not slot.resource_id and slot.allocation_type == 'planning' or not slot.resource_id.calendar_id:
|
|
duration = slot._calculate_slot_duration()
|
|
slot.allocated_percentage = 100 * slot.allocated_hours / duration if duration else 100
|
|
else:
|
|
work_hours = slot._get_working_hours_over_period(start_utc, end_utc, resource_work_intervals, calendar_work_intervals)
|
|
slot.allocated_percentage = 100 * slot.allocated_hours / work_hours if work_hours else 100
|
|
|
|
@api.depends(
|
|
'start_datetime', 'end_datetime', 'resource_id.calendar_id',
|
|
'company_id.resource_calendar_id', 'allocated_percentage')
|
|
def _compute_allocated_hours(self):
|
|
percentage_field = self._fields['allocated_percentage']
|
|
self.env.remove_to_compute(percentage_field, self)
|
|
planning_slots = self.filtered(
|
|
lambda s:
|
|
(s.allocation_type == 'planning' or not s.company_id)
|
|
and not s.resource_id
|
|
or not s.resource_id.calendar_id
|
|
)
|
|
slots_with_calendar = self - planning_slots
|
|
for slot in planning_slots:
|
|
# for each planning slot, compute the duration
|
|
ratio = slot.allocated_percentage / 100.0
|
|
slot.allocated_hours = slot._calculate_slot_duration() * ratio
|
|
if slots_with_calendar:
|
|
# for forecasted slots, compute the conjunction of the slot resource's work intervals and the slot.
|
|
unplanned_slots_with_calendar = slots_with_calendar.filtered_domain([
|
|
'|', ('start_datetime', "=", False), ('end_datetime', "=", False),
|
|
])
|
|
# Unplanned slots will have allocated hours set to 0.0 as there are no enough information
|
|
# to compute the allocated hours (start or end datetime are mandatory for this computation)
|
|
for slot in unplanned_slots_with_calendar:
|
|
slot.allocated_hours = 0.0
|
|
planned_slots_with_calendar = slots_with_calendar - unplanned_slots_with_calendar
|
|
if not planned_slots_with_calendar:
|
|
return
|
|
# if there are at least one slot having start or end date, call the _get_valid_work_intervals
|
|
start_utc = pytz.utc.localize(min(planned_slots_with_calendar.mapped('start_datetime')))
|
|
end_utc = pytz.utc.localize(max(planned_slots_with_calendar.mapped('end_datetime')))
|
|
# work intervals per resource are retrieved with a batch
|
|
resource_work_intervals, calendar_work_intervals = slots_with_calendar.resource_id._get_valid_work_intervals(
|
|
start_utc, end_utc, calendars=slots_with_calendar.company_id.resource_calendar_id
|
|
)
|
|
for slot in planned_slots_with_calendar:
|
|
slot.allocated_hours = slot._get_duration_over_period(
|
|
pytz.utc.localize(slot.start_datetime), pytz.utc.localize(slot.end_datetime),
|
|
resource_work_intervals, calendar_work_intervals, has_allocated_hours=False
|
|
)
|
|
|
|
@api.depends('start_datetime', 'end_datetime', 'resource_id')
|
|
def _compute_working_days_count(self):
|
|
slots_per_calendar = defaultdict(set)
|
|
planned_dates_per_calendar_id = defaultdict(lambda: (datetime.max, datetime.min))
|
|
for slot in self:
|
|
if not slot.employee_id:
|
|
slot.working_days_count = 0
|
|
continue
|
|
calendar = slot.resource_id.calendar_id or slot.resource_id.company_id.resource_calendar_id
|
|
slots_per_calendar[calendar].add(slot.id)
|
|
datetime_begin, datetime_end = planned_dates_per_calendar_id[calendar.id]
|
|
datetime_begin = min(datetime_begin, slot.start_datetime)
|
|
datetime_end = max(datetime_end, slot.end_datetime)
|
|
planned_dates_per_calendar_id[calendar.id] = datetime_begin, datetime_end
|
|
for calendar, slot_ids in slots_per_calendar.items():
|
|
slots = self.env['planning.slot'].browse(list(slot_ids))
|
|
if not calendar:
|
|
slots.working_days_count = 0
|
|
continue
|
|
datetime_begin, datetime_end = planned_dates_per_calendar_id[calendar.id]
|
|
datetime_begin = timezone_datetime(datetime_begin)
|
|
datetime_end = timezone_datetime(datetime_end)
|
|
resources = slots.resource_id
|
|
day_total = calendar._get_resources_day_total(datetime_begin, datetime_end, resources)
|
|
intervals = calendar._work_intervals_batch(datetime_begin, datetime_end, resources)
|
|
for slot in slots:
|
|
slot.working_days_count = calendar._get_days_data(
|
|
intervals[slot.resource_id.id] & Intervals([(
|
|
timezone_datetime(slot.start_datetime),
|
|
timezone_datetime(slot.end_datetime),
|
|
self.env['resource.calendar.attendance']
|
|
)]),
|
|
day_total[slot.resource_id.id]
|
|
)['days']
|
|
|
|
@api.depends('start_datetime', 'end_datetime', 'resource_id')
|
|
def _compute_overlap_slot_count(self):
|
|
if self.ids:
|
|
self.flush_model(['start_datetime', 'end_datetime', 'resource_id'])
|
|
query = """
|
|
SELECT S1.id,ARRAY_AGG(DISTINCT S2.id) as conflict_ids FROM
|
|
planning_slot S1, planning_slot S2
|
|
WHERE
|
|
S1.start_datetime < S2.end_datetime
|
|
AND S1.end_datetime > S2.start_datetime
|
|
AND S1.id <> S2.id AND S1.resource_id = S2.resource_id
|
|
AND S1.allocated_percentage + S2.allocated_percentage > 100
|
|
and S1.id in %s
|
|
AND (%s or S2.state = 'published')
|
|
GROUP BY S1.id;
|
|
"""
|
|
self.env.cr.execute(query, (tuple(self.ids), self.env.user.has_group('planning.group_planning_manager')))
|
|
overlap_mapping = dict(self.env.cr.fetchall())
|
|
for slot in self:
|
|
slot_result = overlap_mapping.get(slot.id, [])
|
|
slot.overlap_slot_count = len(slot_result)
|
|
slot.conflicting_slot_ids = [(6, 0, slot_result)]
|
|
else:
|
|
# Allow fetching overlap without id if there is only one record
|
|
# This is to allow displaying the warning when creating a new record without having an ID yet
|
|
if len(self) == 1 and self.employee_id and self.start_datetime and self.end_datetime:
|
|
query = """
|
|
SELECT ARRAY_AGG(s.id) as conflict_ids
|
|
FROM planning_slot s
|
|
WHERE s.employee_id = %s
|
|
AND s.start_datetime < %s
|
|
AND s.end_datetime > %s
|
|
AND s.allocated_percentage + %s > 100
|
|
"""
|
|
self.env.cr.execute(query, (self.employee_id.id, self.end_datetime,
|
|
self.start_datetime, self.allocated_percentage))
|
|
overlaps = self.env.cr.dictfetchall()
|
|
if overlaps[0]['conflict_ids']:
|
|
self.overlap_slot_count = len(overlaps[0]['conflict_ids'])
|
|
self.conflicting_slot_ids = [(6, 0, overlaps[0]['conflict_ids'])]
|
|
else:
|
|
self.overlap_slot_count = False
|
|
else:
|
|
self.overlap_slot_count = False
|
|
|
|
@api.model
|
|
def _search_overlap_slot_count(self, operator, value):
|
|
if operator not in ['=', '>'] or not isinstance(value, int) or value != 0:
|
|
raise NotImplementedError(_('Operation not supported, you should always compare overlap_slot_count to 0 value with = or > operator.'))
|
|
|
|
query = """
|
|
SELECT S1.id
|
|
FROM planning_slot S1
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM planning_slot S2
|
|
WHERE S1.id <> S2.id
|
|
AND S1.resource_id = S2.resource_id
|
|
AND S1.start_datetime < S2.end_datetime
|
|
AND S1.end_datetime > S2.start_datetime
|
|
AND S1.allocated_percentage + S2.allocated_percentage > 100
|
|
)
|
|
"""
|
|
operator_new = (operator == ">") and "inselect" or "not inselect"
|
|
return [('id', operator_new, (query, ()))]
|
|
|
|
@api.depends('start_datetime', 'end_datetime')
|
|
def _compute_slot_duration(self):
|
|
for slot in self:
|
|
slot.duration = slot._get_slot_duration()
|
|
|
|
def _get_slot_duration(self):
|
|
"""Return the slot (effective) duration expressed in hours.
|
|
"""
|
|
self.ensure_one()
|
|
if not self.start_datetime or not self.end_datetime:
|
|
return False
|
|
|
|
if self.template_id:
|
|
return self.template_id.duration
|
|
return (self.end_datetime - self.start_datetime).total_seconds() / 3600.0
|
|
|
|
def _get_domain_template_slots(self):
|
|
domain = []
|
|
if self.resource_type == 'material':
|
|
domain += [('role_id', '=', False)]
|
|
elif self.role_id:
|
|
domain += ['|', ('role_id', '=', self.role_id.id), ('role_id', '=', False)]
|
|
elif self.employee_id and self.employee_id.sudo().planning_role_ids:
|
|
domain += ['|', ('role_id', 'in', self.employee_id.sudo().planning_role_ids.ids), ('role_id', '=', False)]
|
|
return domain
|
|
|
|
@api.depends('role_id', 'employee_id')
|
|
def _compute_template_autocomplete_ids(self):
|
|
domain = self._get_domain_template_slots()
|
|
templates = self.env['planning.slot.template'].search(domain, order='start_time', limit=10)
|
|
self.template_autocomplete_ids = templates + self.template_id
|
|
|
|
@api.depends('employee_id', 'role_id', 'start_datetime', 'end_datetime')
|
|
def _compute_template_id(self):
|
|
for slot in self.filtered(lambda s: s.template_id):
|
|
slot.previous_template_id = slot.template_id
|
|
slot.template_reset = False
|
|
if slot._different_than_template():
|
|
slot.template_id = False
|
|
slot.previous_template_id = False
|
|
slot.template_reset = True
|
|
|
|
def _different_than_template(self, check_empty=True):
|
|
self.ensure_one()
|
|
if not self.start_datetime:
|
|
return True
|
|
template_fields = self._get_template_fields().items()
|
|
for template_field, slot_field in template_fields:
|
|
if self.template_id[template_field] or not check_empty:
|
|
if template_field == 'start_time':
|
|
h = int(self.template_id.start_time)
|
|
m = round(modf(self.template_id.start_time)[0] * 60.0)
|
|
slot_time = self[slot_field].astimezone(pytz.timezone(self._get_tz()))
|
|
if slot_time.hour != h or slot_time.minute != m:
|
|
return True
|
|
else:
|
|
if self[slot_field] != self.template_id[template_field]:
|
|
return True
|
|
return False
|
|
|
|
@api.depends('template_id', 'role_id', 'allocated_hours', 'start_datetime', 'end_datetime')
|
|
def _compute_allow_template_creation(self):
|
|
for slot in self:
|
|
if not (slot.start_datetime and slot.end_datetime):
|
|
slot.allow_template_creation = False
|
|
continue
|
|
|
|
values = self._prepare_template_values()
|
|
domain = [(x, '=', values[x]) for x in values.keys()]
|
|
existing_templates = self.env['planning.slot.template'].search(domain, limit=1)
|
|
slot.allow_template_creation = not existing_templates and slot._different_than_template(check_empty=False)
|
|
|
|
@api.depends('recurrency_id')
|
|
def _compute_repeat(self):
|
|
for slot in self:
|
|
if slot.recurrency_id:
|
|
slot.repeat = True
|
|
else:
|
|
slot.repeat = False
|
|
|
|
@api.depends('recurrency_id.repeat_interval')
|
|
def _compute_repeat_interval(self):
|
|
recurrency_slots = self.filtered('recurrency_id')
|
|
for slot in recurrency_slots:
|
|
if slot.recurrency_id:
|
|
slot.repeat_interval = slot.recurrency_id.repeat_interval
|
|
(self - recurrency_slots).update(self.default_get(['repeat_interval']))
|
|
|
|
@api.depends('recurrency_id.repeat_until', 'repeat', 'repeat_type')
|
|
def _compute_repeat_until(self):
|
|
for slot in self:
|
|
repeat_until = False
|
|
if slot.repeat and slot.repeat_type == 'until':
|
|
if slot.recurrency_id and slot.recurrency_id.repeat_until:
|
|
repeat_until = slot.recurrency_id.repeat_until
|
|
elif slot.start_datetime:
|
|
repeat_until = slot.start_datetime + relativedelta(weeks=1)
|
|
slot.repeat_until = repeat_until
|
|
|
|
@api.depends('recurrency_id.repeat_number', 'repeat_type')
|
|
def _compute_repeat_number(self):
|
|
recurrency_slots = self.filtered('recurrency_id')
|
|
for slot in recurrency_slots:
|
|
slot.repeat_number = slot.recurrency_id.repeat_number
|
|
(self - recurrency_slots).update(self.default_get(['repeat_number']))
|
|
|
|
@api.depends('recurrency_id.repeat_unit')
|
|
def _compute_repeat_unit(self):
|
|
non_recurrent_slots = self.env['planning.slot']
|
|
for slot in self:
|
|
if slot.recurrency_id:
|
|
slot.repeat_unit = slot.recurrency_id.repeat_unit
|
|
else:
|
|
non_recurrent_slots += slot
|
|
non_recurrent_slots.update(self.default_get(['repeat_unit']))
|
|
|
|
@api.depends('recurrency_id.repeat_type')
|
|
def _compute_repeat_type(self):
|
|
recurrency_slots = self.filtered('recurrency_id')
|
|
for slot in recurrency_slots:
|
|
if slot.recurrency_id:
|
|
slot.repeat_type = slot.recurrency_id.repeat_type
|
|
(self - recurrency_slots).update(self.default_get(['repeat_type']))
|
|
|
|
def _inverse_repeat(self):
|
|
for slot in self:
|
|
if slot.repeat and not slot.recurrency_id.id: # create the recurrence
|
|
repeat_until = False
|
|
repeat_number = 0
|
|
if slot.repeat_type == "until":
|
|
repeat_until = datetime.combine(fields.Date.to_date(slot.repeat_until), datetime.max.time())
|
|
repeat_until = repeat_until.replace(tzinfo=pytz.timezone(slot.company_id.resource_calendar_id.tz or 'UTC')).astimezone(pytz.utc).replace(tzinfo=None)
|
|
if slot.repeat_type == 'x_times':
|
|
repeat_number = slot.repeat_number
|
|
recurrency_values = {
|
|
'repeat_interval': slot.repeat_interval,
|
|
'repeat_unit': slot.repeat_unit,
|
|
'repeat_until': repeat_until,
|
|
'repeat_number': repeat_number,
|
|
'repeat_type': slot.repeat_type,
|
|
'company_id': slot.company_id.id,
|
|
}
|
|
recurrence = self.env['planning.recurrency'].create(recurrency_values)
|
|
slot.recurrency_id = recurrence
|
|
slot.recurrency_id._repeat_slot()
|
|
# user wants to delete the recurrence
|
|
# here we also check that we don't delete by mistake a slot of which the repeat parameters have been changed
|
|
elif not slot.repeat and slot.recurrency_id.id:
|
|
slot.recurrency_id._delete_slot(slot.end_datetime)
|
|
slot.recurrency_id.unlink() # will set recurrency_id to NULL
|
|
|
|
def _inverse_template_creation(self):
|
|
PlanningTemplate = self.env['planning.slot.template']
|
|
for slot in self.filtered(lambda s: s.template_creation):
|
|
values = slot._prepare_template_values()
|
|
domain = [(x, '=', values[x]) for x in values.keys()]
|
|
existing_templates = PlanningTemplate.search(domain, limit=1)
|
|
if not existing_templates:
|
|
template = PlanningTemplate.create(values)
|
|
slot.write({'template_id': template.id, 'previous_template_id': template.id})
|
|
else:
|
|
slot.write({'template_id': existing_templates.id})
|
|
|
|
@api.model
|
|
def _calculate_start_end_dates(self,
|
|
start_datetime,
|
|
end_datetime,
|
|
resource_id,
|
|
template_id,
|
|
previous_template_id,
|
|
template_reset):
|
|
"""
|
|
Calculate the start and end dates for a given planning slot based on various parameters.
|
|
|
|
Returns: A tuple containing the calculated start and end datetime values in UTC without timezone.
|
|
"""
|
|
def convert_datetime_timezone(dt, tz):
|
|
return dt and pytz.utc.localize(dt).astimezone(tz)
|
|
|
|
resource = resource_id or self.env.user.employee_id.resource_id
|
|
company = self.company_id or self.env.company
|
|
employee = resource_id.employee_id if resource_id.resource_type == 'user' else False
|
|
user_tz = pytz.timezone(self.env.user.tz
|
|
or employee and employee.tz
|
|
or resource_id.tz
|
|
or self._context.get('tz')
|
|
or self.env.user.company_id.resource_calendar_id.tz
|
|
or 'UTC')
|
|
|
|
if start_datetime and end_datetime and not template_id:
|
|
# Transform the current column's start/end_datetime to the user's timezone from UTC
|
|
current_start = convert_datetime_timezone(start_datetime, user_tz)
|
|
current_end = convert_datetime_timezone(end_datetime, user_tz)
|
|
# Look at the work intervals to examine whether the current start/end_datetimes are inside working hours
|
|
calendar_id = resource.calendar_id or company.resource_calendar_id
|
|
work_interval = calendar_id._work_intervals_batch(current_start, current_end)[False]
|
|
intervals = [(date_start, date_stop) for date_start, date_stop, attendance in work_interval]
|
|
if not intervals:
|
|
# If we are outside working hours, we do not edit the start/end_datetime
|
|
# Return the start/end times back at UTC and remove the tzinfo from the object
|
|
return (current_start.astimezone(pytz.utc).replace(tzinfo=None),
|
|
current_end.astimezone(pytz.utc).replace(tzinfo=None))
|
|
|
|
# start_datetime and end_datetime are from 00:00 to 23:59 in user timezone
|
|
# Converted in UTC, it gives an offset for any other timezone, _convert_datetime_timezone removes the offset
|
|
start = convert_datetime_timezone(start_datetime, user_tz) if start_datetime else user_tz.localize(self._default_start_datetime())
|
|
end = convert_datetime_timezone(end_datetime, user_tz) if end_datetime else user_tz.localize(self._default_end_datetime())
|
|
|
|
# Get start and end in resource timezone so that it begins/ends at the same hour of the day as it would be in the user timezone
|
|
# This is needed because _adjust_to_calendar takes start as datetime for the start of the day and end as end time for the end of the day
|
|
# This can lead to different results depending on the timezone difference between the current user and the resource.
|
|
# Example:
|
|
# The user is in Europe/Brussels timezone (CET, UTC+1)
|
|
# The resource is Asia/Krasnoyarsk timezone (IST, UTC+7)
|
|
# The resource has two shifts during the day:
|
|
# - Morning shift: 8 to 12
|
|
# - Afternoon shift: 13 to 17
|
|
# When the user selects a day to plan a shift for the resource, he expects to have the shift scheduled according to the resource's calendar given a search range between 00:00 and 23:59
|
|
# The datetime received from the frontend is in the user's timezone meaning that the search interval will be between 23:00 and 22:59 in UTC
|
|
# If the datetime is not adjusted to the resource's calendar beforehand, _adjust_to_calendar and _get_closest_work_time will shift the time to the resource's timezone.
|
|
# The datetime given to _get_closest_work_time will be 6 AM once shifted in the resource's timezone. This will properly find the start of the morning shift at 8AM
|
|
# For the afternoon shift, _get_closest_work_time will search the end of the shift that is close to 6AM the day after.
|
|
# The closest shift found based on the end datetime will be the morning shift meaning that the work_interval_end will be the end of the morning shift the following day.
|
|
if resource:
|
|
work_interval_start, work_interval_end = resource._adjust_to_calendar(start.replace(tzinfo=pytz.timezone(resource.tz)), end.replace(tzinfo=pytz.timezone(resource.tz)), compute_leaves=False)[resource]
|
|
start, end = (work_interval_start or start, work_interval_end or end)
|
|
|
|
if not previous_template_id and not template_reset:
|
|
start = start.astimezone(pytz.utc).replace(tzinfo=None)
|
|
end = end.astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
if template_id and start_datetime:
|
|
h = int(template_id.start_time)
|
|
m = round(modf(template_id.start_time)[0] * 60.0)
|
|
start = pytz.utc.localize(start_datetime).astimezone(pytz.timezone(resource.tz) if
|
|
resource else user_tz)
|
|
start = start.replace(hour=int(h), minute=int(m))
|
|
end = pytz.utc.localize(end_datetime).astimezone(pytz.timezone(resource.tz) if resource else user_tz)
|
|
end = end.replace(hour=int(template_id.end_time), minute=int(round(modf(template_id.end_time)[0] * 60.0)))
|
|
|
|
h, m = divmod(template_id.duration, 1)
|
|
delta = timedelta(hours=int(h), minutes=int(round(m * 60)))
|
|
|
|
if resource and resource.calendar_id:
|
|
work_interval, _dummy = resource._get_valid_work_intervals(
|
|
start,
|
|
end
|
|
)
|
|
start = start.astimezone(pytz.utc).replace(tzinfo=None)
|
|
end = work_interval[resource.id]._items[-1][1].astimezone(pytz.utc).replace(tzinfo=None) \
|
|
if work_interval[resource.id]._items \
|
|
else start + delta
|
|
else:
|
|
h, m = divmod(template_id.duration, 1)
|
|
start = start.astimezone(pytz.utc).replace(tzinfo=None)
|
|
delta = timedelta(hours=int(h), minutes=int(round(m * 60)))
|
|
end = start + delta
|
|
|
|
# Need to remove the tzinfo in start and end as without these it leads to a traceback
|
|
# when the start time is empty
|
|
start = start.astimezone(pytz.utc).replace(tzinfo=None) if start.tzinfo else start
|
|
end = end.astimezone(pytz.utc).replace(tzinfo=None) if end.tzinfo else end
|
|
return (start, end)
|
|
|
|
@api.depends('template_id')
|
|
def _compute_datetime(self):
|
|
for slot in self.filtered(lambda s: s.template_id):
|
|
slot.start_datetime, slot.end_datetime = self._calculate_start_end_dates(slot.start_datetime,
|
|
slot.end_datetime,
|
|
slot.resource_id,
|
|
slot.template_id,
|
|
slot.previous_template_id,
|
|
slot.template_reset)
|
|
|
|
@api.depends(lambda self: self._get_fields_breaking_publication())
|
|
def _compute_publication_warning(self):
|
|
for slot in self:
|
|
slot.publication_warning = slot.resource_id and slot.resource_type != 'material' and slot.state == 'published'
|
|
|
|
def _company_working_hours(self, start, end):
|
|
company = self.company_id or self.env.company
|
|
work_interval = company.resource_calendar_id._work_intervals_batch(start, end)[False]
|
|
intervals = [(date_start, date_stop) for date_start, date_stop, attendance in work_interval]
|
|
start_datetime, end_datetime = (start, end)
|
|
if intervals and (end_datetime-start_datetime).days == 0: # Then we want the first working day and keep the end hours of this day
|
|
start_datetime = intervals[0][0]
|
|
end_datetime = [stop for start, stop in intervals if stop.date() == start_datetime.date()][-1]
|
|
elif intervals and (end_datetime-start_datetime).days >= 0:
|
|
start_datetime = intervals[0][0]
|
|
end_datetime = intervals[-1][1]
|
|
|
|
return (start_datetime, end_datetime)
|
|
|
|
@api.depends('self_unassign_days_before', 'start_datetime')
|
|
def _compute_unassign_deadline(self):
|
|
slots_with_date = self.filtered('start_datetime')
|
|
(self - slots_with_date).unassign_deadline = False
|
|
for slot in slots_with_date:
|
|
slot.unassign_deadline = fields.Datetime.subtract(slot.start_datetime, days=slot.self_unassign_days_before)
|
|
|
|
@api.depends('unassign_deadline')
|
|
def _compute_is_unassign_deadline_passed(self):
|
|
slots_with_date = self.filtered('unassign_deadline')
|
|
(self - slots_with_date).is_unassign_deadline_passed = False
|
|
for slot in slots_with_date:
|
|
slot.is_unassign_deadline_passed = slot.unassign_deadline < fields.Datetime.now()
|
|
|
|
# Used in report
|
|
def _group_slots_by_resource(self):
|
|
grouped_slots = defaultdict(self.browse)
|
|
for slot in self.sorted(key=lambda s: s.resource_id.name or ''):
|
|
grouped_slots[slot.resource_id] |= slot
|
|
return grouped_slots
|
|
|
|
# ----------------------------------------------------
|
|
# ORM overrides
|
|
# ----------------------------------------------------
|
|
|
|
@api.model
|
|
def _read_group_fields_nullify(self):
|
|
return ['working_days_count']
|
|
|
|
@api.model
|
|
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
|
res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
|
|
if lazy:
|
|
return res
|
|
|
|
null_fields = [f for f in self._read_group_fields_nullify() if any(f2.startswith(f) for f2 in fields)]
|
|
if null_fields:
|
|
for r in res:
|
|
for f in null_fields:
|
|
if r[f] == 0:
|
|
r[f] = False
|
|
return res
|
|
|
|
@api.model
|
|
def default_get(self, fields_list):
|
|
res = super(Planning, self).default_get(fields_list)
|
|
|
|
if res.get('resource_id'):
|
|
resource_id = self.env['resource.resource'].browse(res.get('resource_id'))
|
|
template_id, previous_template_id = [res.get(key) for key in ['template_id', 'previous_template_id']]
|
|
template_id = template_id and self.env['planning.slot.template'].browse(template_id)
|
|
previous_template_id = template_id and self.env['planning.slot.template'].browse(previous_template_id)
|
|
res['start_datetime'], res['end_datetime'] = self._calculate_start_end_dates(res.get('start_datetime'),
|
|
res.get('end_datetime'),
|
|
resource_id,
|
|
template_id,
|
|
previous_template_id,
|
|
res.get('template_reset'))
|
|
else:
|
|
if 'start_datetime' in fields_list and not self._context.get('planning_keep_default_datetime', False):
|
|
start_datetime = fields.Datetime.from_string(res.get('start_datetime')) if res.get('start_datetime') else self._default_start_datetime()
|
|
end_datetime = fields.Datetime.from_string(res.get('end_datetime')) if res.get('end_datetime') else self._default_end_datetime()
|
|
start = pytz.utc.localize(start_datetime)
|
|
end = pytz.utc.localize(end_datetime) if end_datetime else self._default_end_datetime()
|
|
opening_hours = self._company_working_hours(start, end)
|
|
res['start_datetime'] = opening_hours[0].astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
if 'end_datetime' in fields_list:
|
|
res['end_datetime'] = opening_hours[1].astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
return res
|
|
|
|
def _init_column(self, column_name):
|
|
""" Initialize the value of the given column for existing rows.
|
|
Overridden here because we need to generate different access tokens
|
|
and by default _init_column calls the default method once and applies
|
|
it for every record.
|
|
"""
|
|
if column_name != 'access_token':
|
|
super(Planning, self)._init_column(column_name)
|
|
else:
|
|
query = """
|
|
UPDATE %(table_name)s
|
|
SET access_token = md5(md5(random()::varchar || id::varchar) || clock_timestamp()::varchar)::uuid::varchar
|
|
WHERE access_token IS NULL
|
|
""" % {'table_name': self._table}
|
|
self.env.cr.execute(query)
|
|
|
|
@api.depends(lambda self: self._display_name_fields())
|
|
@api.depends_context('group_by')
|
|
def _compute_display_name(self):
|
|
group_by = self.env.context.get('group_by', [])
|
|
field_list = [fname for fname in self._display_name_fields() if fname not in group_by]
|
|
|
|
# Sudo as a planning manager is not able to read private project if he is not project manager.
|
|
self = self.sudo()
|
|
for slot in self.with_context(hide_partner_ref=True):
|
|
# label part, depending on context `groupby`
|
|
name_values = [
|
|
self._fields[fname].convert_to_display_name(slot[fname], slot) if fname != 'resource_id' else slot.resource_id.name
|
|
for fname in field_list
|
|
if slot[fname]
|
|
][:4] # limit to 4 labels
|
|
name = ' - '.join(name_values) or slot.resource_id.name
|
|
|
|
# add unicode bubble to tell there is a note
|
|
if slot.name:
|
|
name = f'{name} \U0001F4AC'
|
|
|
|
slot.display_name = name or ''
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
Resource = self.env['resource.resource']
|
|
for vals in vals_list:
|
|
if vals.get('resource_id'):
|
|
resource = Resource.browse(vals.get('resource_id'))
|
|
if not vals.get('company_id'):
|
|
vals['company_id'] = resource.company_id.id
|
|
if resource.resource_type == 'material':
|
|
vals['state'] = 'published'
|
|
if not vals.get('company_id'):
|
|
vals['company_id'] = self.env.company.id
|
|
return super().create(vals_list)
|
|
|
|
def write(self, values):
|
|
new_resource = self.env['resource.resource'].browse(values['resource_id']) if 'resource_id' in values else None
|
|
if new_resource and new_resource.resource_type == 'material':
|
|
values['state'] = 'published'
|
|
# if the resource_id is changed while the shift has already been published and the resource is human, that means that the shift has been re-assigned
|
|
# and thus we should send the email about the shift re-assignment
|
|
for slot in self.filtered(lambda s: new_resource and s.state == 'published' and s.resource_type == 'user' and new_resource.resource_type == 'user'):
|
|
self._send_shift_assigned(slot, new_resource)
|
|
for slot in self:
|
|
if slot.request_to_switch and (
|
|
(new_resource and slot.resource_id != new_resource)
|
|
or ('start_datetime' in values and slot.start_datetime != datetime.strptime(values['start_datetime'], '%Y-%m-%d %H:%M:%S'))
|
|
or ('end_datetime' in values and slot.end_datetime != datetime.strptime(values['end_datetime'], '%Y-%m-%d %H:%M:%S'))
|
|
):
|
|
values['request_to_switch'] = False
|
|
|
|
recurrence_update = values.pop('recurrence_update', 'this')
|
|
if recurrence_update != 'this':
|
|
recurrence_domain = []
|
|
if recurrence_update == 'subsequent':
|
|
for slot in self:
|
|
recurrence_domain = expression.OR([recurrence_domain,
|
|
['&', ('recurrency_id', '=', slot.recurrency_id.id), ('start_datetime', '>=', slot.start_datetime)]
|
|
])
|
|
recurrence_slots = self.search(recurrence_domain)
|
|
if any(
|
|
field_name in values
|
|
for field_name in ('start_datetime', 'end_datetime')
|
|
):
|
|
recurrence_slots -= slot
|
|
values["repeat_type"] = slot.repeat_type
|
|
self -= recurrence_slots
|
|
recurrence_slots.unlink()
|
|
else:
|
|
self |= recurrence_slots
|
|
else:
|
|
recurrence_slots = self.search([('recurrency_id', 'in', self.recurrency_id.ids)])
|
|
if any(
|
|
field_name in values
|
|
for field_name in ('start_datetime', 'end_datetime')
|
|
) and recurrence_slots:
|
|
slot = recurrence_slots[-1]
|
|
values["repeat_type"] = slot.repeat_type # this is to ensure that the subsequent slots are recreated
|
|
recurrence_slots -= slot
|
|
recurrence_slots.unlink()
|
|
self -= recurrence_slots
|
|
self |= slot
|
|
else:
|
|
self |= recurrence_slots
|
|
|
|
result = super().write(values)
|
|
# recurrence
|
|
if any(key in ('repeat', 'repeat_unit', 'repeat_type', 'repeat_until', 'repeat_interval', 'repeat_number') for key in values):
|
|
# User is trying to change this record's recurrence so we delete future slots belonging to recurrence A
|
|
# and we create recurrence B from now on w/ the new parameters
|
|
for slot in self:
|
|
recurrence = slot.recurrency_id
|
|
if recurrence and values.get('repeat') is None:
|
|
repeat_type = values.get('repeat_type') or recurrence.repeat_type
|
|
repeat_until = values.get('repeat_until') or recurrence.repeat_until
|
|
repeat_number = values.get('repeat_number', 0) or slot.repeat_number
|
|
if repeat_type == 'until':
|
|
repeat_until = datetime.combine(fields.Date.to_date(repeat_until), datetime.max.time())
|
|
repeat_until = repeat_until.replace(tzinfo=pytz.timezone(slot.company_id.resource_calendar_id.tz or 'UTC')).astimezone(pytz.utc).replace(tzinfo=None)
|
|
recurrency_values = {
|
|
'repeat_interval': values.get('repeat_interval') or recurrence.repeat_interval,
|
|
'repeat_unit': values.get('repeat_unit') or recurrence.repeat_unit,
|
|
'repeat_until': repeat_until if repeat_type == 'until' else False,
|
|
'repeat_number': repeat_number,
|
|
'repeat_type': repeat_type,
|
|
'company_id': slot.company_id.id,
|
|
}
|
|
recurrence.write(recurrency_values)
|
|
if slot.repeat_type == 'x_times':
|
|
recurrency_values['repeat_until'] = recurrence._get_recurrence_last_datetime()
|
|
end_datetime = slot.end_datetime if values.get('repeat_unit') else recurrency_values.get('repeat_until')
|
|
recurrence._delete_slot(end_datetime)
|
|
recurrence._repeat_slot()
|
|
return result
|
|
|
|
@api.returns(None, lambda value: value[0])
|
|
def copy_data(self, default=None):
|
|
if default is None:
|
|
default = {}
|
|
if self._context.get('planning_split_tool'):
|
|
default['state'] = self.state
|
|
return super().copy_data(default=default)
|
|
|
|
@api.returns('self', lambda value: value.id)
|
|
def copy(self, default=None):
|
|
result = super().copy(default=default)
|
|
# force recompute of stored computed fields depending on start_datetime and resource_id
|
|
if default and {'start_datetime', 'resource_id'} & default.keys():
|
|
result._compute_allocated_hours()
|
|
result._compute_working_days_count()
|
|
return result
|
|
|
|
# ----------------------------------------------------
|
|
# Actions
|
|
# ----------------------------------------------------
|
|
|
|
def action_address_recurrency(self, recurrence_update):
|
|
""" :param recurrence_update: the occurences to be targetted (this, subsequent, all)
|
|
"""
|
|
if recurrence_update == 'this':
|
|
return
|
|
domain = [('id', 'not in', self.ids)]
|
|
if recurrence_update == 'all':
|
|
domain = expression.AND([domain, [('recurrency_id', 'in', self.recurrency_id.ids)]])
|
|
elif recurrence_update == 'subsequent':
|
|
start_date_per_recurrency_id = {}
|
|
sub_domain = []
|
|
for shift in self:
|
|
if shift.recurrency_id.id not in start_date_per_recurrency_id\
|
|
or shift.start_datetime < start_date_per_recurrency_id[shift.recurrency_id.id]:
|
|
start_date_per_recurrency_id[shift.recurrency_id.id] = shift.start_datetime
|
|
for recurrency_id, start_datetime in start_date_per_recurrency_id.items():
|
|
sub_domain = expression.OR([sub_domain,
|
|
['&', ('recurrency_id', '=', recurrency_id), ('start_datetime', '>', start_datetime)]
|
|
])
|
|
domain = expression.AND([domain, sub_domain])
|
|
sibling_slots = self.env['planning.slot'].search(domain)
|
|
self.recurrency_id.unlink()
|
|
sibling_slots.unlink()
|
|
|
|
def action_unlink(self):
|
|
self.unlink()
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
|
|
def action_see_overlaping_slots(self):
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'planning.slot',
|
|
'name': _('Shifts in Conflict'),
|
|
'view_mode': 'gantt,list,form',
|
|
'context': {
|
|
'initialDate': min(self.mapped('start_datetime')),
|
|
'search_default_conflict_shifts': True,
|
|
'search_default_resource_id': self.resource_id.ids
|
|
}
|
|
}
|
|
|
|
def action_self_assign(self):
|
|
""" Allow planning user to self assign open shift. """
|
|
self.ensure_one()
|
|
# user must at least 'read' the shift to self assign (Prevent any user in the system (portal, ...) to assign themselves)
|
|
if not self.check_access_rights('read', raise_exception=False):
|
|
raise AccessError(_("You don't have the right to self assign."))
|
|
if self.resource_id and not self.request_to_switch:
|
|
raise UserError(_("You can not assign yourself to an already assigned shift."))
|
|
return self.sudo().write({'resource_id': self.env.user.employee_id.resource_id.id if self.env.user.employee_id else False})
|
|
|
|
def action_self_unassign(self):
|
|
""" Allow planning user to self unassign from a shift, if the feature is activated """
|
|
self.ensure_one()
|
|
# The following condition will check the read access on planning.slot, and that user must at least 'read' the
|
|
# shift to self unassign. Prevent any user in the system (portal, ...) to unassign any shift.
|
|
if not self.allow_self_unassign:
|
|
raise UserError(_("The company does not allow you to self unassign."))
|
|
if self.is_unassign_deadline_passed:
|
|
raise UserError(_("The deadline for unassignment has passed."))
|
|
if self.employee_id != self.env.user.employee_id:
|
|
raise UserError(_("You can not unassign another employee than yourself."))
|
|
return self.sudo().write({'resource_id': False})
|
|
|
|
def action_switch_shift(self):
|
|
""" Allow planning user to make shift available for other people to assign themselves to. """
|
|
self.ensure_one()
|
|
# same as with self-assign, a user must be able to 'read' the shift in order to request a switch
|
|
if not self.check_access_rights('read', raise_exception=False):
|
|
raise AccessError(_("You don't have the right to switch shifts."))
|
|
if self.employee_id != self.env.user.employee_id:
|
|
raise UserError(_("You can not request to switch a shift that is assigned to another user."))
|
|
if self.is_past:
|
|
raise UserError(_("You cannot switch a shift that is in the past."))
|
|
return self.sudo().write({'request_to_switch': True})
|
|
|
|
def action_cancel_switch(self):
|
|
""" Allows the planning user to cancel the shift switch if they change their mind at a later date """
|
|
self.ensure_one()
|
|
# same as above, the user rights are checked in order for the operation to be completed
|
|
if not self.check_access_rights('read', raise_exception=False):
|
|
raise AccessError(_("You don't have the right to cancel a request to switch."))
|
|
if self.employee_id != self.env.user.employee_id:
|
|
raise UserError(_("You can not cancel a request to switch made by another user."))
|
|
if self.is_past:
|
|
raise UserError(_("You cannot cancel a request to switch that is in the past."))
|
|
return self.sudo().write({'request_to_switch': False})
|
|
|
|
def auto_plan_id(self):
|
|
""" Used in the form view to auto plan a single shift.
|
|
"""
|
|
if not self.with_context(planning_slot_id=self.id).auto_plan_ids([('id', '=', self.id)]):
|
|
return self._get_notification_action("danger", _("There are no resources available for this open shift."))
|
|
|
|
@api.model
|
|
def auto_plan_ids(self, view_domain):
|
|
# We need to make sure we have a specified either one shift in particular or a period to look into.
|
|
assert self._context.get('planning_slot_id') or (
|
|
self._context.get('default_start_datetime') and self._context.get('default_end_datetime')
|
|
), "`default_start_datetime` and `default_end_datetime` attributes should be in the context"
|
|
|
|
# Our goal is to assign empty shifts in this period. So first, let's get them all!
|
|
open_shifts, min_start, max_end = self._read_group(
|
|
expression.AND([
|
|
view_domain,
|
|
[('resource_id', '=', False)],
|
|
]),
|
|
[],
|
|
['id:recordset', 'start_datetime:min', 'end_datetime:max'],
|
|
)[0]
|
|
if not open_shifts:
|
|
return []
|
|
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
|
|
min_start = min_start.astimezone(user_tz)
|
|
max_end = max_end.astimezone(user_tz)
|
|
|
|
# Get all resources that have the role set on those shifts as default role or in their roles.
|
|
Resource = self.env['resource.resource']
|
|
# open_shifts.role_id.ids wouldn't include False, yet we need this information
|
|
open_shift_role_ids = [shift.role_id.id for shift in open_shifts]
|
|
resources = Resource.search([
|
|
('calendar_id', '!=', False),
|
|
'|',
|
|
('default_role_id', 'in', open_shift_role_ids),
|
|
('role_ids', 'in', open_shift_role_ids),
|
|
])
|
|
# And make two dictionnaries out of it (default roles and roles). We will prioritize default roles.
|
|
resource_ids_per_role_id = defaultdict(list)
|
|
resource_ids_per_default_role_id = defaultdict(list)
|
|
for resource in resources:
|
|
resource_ids_per_default_role_id[resource.default_role_id.id].append(resource.id)
|
|
for role in resource.role_ids:
|
|
if role == resource.default_role_id:
|
|
continue
|
|
resource_ids_per_role_id[role.id].append(resource.id)
|
|
# Get the schedule of each resource in the period.
|
|
schedule_intervals_per_resource_id, dummy = resources._get_valid_work_intervals(min_start, max_end)
|
|
|
|
# Now let's get the assigned shifts and count the worked hours per day for each resource
|
|
min_start = min_start.astimezone(pytz.utc).replace(tzinfo=None) + relativedelta(hour=0, minute=0, second=0, microsecond=0)
|
|
max_end = max_end.astimezone(pytz.utc).replace(tzinfo=None) + relativedelta(days=1, hour=0, minute=0, second=0, microsecond=0)
|
|
PlanningShift = self.env['planning.slot']
|
|
same_days_shifts = PlanningShift.search_read([
|
|
('resource_id', 'in', resources.ids),
|
|
('end_datetime', '>', min_start),
|
|
('start_datetime', '<', max_end),
|
|
], ['start_datetime', 'end_datetime', 'resource_id', 'allocated_hours'], load=False)
|
|
timeline_and_worked_hours_per_resource_id = self._shift_records_to_timeline_per_resource_id(same_days_shifts)
|
|
|
|
# Create an "empty timeline" with midnight for each day in the period
|
|
delta_days = (max_end - min_start).days
|
|
empty_timeline = [
|
|
((min_start + relativedelta(days=i + 1)).astimezone(user_tz).replace(tzinfo=None), 0)
|
|
for i in range(delta_days)
|
|
]
|
|
|
|
def find_resource(shift):
|
|
shift_intervals = Intervals([(
|
|
shift.start_datetime.astimezone(user_tz),
|
|
shift.end_datetime.astimezone(user_tz),
|
|
PlanningShift,
|
|
)])
|
|
for resources_dict in [resource_ids_per_default_role_id, resource_ids_per_role_id]:
|
|
resource_ids = resources_dict[shift.role_id.id]
|
|
shuffle(resource_ids)
|
|
for resource in Resource.browse(resource_ids):
|
|
split_shift_intervals = shift_intervals & schedule_intervals_per_resource_id[resource.id]
|
|
# If the shift is out of resource's schedule, skip it.
|
|
if not split_shift_intervals:
|
|
continue
|
|
rate = shift.allocated_hours * 3600 / sum(
|
|
round((end - start).total_seconds())
|
|
for start, end, rec in split_shift_intervals
|
|
)
|
|
# Try to add the shift to the timeline.
|
|
timeline = self._get_new_timeline_if_fits_in(
|
|
split_shift_intervals,
|
|
rate,
|
|
resource.calendar_id.hours_per_day if resource.calendar_id else resource.company_id.resource_calendar_id.hours_per_day,
|
|
timeline_and_worked_hours_per_resource_id[resource.id].copy(),
|
|
empty_timeline,
|
|
)
|
|
# If we got a new timeline (not False), it means the shift fits for the resource
|
|
# (no overload, no "occupation rate" > 100%).
|
|
# If it fits, assign the shift to the resource and update the timeline.
|
|
# If a timeline is found, the resource can work the allocated_hours set on the shift.
|
|
# so the allocated_percentage is recomputed based on the working calendar of the
|
|
# resource and the allocated_hours set on the shift.
|
|
if timeline:
|
|
original_allocated_hours = shift.allocated_hours
|
|
shift.resource_id = resource
|
|
timeline_and_worked_hours_per_resource_id[resource.id] = timeline
|
|
start_utc = pytz.utc.localize(shift.start_datetime)
|
|
end_utc = pytz.utc.localize(shift.end_datetime)
|
|
resource_work_intervals, calendar_work_intervals = shift.resource_id \
|
|
.filtered('calendar_id') \
|
|
._get_valid_work_intervals(start_utc, end_utc, calendars=shift.company_id.resource_calendar_id)
|
|
work_hours = shift._get_working_hours_over_period(start_utc, end_utc, resource_work_intervals, calendar_work_intervals)
|
|
shift.allocated_percentage = 100 * original_allocated_hours / work_hours if work_hours else 100
|
|
return True
|
|
return False
|
|
|
|
return open_shifts.filtered(find_resource).ids
|
|
|
|
# A. Represent the resoures shifts and the open shift on a timeline
|
|
# Legend
|
|
# ┏━━ : open shift ┌─────────────────── 2023/01/02 ────────────────┬─────────────────── 2023/01/03 ────────────────┐
|
|
# ┌── : resource's shifts 0 ───────────── 8 ~~~~~~~~~~~~~ 16 ──────────── 0 ───────────── 8 ~~~~~~~~~~~~~ 16 ──────────── 0
|
|
# ~~~ : resource's schedule ├───────────────┼───────────────┼───────────────┼───────────────┼───────────────┼───────────────┤
|
|
|
|
# a/ Allocated Hours (ah) : ┏━━ 3h ━┓ ┌────── 8h ─────┐
|
|
# ┡━━━━━━━┹────────────────────── 7h ─────────────────────┴───────┬───────┘
|
|
# └───────────────────────────────────────────────────────────────┘
|
|
|
|
# b/ Rates [ah / (end - start)] : ┏━━ 75% ┓ ┌───── 100% ────┐
|
|
# ┡━━━━━━━┹───────────────────── 25% ─────────────────────┴───────┬───────┘
|
|
# └───────────────────────────────────────────────────────────────┘
|
|
|
|
# c/ Increments Timeline : ┌↑75%
|
|
# Visual : └↑25% ↓75% ↑100% ↓25% ↓100%
|
|
# Array : ┗━━━━━━━┹───────────────────────────────────────────────┴───────┴───────┘
|
|
# [
|
|
# (dt(2023, 1, 2, 8, 0), +1.00),
|
|
# (dt(2023, 1, 2, 12, 0), -0.75),
|
|
# (dt(2023, 1, 3, 12, 0), +1.00),
|
|
# (dt(2023, 1, 3, 16, 0), -0.25),
|
|
# (dt(2023, 1, 3, 20, 0), -1.00),
|
|
# ]
|
|
|
|
# d/ Values Timeline :
|
|
# Visual : |100% |25% |125% |100% |0%
|
|
# Array : ┗━━━━━━━┹───────────────────────────────────────────────┴───────┴───────┘
|
|
# [
|
|
# (dt(2023, 1, 2, 8, 0), 1.00),
|
|
# (dt(2023, 1, 2, 12, 0), 0.25),
|
|
# (dt(2023, 1, 3, 12, 0), 1.25),
|
|
# (dt(2023, 1, 3, 16, 0), 1.00),
|
|
# (dt(2023, 1, 3, 20, 0), 0.00),
|
|
# ]
|
|
|
|
# B. Try to assign each open shift to a resource
|
|
|
|
# 1) Check that the shift fits in the resource's schedule
|
|
# We just get the schedule intervals of the resource, convert the shift to intervals, and check the difference.
|
|
|
|
# 2) Check that the resource would not be overloaded this day
|
|
# Delimit days with ghost events at 0:00. Then compute the total time worked per day and compare it to the resource's max load.
|
|
# We do so considering that every resource have the same time zone (the user's one).
|
|
|
|
# 3) ...and that it would not conflict with the resource's other shifts (sum of rates > 100%)
|
|
# Visual : |0% |100% |25% |25% |125% |100% |0% |0%
|
|
# Array : └────── A ──────┗━━ B ━━┹────────── C ──────────┴───────────────────────┴───────┴───────┴───────┘
|
|
# [ ^ ^ ^
|
|
# (dt(2023, 1, 2, 0, 0), 0.00), <
|
|
# (dt(2023, 1, 2, 8, 0), 1.25), 2) Worked Hours on 2023/01/02
|
|
# (dt(2023, 1, 2, 12, 0), 0.25), = 8h * 0% (A) + 4h * 125% (B) + 12h * 25% (C)
|
|
# (dt(2023, 1, 3, 0, 0), 0.25), < = 0h + 5h + 3h
|
|
# (dt(2023, 1, 3, 12, 0), 1.00), = 8h => OK
|
|
# (dt(2023, 1, 3, 16, 0), 0.75),
|
|
# (dt(2023, 1, 3, 20, 0), 0.00), 3) rate(B) = 100% => OK
|
|
# (dt(2023, 1, 4, 0, 0), 0.00), <
|
|
# ]
|
|
|
|
@api.model
|
|
def _shift_records_to_timeline_per_resource_id(self, records):
|
|
timeline_and_worked_hours_per_resource_id = defaultdict(list)
|
|
for record in records:
|
|
rate = record['allocated_hours'] * 3600 / (
|
|
fields.Datetime.from_string(record['end_datetime']) - fields.Datetime.from_string(record['start_datetime'])
|
|
).total_seconds()
|
|
timeline_and_worked_hours_per_resource_id[record['resource_id']].extend([
|
|
(record['start_datetime'], rate), (record['end_datetime'], -rate)
|
|
])
|
|
for resource_id, timeline in timeline_and_worked_hours_per_resource_id.items():
|
|
timeline_and_worked_hours_per_resource_id[resource_id] = self._increments_to_values(timeline)
|
|
return timeline_and_worked_hours_per_resource_id
|
|
|
|
@api.model
|
|
def _get_new_timeline_if_fits_in(self, split_shift_intervals, rate, resource_hours_per_day, timeline, empty_timeline):
|
|
if rate > 1:
|
|
return False
|
|
add_midnights = True
|
|
for split_shift_start, split_shift_end, _ in split_shift_intervals:
|
|
start = split_shift_start.astimezone(pytz.utc).replace(tzinfo=None)
|
|
end = split_shift_end.astimezone(pytz.utc).replace(tzinfo=None)
|
|
increments = self._values_to_increments(timeline) + [(start, rate), (end, -rate)]
|
|
if add_midnights:
|
|
# Add ghost events at 0:00 to delimit days. This condition prevents from adding ghost events on each iteration.
|
|
increments += empty_timeline
|
|
add_midnights = False
|
|
timeline = self._increments_to_values(increments, check=(start, end, resource_hours_per_day))
|
|
if not timeline:
|
|
return False
|
|
return timeline
|
|
|
|
@api.model
|
|
def _increments_to_values(self, increments, check=False):
|
|
""" Transform a timeline of increments into a timeline of values by accumulating the increments.
|
|
If check is a tuple (start, end, resource_hours_per_day), the timeline is checked to ensure
|
|
that the resource would not be overloaded this day or have an "occupation rate" > 100% between start and end.
|
|
|
|
:param increments: List of tuples (instant, increment).
|
|
:param check: False or a tuple (start, end, resource_hours_per_day).
|
|
:return: List of tuples (instant, value) if check is False or the timeline is valid, else False.
|
|
"""
|
|
if not increments:
|
|
return []
|
|
if check:
|
|
start, end, resource_hours_per_day = check
|
|
|
|
values = []
|
|
# Sum and sort increments by instant.
|
|
increments_sum_per_instant = defaultdict(float)
|
|
for instant, increment in increments:
|
|
increments_sum_per_instant[instant] += increment
|
|
increments = list(increments_sum_per_instant.items())
|
|
increments.sort(key=lambda increment: increment[0])
|
|
|
|
def get_instant_plus_days(instant, days):
|
|
return instant + relativedelta(days=days, hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
hours_per_day = defaultdict(float)
|
|
last_instant, last_value = increments[0][0], 0.0
|
|
for increment in increments:
|
|
# Check if the resource is overloaded this day.
|
|
hours_per_day[last_instant.date()] += last_value * (increment[0] - last_instant).total_seconds() / 3600
|
|
if check and hours_per_day[last_instant.date()] > resource_hours_per_day and (
|
|
get_instant_plus_days(start, 0) <= last_instant < get_instant_plus_days(end, 1)
|
|
):
|
|
return False
|
|
last_value += increment[1]
|
|
last_instant = increment[0]
|
|
# Check if the occupation rate exceeds 100%.
|
|
if check and last_value > 1 and start <= last_instant <= end:
|
|
return False
|
|
values.append((last_instant, last_value))
|
|
return values
|
|
|
|
@api.model
|
|
def _values_to_increments(self, values):
|
|
""" Transform a timeline of values into a timeline of increments by subtracting the values.
|
|
|
|
:param values: List of tuples (instant, value).
|
|
:return: List of tuples (instant, increment).
|
|
"""
|
|
increments = []
|
|
last_value = 0
|
|
for value in values:
|
|
increments.append((value[0], value[1] - last_value))
|
|
last_value = value[1]
|
|
return increments
|
|
|
|
# ----------------------------------------------------
|
|
# Gantt - Calendar view
|
|
# ----------------------------------------------------
|
|
|
|
@api.model
|
|
def gantt_resource_work_interval(self, slot_ids):
|
|
""" Returns the work intervals of the resources corresponding to the provided slots
|
|
|
|
This method is used in a rpc call
|
|
|
|
:param slot_ids: The slots the work intervals have to be returned for.
|
|
:return: list of dicts { resource_id: [Intervals] } and { resource_id: flexible_hours }.
|
|
"""
|
|
# Get the oldest start date and latest end date from the slots.
|
|
domain = [("id", "in", slot_ids)]
|
|
read_group_fields = ["start_datetime:min", "end_datetime:max", "resource_id:recordset", "__count"]
|
|
planning_slot_read_group = self.env["planning.slot"]._read_group(domain, [], read_group_fields)
|
|
start_datetime, end_datetime, resources, count = planning_slot_read_group[0]
|
|
if not count:
|
|
return [{}]
|
|
|
|
# Get default start/end datetime if any.
|
|
default_start_datetime = (fields.Datetime.to_datetime(self._context.get('default_start_datetime')) or datetime.min).replace(tzinfo=pytz.utc)
|
|
default_end_datetime = (fields.Datetime.to_datetime(self._context.get('default_end_datetime')) or datetime.max).replace(tzinfo=pytz.utc)
|
|
|
|
start_datetime = max(default_start_datetime, start_datetime.replace(tzinfo=pytz.utc))
|
|
end_datetime = min(default_end_datetime, end_datetime.replace(tzinfo=pytz.utc))
|
|
|
|
# Get slots' resources and current company work intervals.
|
|
work_intervals_per_resource, dummy = resources._get_valid_work_intervals(start_datetime, end_datetime)
|
|
company_calendar = self.env.company.resource_calendar_id
|
|
company_calendar_work_intervals = company_calendar._work_intervals_batch(start_datetime, end_datetime)
|
|
|
|
# Export work intervals in UTC
|
|
work_intervals_per_resource[False] = company_calendar_work_intervals[False]
|
|
work_interval_per_resource = defaultdict(list)
|
|
for resource_id, resource_work_intervals in work_intervals_per_resource.items():
|
|
for resource_work_interval in resource_work_intervals:
|
|
work_interval_per_resource[resource_id].append(
|
|
(resource_work_interval[0].astimezone(pytz.UTC), resource_work_interval[1].astimezone(pytz.UTC))
|
|
)
|
|
# Add the flexible status per resource to the output
|
|
flexible_per_resource = {resource.id: not bool(resource.calendar_id) for resource in set(resources)}
|
|
flexible_per_resource[False] = True
|
|
return [work_interval_per_resource, flexible_per_resource]
|
|
|
|
@api.model
|
|
def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None):
|
|
start_datetime = fields.Datetime.from_string(start_date)
|
|
end_datetime = fields.Datetime.from_string(end_date)
|
|
resource_ids = set()
|
|
|
|
# function to "mark" top level rows concerning resources
|
|
# the propagation of that item to subrows is taken care of in the traverse function below
|
|
def tag_resource_rows(rows):
|
|
for row in rows:
|
|
group_bys = row.get('groupedBy')
|
|
res_id = row.get('resId')
|
|
if group_bys:
|
|
# if resource_id is the first grouping attribute, we mark the row
|
|
if group_bys[0] == 'resource_id' and res_id:
|
|
resource_id = res_id
|
|
resource_ids.add(resource_id)
|
|
row['resource_id'] = resource_id
|
|
# else we recursively traverse the rows where resource_id appears in the group_by
|
|
elif 'resource_id' in group_bys:
|
|
tag_resource_rows(row.get('rows'))
|
|
|
|
tag_resource_rows(rows)
|
|
resources = self.env['resource.resource'].browse(resource_ids).filtered('calendar_id')
|
|
leaves_mapping = resources._get_unavailable_intervals(start_datetime, end_datetime)
|
|
company_leaves = self.env.company.resource_calendar_id._unavailable_intervals(start_datetime.replace(tzinfo=pytz.utc), end_datetime.replace(tzinfo=pytz.utc))
|
|
|
|
# function to recursively replace subrows with the ones returned by func
|
|
def traverse(func, row):
|
|
new_row = dict(row)
|
|
if new_row.get('resource_id'):
|
|
for sub_row in new_row.get('rows'):
|
|
sub_row['resource_id'] = new_row['resource_id']
|
|
new_row['rows'] = [traverse(func, row) for row in new_row.get('rows')]
|
|
return func(new_row)
|
|
|
|
cell_dt = timedelta(hours=1) if scale in ['day', 'week'] else timedelta(hours=12)
|
|
|
|
# for a single row, inject unavailability data
|
|
def inject_unavailability(row):
|
|
new_row = dict(row)
|
|
|
|
calendar = company_leaves
|
|
if row.get('resource_id'):
|
|
resource_id = self.env['resource.resource'].browse(row.get('resource_id'))
|
|
if resource_id:
|
|
if not resource_id.calendar_id:
|
|
return new_row
|
|
calendar = leaves_mapping[resource_id.id]
|
|
|
|
# remove intervals smaller than a cell, as they will cause half a cell to turn grey
|
|
# ie: when looking at a week, a employee start everyday at 8, so there is a unavailability
|
|
# like: 2019-05-22 20:00 -> 2019-05-23 08:00 which will make the first half of the 23's cell grey
|
|
notable_intervals = filter(lambda interval: interval[1] - interval[0] >= cell_dt, calendar)
|
|
new_row['unavailabilities'] = [{'start': interval[0], 'stop': interval[1]} for interval in notable_intervals]
|
|
return new_row
|
|
|
|
return [traverse(inject_unavailability, row) for row in rows]
|
|
|
|
@api.model
|
|
def get_unusual_days(self, date_from, date_to=None):
|
|
return self.env.user.employee_id._get_unusual_days(date_from, date_to)
|
|
|
|
# ----------------------------------------------------
|
|
# Period Duplication
|
|
# ----------------------------------------------------
|
|
|
|
@api.model
|
|
def action_copy_previous_week(self, date_start_week, view_domain):
|
|
date_end_copy = datetime.strptime(date_start_week, DEFAULT_SERVER_DATETIME_FORMAT)
|
|
date_start_copy = date_end_copy - relativedelta(days=7)
|
|
domain = [
|
|
('recurrency_id', '=', False),
|
|
('was_copied', '=', False)
|
|
]
|
|
for dom in view_domain:
|
|
if dom in ['|', '&', '!']:
|
|
domain.append(dom)
|
|
elif dom[0] == 'start_datetime':
|
|
domain.append(('start_datetime', '>=', date_start_copy))
|
|
elif dom[0] == 'end_datetime':
|
|
domain.append(('end_datetime', '<=', date_end_copy))
|
|
else:
|
|
domain.append(tuple(dom))
|
|
slots_to_copy = self.search(domain)
|
|
|
|
new_slot_values = []
|
|
new_slot_values = slots_to_copy._copy_slots(date_start_copy, date_end_copy, relativedelta(days=7))
|
|
slots_to_copy.write({'was_copied': True})
|
|
if new_slot_values:
|
|
return [self.create(new_slot_values).ids, slots_to_copy.ids]
|
|
return False
|
|
|
|
def action_rollback_copy_previous_week(self, copied_slot_ids):
|
|
self.browse(copied_slot_ids).was_copied = False
|
|
self.unlink()
|
|
|
|
# ----------------------------------------------------
|
|
# Sending Shifts
|
|
# ----------------------------------------------------
|
|
|
|
def get_employees_without_work_email(self):
|
|
""" Check if the employees to send the slot have a work email set.
|
|
|
|
This method is used in a rpc call.
|
|
|
|
:returns: a dictionnary containing the all needed information to continue the process.
|
|
Returns None, if no employee or all employees have an email set.
|
|
"""
|
|
self.ensure_one()
|
|
if not self.employee_id.check_access_rights('write', raise_exception=False):
|
|
return None
|
|
employees = self.employee_id or self._get_employees_to_send_slot()
|
|
employee_ids_without_work_email = employees.filtered(lambda employee: not employee.work_email).ids
|
|
if not employee_ids_without_work_email:
|
|
return None
|
|
context = dict(self._context)
|
|
context['force_email'] = True
|
|
context['form_view_ref'] = 'planning.hr_employee_view_form_simplified'
|
|
return {
|
|
'relation': 'hr.employee',
|
|
'res_ids': employee_ids_without_work_email,
|
|
'context': context,
|
|
}
|
|
|
|
def _get_employees_to_send_slot(self):
|
|
self.ensure_one()
|
|
if not self.employee_id or not self.employee_id.work_email:
|
|
domain = [('company_id', '=', self.company_id.id), ('work_email', '!=', False)]
|
|
if self.role_id:
|
|
domain = expression.AND([
|
|
domain,
|
|
['|', ('planning_role_ids', '=', False), ('planning_role_ids', 'in', self.role_id.id)]])
|
|
return self.env['hr.employee'].sudo().search(domain)
|
|
return self.employee_id
|
|
|
|
def _get_notification_action(self, notif_type, message):
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'type': notif_type,
|
|
'message': message,
|
|
'next': {'type': 'ir.actions.act_window_close'},
|
|
}
|
|
}
|
|
|
|
def action_planning_publish_and_send(self):
|
|
notif_type = "success"
|
|
start, end = min(self.mapped('start_datetime')), max(self.mapped('end_datetime'))
|
|
if all(shift.state == 'published' for shift in self) or not start or not end:
|
|
notif_type = "warning"
|
|
message = _('There are no shifts to publish and send.')
|
|
else:
|
|
planning = self.env['planning.planning'].create({
|
|
'start_datetime': start,
|
|
'end_datetime': end,
|
|
})
|
|
planning._send_planning(slots=self, employees=self.employee_id)
|
|
message = _('The shifts have successfully been published and sent.')
|
|
return self._get_notification_action(notif_type, message)
|
|
|
|
def action_send(self):
|
|
self.ensure_one()
|
|
if not self.employee_id or not self.employee_id.work_email:
|
|
self.state = 'published'
|
|
employee_ids = self._get_employees_to_send_slot()
|
|
self._send_slot(employee_ids, self.start_datetime, self.end_datetime)
|
|
message = _("The shift has successfully been sent.")
|
|
return self._get_notification_action('success', message)
|
|
|
|
def action_unpublish(self):
|
|
if not self.env.user.has_group('planning.group_planning_manager'):
|
|
raise AccessError(_('You are not allowed to reset to draft shifts.'))
|
|
published_shifts = self.filtered(lambda shift: shift.state == 'published' and shift.resource_type != 'material')
|
|
if published_shifts:
|
|
published_shifts.write({'state': 'draft', 'publication_warning': False,})
|
|
notif_type = "success"
|
|
message = _('The shifts have been successfully reset to draft.')
|
|
else:
|
|
notif_type = "warning"
|
|
message = _('There are no shifts to reset to draft.')
|
|
return self._get_notification_action(notif_type, message)
|
|
|
|
# ----------------------------------------------------
|
|
# Business Methods
|
|
# ----------------------------------------------------
|
|
|
|
def _calculate_slot_duration(self):
|
|
self.ensure_one()
|
|
if not self.start_datetime or not self.end_datetime:
|
|
return 0.0
|
|
period = self.end_datetime - self.start_datetime
|
|
resource = self.resource_id or self.env.user.employee_id.resource_id
|
|
if resource and resource.calendar_id:
|
|
work_intervals, calendar_intervals = resource._get_valid_work_intervals(
|
|
pytz.utc.localize(self.start_datetime).astimezone(pytz.timezone(resource.tz)),
|
|
pytz.utc.localize(self.end_datetime).astimezone(pytz.timezone(resource.tz))
|
|
)
|
|
working_intervals = work_intervals[resource.id] \
|
|
if resource \
|
|
else calendar_intervals.get(self.company_id.resource_calendar_id.id, calendar_intervals[self.company_id.id])
|
|
slot_duration = sum_intervals(working_intervals)
|
|
else:
|
|
slot_duration = period.total_seconds() / 3600
|
|
max_duration = (period.days + (1 if period.seconds else 0)) * self.company_id.resource_calendar_id.hours_per_day
|
|
if not max_duration or max_duration >= slot_duration:
|
|
return slot_duration
|
|
return max_duration
|
|
|
|
# ----------------------------------------------------
|
|
# Copy Slots
|
|
# ----------------------------------------------------
|
|
|
|
def _add_delta_with_dst(self, start, delta):
|
|
"""
|
|
Add to start, adjusting the hours if needed to account for a shift in the local timezone between the
|
|
start date and the resulting date (typically, because of DST)
|
|
|
|
:param start: origin date in UTC timezone, but without timezone info (a naive date)
|
|
:return resulting date in the UTC timezone (a naive date)
|
|
"""
|
|
try:
|
|
tz = pytz.timezone(self._get_tz())
|
|
except pytz.UnknownTimeZoneError:
|
|
tz = pytz.UTC
|
|
start = start.replace(tzinfo=pytz.utc).astimezone(tz).replace(tzinfo=None)
|
|
result = start + delta
|
|
return tz.localize(result).astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
def _get_half_day_interval(self, values):
|
|
"""
|
|
This method computes the afternoon and/or the morning whole interval where the planning slot exists.
|
|
The resulting interval frames the slot in a bigger interval beginning before the slot (max 11:59:59 sooner)
|
|
and finishing later (max 11:59:59 later)
|
|
|
|
:param values: a dict filled in with new planning.slot vals
|
|
:return an interval
|
|
"""
|
|
return Intervals([(
|
|
self._get_half_day_datetime(values['start_datetime']),
|
|
self._get_half_day_datetime(values['end_datetime'], end=True),
|
|
self.env['resource.calendar.attendance']
|
|
)])
|
|
|
|
def _get_half_day_datetime(self, dt, end=False):
|
|
"""
|
|
This method computes a datetime in order to frame the slot in a bigger interval begining at midnight or
|
|
noon and ending at midnight or noon.
|
|
|
|
This method returns :
|
|
- If end is False : Greatest datetime between midnight and noon that is sooner than the `dt` datetime;
|
|
- Otherwise : Lowest datetime between midnight and noon that is later than the `dt` datetime.
|
|
|
|
:param dt: input datetime
|
|
:param end: wheter the dt is the end, resp. the start, of the interval if set, resp. not set.
|
|
:return a datetime
|
|
"""
|
|
self.ensure_one()
|
|
tz = pytz.timezone(self._get_tz())
|
|
localized_dt = pytz.utc.localize(dt).astimezone(tz)
|
|
midday = localized_dt.replace(hour=12, minute=0, second=0)
|
|
if end:
|
|
return midday if midday > localized_dt else (localized_dt.replace(hour=0, minute=0, second=0) + timedelta(days=1))
|
|
return midday if midday < localized_dt else localized_dt.replace(hour=0, minute=0, second=0)
|
|
|
|
def _init_remaining_hours_to_plan(self, remaining_hours_to_plan):
|
|
"""
|
|
Inits the remaining_hours_to_plan dict for a given slot and returns wether
|
|
there are enough remaining hours.
|
|
|
|
:return a bool representing wether or not there are still hours remaining
|
|
"""
|
|
self.ensure_one()
|
|
return True
|
|
|
|
def _update_remaining_hours_to_plan_and_values(self, remaining_hours_to_plan, values):
|
|
"""
|
|
Update the remaining_hours_to_plan with the allocated hours of the slot in `values`
|
|
and returns wether there are enough remaining hours.
|
|
|
|
If remaining_hours is strictly positive, and the allocated hours of the slot in `values` is
|
|
higher than remaining hours, than update the values in order to consume at most the
|
|
number of remaining_hours still available.
|
|
|
|
:return a bool representing wether or not there are still hours remaining
|
|
"""
|
|
self.ensure_one()
|
|
return True
|
|
|
|
def _get_split_slot_values(self, values, intervals, remaining_hours_to_plan, unassign=False):
|
|
"""
|
|
Generates and returns slots values within the given intervals
|
|
|
|
The slot in values, which represents a forecast planning slot, is split in multiple parts
|
|
filling the (available) intervals.
|
|
|
|
:return a vals list of the slot to create
|
|
"""
|
|
self.ensure_one()
|
|
splitted_slot_values = []
|
|
for start_inter, end_inter, _resource in intervals:
|
|
new_slot_vals = {
|
|
**values,
|
|
'start_datetime': start_inter.astimezone(pytz.utc).replace(tzinfo=None),
|
|
'end_datetime': end_inter.astimezone(pytz.utc).replace(tzinfo=None),
|
|
}
|
|
was_updated = self._update_remaining_hours_to_plan_and_values(remaining_hours_to_plan, new_slot_vals)
|
|
new_slot_vals['allocated_hours'] = float_utils.float_round(
|
|
((end_inter - start_inter).total_seconds() / 3600.0) * (self.allocated_percentage / 100.0),
|
|
precision_digits=2
|
|
)
|
|
if not was_updated:
|
|
return splitted_slot_values
|
|
if unassign:
|
|
new_slot_vals['resource_id'] = False
|
|
splitted_slot_values.append(new_slot_vals)
|
|
return splitted_slot_values
|
|
|
|
def _copy_slots(self, start_dt, end_dt, delta):
|
|
"""
|
|
Copy slots planned between `start_dt` and `end_dt`, after a `delta`
|
|
|
|
Takes into account the resource calendar and the slots already planned.
|
|
All the slots will be copied, whatever the value of was_copied is.
|
|
|
|
:return a vals list of the slot to create
|
|
"""
|
|
# 1) Retrieve all the slots of the new period and create intervals within the slots will have to be unassigned (resource_slots_intervals),
|
|
# add it to `unavailable_intervals_per_resource`
|
|
# 2) Retrieve all the calendars for the resource and their validity intervals (intervals within which the calendar is valid for the resource)
|
|
# 3) For each calendar, retrieve the attendances and the leaves. Add attendances by resource in `attendance_intervals_per_resource` and
|
|
# the leaves by resource in `unavailable_intervals_per_resource`
|
|
# 4) For each slot, check if the slot is at least within an attendance and outside a company leave :
|
|
# - If it is a planning :
|
|
# - Copy it if the resource is available
|
|
# - Copy and unassign it if the resource isn't available
|
|
# - Otherwise :
|
|
# - Split it and assign the part within resource work intervals
|
|
# - Split it and unassign the part within resource leaves and outside company leaves
|
|
resource_per_calendar = defaultdict(lambda: self.env['resource.resource'])
|
|
resource_calendar_validity_intervals = defaultdict(dict)
|
|
attendance_intervals_per_resource = defaultdict(Intervals) # key: resource, values: attendance intervals
|
|
unavailable_intervals_per_resource = defaultdict(Intervals) # key: resource, values: unavailable intervals
|
|
attendance_intervals_per_calendar = defaultdict(Intervals) # key: calendar, values: attendance intervals (used for company calendars)
|
|
leave_intervals_per_calendar = defaultdict(Intervals) # key: calendar, values: leave intervals (used for company calendars)
|
|
new_slot_values = []
|
|
# date utils variable
|
|
start_dt_delta = start_dt + delta
|
|
end_dt_delta = end_dt + delta
|
|
start_dt_delta_utc = pytz.utc.localize(start_dt_delta)
|
|
end_dt_delta_utc = pytz.utc.localize(end_dt_delta)
|
|
# 1)
|
|
# Search for all resource slots already planned
|
|
resource_slots = self.search([
|
|
('start_datetime', '>=', start_dt_delta),
|
|
('end_datetime', '<=', end_dt_delta),
|
|
('resource_id', 'in', self.resource_id.ids)
|
|
])
|
|
# And convert it into intervals
|
|
for slot in resource_slots:
|
|
unavailable_intervals_per_resource[slot.resource_id] |= Intervals([(
|
|
pytz.utc.localize(slot.start_datetime),
|
|
pytz.utc.localize(slot.end_datetime),
|
|
self.env['resource.calendar.leaves'])])
|
|
# 2)
|
|
resource_calendar_validity_intervals = self.resource_id.sudo()._get_calendars_validity_within_period(
|
|
start_dt_delta_utc, end_dt_delta_utc)
|
|
for slot in self:
|
|
if slot.resource_id:
|
|
for calendar in resource_calendar_validity_intervals[slot.resource_id.id]:
|
|
resource_per_calendar[calendar] |= slot.resource_id
|
|
company_calendar_id = slot.company_id.resource_calendar_id
|
|
resource_per_calendar[company_calendar_id] |= self.env['resource.resource'] # ensures the company_calendar will be in resource_per_calendar keys.
|
|
# 3)
|
|
for calendar, resources in resource_per_calendar.items():
|
|
# For each calendar, retrieves the work intervals of every resource
|
|
attendances = calendar._attendance_intervals_batch(
|
|
start_dt_delta_utc,
|
|
end_dt_delta_utc,
|
|
resources=resources
|
|
)
|
|
leaves = calendar._leave_intervals_batch(
|
|
start_dt_delta_utc,
|
|
end_dt_delta_utc,
|
|
resources=resources
|
|
)
|
|
attendance_intervals_per_calendar[calendar] = attendances[False]
|
|
leave_intervals_per_calendar[calendar] = leaves[False]
|
|
for resource in resources:
|
|
# for each resource, adds his/her attendances and unavailabilities for this calendar, during the calendar validity interval.
|
|
attendance_intervals_per_resource[resource] |= (attendances[resource.id] & resource_calendar_validity_intervals[resource.id][calendar])
|
|
unavailable_intervals_per_resource[resource] |= (leaves[resource.id] & resource_calendar_validity_intervals[resource.id][calendar])
|
|
# 4)
|
|
remaining_hours_to_plan = {}
|
|
for slot in self:
|
|
if not slot._init_remaining_hours_to_plan(remaining_hours_to_plan):
|
|
continue
|
|
values = slot.copy_data(default={'state': 'draft'})[0]
|
|
if not values.get('start_datetime') or not values.get('end_datetime'):
|
|
continue
|
|
values['start_datetime'] = slot._add_delta_with_dst(values['start_datetime'], delta)
|
|
values['end_datetime'] = slot._add_delta_with_dst(values['end_datetime'], delta)
|
|
if any(
|
|
new_slot['resource_id'] == values['resource_id'] and
|
|
new_slot['start_datetime'] <= values['end_datetime'] and
|
|
new_slot['end_datetime'] >= values['start_datetime']
|
|
for new_slot in new_slot_values
|
|
):
|
|
values['resource_id'] = False
|
|
interval = Intervals([(
|
|
pytz.utc.localize(values.get('start_datetime')),
|
|
pytz.utc.localize(values.get('end_datetime')),
|
|
self.env['resource.calendar.attendance']
|
|
)])
|
|
company_calendar = slot.company_id.resource_calendar_id
|
|
# Check if interval is contained in the resource work interval
|
|
attendance_resource = attendance_intervals_per_resource[slot.resource_id] if slot.resource_id else attendance_intervals_per_calendar[company_calendar]
|
|
attendance_interval_resource = interval & attendance_resource
|
|
# Check if interval is contained in the company attendances interval
|
|
attendance_interval_company = interval & attendance_intervals_per_calendar[company_calendar]
|
|
# Check if interval is contained in the company leaves interval
|
|
unavailable_interval_company = interval & leave_intervals_per_calendar[company_calendar]
|
|
if slot.allocation_type == 'planning' and not unavailable_interval_company and not attendance_interval_resource:
|
|
# If the slot is not a forecast and there are no expected attendance, neither a company leave
|
|
# check if the slot is planned during an afternoon or a morning during which the resource/company works/is opened
|
|
|
|
# /!\ Name of such attendance is an "Extended Attendance", see hereafter
|
|
interval = slot._get_half_day_interval(values) # Get the afternoon and/or the morning whole interval where the planning slot exists.
|
|
attendance_interval_resource = interval & attendance_resource
|
|
attendance_interval_company = interval & attendance_intervals_per_calendar[company_calendar]
|
|
unavailable_interval_company = interval & leave_intervals_per_calendar[company_calendar]
|
|
unavailable_interval_resource = unavailable_interval_company if not slot.resource_id else (interval & unavailable_intervals_per_resource[slot.resource_id])
|
|
if (attendance_interval_resource - unavailable_interval_company) or (attendance_interval_company - unavailable_interval_company):
|
|
# Either the employee has, at least, some attendance that are not during the company unavailability
|
|
# Either the company has, at least, some attendance that are not during the company unavailability
|
|
|
|
if slot.allocation_type == 'planning':
|
|
# /!\ It can be an "Extended Attendance" (see hereabove), and the slot may be unassigned.
|
|
if unavailable_interval_resource or not attendance_interval_resource:
|
|
# if the slot is during an resourece unavailability, or the employee is not attending during the slot
|
|
if slot.resource_type != 'user':
|
|
# if the resource is not an employee and the resource is not available, do not copy it nor unassign it
|
|
continue
|
|
values['resource_id'] = False
|
|
if not slot._update_remaining_hours_to_plan_and_values(remaining_hours_to_plan, values):
|
|
# make sure the hours remaining are enough
|
|
continue
|
|
new_slot_values.append(values)
|
|
else:
|
|
if attendance_interval_resource:
|
|
# if the resource has attendances, at least during a while of the future slot lifetime,
|
|
# 1) Work interval represents the availabilities of the employee
|
|
# 2) The unassigned intervals represents the slots where the employee should be unassigned
|
|
# (when the company is not unavailable and the employee is unavailable)
|
|
work_interval_employee = (attendance_interval_resource - unavailable_interval_resource)
|
|
unassigned_interval = unavailable_interval_resource - unavailable_interval_company
|
|
split_slot_values = slot._get_split_slot_values(values, work_interval_employee, remaining_hours_to_plan)
|
|
if slot.resource_type == 'user':
|
|
split_slot_values += slot._get_split_slot_values(values, unassigned_interval, remaining_hours_to_plan, unassign=True)
|
|
elif slot.resource_type != 'user':
|
|
# If the resource type is not user and the slot can not be assigned to the resource, do not copy not unassign it
|
|
continue
|
|
else:
|
|
# When the employee has no attendance at all, we are in the case where the employee has a calendar different than the
|
|
# company (or no more calendar), so the slot will be unassigned
|
|
unassigned_interval = attendance_interval_company - unavailable_interval_company
|
|
split_slot_values = slot._get_split_slot_values(values, unassigned_interval, remaining_hours_to_plan, unassign=True)
|
|
# merge forecast slots in order to have visually bigger slots
|
|
new_slot_values += self._merge_slots_values(split_slot_values, unassigned_interval)
|
|
return new_slot_values
|
|
|
|
def _display_name_fields(self):
|
|
""" List of fields that can be displayed in the display_name """
|
|
return ['resource_id', 'role_id']
|
|
|
|
def _get_fields_breaking_publication(self):
|
|
""" Fields list triggering the `publication_warning` to True when updating shifts """
|
|
return [
|
|
'resource_id',
|
|
'resource_type',
|
|
'start_datetime',
|
|
'end_datetime',
|
|
'role_id',
|
|
]
|
|
|
|
@api.model
|
|
def _get_template_fields(self):
|
|
# key -> field from template
|
|
# value -> field from slot
|
|
return {'role_id': 'role_id', 'start_time': 'start_datetime', 'duration': 'duration'}
|
|
|
|
def _get_tz(self):
|
|
return (self.env.user.tz
|
|
or self.employee_id.tz
|
|
or self.resource_id.tz
|
|
or self._context.get('tz')
|
|
or self.company_id.resource_calendar_id.tz
|
|
or 'UTC')
|
|
|
|
def _prepare_template_values(self):
|
|
""" extract values from shift to create a template """
|
|
# compute duration w/ tzinfo otherwise DST will not be taken into account
|
|
destination_tz = pytz.timezone(self._get_tz())
|
|
start_datetime = pytz.utc.localize(self.start_datetime).astimezone(destination_tz)
|
|
end_datetime = pytz.utc.localize(self.end_datetime).astimezone(destination_tz)
|
|
|
|
# convert time delta to hours and minutes
|
|
total_seconds = (end_datetime - start_datetime).total_seconds()
|
|
m, s = divmod(total_seconds, 60)
|
|
h, m = divmod(m, 60)
|
|
|
|
return {
|
|
'start_time': start_datetime.hour + start_datetime.minute / 60.0,
|
|
'duration': h + (m / 60.0),
|
|
'role_id': self.role_id.id
|
|
}
|
|
|
|
def _manage_archived_resources(self, departure_date):
|
|
shift_vals_list = []
|
|
shift_ids_to_remove_resource = []
|
|
for slot in self:
|
|
split_time = pytz.timezone(self._get_tz()).localize(departure_date).astimezone(pytz.utc).replace(tzinfo=None)
|
|
if (slot.start_datetime < split_time) and (slot.end_datetime > split_time):
|
|
shift_vals_list.append({
|
|
'start_datetime': split_time,
|
|
**slot._prepare_shift_vals(),
|
|
})
|
|
if split_time > slot.start_datetime:
|
|
slot.write({'end_datetime': split_time})
|
|
elif slot.start_datetime >= split_time:
|
|
shift_ids_to_remove_resource.append(slot.id)
|
|
if shift_vals_list:
|
|
self.sudo().create(shift_vals_list)
|
|
if shift_ids_to_remove_resource:
|
|
self.sudo().browse(shift_ids_to_remove_resource).write({'resource_id': False})
|
|
|
|
def _group_expand_resource_id(self, resources, domain, order):
|
|
dom_tuples = [(dom[0], dom[1]) for dom in domain if isinstance(dom, (tuple, list)) and len(dom) == 3]
|
|
resource_ids = self.env.context.get('filter_resource_ids', False)
|
|
if resource_ids:
|
|
return self.env['resource.resource'].search([('id', 'in', resource_ids)], order=order)
|
|
if self.env.context.get('planning_expand_resource') and ('start_datetime', '<=') in dom_tuples and ('end_datetime', '>=') in dom_tuples:
|
|
if ('resource_id', '=') in dom_tuples or ('resource_id', 'ilike') in dom_tuples or ('resource_id', 'in') in dom_tuples:
|
|
filter_domain = self._expand_domain_m2o_groupby(domain, 'resource_id')
|
|
return self.env['resource.resource'].search(filter_domain, order=order)
|
|
filters = self._expand_domain_dates(domain)
|
|
resources = self.env['planning.slot'].search(filters).mapped('resource_id')
|
|
return resources.search([('id', 'in', resources.ids)], order=order)
|
|
return resources
|
|
|
|
def _read_group_role_id(self, roles, domain, order):
|
|
dom_tuples = [(dom[0], dom[1]) for dom in domain if isinstance(dom, list) and len(dom) == 3]
|
|
if self._context.get('planning_expand_role') and ('start_datetime', '<=') in dom_tuples and ('end_datetime', '>=') in dom_tuples:
|
|
if ('role_id', '=') in dom_tuples or ('role_id', 'ilike') in dom_tuples:
|
|
filter_domain = self._expand_domain_m2o_groupby(domain, 'role_id')
|
|
return self.env['planning.role'].search(filter_domain, order=order)
|
|
filters = expression.AND([[('role_id.active', '=', True)], self._expand_domain_dates(domain)])
|
|
return self.env['planning.slot'].search(filters).mapped('role_id')
|
|
return roles
|
|
|
|
@api.model
|
|
def _expand_domain_m2o_groupby(self, domain, filter_field=False):
|
|
filter_domain = []
|
|
for dom in domain:
|
|
if dom[0] == filter_field:
|
|
field = self._fields[dom[0]]
|
|
if field.type == 'many2one' and len(dom) == 3:
|
|
if dom[1] in ['=', 'in']:
|
|
filter_domain = expression.OR([filter_domain, [('id', dom[1], dom[2])]])
|
|
elif dom[1] == 'ilike':
|
|
rec_name = self.env[field.comodel_name]._rec_name
|
|
filter_domain = expression.OR([filter_domain, [(rec_name, dom[1], dom[2])]])
|
|
return filter_domain
|
|
|
|
def _expand_domain_dates(self, domain):
|
|
filters = []
|
|
for dom in domain:
|
|
if len(dom) == 3 and dom[0] == 'start_datetime' and dom[1] == '<=':
|
|
max_date = dom[2] if dom[2] else datetime.now()
|
|
max_date = max_date if isinstance(max_date, date) else datetime.strptime(max_date, '%Y-%m-%d %H:%M:%S')
|
|
max_date = max_date + timedelta(days=7)
|
|
filters.append((dom[0], dom[1], max_date))
|
|
elif len(dom) == 3 and dom[0] == 'end_datetime' and dom[1] == '>=':
|
|
min_date = dom[2] if dom[2] else datetime.now()
|
|
min_date = min_date if isinstance(min_date, date) else datetime.strptime(min_date, '%Y-%m-%d %H:%M:%S')
|
|
min_date = min_date - timedelta(days=7)
|
|
filters.append((dom[0], dom[1], min_date))
|
|
else:
|
|
filters.append(dom)
|
|
return filters
|
|
|
|
@api.model
|
|
def _format_datetime_to_user_tz(self, datetime_without_tz, record_env, tz=None, lang_code=False):
|
|
return format_datetime(record_env, datetime_without_tz, tz=tz, dt_format='short', lang_code=lang_code)
|
|
|
|
def _send_slot(self, employee_ids, start_datetime, end_datetime, include_unassigned=True, message=None):
|
|
if not include_unassigned:
|
|
self = self.filtered(lambda s: s.resource_id)
|
|
if not self:
|
|
return False
|
|
self.ensure_one()
|
|
|
|
employee_with_backend = employee_ids.filtered(lambda e: e.user_id and e.user_id.has_group('planning.group_planning_user'))
|
|
employee_without_backend = employee_ids - employee_with_backend
|
|
planning = False
|
|
if employee_without_backend:
|
|
planning = self.env['planning.planning'].create({
|
|
'start_datetime': start_datetime,
|
|
'end_datetime': end_datetime,
|
|
'include_unassigned': include_unassigned,
|
|
})
|
|
|
|
template = self.env.ref('planning.email_template_slot_single')
|
|
employee_url_map = {**employee_without_backend.sudo()._planning_get_url(planning), **employee_with_backend._slot_get_url(self)}
|
|
|
|
view_context = dict(self._context)
|
|
view_context.update({
|
|
'open_shift_available': not self.employee_id,
|
|
'mail_subject': _('Planning: new open shift available on'),
|
|
})
|
|
|
|
if self.employee_id:
|
|
employee_ids = self.employee_id
|
|
if self.allow_self_unassign:
|
|
if employee_ids.filtered(lambda e: e.user_id and e.user_id.has_group('planning.group_planning_user')):
|
|
unavailable_link = '/planning/unassign/%s/%s' % (self.employee_id.sudo().employee_token, self.id)
|
|
else:
|
|
unavailable_link = '/planning/%s/%s/unassign/%s?message=1' % (planning.access_token, self.employee_id.sudo().employee_token, self.id)
|
|
view_context.update({'unavailable_link': unavailable_link})
|
|
view_context.update({'mail_subject': _('Planning: new shift on')})
|
|
|
|
mails_to_send_ids = []
|
|
for employee in employee_ids.filtered(lambda e: e.work_email):
|
|
if not self.employee_id and employee in employee_with_backend:
|
|
view_context.update({'available_link': '/planning/assign/%s/%s' % (employee.sudo().employee_token, self.id)})
|
|
elif not self.employee_id:
|
|
view_context.update({'available_link': '/planning/%s/%s/assign/%s?message=1' % (planning.access_token, employee.sudo().employee_token, self.id)})
|
|
start_datetime = self._format_datetime_to_user_tz(self.start_datetime, employee.env, tz=employee.tz, lang_code=employee.user_partner_id.lang)
|
|
end_datetime = self._format_datetime_to_user_tz(self.end_datetime, employee.env, tz=employee.tz, lang_code=employee.user_partner_id.lang)
|
|
unassign_deadline = self._format_datetime_to_user_tz(self.unassign_deadline, employee.env, tz=employee.tz, lang_code=employee.user_partner_id.lang)
|
|
# update context to build a link for view in the slot
|
|
view_context.update({
|
|
'link': employee_url_map[employee.id],
|
|
'start_datetime': start_datetime,
|
|
'end_datetime': end_datetime,
|
|
'employee_name': employee.name,
|
|
'work_email': employee.work_email,
|
|
'unassign_deadline': unassign_deadline
|
|
})
|
|
mail_id = template.with_context(view_context).send_mail(self.id, email_layout_xmlid='mail.mail_notification_light')
|
|
mails_to_send_ids.append(mail_id)
|
|
|
|
mails_to_send = self.env['mail.mail'].sudo().browse(mails_to_send_ids)
|
|
if mails_to_send:
|
|
mails_to_send.send()
|
|
|
|
self.write({
|
|
'state': 'published',
|
|
'publication_warning': False,
|
|
})
|
|
|
|
def _send_shift_assigned(self, slot, human_resource):
|
|
email_from = slot.company_id.email or ''
|
|
assignee = slot.resource_id.employee_id
|
|
|
|
template = self.env.ref('planning.email_template_shift_switch_email', raise_if_not_found=False)
|
|
start_datetime = self._format_datetime_to_user_tz(slot.start_datetime, assignee.env, tz=assignee.tz, lang_code=assignee.user_partner_id.lang)
|
|
end_datetime = self._format_datetime_to_user_tz(slot.end_datetime, assignee.env, tz=assignee.tz, lang_code=assignee.user_partner_id.lang)
|
|
template_context = {
|
|
'old_assignee_name': assignee.name,
|
|
'new_assignee_name': human_resource.employee_id.name,
|
|
'start_datetime': start_datetime,
|
|
'end_datetime': end_datetime,
|
|
}
|
|
if template and assignee != human_resource.employee_id:
|
|
template.with_context(**template_context).send_mail(
|
|
slot.id,
|
|
email_values={
|
|
'email_to': assignee.work_email,
|
|
'email_from': email_from,
|
|
},
|
|
email_layout_xmlid='mail.mail_notification_light',
|
|
)
|
|
|
|
# ---------------------------------------------------
|
|
# Slots generation/copy
|
|
# ---------------------------------------------------
|
|
|
|
@api.model
|
|
def _merge_slots_values(self, slots_to_merge, unforecastable_intervals):
|
|
"""
|
|
Return a list of merged slots
|
|
|
|
- `slots_to_merge` is a sorted list of slots
|
|
- `unforecastable_intervals` are the intervals where the employee cannot work
|
|
|
|
Example:
|
|
slots_to_merge = [{
|
|
'start_datetime': '2021-08-01 08:00:00',
|
|
'end_datetime': '2021-08-01 12:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 4.0,
|
|
}, {
|
|
'start_datetime': '2021-08-01 13:00:00',
|
|
'end_datetime': '2021-08-01 17:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 4.0,
|
|
}, {
|
|
'start_datetime': '2021-08-02 08:00:00',
|
|
'end_datetime': '2021-08-02 12:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 4.0,
|
|
}, {
|
|
'start_datetime': '2021-08-03 08:00:00',
|
|
'end_datetime': '2021-08-03 12:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 4.0,
|
|
}, {
|
|
'start_datetime': '2021-08-04 13:00:00',
|
|
'end_datetime': '2021-08-04 17:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 4.0,
|
|
}]
|
|
unforecastable = Intervals([(
|
|
datetime.datetime(2021, 8, 2, 13, 0, 0, tzinfo='UTC')',
|
|
datetime.datetime(2021, 8, 2, 17, 0, 0, tzinfo='UTC')',
|
|
self.env['resource.calendar.attendance'],
|
|
)])
|
|
|
|
result : [{
|
|
'start_datetime': '2021-08-01 08:00:00',
|
|
'end_datetime': '2021-08-02 12:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 12.0,
|
|
}, {
|
|
'start_datetime': '2021-08-03 08:00:00',
|
|
'end_datetime': '2021-08-03 12:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 4.0,
|
|
}, {
|
|
'start_datetime': '2021-08-04 13:00:00',
|
|
'end_datetime': '2021-08-04 17:00:00',
|
|
'employee_id': 1,
|
|
'allocated_hours': 4.0,
|
|
}]
|
|
|
|
:return list of merged slots
|
|
"""
|
|
if not slots_to_merge:
|
|
return slots_to_merge
|
|
# resulting vals_list of the merged slots
|
|
new_slots_vals_list = []
|
|
# accumulator for mergeable slots
|
|
sum_allocated_hours = 0
|
|
to_merge = []
|
|
# invariants for mergeable slots
|
|
common_allocated_percentage = slots_to_merge[0]['allocated_percentage']
|
|
resource_id = slots_to_merge[0].get('resource_id')
|
|
start_datetime = slots_to_merge[0]['start_datetime']
|
|
previous_end_datetime = start_datetime
|
|
for slot in slots_to_merge:
|
|
mergeable = True
|
|
if (not slot['start_datetime']
|
|
or common_allocated_percentage != slot['allocated_percentage']
|
|
or resource_id != slot['resource_id']
|
|
or (slot['start_datetime'] - previous_end_datetime).total_seconds() > 3600 * 24):
|
|
# last condition means the elapsed time between the previous end time and the
|
|
# start datetime of the current slot should not be bigger than 24hours
|
|
# if it's the case, then the slot can not be merged.
|
|
mergeable = False
|
|
if mergeable:
|
|
end_datetime = slot['end_datetime']
|
|
interval = Intervals([(
|
|
pytz.utc.localize(start_datetime),
|
|
pytz.utc.localize(end_datetime),
|
|
self.env['resource.calendar.attendance']
|
|
)])
|
|
if not (interval & unforecastable_intervals):
|
|
sum_allocated_hours += slot['allocated_hours']
|
|
if (end_datetime - start_datetime).total_seconds() < 3600 * 24:
|
|
# If the elapsed time between the first start_datetime and the
|
|
# current end_datetime is not higher than 24hours,
|
|
# slots cannot be merged as it won't be a forecast
|
|
to_merge.append(slot)
|
|
else:
|
|
to_merge = [{
|
|
**slot,
|
|
'start_datetime': start_datetime,
|
|
'allocated_hours': sum_allocated_hours,
|
|
}]
|
|
else:
|
|
mergeable = False
|
|
if not mergeable:
|
|
new_slots_vals_list += to_merge
|
|
to_merge = [slot]
|
|
start_datetime = slot['start_datetime']
|
|
common_allocated_percentage = slot['allocated_percentage']
|
|
resource_id = slot.get('resource_id')
|
|
sum_allocated_hours = slot['allocated_hours']
|
|
previous_end_datetime = slot['end_datetime']
|
|
new_slots_vals_list += to_merge
|
|
return new_slots_vals_list
|
|
|
|
def _get_working_hours_over_period(self, start_utc, end_utc, work_intervals, calendar_intervals):
|
|
start = max(start_utc, pytz.utc.localize(self.start_datetime))
|
|
end = min(end_utc, pytz.utc.localize(self.end_datetime))
|
|
slot_interval = Intervals([(
|
|
start, end, self.env['resource.calendar.attendance']
|
|
)])
|
|
working_intervals = work_intervals[self.resource_id.id] \
|
|
if self.resource_id \
|
|
else calendar_intervals[self.company_id.resource_calendar_id.id]
|
|
return round(sum_intervals(slot_interval & working_intervals), 2)
|
|
|
|
def _get_duration_over_period(self, start_utc, stop_utc, work_intervals, calendar_intervals, has_allocated_hours=True):
|
|
assert start_utc.tzinfo and stop_utc.tzinfo
|
|
self.ensure_one()
|
|
start, stop = start_utc.replace(tzinfo=None), stop_utc.replace(tzinfo=None)
|
|
if has_allocated_hours and self.start_datetime >= start and self.end_datetime <= stop:
|
|
return self.allocated_hours
|
|
# if the slot goes over the gantt period, compute the duration only within
|
|
# the gantt period
|
|
ratio = self.allocated_percentage / 100.0
|
|
working_hours = self._get_working_hours_over_period(start_utc, stop_utc, work_intervals, calendar_intervals)
|
|
return working_hours * ratio
|
|
|
|
def _gantt_progress_bar_resource_id(self, res_ids, start, stop):
|
|
start_naive, stop_naive = start.replace(tzinfo=None), stop.replace(tzinfo=None)
|
|
|
|
resources = self.env['resource.resource'].with_context(active_test=False).search([('id', 'in', res_ids)])
|
|
planning_slots = self.env['planning.slot'].search([
|
|
('resource_id', 'in', res_ids),
|
|
('start_datetime', '<=', stop_naive),
|
|
('end_datetime', '>=', start_naive),
|
|
])
|
|
planned_hours_mapped = defaultdict(float)
|
|
resource_work_intervals, calendar_work_intervals = resources.sudo()._get_valid_work_intervals(start, stop)
|
|
for slot in planning_slots:
|
|
planned_hours_mapped[slot.resource_id.id] += slot._get_duration_over_period(
|
|
start, stop, resource_work_intervals, calendar_work_intervals
|
|
)
|
|
# Compute employee work hours based on its work intervals.
|
|
work_hours = {
|
|
resource_id: sum_intervals(work_intervals)
|
|
for resource_id, work_intervals in resource_work_intervals.items()
|
|
}
|
|
return {
|
|
resource.id: {
|
|
'is_material_resource': resource.resource_type == 'material',
|
|
'resource_color': resource.color,
|
|
'value': planned_hours_mapped[resource.id],
|
|
'max_value': work_hours.get(resource.id, 0.0),
|
|
'employee_id': resource.employee_id.id,
|
|
'employee_model': 'hr.employee' if self.env.user.has_group('hr.group_hr_user') else 'hr.employee.public',
|
|
}
|
|
for resource in resources
|
|
}
|
|
|
|
def _gantt_progress_bar(self, field, res_ids, start, stop):
|
|
if field == 'resource_id':
|
|
return dict(
|
|
self._gantt_progress_bar_resource_id(res_ids, start, stop),
|
|
warning=_("As there is no running contract during this period, this resource is not expected to work a shift. Planned hours:")
|
|
)
|
|
raise NotImplementedError(_("This Progress Bar is not implemented."))
|
|
|
|
@api.model
|
|
def gantt_progress_bar(self, fields, res_ids, date_start_str, date_stop_str):
|
|
if not self.user_has_groups("base.group_user"):
|
|
return {field: {} for field in fields}
|
|
|
|
start_utc, stop_utc = string_to_datetime(date_start_str), string_to_datetime(date_stop_str)
|
|
|
|
progress_bars = {}
|
|
for field in fields:
|
|
progress_bars[field] = self._gantt_progress_bar(field, res_ids[field], start_utc, stop_utc)
|
|
|
|
return progress_bars
|
|
|
|
def _prepare_shift_vals(self):
|
|
""" Generate shift vals"""
|
|
self.ensure_one()
|
|
return {
|
|
'resource_id': False,
|
|
'end_datetime': self.end_datetime,
|
|
'role_id': self.role_id.id,
|
|
'company_id': self.company_id.id,
|
|
'allocated_percentage': self.allocated_percentage,
|
|
'name': self.name,
|
|
'recurrency_id': self.recurrency_id.id,
|
|
'repeat': self.repeat,
|
|
'repeat_interval': self.repeat_interval,
|
|
'repeat_unit': self.repeat_unit,
|
|
'repeat_type': self.repeat_type,
|
|
'repeat_until': self.repeat_until,
|
|
'repeat_number': self.repeat_number,
|
|
'template_id': self.template_id.id,
|
|
}
|
|
|
|
class PlanningRole(models.Model):
|
|
_name = 'planning.role'
|
|
_description = "Planning Role"
|
|
_order = 'sequence'
|
|
_rec_name = 'name'
|
|
|
|
def _get_default_color(self):
|
|
return randint(1, 11)
|
|
|
|
active = fields.Boolean('Active', default=True)
|
|
name = fields.Char('Name', required=True, translate=True)
|
|
color = fields.Integer("Color", default=_get_default_color)
|
|
resource_ids = fields.Many2many('resource.resource', 'resource_resource_planning_role_rel',
|
|
'planning_role_id', 'resource_resource_id', 'Resources')
|
|
sequence = fields.Integer()
|
|
slot_properties_definition = fields.PropertiesDefinition('Planning Slot Properties')
|
|
|
|
@api.returns('self', lambda value: value.id)
|
|
def copy(self, default=None):
|
|
self.ensure_one()
|
|
if default is None:
|
|
default = {}
|
|
if not default.get('name'):
|
|
default['name'] = _('%s (copy)', self.name)
|
|
return super().copy(default=default)
|
|
|
|
|
|
class PlanningPlanning(models.Model):
|
|
_name = 'planning.planning'
|
|
_description = 'Schedule'
|
|
|
|
@api.model
|
|
def _default_access_token(self):
|
|
return str(uuid.uuid4())
|
|
|
|
start_datetime = fields.Datetime("Start Date", required=True)
|
|
end_datetime = fields.Datetime("Stop Date", required=True)
|
|
include_unassigned = fields.Boolean("Includes Open Shifts", default=True)
|
|
access_token = fields.Char("Security Token", default=_default_access_token, required=True, copy=False, readonly=True)
|
|
company_id = fields.Many2one('res.company', string="Company", required=True, default=lambda self: self.env.company,
|
|
help="Company linked to the material resource. Leave empty for the resource to be available in every company.")
|
|
date_start = fields.Date('Date Start', compute='_compute_dates')
|
|
date_end = fields.Date('Date End', compute='_compute_dates')
|
|
allow_self_unassign = fields.Boolean('Let Employee Unassign Themselves', related='company_id.planning_allow_self_unassign')
|
|
self_unassign_days_before = fields.Integer("Days before shift for unassignment", related="company_id.planning_self_unassign_days_before", help="Deadline in days for shift unassignment")
|
|
|
|
@api.depends('start_datetime', 'end_datetime')
|
|
@api.depends_context('uid')
|
|
def _compute_dates(self):
|
|
tz = pytz.timezone(self.env.user.tz or 'UTC')
|
|
for planning in self:
|
|
planning.date_start = pytz.utc.localize(planning.start_datetime).astimezone(tz).replace(tzinfo=None)
|
|
planning.date_end = pytz.utc.localize(planning.end_datetime).astimezone(tz).replace(tzinfo=None)
|
|
|
|
def _compute_display_name(self):
|
|
""" This override is need to have a human readable string in the email light layout header (`message.record_name`) """
|
|
self.display_name = _('Planning')
|
|
|
|
# ----------------------------------------------------
|
|
# Business Methods
|
|
# ----------------------------------------------------
|
|
|
|
def _is_slot_in_planning(self, slot_sudo):
|
|
return (
|
|
self
|
|
and slot_sudo.start_datetime > self.start_datetime
|
|
and slot_sudo.end_datetime < self.end_datetime
|
|
and slot_sudo.state == "published"
|
|
)
|
|
|
|
def _send_planning(self, slots, message=None, employees=False):
|
|
email_from = self.env.user.email or self.env.user.company_id.email or ''
|
|
# extract planning URLs
|
|
employee_url_map = employees.sudo()._planning_get_url(self)
|
|
|
|
# send planning email template with custom domain per employee
|
|
template = self.env.ref('planning.email_template_planning_planning', raise_if_not_found=False)
|
|
template_context = {
|
|
'slot_unassigned': self.include_unassigned,
|
|
'message': message,
|
|
}
|
|
if template:
|
|
# /!\ For security reason, we only given the public employee to render mail template
|
|
for employee in self.env['hr.employee.public'].browse(employees.ids):
|
|
if employee.work_email:
|
|
template_context['employee'] = employee
|
|
template_context['start_datetime'] = self.date_start
|
|
template_context['end_datetime'] = self.date_end
|
|
template_context['planning_url'] = employee_url_map[employee.id]
|
|
template_context['assigned_new_shift'] = bool(slots.filtered(lambda slot: slot.employee_id.id == employee.id))
|
|
template.with_context(**template_context).send_mail(self.id, email_values={'email_to': employee.work_email, 'email_from': email_from}, email_layout_xmlid='mail.mail_notification_light')
|
|
# mark as sent
|
|
slots.write({
|
|
'state': 'published',
|
|
'publication_warning': False
|
|
})
|
|
return True
|