1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/planning/models/planning.py
2024-12-10 09:04:09 +07:00

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