# 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