Files
enterprise-addons/planning/models/planning_slot.py
T

2785 lines
154 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, time, timedelta
from math import modf
from random import shuffle
import pytz
from dateutil.relativedelta import relativedelta
from werkzeug.urls import url_encode
from odoo import api, fields, models
from odoo.exceptions import AccessError, UserError
from odoo.fields import Datetime, Domain
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, SQL, float_utils, format_datetime, get_lang, babel_locale_parse, format_time, format_date
from odoo.tools.date_utils import get_timedelta, sum_intervals, weeknumber, weekstart, weekend
from odoo.tools.intervals import Intervals
def days_span(start_datetime, end_datetime):
if not isinstance(start_datetime, datetime):
raise TypeError
if not isinstance(end_datetime, datetime):
raise TypeError
end = datetime.combine(end_datetime, datetime.min.time())
start = datetime.combine(start_datetime, datetime.min.time())
duration = end - start
return duration.days + 1
class PlanningSlot(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")
resource_roles = fields.Many2many(related='resource_id.role_ids')
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_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 will perform (e.g. Chef, Bartender, Waiter). Create open shifts based on the roles needed for a mission, then assign those shifts to available resources.")
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(default=lambda self: str(uuid.uuid4()), required=True, copy=False, readonly=True, export_string_translation=False)
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', compute='_compute_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", export_string_translation=False)
is_unassign_deadline_passed = fields.Boolean(compute="_compute_is_unassign_deadline_passed", export_string_translation=False)
conflicting_slot_ids = fields.Many2many('planning.slot', compute='_compute_overlap_slot_count', export_string_translation=False)
overlap_slot_count = fields.Integer(compute='_compute_overlap_slot_count', search='_search_overlap_slot_count', export_string_translation=False)
is_past = fields.Boolean('Is This Shift In The Past?', compute='_compute_past_shift', export_string_translation=False)
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', export_string_translation=False)
request_to_switch = fields.Boolean('Has there been a request to switch on this shift slot?', default=False, readonly=True, export_string_translation=False)
# 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,
aggregator="avg")
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', export_string_translation=False)
template_id = fields.Many2one('planning.slot.template', string='Shift Templates', compute='_compute_template_id', readonly=False, store=True)
template_reset = fields.Boolean(export_string_translation=False)
previous_template_id = fields.Many2one('planning.slot.template', export_string_translation=False)
allow_template_creation = fields.Boolean(string='Allow Template Creation', compute='_compute_allow_template_creation', export_string_translation=False)
# 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, export_string_translation=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(compute='_compute_confirm_delete', export_string_translation=False)
is_hatched = fields.Boolean(compute='_compute_is_hatched', export_string_translation=False)
slot_properties = fields.Properties('Properties', definition='role_id.slot_properties_definition', precompute=False)
_check_start_date_lower_end_date = models.Constraint(
'CHECK(end_datetime > start_datetime)',
"The end date of a shift should be after its start date.",
)
_check_allocated_hours_positive = models.Constraint(
'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')
def _compute_confirm_delete(self):
for slot in self:
if slot.recurrency_id and slot.repeat_until:
slot.confirm_delete = fields.Date.to_date(slot.recurrency_id.slot_ids.sorted('end_datetime')[-1].end_datetime) > 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(self.env._(
"Uh-oh! Let's keep things in the right order: the recurrence end date should always "
"come after the shift start date. It's like trying to eat your breakfast before waking up - not possible!"
))
@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('end_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
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('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 != 'in':
return NotImplemented
user_resource_roles = self.env['resource.resource'].search([('user_id', '=', self.env.user.id)]).role_ids
if not user_resource_roles:
return [(1, '=', 1)]
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('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')))
resources = slots.resource_id
flexible_resources = resources.filtered(lambda r: r._is_flexible())
regular_resources = resources - flexible_resources
resource_work_intervals, calendar_work_intervals = regular_resources._get_valid_work_intervals(start_utc, end_utc, calendars=slots.company_id.resource_calendar_id)
flexible_resources_work_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week = flexible_resources._get_flexible_resource_valid_work_intervals(start_utc, end_utc)
resource_work_intervals.update(flexible_resources_work_intervals)
for slot in slots:
if not slot.resource_id and slot.allocation_type == 'planning':
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, flexible_resources_hours_per_day, flexible_resources_hours_per_week)
slot.allocated_percentage = 100 * slot.allocated_hours / work_hours if work_hours else 100
@api.depends(
'start_datetime', 'end_datetime', 'resource_id.calendar_id.flexible_hours',
'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)
open_slots = self.filtered(
lambda s: (s.allocation_type == 'planning' or not s.company_id) and not s.resource_id
)
assigned_slots = self - open_slots
for slot in open_slots:
# for each planning slot, compute the duration
ratio = slot.allocated_percentage / 100.0
slot.allocated_hours = slot._calculate_slot_duration() * ratio
if assigned_slots:
# for forecasted slots, compute the conjunction of the slot resource's work intervals and the slot.
unplanned_assigned_slots = assigned_slots.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_assigned_slots:
slot.allocated_hours = 0.0
planned_assigned_slots = assigned_slots - unplanned_assigned_slots
if not planned_assigned_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(planned_assigned_slots.mapped('start_datetime')))
end_utc = pytz.utc.localize(max(planned_assigned_slots.mapped('end_datetime')))
resources = assigned_slots.resource_id
flexible_resources = resources.filtered(lambda r: r._is_flexible())
regular_resources = resources - flexible_resources
# work intervals per resource are retrieved with a batch
resource_work_intervals, calendar_work_intervals = regular_resources._get_valid_work_intervals(
start_utc, end_utc, calendars=assigned_slots.company_id.resource_calendar_id
)
flexible_resources_work_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week = flexible_resources._get_flexible_resource_valid_work_intervals(start_utc, end_utc)
resource_work_intervals.update(flexible_resources_work_intervals)
slots_by_allocated_hours = defaultdict(lambda: self.env['planning.slot'])
for slot in planned_assigned_slots:
allocated_hour = slot._get_duration_over_period(
pytz.utc.localize(slot.start_datetime), pytz.utc.localize(slot.end_datetime),
resource_work_intervals, calendar_work_intervals,
flexible_resources_hours_per_day, flexible_resources_hours_per_week,
has_allocated_hours=False,
)
slots_by_allocated_hours[allocated_hour] |= slot
for allocated_hours, slots in slots_by_allocated_hours.items():
slots.allocated_hours = allocated_hours
@api.depends('start_datetime', 'end_datetime', 'resource_id')
def _compute_overlap_slot_count(self):
if all(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.resource_id and self.start_datetime and self.end_datetime:
query = """
SELECT ARRAY_AGG(s.id) as conflict_ids
FROM planning_slot s
WHERE s.resource_id = %s
AND s.start_datetime < %s
AND s.end_datetime > %s
AND s.allocated_percentage + %s > 100
"""
self.env.cr.execute(query, (self.resource_id.id, self.end_datetime,
self.start_datetime, self.allocated_percentage))
overlaps = self.env.cr.dictfetchall()
conflict_slot_ids = overlaps[0]['conflict_ids']
if conflict_slot_ids:
if self._origin:
conflict_slot_ids = [slot_id for slot_id in conflict_slot_ids if slot_id != self._origin.id]
self.overlap_slot_count = len(conflict_slot_ids)
self.conflicting_slot_ids = [(6, 0, conflict_slot_ids)]
else:
self.overlap_slot_count = False
self.conflicting_slot_ids = False
else:
self.overlap_slot_count = False
self.conflicting_slot_ids = False
@api.model
def _search_overlap_slot_count(self, operator, value):
if operator == 'in':
return Domain.OR(self._search_overlap_slot_count('=', v) for v in value)
if operator not in ['=', '>'] or not isinstance(value, int) or value != 0:
raise NotImplementedError(self.env._('Operation not supported, you should always compare overlap_slot_count to 0 value with = or > operator.'))
sql = SQL("""(
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 = "in" if operator == ">" else "not in"
return [('id', operator_new, sql)]
@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()
resource = self.resource_id
if not self.start_datetime or not self.end_datetime:
return False
if resource:
start = pytz.utc.localize(self.start_datetime).astimezone(pytz.timezone(resource.tz))
end = pytz.utc.localize(self.end_datetime).astimezone(pytz.timezone(resource.tz))
if resource._is_flexible():
work_intervals, hours_per_day, hours_per_week = self.resource_id._get_flexible_resource_valid_work_intervals(start, end)
return self.resource_id._get_flexible_resource_work_hours(work_intervals[self.resource_id.id], hours_per_day[self.resource_id.id], hours_per_week[self.resource_id.id])
else:
work_intervals, _dummy = resource._get_valid_work_intervals(start, end)
return sum_intervals(work_intervals[resource.id])
return (self.end_datetime - self.start_datetime).total_seconds() / 3600.0
def _get_domain_template_slots(self):
domain = []
roles = self.resource_id.role_ids
if self.role_id:
roles |= self.role_id
if roles:
domain += ['|', ('role_id', 'in', roles.ids), ('role_id', '=', False)]
return domain
@api.depends('role_id', 'employee_id')
def _compute_template_autocomplete_ids(self):
for slot in self:
domain = slot._get_domain_template_slots()
templates = self.env['planning.slot.template'].search(domain, order='start_time', limit=10)
slot.template_autocomplete_ids = templates + slot.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 and self.end_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 in ('start_time', 'end_time'):
h = int(self.template_id[template_field])
m = round(modf(self.template_id[template_field])[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
elif template_field == 'duration_days':
if self.start_datetime and self.end_datetime and \
days_span(self.start_datetime, self.end_datetime) != self.template_id[template_field]:
return True
elif 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 = slot._prepare_template_values()
domain = [(x, '=', values[x]) for x in values]
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]
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})
def _get_non_working_days_bounds(self, start_datetime, end_datetime, resource=False):
resource = resource or self.env.user.employee_id.resource_id
user_tz = pytz.timezone(self.env.user.tz
or (resource.employee_id and resource.employee_id.tz)
or resource.tz
or self.env.context.get('tz')
or self.env.user.company_id.resource_calendar_id.tz
or 'UTC'
)
start_date_in_user_tz = start_datetime.astimezone(user_tz)
offset = start_date_in_user_tz.utcoffset().total_seconds() / 3600
return (
(start_datetime.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + timedelta(hours=8 - offset)),
(end_datetime.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + timedelta(hours=17 - offset))
)
@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.env.context.get('tz')
or self.env.user.company_id.resource_calendar_id.tz
or 'UTC')
# 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
# If start_datetime and end_datetime are None, the resource should follow the company's working calendar and return the work intervals based on the time zone and the company's working calendar.
intervals = []
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())
if not template_id:
# Transform the current column's start/end_datetime to the user's timezone from UTC
# 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(start, end)[False]
intervals = [(date_start, date_stop) for date_start, date_stop, attendance in work_interval]
if not intervals and not self.env.context.get('planning_keep_default_datetime', False):
# 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 self._get_non_working_days_bounds(start, end, resource)
# 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.
# Determine the start and end dates from _work_intervals_batch and convert them to the resource's time zone.
if resource and intervals:
start = pytz.timezone(resource.tz).localize(intervals[0][0].replace(tzinfo=None))
end = pytz.timezone(resource.tz).localize(intervals[-1][-1].replace(tzinfo=None))
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))
h = int(template_id.end_time)
m = round(modf(template_id.end_time)[0] * 60.0)
end = (start + relativedelta(days=(template_id.duration_days - 1), hour=0, minute=0, second=0))
if template_id.duration_days > 1 and resource_id.calendar_id:
end = resource.calendar_id.plan_days(template_id.duration_days, start, compute_leaves=True) or end
end = end.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
# 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]
if not intervals:
return ()
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)
def _compute_allow_self_unassign(self):
allow_self_unassign_planning = self.filtered(lambda p: p.company_id.planning_employee_unavailabilities == 'unassign')
allow_self_unassign_planning.allow_self_unassign = True
(self - allow_self_unassign_planning).allow_self_unassign = False
@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()
# ----------------------------------------------------
# ORM overrides
# ----------------------------------------------------
@api.model
def _read_group_fields_nullify(self):
return []
@api.model
def formatted_read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None) -> list[dict]:
res = super().formatted_read_group(domain, groupby, aggregates, having, offset, limit, order)
for aggregate_nullify in self._read_group_fields_nullify():
if aggregate_nullify not in aggregates:
continue
for row in res:
if row[aggregate_nullify] == 0:
row[aggregate_nullify] = False
return res
@api.model
def default_get(self, fields):
res = super().default_get(fields)
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 and not self.env.context.get('planning_keep_default_datetime', False):
start_datetime = Datetime.to_datetime(res.get('start_datetime')) if res.get('start_datetime') else self._default_start_datetime()
end_datetime = Datetime.to_datetime(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)
if opening_hours:
res['start_datetime'] = opening_hours[0].astimezone(pytz.utc).replace(tzinfo=None)
if 'end_datetime' in fields:
res['end_datetime'] = opening_hours[1].astimezone(pytz.utc).replace(tzinfo=None)
else:
res['start_datetime'], end_datetime = self._get_non_working_days_bounds(start_datetime, end_datetime)
if 'end_datetime' in fields:
res['end_datetime'] = end_datetime
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()._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() # noqa: PLW0642
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] and not (fname == 'resource_id' and slot.resource_type != 'material')
][:4] # limit to 4 labels
name = ' - '.join(name_values)
# 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
if self.env.context.get("multi_create"):
vals_list_updated = []
resource_per_id = {}
user_tz = pytz.timezone(self._get_tz())
min_datetime = fields.Datetime.from_string(vals_list[0]['start_datetime']).astimezone(user_tz)
max_datetime = fields.Datetime.from_string(vals_list[-1]['end_datetime']).astimezone(user_tz)
for vals in vals_list:
if resource_id := vals.get('resource_id'):
resource = resource_per_id.get(resource_id)
if not resource:
resource = Resource.browse(resource_id)
resource_per_id[resource_id] = resource
Resource |= resource
schedule, _ = Resource._get_valid_work_intervals(min_datetime, max_datetime)
for vals in vals_list:
if resource_id := vals.get('resource_id'):
shift_interval = Intervals([(
fields.Datetime.from_string(vals.get('start_datetime')).astimezone(user_tz),
fields.Datetime.from_string(vals.get('end_datetime')).astimezone(user_tz),
self.env['resource.calendar.attendance'],
)])
if shift_interval & schedule[resource_id]:
vals_list_updated.append(vals)
if vals_list_updated:
vals_list = vals_list_updated
return super().create(vals_list)
def create_batch_from_calendar(self, vals_list):
if not len(vals_list):
return
template_id = self.env['planning.slot.template'].browse(vals_list[0]['template_id'])
resources = self.env['resource.resource']
resource_per_id = {}
min_datetime = datetime.strptime(vals_list[0]['start_datetime'], '%Y-%m-%d %H:%M:%S')
max_datetime = datetime.strptime(vals_list[-1]['end_datetime'], '%Y-%m-%d %H:%M:%S')
if template_id.duration_days > 1:
max_datetime = max_datetime + relativedelta(months=2)
for vals in vals_list:
if resource_id := vals.get('resource_id'):
resource = resource_per_id.get(resource_id)
if not resource:
resource = resources.browse(resource_id)
resource_per_id[resource_id] = resource
resources |= resource
user_tz = pytz.timezone(self._get_tz())
schedule, _ = resources._get_valid_work_intervals(min_datetime.astimezone(user_tz),
max_datetime.astimezone(user_tz))
vals_list_updated_slots = []
if template_id.duration_days > 1:
for vals in vals_list:
if resource_id := vals.get('resource_id'):
end_datetime = datetime.strptime(vals['end_datetime'], '%Y-%m-%d %H:%M:%S')
current_end_datetime = end_datetime - relativedelta(days=template_id.duration_days)
current_start_datetime = datetime.strptime(vals['start_datetime'], '%Y-%m-%d %H:%M:%S') - relativedelta(days=1)
working_days_to_assign = template_id.duration_days
while working_days_to_assign > 0 and current_end_datetime < max_datetime:
current_start_datetime += relativedelta(days=1)
current_end_datetime += relativedelta(days=1)
shift_interval = Intervals([(
current_start_datetime.astimezone(user_tz),
current_end_datetime.astimezone(user_tz),
self.env['resource.calendar.attendance'],
)])
if shift_interval & schedule[resource_id]:
working_days_to_assign -= 1
vals['end_datetime'] = current_end_datetime.strftime('%Y-%m-%d %H:%M:%S')
vals_list_updated_slots.append(vals)
else:
vals_list_updated_slots = vals_list
break
else:
for vals in vals_list:
if resource_id := vals.get('resource_id'):
shift_interval = Intervals([(
datetime.strptime(vals['start_datetime'], '%Y-%m-%d %H:%M:%S').astimezone(user_tz),
datetime.strptime(vals['end_datetime'], '%Y-%m-%d %H:%M:%S').astimezone(user_tz),
self.env['resource.calendar.attendance'],
)])
if shift_interval & schedule[resource_id]:
vals_list_updated_slots.append(vals)
else:
vals_list_updated_slots.append(vals)
return self.create(vals_list_updated_slots)
def write(self, vals):
values = vals
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 != values['start_datetime'])
or ('end_datetime' in values and slot.end_datetime != values['end_datetime'])
):
values['request_to_switch'] = False
recurrence_update = values.pop('recurrence_update', 'this')
shifts_to_update = self
# `self` has no recurrence when we just set it, in which case there's no other slot to update
if shifts_to_update.recurrency_id and recurrence_update != 'this':
# Updating "all" or "subsequent" slots is only possible on one record at a time
shifts_to_update.ensure_one()
datetime_keys = values.keys() & {'start_datetime', 'end_datetime'}
if recurrence_update == 'subsequent':
subsequent_slots = self.search([
'&',
('recurrency_id', '=', shifts_to_update.recurrency_id.id),
('start_datetime', '>', shifts_to_update.start_datetime),
])
if datetime_keys:
values["repeat_type"] = self.repeat_type
values["repeat_number"] = 1 + len(subsequent_slots)
(shifts_to_update.recurrency_id.slot_ids - subsequent_slots - shifts_to_update).recurrency_id = False
subsequent_slots.unlink()
else:
shifts_to_update |= subsequent_slots
else:
all_slots = shifts_to_update.recurrency_id.slot_ids.sorted('start_datetime')
if datetime_keys:
first_slot = all_slots[0]
values.update({
datetime_key: fields.Datetime.from_string(values[datetime_key]) - (self[datetime_key] - first_slot[datetime_key])
for datetime_key in datetime_keys
})
values["repeat_type"] = first_slot.repeat_type # this is to ensure that the subsequent slots are recreated
(all_slots - first_slot).unlink()
shifts_to_update = first_slot
else:
shifts_to_update |= all_slots
result = super(PlanningSlot, shifts_to_update).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 shifts_to_update:
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':
final_slot = min(repeat_number, len(recurrence.slot_ids))
recurrency_values['repeat_until'] = recurrence.slot_ids.sorted('end_datetime')[final_slot - 1].end_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
def copy_data(self, default=None):
default = dict(default or {})
vals_list = super().copy_data(default=default)
active_resources = self.env['resource.resource']
planning_split_tool = self.env.context.get('planning_split_tool')
check_resource_active = not ((default and 'resource_id' in default) or planning_split_tool)
if check_resource_active:
active_resources = self.resource_id.filtered('active')
for planning, vals in zip(self, vals_list):
if planning_split_tool:
vals['state'] = planning.state
if check_resource_active and planning.resource_id and planning.resource_id not in active_resources:
vals['resource_id'] = False
return vals_list
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()
return result
def split_pill(self, values):
"""
Split the slot in two parts
1. Copy the pill and modify the start time (second pill)
2. Modify the original pill and modify the end time (first pill)
values expected:
:start_datetime: the start datetime of the second pill
:end_datetime: the end datetime of the first pill
"""
# Important Note: When you change this method logic, change it also in _split_fake_pill
# in the same file, they should be identical
result = self.copy({'start_datetime': values.get('start_datetime')})
self.write({'end_datetime': values.get('end_datetime')})
return result.id
# ----------------------------------------------------
# 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 = Domain('id', 'not in', self.ids)
if recurrence_update == 'all':
domain &= Domain('recurrency_id', 'in', self.recurrency_id.ids)
elif recurrence_update == 'subsequent':
start_date_per_recurrency_id = {}
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
domain &= Domain.OR(
Domain('recurrency_id', '=', recurrency_id) & Domain('start_datetime', '>', start_datetime)
for recurrency_id, start_datetime in start_date_per_recurrency_id.items()
)
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': self.env._('Shifts in Conflict'),
'views': [[False, "gantt"], [False, "list"], [False, "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.has_access('read'):
raise AccessError(self.env._("You don't have the right to assign yourself to shifts."))
if self.resource_id and not self.request_to_switch:
raise UserError(self.env._("You can not assign yourself to an already assigned shift."))
if self.is_past:
if self.request_to_switch:
self.sudo().write({'request_to_switch': False})
raise UserError(self.env._("You cannot assign yourself to a shift in the past."))
if self.company_id in self.env.user.employee_ids.mapped('company_id'):
resource_id = self.env.user.employee_ids.filtered(lambda e: e.company_id == self.company_id)[
0].resource_id.id
elif len(self.env.user.employee_ids) == 1:
resource_id = self.env.user.employee_ids.resource_id.id
else:
raise UserError(self.env._("You cannot assign yourself to a shift belonging to another company."))
return self.sudo().write({'resource_id': resource_id})
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(self.env._("The company does not allow you to unassign yourself from shifts."))
if self.is_unassign_deadline_passed:
raise UserError(self.env._("The deadline for unassignment has passed."))
if self.employee_id not in self.env.user.employee_ids:
raise UserError(self.env._("You can not unassign another employee than yourself."))
if self.is_past:
raise UserError(self.env._("You cannot unassign yourself from a shift in the past."))
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.has_access('read'):
raise AccessError(self.env._("You don't have the right to switch shifts."))
if self.employee_id != self.env.user.employee_id:
raise UserError(self.env._("You cannot request to switch a shift that is assigned to another user."))
if self.is_past:
raise UserError(self.env._("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.has_access('read'):
raise AccessError(self.env._("You don't have the right to cancel a request to switch."))
if self.employee_id != self.env.user.employee_id:
raise UserError(self.env._("You cannot cancel a request to switch made by another user."))
if self.is_past:
raise UserError(self.env._("You cannot cancel a request to switch that is in the past."))
return self.sudo().write({'request_to_switch': False})
def _get_ics_file(self, calendar, employee_tz):
def ics_datetime(idate):
tz_info = employee_tz or self.env.user.tz or 'UTC'
return idate and idate.astimezone(pytz.timezone(tz_info))
for slot in self:
event = calendar.add('vevent')
if not slot.start_datetime or not slot.end_datetime:
raise UserError(self.env._("First you have to specify the date of the invitation."))
event.add('created').value = ics_datetime(fields.Datetime.now())
event.add('dtstart').value = ics_datetime(slot.start_datetime)
event.add('dtend').value = ics_datetime(slot.end_datetime)
event.add('summary').value = slot.display_name
ics_description_data = {
'shift': slot._get_ics_description_data(),
'is_google_url': False,
}
event.add('description').value = self.env['ir.qweb']._render('planning.planning_shift_ics_description', ics_description_data)
return calendar
def _get_ics_description_data(self):
return {
'name': self.name,
'allocated_hours': self.allocated_hours,
'allocated_percentage': self.allocated_percentage,
'role': self.role_id.name,
}
def auto_plan_id(self):
""" Used in the form view to auto plan a single shift.
"""
self.ensure_one()
if not self.with_context(planning_slot_id=self.id).auto_plan_ids([('id', '=', self.id)])['open_shift_assigned']:
return self._get_notification_action("danger", self.env._("There are no resources available for this open shift."))
return None
def _get_open_shifts_resources(self):
# Get all resources that have the role set on those shifts as default role or in their roles.
# 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 self]
resources = self.env['resource.resource'].search(['|', ('default_role_id', 'in', open_shift_role_ids), ('role_ids', 'in', self.role_id.ids)])
# And make two dictionnaries out of it (default roles and roles). We will prioritize default roles.
resource_ids_per_role = defaultdict(list)
resource_ids_per_default_role = defaultdict(list)
for resource in resources:
resource_ids_per_default_role[resource.default_role_id].append(resource.id)
for role in resource.role_ids:
if role != resource.default_role_id:
resource_ids_per_role[role].append(resource.id)
return resources, [resource_ids_per_default_role, resource_ids_per_role]
def _get_resources_dict_values(self, resource_dict):
self.ensure_one()
resource_ids = resource_dict.get(self.role_id, [])
shuffle(resource_ids)
return resource_ids
@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.env.context.get('planning_slot_id') or (
self.env.context.get('default_start_datetime') and self.env.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(
Domain.AND([
view_domain,
[('resource_id', '=', False)],
]),
[],
['id:recordset', 'start_datetime:min', 'end_datetime:max'],
)[0]
if not open_shifts:
return {"open_shift_assigned": []}
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
min_start = min_start.astimezone(user_tz)
max_end = max_end.astimezone(user_tz)
resources, resources_dicts = open_shifts._get_open_shifts_resources()
# Get the schedule of each resource in the period.
flexible_resources = resources.filtered(lambda r: r._is_flexible())
regular_resources = resources - flexible_resources
schedule_intervals_per_resource_id, _dummy = regular_resources._get_valid_work_intervals(min_start, max_end)
locale = babel_locale_parse(get_lang(self.env).code)
# we assume that if the employee has currently a flexible contract, all other contracts are also flexible
flexible_resource_work_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week = flexible_resources._get_flexible_resource_valid_work_intervals(min_start, max_end)
schedule_intervals_per_resource_id.update(flexible_resource_work_intervals)
if flexible_resources:
min_start = weekstart(locale, min_start)
max_end = weekend(locale, 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)
remaining_hours_per_day = deepcopy(flexible_resources_hours_per_day)
remaining_hours_per_week = deepcopy(flexible_resources_hours_per_week)
timeline_and_worked_hours_per_resource_id = self._shift_records_to_timeline_per_resource_id(same_days_shifts, flexible_resources, min_start, max_end, remaining_hours_per_day, remaining_hours_per_week, locale)
# 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 resources_dicts:
resource_ids = shift._get_resources_dict_values(resources_dict)
for resource in self.env['resource.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
is_flex_resource = resource._is_flexible()
if is_flex_resource:
work_hours_per_day = defaultdict(float)
working_hours = resource._get_flexible_resource_work_hours(split_shift_intervals, flexible_resources_hours_per_day[resource.id], flexible_resources_hours_per_week[resource.id], work_hours_per_day)
rate = shift.allocated_hours / working_hours if working_hours > 0.0 else float('inf')
is_overloaded = False
for day, hours in work_hours_per_day.items():
week = weeknumber(locale, day)
hours_to_work = hours * rate
if hours_to_work > remaining_hours_per_day[resource.id].get(day, 0.0) or hours_to_work > remaining_hours_per_week[resource.id].get(week, 0.0):
is_overloaded = True
break
if is_overloaded:
continue
else:
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
shift._compute_allocated_hours()
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)
if is_flex_resource:
resource_work_intervals, resource_hours_per_day, resource_hours_per_week = resource._get_flexible_resource_valid_work_intervals(start_utc, end_utc)
hours_needed_to_plan_by_day = defaultdict(float)
work_hours = resource._get_flexible_resource_work_hours(resource_work_intervals[resource.id], resource_hours_per_day[resource.id], resource_hours_per_week[resource.id], hours_needed_to_plan_by_day)
assert work_hours > 0.0, "it doesn't make sens to have a timeline, then no work hours to plan"
rate = original_allocated_hours / work_hours
for day, hours in hours_needed_to_plan_by_day.items():
remaining_hours_per_day[resource.id][day] -= hours * rate
remaining_hours_per_week[resource.id][weeknumber(locale, day)] -= hours * rate
else:
resource_work_intervals, calendar_work_intervals = shift.resource_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_shift_assigned": 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, flexible_resources, min_start, max_end, remaining_hours_per_day, remaining_hours_per_week, locale):
timeline_and_worked_hours_per_resource_id = defaultdict(list)
resource_by_id = {}
flexible_resource_work_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week = flexible_resources._get_flexible_resource_valid_work_intervals(pytz.utc.localize(min_start), pytz.utc.localize(max_end))
for record in records:
start, end = record['start_datetime'], record['end_datetime']
resource_id = record['resource_id']
allocated_hours = record['allocated_hours']
if resource_id not in flexible_resources.ids:
rate = allocated_hours * 3600 / (
fields.Datetime.from_string(record['end_datetime']) - fields.Datetime.from_string(record['start_datetime'])
).total_seconds() / 3600
else:
record_intervals = Intervals([(pytz.utc.localize(start), pytz.utc.localize(end), set())]) & flexible_resource_work_intervals[resource_id]
work_hours_per_day = defaultdict(float)
if resource_id not in resource_by_id:
resource_by_id[resource_id] = self.env['resource.resource'].browse(resource_id)
work_hours = resource_by_id[resource_id]._get_flexible_resource_work_hours(record_intervals, flexible_resources_hours_per_day[resource_id], flexible_resources_hours_per_week[resource_id], work_hours_per_day)
rate = allocated_hours / work_hours if work_hours > 0.0 else float('inf')
for day, hours in work_hours_per_day.items():
remaining_hours_per_day[resource_id][day] -= rate * hours
remaining_hours_per_week[resource_id][weeknumber(locale, day)] -= rate * hours
timeline_and_worked_hours_per_resource_id[resource_id].extend([
(start, rate), (end, -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, _dummy = 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])
last_instant, last_value = increments[0][0], 0.0
for increment in increments:
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
@api.model
def action_rollback_auto_plan_ids(self, shifts_data):
open_shift_assigned = shifts_data["open_shift_assigned"]
shifts = self.browse(open_shift_assigned)
with self.env.protecting([self._fields['allocated_hours']], shifts):
shifts.write({'resource_id': False})
# ----------------------------------------------------
# Gantt - Calendar view
# ----------------------------------------------------
@api.model
def get_gantt_data(self, domain, groupby, read_specification, limit=None, offset=0, unavailability_fields=None, progress_bar_fields=None, start_date=None, stop_date=None, scale=None):
result = super(PlanningSlot, self.with_context(scale=scale)).get_gantt_data(domain, groupby, read_specification, limit=limit, offset=offset, unavailability_fields=unavailability_fields, progress_bar_fields=progress_bar_fields, start_date=start_date, stop_date=stop_date, scale=scale)
if "resource_id" in groupby:
result["working_periods"] = self._gantt_resource_employees_working_periods(result["groups"], start_date, stop_date)
planning_data = self._get_gantt_planning_data(domain, start_date, stop_date)
result.update({"planning_data": planning_data})
return result
def _get_gantt_planning_data(self, domain, start_date, stop_date):
resources = self.search_fetch(domain).mapped('resource_id')
work_interval_per_resource = defaultdict(list)
flexible_per_resource = {False: False}
avg_hours_per_resource = {False: 0}
if resources:
start_datetime, end_datetime = fields.Datetime.from_string(start_date).replace(tzinfo=pytz.utc), fields.Datetime.from_string(stop_date).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]
for resource_id, resource_work_intervals_per_resource in work_intervals_per_resource.items():
calendar_tz = pytz.timezone(resources.browse(resource_id).calendar_id.tz) if resources.browse(resource_id).calendar_id else pytz.UTC
for resource_work_interval in resource_work_intervals_per_resource:
def fix_tz(dt, target_tz):
# Strip existing tz, localize with target_tz, then convert to UTC
return target_tz.localize(dt.replace(tzinfo=None)).astimezone(pytz.UTC)
start_utc = fix_tz(resource_work_interval[0], calendar_tz)
end_utc = fix_tz(resource_work_interval[1], calendar_tz)
work_interval_per_resource[resource_id].append((start_utc, end_utc))
# Add the flexible status per resource and the average daily work hours per resource calendar to the output
for resource in set(resources):
flexible_per_resource[resource.id] = resource._is_flexible()
if resource._is_fully_flexible():
avg_hours_per_resource[resource.id] = 24 # set to 24 hours if the resource is fully flexible
else:
avg_hours_per_resource[resource.id] = (resource.calendar_id or company_calendar).hours_per_day
return {
"work_intervals": work_interval_per_resource,
"is_flexible": flexible_per_resource,
"avg_hours": avg_hours_per_resource,
}
@api.model
def _gantt_unavailability(self, field, res_ids, start, stop, scale):
if field != "resource_id":
return super()._gantt_unavailability(field, res_ids, start, stop, scale)
resources = self.env['resource.resource'].browse(res_ids).filtered('calendar_id')
leaves_mapping = resources._get_unavailable_intervals(start, stop)
company_leaves = self.env.company.resource_calendar_id._unavailable_intervals(start.replace(tzinfo=pytz.utc), stop.replace(tzinfo=pytz.utc))
cell_dt = timedelta(hours=1) if scale in ['day', 'week'] else timedelta(hours=12)
result = {False: []}
for resource in resources:
# return no unavailability if the resource is fully flexible hours (both material and employee).
if ((resource.id not in res_ids)
or (resource and resource._is_fully_flexible())
or (resource.id not in leaves_mapping and resource and resource._is_flexible())):
continue
calendar = leaves_mapping.get(resource.id, company_leaves)
# 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)
result[resource.id] = [{'start': interval[0], 'stop': interval[1]} for interval in notable_intervals]
return result
@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.exists().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.has_access('write'):
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.env.context)
context['force_email'] = True
context['form_view_ref'] = 'planning.hr_employee_view_form_email'
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 = Domain('company_id', '=', self.company_id.id) & Domain('work_email', '!=', False)
if self.role_id:
domain &= Domain('planning_role_ids', '=', False) | Domain('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 = self.env._('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 = self.env._('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 = self.env._("Shift sent")
return self._get_notification_action('success', message)
def action_save_template(self):
""" Used to save template of a shift."""
self.ensure_one()
if self.allow_template_creation:
self.template_creation = True
def action_unpublish(self):
if not self.env.user.has_group('planning.group_planning_manager'):
raise AccessError(self.env._('You are not allowed to reset shifts to draft.'))
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 = self.env._('Shifts reset to draft')
else:
notif_type = "warning"
message = self.env._('There are no shifts to reset to draft.')
return self._get_notification_action(notif_type, message)
# ----------------------------------------------------
# Print planning
# ----------------------------------------------------
def _print_planning_get_fields_to_copy(self):
return ['employee_id', 'company_id', 'allocated_percentage', 'allocated_hours', 'resource_id', 'start_datetime', 'end_datetime', 'role_id']
def _print_planning_get_slot_title(self, slot_start, slot_end, tz_info, group_by):
def print_planning_format_time(date, tz_info):
return format_time(self.env, date.time(), tz_info, 'short')
allocated_hours_formatted = ""
if self.allocated_percentage != 100:
(unitary_part, decimal_part) = float_utils.float_split_str(
self.allocated_hours,
precision_digits=2,
)
allocated_hours_formatted = f" ({unitary_part}h{decimal_part if decimal_part != '00' else ''})"
name_get = ""
if group_by == "role_id":
if self.resource_id:
name_get = f" {self.resource_id.name}"
elif group_by == "resource_id":
if self.role_id:
name_get = f" {self.role_id.name}"
else:
name_get = f" {self.resource_id.name}" if self.resource_id else ""
if self.role_id:
if name_get:
name_get += f" - {self.role_id.name}"
else:
name_get = f" {self.role_id.name}"
return f"{print_planning_format_time(slot_start, tz_info)} – {print_planning_format_time(slot_end, tz_info)}{allocated_hours_formatted}{name_get}"
@api.model
def action_print_plannings(self, date_start, date_end, group_bys, domain):
def print_planning_split_fake_pill(shift, values):
shift.ensure_one()
assert 'start_datetime' in values and 'end_datetime' in values and not shift._origin
record = print_planning_create_fake_pill(shift, {'start_datetime': values.get('start_datetime').astimezone(pytz.utc).replace(tzinfo=None)})
shift.update({'end_datetime': values.get('end_datetime').astimezone(pytz.utc).replace(tzinfo=None)})
return record
def print_planning_create_fake_pill(shift, vals=None):
record = self.env['planning.slot'].new({
**shift._read_format(shift._print_planning_get_fields_to_copy())[0],
**(vals or {}),
})
# copy method called inside the original split_pill method recomputes the allocated_hours again after the split (dates changed)
record._compute_allocated_hours()
return record
def print_planning_get_fake_pill_datetime(datetime_per_resource_per_day, resource_id, day, tz_info, default):
if day in datetime_per_resource_per_day[resource_id]:
return datetime_per_resource_per_day[resource_id][day].astimezone(tz_info)
return tz_info.localize(datetime.combine(day, default))
def print_planning_get_fake_pill_start_datetime(start_datetime_per_resource_per_day, resource_id, day, tz_info):
return print_planning_get_fake_pill_datetime(start_datetime_per_resource_per_day, resource_id, day, tz_info, time.min)
def print_planning_get_fake_pill_end_datetime(end_datetime_per_resource_per_day, resource_id, day, tz_info):
return print_planning_get_fake_pill_datetime(end_datetime_per_resource_per_day, resource_id, day, tz_info, time.max)
def print_planning_add_slot(shift, tz_info, group_by_slots_per_day_per_week, group, weeks, group_by):
if float_utils.float_is_zero(shift.allocated_hours, precision_digits=2):
return
slot_start = shift.start_datetime.astimezone(tz_info)
slot_end = shift.end_datetime.astimezone(tz_info)
date = format_date(self.env, slot_start)
for index, week, _dummy in weeks:
if date in week:
group_by_slots_per_day_per_week[index][group][date].append({
"title": shift._print_planning_get_slot_title(slot_start, slot_end, tz_info, group_by),
"style": f"background-color: {shift.role_id._get_light_color(0.5, not shift.resource_id)};"
})
return
def print_planning_update_datetime(_datetime, resource_id, datetime_per_resource_per_day, func):
day = _datetime.date()
if day in datetime_per_resource_per_day[resource_id]:
datetime_per_resource_per_day[resource_id][day] = func(datetime_per_resource_per_day[resource_id][day], _datetime)
else:
datetime_per_resource_per_day[resource_id][day] = _datetime
def print_planning_flexible_resources_work_intervals(start, end, resources):
assert all(resource._is_flexible() and not resource._is_fully_flexible() for resource in resources)
return {resource.id: Intervals([(start, end, self.env['resource.calendar.attendance'])]) for resource in resources}
group_bys = [g for g in (group_bys or []) if self.fields_get(g)[g.split(':')[0]]['type'] not in {"datetime", "date"}]
if not group_bys:
group_bys = ['resource_id']
group_by = group_bys[-1]
date_start = datetime.strptime(date_start, DEFAULT_SERVER_DATETIME_FORMAT)
date_end = datetime.strptime(date_end, DEFAULT_SERVER_DATETIME_FORMAT)
if date_end < date_start:
date_start, date_end = date_end, date_start
group_by_slots = self.env['planning.slot']._read_group(
domain,
[group_by],
['id:recordset'],
)
if not group_by_slots:
return False
tz_info = pytz.timezone(self._get_tz())
day_start_in_user_tz = date_start.astimezone(tz_info).date()
day_end_in_user_tz = date_end.astimezone(tz_info).date()
days_count = (day_end_in_user_tz - day_start_in_user_tz).days + 1
days = [day_start_in_user_tz + timedelta(days=i) for i in range(days_count)]
group_by_slots_per_day_per_week = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
unassigned = self.env._("Open Shifts") if group_by == 'resource_id' else self.env._("Undefined %(group)s", group=self.fields_get(group_by)[group_by]["string"])
group_by_name_by_id = {}
non_flexible_resources_ids = set()
flexible_resources_ids = set()
for g, slots in group_by_slots:
group_by_name_by_id[g.id if g else False] = g.display_name if g else unassigned
for slot in slots:
if slot.resource_id:
(flexible_resources_ids if not slot.resource_id._is_fully_flexible() and slot.resource_id._is_flexible() else non_flexible_resources_ids).add(slot.resource_id.id)
non_flexible_resources = self.env['resource.resource'].browse(non_flexible_resources_ids)
flexible_resources = self.env['resource.resource'].browse(flexible_resources_ids)
start_utc, end_utc = pytz.utc.localize(date_start), pytz.utc.localize(date_end)
resources_work_intervals, _dummy = non_flexible_resources._get_valid_work_intervals(
start_utc, end_utc
)
resources_work_intervals.update(print_planning_flexible_resources_work_intervals(start_utc, end_utc, flexible_resources))
weeks = [
(
int(i / 7),
[format_date(self.env, day) for day in days[i:i + 7]],
self.env._(
"Week from %(start_date)s to %(end_date)s",
start_date=format_date(self.env, days[i:i + 7][0]),
end_date=format_date(self.env, days[i:i + 7][-1])
),
)
for i in range(0, len(days), 7)
]
start_datetime_per_resource_per_day, end_datetime_per_resource_per_day = defaultdict(dict), defaultdict(dict)
for resource_id, intervals in resources_work_intervals.items():
for start_datetime, end_datetime, _dummy in intervals._items:
print_planning_update_datetime(start_datetime, resource_id, start_datetime_per_resource_per_day, min)
print_planning_update_datetime(end_datetime, resource_id, end_datetime_per_resource_per_day, max)
for group_id, slots in sorted(group_by_slots, key=lambda x: x[0].display_name if x[0] else ''):
group = group_id.id if group_id else False
for slot in slots.sorted(key=lambda s: (s.start_datetime, s.id)):
resource_id = slot.resource_id
slot_start = slot.start_datetime.astimezone(tz_info)
slot_end = slot.end_datetime.astimezone(tz_info)
slot_start_day = slot_start.date()
slot_end_day = slot_end.date()
# one day slot
if slot_start_day == slot_end_day:
print_planning_add_slot(slot, tz_info, group_by_slots_per_day_per_week, group, weeks, group_by)
else:
# slot on more than one day, we do a fake copy using new without storing in db and
# we use split_pill function to cut the slot in many days
fake_slot = print_planning_create_fake_pill(slot)
# remove the part of the pill happening in the previous weeks
first_day = days[0]
if slot_start_day < first_day:
fake_slot = print_planning_split_fake_pill(fake_slot, {
"start_datetime": print_planning_get_fake_pill_start_datetime(start_datetime_per_resource_per_day, resource_id.id, first_day, tz_info),
"end_datetime": print_planning_get_fake_pill_end_datetime(end_datetime_per_resource_per_day, resource_id.id, first_day + timedelta(days=-1), tz_info)
})
# remove the part of the pill happening in the next weeks
last_day = days[-1]
if slot_end_day > last_day:
print_planning_split_fake_pill(fake_slot, {
"start_datetime": print_planning_get_fake_pill_start_datetime(start_datetime_per_resource_per_day, resource_id.id, last_day + timedelta(days=1), tz_info),
"end_datetime": print_planning_get_fake_pill_end_datetime(end_datetime_per_resource_per_day, resource_id.id, last_day, tz_info),
})
# split the pill in different pills, pill per day
current_day = fake_slot.start_datetime.date()
last_day = fake_slot.end_datetime.date()
while current_day < last_day:
split_slot = print_planning_split_fake_pill(fake_slot, {
"start_datetime": print_planning_get_fake_pill_start_datetime(start_datetime_per_resource_per_day, resource_id.id, current_day + timedelta(days=1), tz_info),
"end_datetime": print_planning_get_fake_pill_end_datetime(end_datetime_per_resource_per_day, resource_id.id, current_day, tz_info),
})
print_planning_add_slot(fake_slot, tz_info, group_by_slots_per_day_per_week, group, weeks, group_by)
fake_slot = split_slot
current_day += timedelta(days=1)
print_planning_add_slot(fake_slot, tz_info, group_by_slots_per_day_per_week, group, weeks, group_by)
# groups were inserted in the dict in a specific order (false, then sorted non DESC order)
# but in Qweb, the order was lost
# so we transformed it to a dict {week: (group, {day: slots})}
group_by_slots_per_day_per_week_formatted = {
week: [(group, val) for group, val in data.items()]
for week, data in group_by_slots_per_day_per_week.items()
}
return self.env.ref('planning.report_planning_slot').with_context(discard_logo_check=True, allow_printing_planning_report=True).report_action(None,
data={
'group_by_slots_per_day_per_week': group_by_slots_per_day_per_week_formatted,
'weeks': weeks,
'group_by_name_by_id': group_by_name_by_id,
}
)
# ----------------------------------------------------
# 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
if self.resource_id:
start = pytz.utc.localize(self.start_datetime).astimezone(pytz.timezone(self.resource_id.tz))
end = pytz.utc.localize(self.end_datetime).astimezone(pytz.timezone(self.resource_id.tz))
if self.resource_id._is_flexible():
work_intervals, hours_per_day, hours_per_week = self.resource_id._get_flexible_resource_valid_work_intervals(start, end)
slot_duration = self.resource_id._get_flexible_resource_work_hours(work_intervals[self.resource_id.id], hours_per_day[self.resource_id.id], hours_per_week[self.resource_id.id])
else:
work_intervals, _dummy = self.resource_id._get_valid_work_intervals(start, end)
slot_duration = sum_intervals(work_intervals[self.resource_id.id])
else:
slot_duration = period.total_seconds() / 3600
# if the resource is an employee, the hours_per_day of its calendar is used as max_hours_per_day.
if self.employee_id:
max_hours_per_day = self.employee_id.resource_calendar_id.hours_per_day
# For other resource and open shifts, we refer to the max of the company's default contract
else:
max_hours_per_day = self.company_id.resource_calendar_id.hours_per_day
max_duration = (period.days + (1 if period.seconds else 0)) * max_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 _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()
split_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)
period = end_inter - start_inter
slot_duration = period.total_seconds() / 3600
# If the slot is assigned to a flexible resource, the allocated hours are computed based on the hours_per_day of the resource calendar
# However if it's a fully flexible resource, the allocated hours are the same as the slot duration.
if (
not unassign
and self.resource_id._is_flexible() and
not self.resource_id._is_fully_flexible()
):
# refer to the hours_per_day if the slot duration exceeds it.
max_hours_per_day = self.employee_id.resource_calendar_id.hours_per_day
max_duration = (period.days + (1 if period.seconds else 0)) * max_hours_per_day
slot_duration = min(slot_duration, max_duration)
new_slot_vals['allocated_hours'] = float_utils.float_round(
slot_duration * (self.allocated_percentage / 100.0),
precision_digits=2
)
if not was_updated:
return split_slot_values
if unassign:
new_slot_vals['resource_id'] = False
split_slot_values.append(new_slot_vals)
return split_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().filtered(lambda resource: not resource._is_flexible())._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 empty calendar (fully flexible resource), we do not need to retrieve the attendances nor the leaves.
if not calendar:
continue
# For each calendar, retrieves the work intervals of every resource
attendances = {
resource_id: Intervals(work_interval._items)
for resource_id, work_interval in calendar._attendance_intervals_batch(
start_dt_delta_utc,
end_dt_delta_utc,
resources=resources
).items()
}
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 slot.allocation_type == 'forecast' and (not slot.resource_id or slot.resource_id._is_fully_flexible()):
new_slot_values.append(values)
continue
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.
# For flexible hours, ignore the resource work interval and return the whole interval of slot as valid.
if slot.resource_id and slot.resource_id._is_flexible():
attendance_interval_resource = interval
attendance_interval_company = interval
else:
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]
unavailable_interval_resource = unavailable_interval_company if not slot.resource_id else (interval & unavailable_intervals_per_resource[slot.resource_id])
if not 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) & (attendance_interval_company - 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', 'end_time': 'end_datetime', 'duration_days': 'working_days_count'}
def _get_tz(self):
return (self.env.user.tz
or self.employee_id.tz
or self.resource_id.tz
or self.env.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)
# get days span from slot
duration_days = days_span(start_datetime, end_datetime)
return {
'start_time': start_datetime.hour + start_datetime.minute / 60.0,
'end_time': end_datetime.hour + end_datetime.minute / 60.0,
'duration_days': duration_days,
'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):
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)])
if self.env.context.get('planning_expand_resource') and ('start_datetime', '<') in dom_tuples and ('end_datetime', '>') in dom_tuples:
# Search on the roles and resources
search_on_role_domain = Domain.TRUE
search_on_ressource_domain = Domain.TRUE
if ('role_id', '=') in dom_tuples or ('role_id', 'ilike') in dom_tuples or ('role_id', 'in') in dom_tuples:
role_search_domain = Domain(self._expand_domain_m2o_groupby(domain, 'role_id'))
role_ids = self.env["planning.role"].search(role_search_domain).ids
search_on_role_domain = Domain('role_ids', 'in', role_ids)
if ('resource_id', '=') in dom_tuples or ('resource_id', 'ilike') in dom_tuples or ('resource_id', 'in') in dom_tuples:
search_on_ressource_domain = Domain(self._expand_domain_m2o_groupby(domain, 'resource_id'))
# Search on the slots
filters = self._expand_domain_dates(domain)
resources = self.env['planning.slot'].search(filters).mapped('resource_id')
search_on_expanded_dates = Domain('id', 'in', resources.ids)
# Merge the search domains
if search_on_role_domain or search_on_ressource_domain:
search_domain = search_on_role_domain & search_on_ressource_domain
return self.env["resource.resource"].search(search_domain | search_on_expanded_dates)
return self.env["resource.resource"].search(search_on_expanded_dates)
return resources
def _read_group_role_id(self, roles, domain):
dom_tuples = [(dom[0], dom[1]) for dom in domain if isinstance(dom, list) and len(dom) == 3]
if self.env.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)
filters = Domain.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_domains = []
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_domains.append([('id', dom[1], dom[2])])
elif dom[1] == 'ilike':
rec_name = self.env[field.comodel_name]._rec_name
filter_domains.append([(rec_name, dom[1], dom[2])])
return Domain.OR(filter_domains) if filter_domains else Domain.TRUE
def _expand_domain_dates(self, domain):
delta = get_timedelta(1, self.env.context.get("scale", "week"))
def update_start_end_dates(cond):
if cond.field_expr == 'start_datetime' and cond.operator[0] == '<':
value = cond.value or datetime.now()
value += delta
elif cond.field_expr == 'end_datetime' and cond.operator[0] == '>':
value = cond.value or datetime.now()
value -= delta
else:
return cond
return Domain(cond.field_expr, cond.operator, value)
return Domain(domain).optimize_full(self).map_conditions(update_start_end_dates)
@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) # noqa: PLW0642
if not self:
return False
self.ensure_one()
employee_with_backend = employee_ids.filtered(lambda e: e.user_id)
employee_without_backend = employee_ids - employee_with_backend
planning = False
employee_url_map = {}
if employee_without_backend:
planning = self.env['planning.planning'].create({
'start_datetime': start_datetime,
'end_datetime': end_datetime,
'include_unassigned': include_unassigned,
})
employee_url_map = employee_without_backend.sudo()._planning_get_url(
planning.date_start, planning.date_end, planning.access_token)
template = self.env.ref('planning.email_template_slot_single')
employee_url_map.update(employee_with_backend._planning_get_url(start_datetime.date(), end_datetime.date()))
cal_url = self._get_slot_resource_urls()
view_context = dict(self.env.context)
view_context.update({
'open_shift_available': not self.employee_id,
'mail_subject': self.env._('Planning: new open shift available on'),
'google_url': cal_url['google_url'],
'iCal_url': cal_url['iCal'],
})
if self.employee_id:
employee_ids = self.employee_id
if self.allow_self_unassign and not self.is_unassign_deadline_passed:
if employee_ids.filtered(lambda e: e.user_id):
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': self.env._('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 and not self.is_past:
view_context.update({'available_link': '/planning/assign/%s/%s' % (employee.sudo().employee_token, self.id)})
elif not self.employee_id and not self.is_past:
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)
allocated_hours = timedelta(hours=self.allocated_hours).total_seconds()
formatted_allocated_hours = "%d:%02d" % (allocated_hours // 3600, round(allocated_hours % 3600 / 60))
allocated_percentage = float_utils.float_repr(self.allocated_percentage, precision_digits=0)
lang = employee.user_partner_id.lang or employee.work_contact_id.lang or self.env.context.get('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,
'allocated_hours': formatted_allocated_hours,
'allocated_percentage': allocated_percentage,
'unassign_deadline': unassign_deadline,
'lang': lang,
})
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,
})
return None
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)
allocated_hours = float_utils.float_repr(self.allocated_hours, precision_digits=2)
allocated_percentage = float_utils.float_repr(self.allocated_percentage, precision_digits=0)
template_context = {
'old_assignee_name': assignee.name,
'new_assignee_name': human_resource.employee_id.name,
'start_datetime': start_datetime,
'end_datetime': end_datetime,
'allocated_hours': allocated_hours,
'allocated_percentage': allocated_percentage,
}
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 = None
# 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']
to_merge = {
**slot,
'start_datetime': start_datetime,
'allocated_hours': sum_allocated_hours,
}
else:
mergeable = False
if not mergeable:
if to_merge:
new_slots_vals_list.append(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']
if to_merge:
new_slots_vals_list.append(to_merge)
return new_slots_vals_list
def _get_working_hours_over_period(self, start_utc, end_utc, work_intervals, calendar_intervals, flexible_resources_hours_per_day=None, flexible_resources_hours_per_week=None):
"""
Compute the total work hours of the slot based on its work intervals or its working calendar.
The following are the different cases:
1) If the assigned resource has a flexible contract, its working hours is computed via _get_flexible_resource_work_hours
that takes into account contract, timeoff, max hours per day and per week
3) If the slot is an open shift, take the `hours_per_day` of the company's calendar to calculate the working hours.
this allows the creation of open slots outside the company's calendar attendance (such as weekends).
4) If the resource is assigned and has fixed working hours, compute the work hours based on its work 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']
)])
if self.resource_id and self.resource_id._is_flexible():
assert flexible_resources_hours_per_day is not None and flexible_resources_hours_per_week is not None
return self.resource_id._get_flexible_resource_work_hours(slot_interval & work_intervals[self.resource_id.id], flexible_resources_hours_per_day.get(self.resource_id.id, 0.0), flexible_resources_hours_per_week.get(self.resource_id.id, 0.0))
period = self.end_datetime - self.start_datetime
slot_duration = period.total_seconds() / 3600
# For open shift, take the `hours_per_day` of the company's default calendar
if not self.resource_id and self.allocation_type == 'forecast':
max_hours_per_day = self.company_id.resource_calendar_id.hours_per_day
max_duration = (period.days + (1 if period.seconds else 0)) * max_hours_per_day
return round(min(slot_duration, max_duration), 2)
# Resource with fixed working hours, else we refer to the company's calendar
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_slot_resource_urls(self):
def get_url_dt(dt):
return dt.astimezone(pytz.timezone(self._get_tz())).strftime('%Y%m%dT%H%M%S')
ics_description_data = {
'shift': self._get_ics_description_data(),
'is_google_url': True,
}
return {
'google_url': "https://www.google.com/calendar/render?" + url_encode({
'action': 'TEMPLATE',
'text': self.display_name or self.env._('New Shift'), # Event title
'dates': f'{get_url_dt(self.start_datetime)}/{get_url_dt(self.end_datetime)}', # Event start and end date/time
'ctz': self._get_tz(),
'details': self.env['ir.qweb']._render('planning.planning_shift_ics_description', ics_description_data),
}),
'iCal': f'/slot/{self.access_token}.ics',
}
def _get_duration_over_period(self, start_utc, stop_utc, work_intervals, calendar_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week, 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, flexible_resources_hours_per_day, flexible_resources_hours_per_week)
return working_hours * ratio
def _get_employee_work_hours_within_interval(self, resource, work_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week):
"""
Compute the total work hours of the employee based on its work intervals or its calendar flexible hours.
:param work_intervals: a dictionary {work_entry_id: hours_1, work_entry_2: hours_2}
:return: the number of work hours
This covers 2 user cases:
1) If the employee has a flexible contract, its working hours is computed via _get_flexible_resource_work_hours
that takes into account contract, timeoff, max hours per day and per week
2) if the employee has a fixed working hours, we compute the work hours based on its work intervals.
"""
if resource._is_flexible():
return resource._get_flexible_resource_work_hours(work_intervals, flexible_resources_hours_per_day[resource.id], flexible_resources_hours_per_week[resource.id])
return sum_intervals(work_intervals)
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),
])
flexible_resources = resources.filtered(lambda r: r._is_flexible())
regular_resources = resources - flexible_resources
planned_hours_mapped = defaultdict(float)
resource_work_intervals, calendar_work_intervals = regular_resources.sudo()._get_valid_work_intervals(start, stop)
flexible_resource_work_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week = flexible_resources._get_flexible_resource_valid_work_intervals(start, stop)
resource_work_intervals.update(flexible_resource_work_intervals)
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, flexible_resources_hours_per_day, flexible_resources_hours_per_week
)
# Compute employee work hours based on its work intervals or flexible hours.
work_hours = {}
for resource_id, work_intervals in resource_work_intervals.items():
resource = resources.browse(resource_id)
work_hours[resource_id] = self._get_employee_work_hours_within_interval(resource, work_intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week)
return {
resource.id: {
'is_material_resource': resource.resource_type == 'material',
'resource_color': resource.color,
'display_popover_material_resource': len(resource.role_ids) > 1,
'value': planned_hours_mapped[resource.id],
'max_value': work_hours.get(resource.id, 0.0),
'employee_id': resource.employee_id.id,
'is_flexible_hours': resource._is_flexible(),
'is_fully_flexible_hours': resource._is_fully_flexible(),
}
for resource in resources
}
def _gantt_progress_bar(self, field, res_ids, start, stop):
if not self.env.user._is_internal():
return {}
if field == 'resource_id':
start, stop = pytz.utc.localize(start), pytz.utc.localize(stop)
return dict(
self._gantt_progress_bar_resource_id(res_ids, start, stop),
warning=self.env._("This employee is not expected to work during this period, either because they do not have a current contract or because they are on leave.")
)
raise NotImplementedError(self.env._("This Progress Bar is not implemented."))
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,
}
def undo_split_shift(self, start_datetime, end_datetime, resource_id):
if len(self) != 2:
raise ValueError(self.env._("This method must take two slots in argument."))
initial_shift, copied_shift = self
if not (initial_shift.exists() and copied_shift.exists()):
return False
initial_shift.start_datetime = start_datetime
initial_shift.end_datetime = end_datetime
initial_shift.resource_id = resource_id
copied_shift.unlink()
return True
@api.model
def _gantt_resource_employees_working_periods(self, groups, start_date, stop_date):
if not self.env.user.has_group('planning.group_planning_manager'):
return {}
resource_ids = {group["resource_id"][0] for group in groups if group["resource_id"]}
employee_ids = set()
employee_id_to_ressource_id = {}
working_periods = {}
for resource in self.env["resource.resource"].browse(resource_ids):
if not resource.employee_id:
continue
resource_id = resource.id
employee_id = resource.employee_id.id
employee_ids.add(employee_id)
employee_id_to_ressource_id[employee_id] = resource_id
working_periods[resource_id] = []
if employee_ids:
start, stop = fields.Datetime.from_string(start_date), fields.Datetime.from_string(stop_date)
employees_sudo = self.env["hr.employee"].sudo().browse(employee_ids)
employees_with_contract = dict(
self.env["hr.version"].sudo()._read_group(
domain=[
("employee_id", "in", employees_sudo.ids),
('contract_date_start', '!=', False),
],
groupby=["employee_id"],
aggregates=["__count"],
)
)
contracts = employees_sudo._get_versions_with_contract_overlap_with_period(start.date(), stop.date())
employees_with_contract_in_current_scale = []
for contract in contracts:
employee_id = contract.employee_id.id
end_datetime = contract.contract_date_end and contract.contract_date_end + relativedelta(hour=23, minute=59, second=59)
if end_datetime:
user_tz = pytz.timezone(self.env.user.tz or self.env.context.get('tz') or 'UTC')
end_datetime = user_tz.localize(end_datetime).astimezone(pytz.utc).replace(tzinfo=None)
end_datetime = fields.Datetime.to_string(end_datetime)
employees_with_contract_in_current_scale.append(employee_id)
working_periods[employee_id_to_ressource_id[employee_id]].append({
"start": fields.Datetime.to_string(contract.contract_date_start),
"end": end_datetime,
})
for employee in employees_sudo - self.env["hr.employee"].browse(employees_with_contract_in_current_scale):
if employees_with_contract.get(employee):
continue
working_periods[employee_id_to_ressource_id[employee.id]].append({
"start": start_date,
"end": stop_date,
})
return working_periods