# Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import defaultdict from datetime import datetime, time, timedelta from functools import partial from itertools import chain from typing import NamedTuple from dateutil.relativedelta import relativedelta from dateutil.rrule import DAILY, rrule from pytz import timezone, utc from odoo import api, fields, models from odoo.exceptions import ValidationError from odoo.fields import Command, Domain from odoo.tools import date_utils, float_compare, ormcache from odoo.tools.date_utils import float_to_time, localized, to_timezone from odoo.tools.float_utils import float_round from odoo.tools.intervals import Intervals from odoo.addons.base.models.res_partner import _tz_get class DummyAttendance(NamedTuple): hour_from: float hour_to: float dayofweek: str day_period: str | None week_type: str | None class ResourceCalendar(models.Model): """ Calendar model for a resource. It has - attendance_ids: list of resource.calendar.attendance that are a working interval in a given weekday. - leave_ids: list of leaves linked to this calendar. A leave can be general or linked to a specific resource, depending on its resource_id. All methods in this class use intervals. An interval is a tuple holding (begin_datetime, end_datetime). A list of intervals is therefore a list of tuples, holding several intervals of work or leaves. """ _name = 'resource.calendar' _description = "Resource Working Time" @api.model def default_get(self, fields): res = super().default_get(fields) if not res.get('name') and res.get('company_id'): res['name'] = self.env._('Working Hours of %s', self.env['res.company'].browse(res['company_id']).name) if 'attendance_ids' in fields and not res.get('attendance_ids'): company_id = res.get('company_id', self.env.company.id) company = self.env['res.company'].browse(company_id) res["attendance_ids"] = self._get_default_attendance_ids(company) res["two_weeks_calendar"] = company.resource_calendar_id.two_weeks_calendar if 'full_time_required_hours' in fields and not res.get('full_time_required_hours'): company_id = res.get('company_id', self.env.company.id) company = self.env['res.company'].browse(company_id) res['full_time_required_hours'] = company.resource_calendar_id.full_time_required_hours return res name = fields.Char(required=True) active = fields.Boolean("Active", default=True, help="If the active field is set to false, it will allow you to hide the Working Time without removing it.") attendance_ids = fields.One2many( 'resource.calendar.attendance', 'calendar_id', 'Working Time', compute='_compute_attendance_ids', store=True, readonly=False, copy=True) attendance_ids_1st_week = fields.One2many('resource.calendar.attendance', 'calendar_id', 'Working Time 1st Week', compute="_compute_two_weeks_attendance", inverse="_inverse_two_weeks_calendar") attendance_ids_2nd_week = fields.One2many('resource.calendar.attendance', 'calendar_id', 'Working Time 2nd Week', compute="_compute_two_weeks_attendance", inverse="_inverse_two_weeks_calendar") company_id = fields.Many2one( 'res.company', 'Company', domain=lambda self: [('id', 'in', self.env.companies.ids)], default=lambda self: self.env.company, index='btree_not_null') leave_ids = fields.One2many( 'resource.calendar.leaves', 'calendar_id', 'Time Off') schedule_type = fields.Selection( [ ('flexible', 'Flexible'), ('fully_fixed', 'Fully Fixed'), ], string='Schedule Type', required=True, default='fully_fixed', help="Choose which level of definition you want to define on your Schedule\n" "- Flexible : Define an amount of hours to work on the week.\n" "- Fully Fixed : define the days, periods and the start & end time for each period of the day", ) duration_based = fields.Boolean("Attendance based on duration", help="The hours will be centered around 12:00 to cover the duration for the day") flexible_hours = fields.Boolean(string="Flexible Hours", compute="_compute_flexible_hours", inverse="_inverse_flexible_hours", store=True, help="When enabled, it will allow employees to work flexibly, without relying on the company's working schedule (working hours).") full_time_required_hours = fields.Float( string="Full Time Equivalent", compute="_compute_full_time_required_hours", store=True, readonly=False, help="Number of hours to work on the company schedule to be considered as fulltime.") global_leave_ids = fields.One2many( 'resource.calendar.leaves', 'calendar_id', 'Global Time Off', compute='_compute_global_leave_ids', store=True, readonly=False, domain=[('resource_id', '=', False)], copy=True, ) hours_per_day = fields.Float("Average Hour per Day", store=True, compute="_compute_hours_per_day", digits=(2, 2), readonly=False, help="Average hours per day a resource is supposed to work with this calendar.") hours_per_week = fields.Float( string="Hours per Week", compute="_compute_hours_per_week", store=True, readonly=False, copy=False) is_fulltime = fields.Boolean(compute='_compute_work_time_rate', string="Is Full Time") two_weeks_calendar = fields.Boolean(string="Calendar in 2 weeks mode") two_weeks_explanation = fields.Char('Explanation', compute="_compute_two_weeks_explanation") tz = fields.Selection( _tz_get, string='Timezone', required=True, default=lambda self: self.env.context.get('tz') or self.env.user.tz or self.env.ref('base.user_admin').tz or 'UTC', help="This field is used in order to define in which timezone the resources will work.") tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset') work_resources_count = fields.Integer("Work Resources count", compute='_compute_work_resources_count') work_time_rate = fields.Float(string='Work Time Rate', compute='_compute_work_time_rate', search='_search_work_time_rate', help='Work time rate versus full time working schedule, should be between 0 and 100 %.') # -------------------------------------------------- # Constrains # -------------------------------------------------- @api.constrains('attendance_ids') def _check_attendance_ids(self): for res_calendar in self: if (res_calendar.two_weeks_calendar and res_calendar.attendance_ids.filtered(lambda a: a.display_type == 'line_section') and not res_calendar.attendance_ids.sorted('sequence')[0].display_type): raise ValidationError(self.env._("In a calendar with 2 weeks mode, all periods need to be in the sections.")) # Avoid superimpose in attendance attendance_ids = res_calendar.attendance_ids.filtered( lambda attendance: not attendance.display_type) if res_calendar.two_weeks_calendar: res_calendar._check_overlap(attendance_ids.filtered(lambda attendance: attendance.week_type == '0')) res_calendar._check_overlap(attendance_ids.filtered(lambda attendance: attendance.week_type == '1')) else: res_calendar._check_overlap(attendance_ids) # -------------------------------------------------- # Compute Methods # -------------------------------------------------- @api.depends('two_weeks_calendar') def _compute_two_weeks_attendance(self): for calendar in self: if not calendar.two_weeks_calendar: continue calendar.attendance_ids_1st_week = calendar.attendance_ids.filtered(lambda a: a.week_type == '0') calendar.attendance_ids_2nd_week = calendar.attendance_ids.filtered(lambda a: a.week_type == '1') def _inverse_two_weeks_calendar(self): for calendar in self: if not calendar.two_weeks_calendar: continue calendar.attendance_ids = calendar.attendance_ids_1st_week + calendar.attendance_ids_2nd_week @api.depends('hours_per_week', 'company_id.resource_calendar_id.hours_per_week') def _compute_full_time_required_hours(self): for calendar in self.filtered("company_id"): calendar.full_time_required_hours = calendar.company_id.resource_calendar_id.hours_per_week @api.depends("schedule_type") def _compute_flexible_hours(self): for calendar in self: calendar.flexible_hours = calendar.schedule_type == 'flexible' def _inverse_flexible_hours(self): for calendar in self: calendar.schedule_type = 'flexible' if calendar.flexible_hours else 'fully_fixed' @api.depends('company_id') def _compute_attendance_ids(self): for calendar in self.filtered(lambda c: not c._origin or (c._origin.company_id != c.company_id and c.company_id)): company_calendar = calendar.company_id.resource_calendar_id calendar.update({ 'two_weeks_calendar': company_calendar.two_weeks_calendar, 'tz': company_calendar.tz, 'attendance_ids': [(5, 0, 0)] + [ (0, 0, attendance._copy_attendance_vals()) for attendance in company_calendar.attendance_ids], }) @api.onchange('attendance_ids') def _onchange_attendance_ids(self): if not self.two_weeks_calendar: return even_week_seq = self.attendance_ids.filtered(lambda att: att.display_type == 'line_section' and att.week_type == '0') odd_week_seq = self.attendance_ids.filtered(lambda att: att.display_type == 'line_section' and att.week_type == '1') if len(even_week_seq) != 1 or len(odd_week_seq) != 1: raise ValidationError(self.env._("You can't delete section between weeks.")) even_week_seq = even_week_seq.sequence odd_week_seq = odd_week_seq.sequence for line in self.attendance_ids.filtered(lambda att: att.display_type is False): if even_week_seq > odd_week_seq: line.week_type = '1' if even_week_seq > line.sequence else '0' else: line.week_type = '0' if odd_week_seq > line.sequence else '1' @api.depends('company_id') def _compute_global_leave_ids(self): for calendar in self.filtered(lambda c: not c._origin or c._origin.company_id != c.company_id): calendar.update({ 'global_leave_ids': [(5, 0, 0)] + [ (0, 0, leave._copy_leave_vals()) for leave in calendar.company_id.resource_calendar_id.global_leave_ids], }) @api.depends('attendance_ids', 'attendance_ids.hour_from', 'attendance_ids.hour_to', 'two_weeks_calendar', 'flexible_hours') def _compute_hours_per_day(self): """ Compute the average hours per day. Cannot directly depend on hours_per_week because of rounding issues. """ for calendar in self.filtered(lambda c: not c.flexible_hours): calendar.hours_per_day = float_round(calendar._get_hours_per_day(), precision_digits=2) @api.depends('attendance_ids', 'attendance_ids.hour_from', 'attendance_ids.hour_to', 'two_weeks_calendar', 'flexible_hours') def _compute_hours_per_week(self): """ Compute the average hours per week """ for calendar in self.filtered(lambda c: not c.flexible_hours): calendar.hours_per_week = float_round(calendar._get_hours_per_week(), precision_digits=2) @api.depends('two_weeks_calendar') def _compute_two_weeks_explanation(self): today = fields.Date.today() week_type = self.env['resource.calendar.attendance'].get_week_type(today) week_type_str = self.env._("even") if week_type else self.env._("odd") first_day = date_utils.start_of(today, 'week') last_day = date_utils.end_of(today, 'week') self.two_weeks_explanation = self.env._( "The current week (from %(first_day)s to %(last_day)s) corresponds to %(number)s week.", first_day=first_day, last_day=last_day, number=week_type_str, ) @api.depends('tz') def _compute_tz_offset(self): for calendar in self: calendar.tz_offset = datetime.now(timezone(calendar.tz or 'GMT')).strftime('%z') def _compute_work_resources_count(self): resources_per_calendar = dict(self.env['resource.resource']._read_group( domain=[('calendar_id', 'in', self.ids)], groupby=['calendar_id'], aggregates=['__count'])) for calendar in self: calendar.work_resources_count = resources_per_calendar.get(calendar, 0) @api.depends('hours_per_week', 'full_time_required_hours') def _compute_work_time_rate(self): for calendar in self: if calendar.full_time_required_hours: calendar.work_time_rate = calendar.hours_per_week / calendar.full_time_required_hours * 100 else: calendar.work_time_rate = 100 calendar.is_fulltime = float_compare(calendar.full_time_required_hours, calendar.hours_per_week, 3) == 0 @api.model def _search_work_time_rate(self, operator, value): if operator in ('in', 'not in'): if not all(isinstance(v, int) for v in value): return NotImplemented elif operator in ('<', '>'): if not isinstance(value, int): return NotImplemented else: return NotImplemented calendar_ids = self.env['resource.calendar'].search([]) if operator == 'in': calender = calendar_ids.filtered(lambda m: m.work_time_rate in value) elif operator == 'not in': calender = calendar_ids.filtered(lambda m: m.work_time_rate not in value) elif operator == '<': calender = calendar_ids.filtered(lambda m: m.work_time_rate < value) elif operator == '>': calender = calendar_ids.filtered(lambda m: m.work_time_rate > value) return [('id', 'in', calender.ids)] # -------------------------------------------------- # Overrides # -------------------------------------------------- def copy_data(self, default=None): vals_list = super().copy_data(default=default) return [dict(vals, name=self.env._("%s (copy)", calendar.name)) for calendar, vals in zip(self, vals_list)] # -------------------------------------------------- # Actions # -------------------------------------------------- def switch_calendar_type(self): self.ensure_one() if not self.two_weeks_calendar: self.two_weeks_calendar = True final_attendances = self._get_two_weeks_attendance() self.attendance_ids = [Command.clear()] + final_attendances else: self.two_weeks_calendar = False self.attendance_ids.unlink() self.duration_based = False self.attendance_ids = self._get_default_attendance_ids(self.company_id) def switch_based_on_duration(self): self.ensure_one() self.duration_based = not self.duration_based if self.duration_based: self.attendance_ids.filtered(lambda att: att.day_period == 'lunch').unlink() else: self.attendance_ids.unlink() self.attendance_ids = self._get_default_attendance_ids(self.company_id) if self.two_weeks_calendar: self.attendance_ids = self._get_two_weeks_attendance() # -------------------------------------------------- # Computation API # -------------------------------------------------- def _attendance_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None, lunch=False): assert start_dt.tzinfo and end_dt.tzinfo self.ensure_one() if not resources: resources = self.env['resource.resource'] resources_list = [resources] else: resources_list = list(resources) + [self.env['resource.resource']] if self.flexible_hours and lunch: return {resource.id: Intervals([], keep_distinct=True) for resource in resources_list} domain = Domain.AND([ Domain(domain or Domain.TRUE), Domain('calendar_id', '=', self.id), Domain('display_type', '=', False), Domain('day_period', '!=' if not lunch else '=', 'lunch'), ]) attendances = self.env['resource.calendar.attendance'].search(domain) # Since we only have one calendar to take in account # Group resources per tz they will all have the same result resources_per_tz = defaultdict(list) for resource in resources_list: resources_per_tz[tz or timezone((resource or self).tz)].append(resource) # Resource specific attendances # Calendar attendances per day of the week # * 7 days per week * 2 for two week calendars attendances_per_day = [self.env['resource.calendar.attendance']] * 7 * 2 weekdays = set() for attendance in attendances: weekday = int(attendance.dayofweek) weekdays.add(weekday) if self.two_weeks_calendar: weektype = int(attendance.week_type) attendances_per_day[weekday + 7 * weektype] |= attendance else: attendances_per_day[weekday] |= attendance attendances_per_day[weekday + 7] |= attendance start = start_dt.astimezone(utc) end = end_dt.astimezone(utc) bounds_per_tz = { tz: (start_dt.astimezone(tz), end_dt.astimezone(tz)) for tz in resources_per_tz } # Use the outer bounds from the requested timezones for low, high in bounds_per_tz.values(): start = min(start, low.replace(tzinfo=utc)) end = max(end, high.replace(tzinfo=utc)) # Generate once with utc as timezone days = rrule(DAILY, start.date(), until=end.date(), byweekday=weekdays) ResourceCalendarAttendance = self.env['resource.calendar.attendance'] base_result = [] for day in days: week_type = ResourceCalendarAttendance.get_week_type(day) attendances = attendances_per_day[day.weekday() + 7 * week_type] for attendance in attendances: day_from = datetime.combine(day, float_to_time(attendance.hour_from)) day_to = datetime.combine(day, float_to_time(attendance.hour_to)) base_result.append((day_from, day_to, attendance)) # Copy the result localized once per necessary timezone # Strictly speaking comparing start_dt < time or start_dt.astimezone(tz) < time # should always yield the same result. however while working with dates it is easier # if all dates have the same format result_per_tz = { tz: [(max(bounds_per_tz[tz][0], tz.localize(val[0])), min(bounds_per_tz[tz][1], tz.localize(val[1])), val[2]) for val in base_result] for tz in resources_per_tz } resource_calendars = resources._get_calendar_at(start_dt, tz) result_per_resource_id = dict() for tz, tz_resources in resources_per_tz.items(): res = result_per_tz[tz] res_intervals = Intervals(res, keep_distinct=True) start_datetime = start_dt.astimezone(tz) end_datetime = end_dt.astimezone(tz) for resource in tz_resources: if resource and not resource_calendars.get(resource, False): # If the resource is fully flexible, return the whole period from start_dt to end_dt with a dummy attendance hours = (end_dt - start_dt).total_seconds() / 3600 days = hours / 24 dummy_attendance = self.env['resource.calendar.attendance'].new({ 'duration_hours': hours, 'duration_days': days, }) result_per_resource_id[resource.id] = Intervals([(start_datetime, end_datetime, dummy_attendance)], keep_distinct=True) elif self.flexible_hours or (resource and resource_calendars[resource].flexible_hours): # For flexible Calendars, we create intervals to fill in the weekly intervals with the average daily hours # until the full time required hours are met. This gives us the most correct approximation when looking at a daily # and weekly range for time offs and overtime calculations and work entry generation start_date = start_datetime.date() end_datetime_adjusted = end_datetime - relativedelta(seconds=1) end_date = end_datetime_adjusted.date() calendar = resource_calendars[resource] if resource else self max_hours_per_week = calendar.hours_per_week max_hours_per_day = calendar.hours_per_day intervals = [] current_start_day = start_date while current_start_day <= end_date: current_end_of_week = current_start_day + timedelta(days=6) week_start = max(current_start_day, start_date) week_end = min(current_end_of_week, end_date) if current_start_day < start_date: prior_days = (start_date - current_start_day).days prior_hours = min(max_hours_per_week, max_hours_per_day * prior_days) else: prior_hours = 0 remaining_hours = max(0, max_hours_per_week - prior_hours) remaining_hours = min(remaining_hours, (end_dt - start_dt).total_seconds() / 3600) current_day = week_start while current_day <= week_end: if remaining_hours > 0: day_start = tz.localize(datetime.combine(current_day, time.min)) day_end = tz.localize(datetime.combine(current_day, time.max)) day_period_start = max(start_datetime, day_start) day_period_end = min(end_datetime, day_end) allocate_hours = min(max_hours_per_day, remaining_hours, (day_period_end - day_period_start).total_seconds() / 3600) remaining_hours -= allocate_hours # Create interval centered at 12:00 PM (or as close as possible) midpoint = tz.localize(datetime.combine(current_day, time(12, 0))) start_time = midpoint - timedelta(hours=allocate_hours / 2) end_time = midpoint + timedelta(hours=allocate_hours / 2) if start_time < day_period_start: start_time = day_period_start end_time = start_time + timedelta(hours=allocate_hours) elif end_time > day_period_end: end_time = day_period_end start_time = end_time - timedelta(hours=allocate_hours) dummy_attendance = self.env['resource.calendar.attendance'].new({ 'duration_hours': allocate_hours, 'duration_days': 1, }) intervals.append((start_time, end_time, dummy_attendance)) current_day += timedelta(days=1) current_start_day += timedelta(days=7) result_per_resource_id[resource.id] = Intervals(intervals, keep_distinct=True) else: result_per_resource_id[resource.id] = res_intervals return result_per_resource_id def _handle_flexible_leave_interval(self, dt0, dt1, leave): """Hook method to handle flexible leave intervals. Can be overridden in other modules.""" tz = dt0.tzinfo # Get the timezone information from dt0 dt0 = datetime.combine(dt0.date(), time.min).replace(tzinfo=tz) dt1 = datetime.combine(dt1.date(), time.max).replace(tzinfo=tz) return dt0, dt1 def _leave_intervals(self, start_dt, end_dt, resource=None, domain=None, tz=None): if resource is None: resource = self.env['resource.resource'] return self._leave_intervals_batch( start_dt, end_dt, resources=resource, domain=domain, tz=tz, )[resource.id] def _leave_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None): """ Return the leave intervals in the given datetime range. The returned intervals are expressed in specified tz or in the calendar's timezone. """ assert start_dt.tzinfo and end_dt.tzinfo if not resources: resources = self.env['resource.resource'] resources_list = [resources] else: resources_list = list(resources) + [self.env['resource.resource']] if domain is None: domain = [('time_type', '=', 'leave')] if self: domain = domain + [('calendar_id', 'in', [False] + self.ids)] # for the computation, express all datetimes in UTC # Public leave don't have a resource_id domain = domain + [ ('resource_id', 'in', [False] + [r.id for r in resources_list]), ('date_from', '<=', end_dt.astimezone(utc).replace(tzinfo=None)), ('date_to', '>=', start_dt.astimezone(utc).replace(tzinfo=None)), ] # retrieve leave intervals in (start_dt, end_dt) result = defaultdict(list) tz_dates = {} all_leaves = self.env['resource.calendar.leaves'].search(domain) for leave in all_leaves: leave_resource = leave.resource_id leave_company = leave.company_id leave_date_from = leave.date_from leave_date_to = leave.date_to for resource in resources_list: if leave_resource.id not in [False, resource.id] or (not leave_resource and resource and resource.company_id != leave_company): continue tz = tz if tz else timezone((resource or self).tz) if (tz, start_dt) in tz_dates: start = tz_dates[tz, start_dt] else: start = start_dt.astimezone(tz) tz_dates[tz, start_dt] = start if (tz, end_dt) in tz_dates: end = tz_dates[tz, end_dt] else: end = end_dt.astimezone(tz) tz_dates[tz, end_dt] = end dt0 = leave_date_from.astimezone(tz) dt1 = leave_date_to.astimezone(tz) if leave_resource and leave_resource._is_fully_flexible(): dt0, dt1 = self._handle_flexible_leave_interval(dt0, dt1, leave) result[resource.id].append((max(start, dt0), min(end, dt1), leave)) return {r.id: Intervals(result[r.id]) for r in resources_list} def _work_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None, compute_leaves=True): """ Return the effective work intervals between the given datetimes. """ if not resources: resources = self.env['resource.resource'] resources_list = [resources] else: resources_list = list(resources) + [self.env['resource.resource']] attendance_intervals = self._attendance_intervals_batch(start_dt, end_dt, resources, tz=tz or self.env.context.get("employee_timezone")) if compute_leaves: leave_intervals = self._leave_intervals_batch(start_dt, end_dt, resources, domain, tz=tz) return { r.id: (attendance_intervals[r.id] - leave_intervals[r.id]) for r in resources_list } return { r.id: attendance_intervals[r.id] for r in resources_list } def _unavailable_intervals(self, start_dt, end_dt, resource=None, domain=None, tz=None): if resource is None: resource = self.env['resource.resource'] return self._unavailable_intervals_batch( start_dt, end_dt, resources=resource, domain=domain, tz=tz, )[resource.id] def _unavailable_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None): """ Return the unavailable intervals between the given datetimes. """ if not resources: resources = self.env['resource.resource'] resources_list = [resources] else: resources_list = list(resources) resources_work_intervals = self._work_intervals_batch(start_dt, end_dt, resources, domain, tz) result = {} for resource in resources_list: if resource and resource._is_flexible(): leaves = self._leave_intervals_batch(start_dt, end_dt, resource, domain, tz=tz) if res_leaves := leaves.get(resource.id, []): result[resource.id] = [(i[0], i[1]) for i in res_leaves] continue work_intervals = [(start, stop) for start, stop, meta in resources_work_intervals[resource.id]] # start + flatten(intervals) + end work_intervals = [start_dt] + list(chain.from_iterable(work_intervals)) + [end_dt] # put it back to UTC work_intervals = [dt.astimezone(utc) for dt in work_intervals] # pick groups of two work_intervals = list(zip(work_intervals[0::2], work_intervals[1::2])) result[resource.id] = work_intervals return result # -------------------------------------------------- # Private Methods / Helpers # -------------------------------------------------- def _check_overlap(self, attendance_ids): """ attendance_ids correspond to attendance of a week, will check for each day of week that there are no superimpose. """ result = [] for attendance in attendance_ids: # 0.000001 is added to each start hour to avoid to detect two contiguous intervals as superimposing. # Indeed Intervals function will join 2 intervals with the start and stop hour corresponding. result.append((int(attendance.dayofweek) * 24 + attendance.hour_from + 0.000001, int(attendance.dayofweek) * 24 + attendance.hour_to, attendance)) if len(Intervals(result)) != len(result): raise ValidationError(self.env._("Attendances can't overlap.")) def _get_attendance_intervals_days_data(self, attendance_intervals): """ helper function to compute duration of `intervals` that have 'resource.calendar.attendance' records as payload (3rd element in tuple). expressed in days and hours. resource.calendar.attendance records have durations associated with them so this method merely calculates the proportion that is covered by the intervals. """ day_hours = defaultdict(float) day_days = defaultdict(float) for start, stop, meta in attendance_intervals: # If the interval covers only a part of the original attendance, we # take durations in days proportionally to what is left of the interval. interval_hours = (stop - start).total_seconds() / 3600 day_hours[start.date()] += interval_hours if len(self) == 1 and self.flexible_hours: day_days[start.date()] += interval_hours / self.hours_per_day if self.hours_per_day else 0 else: day_days[start.date()] += sum(meta.mapped('duration_days')) * interval_hours / sum(meta.mapped('duration_hours')) return { # Round the number of days to the closest 16th of a day. 'days': float_round(sum(day_days[day] for day in day_days), precision_rounding=0.001), 'hours': sum(day_hours.values()), } def _get_closest_work_time(self, dt, match_end=False, resource=None, search_range=None, compute_leaves=True): """Return the closest work interval boundary within the search range. Consider only starts of intervals unless `match_end` is True. It will then only consider ends of intervals. :param dt: reference datetime :param match_end: wether to search for the begining of an interval or the end. :param search_range: time interval considered. Defaults to the entire day of `dt` :rtype: datetime | None """ def interval_dt(interval): return interval[1 if match_end else 0] tz = resource.tz if resource else self.tz if resource is None: resource = self.env['resource.resource'] if not dt.tzinfo or (search_range and not (search_range[0].tzinfo and search_range[1].tzinfo)): raise ValueError(self.env._('Provided datetimes needs to be timezoned')) dt = dt.astimezone(timezone(tz)) if not search_range: range_start = dt + relativedelta(hour=0, minute=0, second=0) range_end = dt + relativedelta(days=1, hour=0, minute=0, second=0) else: range_start, range_end = search_range if not range_start <= dt <= range_end: return None work_intervals = sorted( self._work_intervals_batch(range_start, range_end, resource, compute_leaves=compute_leaves)[resource.id], key=lambda i: abs(interval_dt(i) - dt), ) return interval_dt(work_intervals[0]) if work_intervals else None def _get_days_per_week(self): # If the employee didn't work a full day, it is still counted, i.e. 19h / week (M/T/W(half day)) -> 3 days self.ensure_one() attendances = self._get_global_attendances() if self.two_weeks_calendar: number_of_days = len(set(attendances.filtered(lambda cal: cal.week_type == '1').mapped('dayofweek'))) number_of_days += len(set(attendances.filtered(lambda cal: cal.week_type == '0').mapped('dayofweek'))) else: number_of_days = len(set(attendances.mapped('dayofweek'))) return number_of_days / 2 if self.two_weeks_calendar else number_of_days def _get_hours_per_week(self): """ Calculate the average hours worked per week. """ self.ensure_one() hour_count = 0.0 for attendance in self._get_global_attendances(): hour_count += attendance.duration_hours return hour_count / 2 if self.two_weeks_calendar else hour_count def _get_hours_per_day(self): """ Calculate the average hours worked per workday. """ hour_per_week = self._get_hours_per_week() number_of_days = self._get_days_per_week() return hour_per_week / number_of_days if number_of_days else 0 def _get_global_attendances(self): return self.attendance_ids.filtered(lambda attendance: attendance.day_period != 'lunch' and not attendance.display_type) def _get_unusual_days(self, start_dt, end_dt, company_id=False): if not self: return {} self.ensure_one() if not start_dt.tzinfo: start_dt = start_dt.replace(tzinfo=utc) if not end_dt.tzinfo: end_dt = end_dt.replace(tzinfo=utc) domain = [] if company_id: domain = [('company_id', 'in', (company_id.id, False))] if self.flexible_hours: leave_intervals = self._leave_intervals_batch(start_dt, end_dt, domain=domain)[False] works = set() for start_int, end_int, _ in leave_intervals: works.update(start_int.date() + timedelta(days=i) for i in range((end_int.date() - start_int.date()).days + 1)) return {fields.Date.to_string(day.date()): (day.date() in works) for day in rrule(DAILY, start_dt, until=end_dt)} works = {d[0].date() for d in self._work_intervals_batch(start_dt, end_dt, domain=domain)[False]} return {fields.Date.to_string(day.date()): (day.date() not in works) for day in rrule(DAILY, start_dt, until=end_dt)} def _get_default_attendance_ids(self, company_id=None): """ return a copy of the company's calendar attendance or default 40 hours/week """ if company_id and (attendances := company_id.resource_calendar_id.attendance_ids): return [ Command.create({ 'name': attendance.name, 'dayofweek': attendance.dayofweek, 'week_type': attendance.week_type, 'hour_from': attendance.hour_from, 'hour_to': attendance.hour_to, 'day_period': attendance.day_period, 'display_type': attendance.display_type, }) for attendance in attendances ] return [ Command.create({'name': self.env._('Monday Morning'), 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), Command.create({'name': self.env._('Monday Lunch'), 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), Command.create({'name': self.env._('Monday Afternoon'), 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), Command.create({'name': self.env._('Tuesday Morning'), 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), Command.create({'name': self.env._('Tuesday Lunch'), 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), Command.create({'name': self.env._('Tuesday Afternoon'), 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), Command.create({'name': self.env._('Wednesday Morning'), 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), Command.create({'name': self.env._('Wednesday Lunch'), 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), Command.create({'name': self.env._('Wednesday Afternoon'), 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), Command.create({'name': self.env._('Thursday Morning'), 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), Command.create({'name': self.env._('Thursday Lunch'), 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), Command.create({'name': self.env._('Thursday Afternoon'), 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), Command.create({'name': self.env._('Friday Morning'), 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), Command.create({'name': self.env._('Friday Lunch'), 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}), Command.create({'name': self.env._('Friday Afternoon'), 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), ] def _get_two_weeks_attendance(self): final_attendances = [ Command.create({ 'name': 'First week', 'dayofweek': '0', 'sequence': '0', 'hour_from': 0, 'day_period': 'morning', 'week_type': '0', 'hour_to': 0, 'display_type': 'line_section'}), Command.create({ 'name': 'Second week', 'dayofweek': '0', 'sequence': '25', 'hour_from': 0, 'day_period': 'morning', 'week_type': '1', 'hour_to': 0, 'display_type': 'line_section'}), ] for idx, att in enumerate(self.attendance_ids): final_attendances.append(Command.create(dict(att._copy_attendance_vals(), week_type='0', sequence=idx + 1))) final_attendances.append(Command.create(dict(att._copy_attendance_vals(), week_type='1', sequence=idx + 26))) return final_attendances # -------------------------------------------------- # External API # -------------------------------------------------- def get_work_hours_count(self, start_dt, end_dt, compute_leaves=True, domain=None): """ `compute_leaves` controls whether or not this method is taking into account the global leaves. `domain` controls the way leaves are recognized. None means default value ('time_type', '=', 'leave') Counts the number of work hours between two datetimes. """ self.ensure_one() # Set timezone in UTC if no timezone is explicitly given if not start_dt.tzinfo: start_dt = start_dt.replace(tzinfo=utc) if not end_dt.tzinfo: end_dt = end_dt.replace(tzinfo=utc) if compute_leaves: intervals = self._work_intervals_batch(start_dt, end_dt, domain=domain)[False] else: intervals = self._attendance_intervals_batch(start_dt, end_dt)[False] return sum( (stop - start).total_seconds() / 3600 for start, stop, meta in intervals ) def get_work_duration_data(self, from_datetime, to_datetime, compute_leaves=True, domain=None): """ Get the working duration (in days and hours) for a given period, only based on the current calendar. This method does not use resource to compute it. `domain` is used in order to recognise the leaves to take, None means default value ('time_type', '=', 'leave') Returns a dict {'days': n, 'hours': h} containing the quantity of working time expressed as days and as hours. """ # naive datetimes are made explicit in UTC from_datetime = localized(from_datetime) to_datetime = localized(to_datetime) # actual hours per day if compute_leaves: intervals = self._work_intervals_batch(from_datetime, to_datetime, domain=domain)[False] else: intervals = self._attendance_intervals_batch(from_datetime, to_datetime, domain=domain)[False] return self._get_attendance_intervals_days_data(intervals) def plan_hours(self, hours, day_dt, compute_leaves=False, domain=None, resource=None): """ `compute_leaves` controls whether or not this method is taking into account the global leaves. `domain` controls the way leaves are recognized. None means default value ('time_type', '=', 'leave') Return datetime after having planned hours """ revert = to_timezone(day_dt.tzinfo) day_dt = localized(day_dt) if resource is None: resource = self.env['resource.resource'] # which method to use for retrieving intervals if compute_leaves: get_intervals = partial(self._work_intervals_batch, domain=domain, resources=resource) resource_id = resource.id else: get_intervals = self._attendance_intervals_batch resource_id = False if hours >= 0: delta = timedelta(days=14) for n in range(100): dt = day_dt + delta * n for start, stop, _meta in get_intervals(dt, dt + delta)[resource_id]: interval_hours = (stop - start).total_seconds() / 3600 if hours <= interval_hours: return revert(start + timedelta(hours=hours)) hours -= interval_hours return False hours = abs(hours) delta = timedelta(days=14) for n in range(100): dt = day_dt - delta * n for start, stop, _meta in reversed(get_intervals(dt - delta, dt)[resource_id]): interval_hours = (stop - start).total_seconds() / 3600 if hours <= interval_hours: return revert(stop - timedelta(hours=hours)) hours -= interval_hours return False def plan_days(self, days, day_dt, compute_leaves=False, domain=None): """ `compute_leaves` controls whether or not this method is taking into account the global leaves. `domain` controls the way leaves are recognized. None means default value ('time_type', '=', 'leave') Returns the datetime of a days scheduling. """ revert = to_timezone(day_dt.tzinfo) day_dt = localized(day_dt) # which method to use for retrieving intervals if compute_leaves: get_intervals = partial(self._work_intervals_batch, domain=domain) else: get_intervals = self._attendance_intervals_batch if days > 0: found = set() delta = timedelta(days=14) for n in range(100): dt = day_dt + delta * n for start, stop, _meta in get_intervals(dt, dt + delta)[False]: found.add(start.date()) if len(found) == days: return revert(stop) return False if days < 0: days = abs(days) found = set() delta = timedelta(days=14) for n in range(100): dt = day_dt - delta * n for start, _stop, _meta in reversed(get_intervals(dt - delta, dt)[False]): found.add(start.date()) if len(found) == days: return revert(start) return False return revert(day_dt) def _works_on_date(self, date): self.ensure_one() working_days = self._get_working_hours() dayofweek = str(date.weekday()) if self.two_weeks_calendar: weektype = str(self.env['resource.calendar.attendance'].get_week_type(date)) return working_days[weektype][dayofweek] return working_days[False][dayofweek] def _get_hours_for_date(self, target_date, day_period=None): """ An instance method on a calendar to get the start and end float hours for a given date. :param target_date: The date to find working hours. :param day_period: Optional string ('morning', 'afternoon') to filter for half-days. :return: A tuple of floats (hour_from, hour_to). """ self.ensure_one() if not target_date: err = "Target Date cannot be empty" raise ValueError(err) if self.flexible_hours: # Quick calculation to center flexible hours around 12PM midday datetimes = [12.0 - self.hours_per_day / 2.0, 12.0, 12.0 + self.hours_per_day / 2.0] if day_period: return (datetimes[0], datetimes[1]) if day_period == 'morning' else (datetimes[1], datetimes[2]) return (datetimes[0], datetimes[2]) domain = [ ('calendar_id', '=', self.id), ('display_type', '=', False), ('day_period', '!=', 'lunch'), ] init_attendances = self.env['resource.calendar.attendance']._read_group(domain=domain, groupby=['week_type', 'dayofweek', 'day_period'], aggregates=['hour_from:min', 'hour_to:max'], order='dayofweek,hour_from:min') init_attendances = [DummyAttendance(hour_from, hour_to, dayofweek, day_period, week_type) for week_type, dayofweek, day_period, hour_from, hour_to in init_attendances] if day_period: attendances = [att for att in init_attendances if att.day_period == day_period] for attendance in filter(lambda att: att.day_period == 'full_day', init_attendances): # Split full-day attendances at their midpoint. half_time = (attendance.hour_from + attendance.hour_to) / 2 attendances.append(attendance._replace( hour_from=attendance.hour_from if day_period == 'morning' else half_time, hour_to=attendance.hour_to if day_period == 'afternoon' else half_time, )) else: attendances = init_attendances default_start = min((att.hour_from for att in attendances), default=0.0) default_end = max((att.hour_to for att in attendances), default=0.0) week_type = False if self.two_weeks_calendar: week_type = str(self.env['resource.calendar.attendance'].get_week_type(target_date)) filtered_attendances = [att for att in attendances if att.week_type == week_type and int(att.dayofweek) == target_date.weekday()] hour_from = min((att.hour_from for att in filtered_attendances), default=default_start) hour_to = max((att.hour_to for att in filtered_attendances), default=default_end) return (hour_from, hour_to) @ormcache('self.id') def _get_working_hours(self): self.ensure_one() working_days = defaultdict(lambda: defaultdict(lambda: False)) for attendance in self.attendance_ids: working_days[attendance.week_type][attendance.dayofweek] = True return working_days