oca-ocb-core/odoo-bringout-oca-ocb-resource/resource/models/resource_calendar.py
Ernad Husremovic 2d3ee4855a 19.0 vanilla
2026-03-09 09:30:27 +01:00

1011 lines
49 KiB
Python

# 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