19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,7 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import resource
from . import resource_mixin
from . import res_company
from . import res_users
from . import resource_calendar
from . import resource_calendar_attendance
from . import resource_calendar_leaves
from . import resource_mixin
from . import resource_resource
from . import utils

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,132 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import math
from odoo import api, fields, models
from odoo.exceptions import UserError
class ResourceCalendarAttendance(models.Model):
_name = 'resource.calendar.attendance'
_description = "Work Detail"
_order = 'sequence, week_type, dayofweek, hour_from'
name = fields.Char(required=True)
dayofweek = fields.Selection([
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday')
], 'Day of Week', required=True, index=True, default='0')
hour_from = fields.Float(string='Work from', default=0, required=True, index=True,
help="Start and End time of working.\n"
"A specific value of 24:00 is interpreted as 23:59:59.999999.")
hour_to = fields.Float(string='Work to', default=0, required=True)
# For the hour duration, the compute function is used to compute the value
# unambiguously, while the duration in days is computed for the default
# value based on the day_period but can be manually overridden.
duration_hours = fields.Float(compute='_compute_duration_hours', inverse='_inverse_duration_hours', string='Duration (hours)', store=True, readonly=False)
duration_days = fields.Float(compute='_compute_duration_days', string='Duration (days)', store=True, readonly=False)
calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, index=True, ondelete='cascade')
duration_based = fields.Boolean(related="calendar_id.duration_based")
day_period = fields.Selection([
('morning', 'Morning'),
('lunch', 'Break'),
('afternoon', 'Afternoon'),
('full_day', 'Full Day')], required=True, default='morning')
week_type = fields.Selection([
('1', 'Second'),
('0', 'First')
], 'Week Number', default=False)
two_weeks_calendar = fields.Boolean("Calendar in 2 weeks mode", related='calendar_id.two_weeks_calendar')
display_type = fields.Selection([
('line_section', "Section")], default=False, help="Technical field for UX purpose.")
sequence = fields.Integer(default=10,
help="Gives the sequence of this line when displaying the resource calendar.")
@api.onchange('hour_from', 'hour_to')
def _onchange_hours(self):
# avoid negative or after midnight
self.hour_from = min(self.hour_from, 23.99)
self.hour_from = max(self.hour_from, 0.0)
self.hour_to = min(self.hour_to, 24)
self.hour_to = max(self.hour_to, 0.0)
# avoid wrong order
self.hour_to = max(self.hour_to, self.hour_from)
@api.constrains('day_period')
def _check_day_period(self):
for attendance in self:
if attendance.day_period == 'lunch' and attendance.duration_based:
raise UserError(self.env._("%(att)s is a break attendance, You should not have such record on duration based calendar", att=attendance.name))
@api.model
def get_week_type(self, date):
# week_type is defined by
# * counting the number of days from January 1 of year 1
# (extrapolated to dates prior to the first adoption of the Gregorian calendar)
# * converted to week numbers and then the parity of this number is asserted.
# It ensures that an even week number always follows an odd week number. With classical week number,
# some years have 53 weeks. Therefore, two consecutive odd week number follow each other (53 --> 1).
return int(math.floor((date.toordinal() - 1) / 7) % 2)
@api.depends('hour_from', 'hour_to')
def _compute_duration_hours(self):
for attendance in self.filtered('hour_to'):
attendance.duration_hours = (attendance.hour_to - attendance.hour_from) if attendance.day_period != 'lunch' else 0
def _inverse_duration_hours(self):
for calendar, attendances in self.grouped('calendar_id').items():
if not calendar.duration_based:
continue
for attendance in attendances:
if attendance.day_period == 'full_day':
period_duration = attendance.duration_hours / 2
attendance.hour_to = 12 + period_duration
attendance.hour_from = 12 - period_duration
elif attendance.day_period == 'morning':
attendance.hour_to = 12
attendance.hour_from = 12 - attendance.duration_hours
elif attendance.day_period == 'afternoon':
attendance.hour_to = 12 + attendance.duration_hours
attendance.hour_from = 12
@api.depends('day_period')
def _compute_duration_days(self):
for attendance in self:
if attendance.day_period == 'lunch':
attendance.duration_days = 0
elif attendance.day_period == 'full_day':
attendance.duration_days = 1
else:
attendance.duration_days = 0.5 if attendance.duration_hours <= attendance.calendar_id.hours_per_day * 3 / 4 else 1
@api.depends('week_type')
def _compute_display_name(self):
super()._compute_display_name()
this_week_type = str(self.get_week_type(fields.Date.context_today(self)))
section_names = {'0': self.env._('First week'), '1': self.env._('Second week')}
section_info = {True: self.env._('this week'), False: self.env._('other week')}
for record in self.filtered(lambda l: l.display_type == 'line_section'):
section_name = f"{section_names[record.week_type]} ({section_info[this_week_type == record.week_type]})"
record.display_name = section_name
def _copy_attendance_vals(self):
self.ensure_one()
return {
'name': self.name,
'dayofweek': self.dayofweek,
'hour_from': self.hour_from,
'hour_to': self.hour_to,
'day_period': self.day_period,
'week_type': self.week_type,
'display_type': self.display_type,
'sequence': self.sequence,
}
def _is_work_period(self):
return self.day_period != 'lunch' and not self.display_type

View file

@ -0,0 +1,87 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, time
from dateutil.relativedelta import relativedelta
from pytz import timezone, utc
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.fields import Datetime
class ResourceCalendarLeaves(models.Model):
_name = 'resource.calendar.leaves'
_description = "Resource Time Off Detail"
_order = "date_from"
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if 'date_from' in fields and 'date_to' in fields and not res.get('date_from') and not res.get('date_to'):
# Then we give the current day and we search the begin and end hours for this day in resource.calendar of the current company
today = Datetime.now()
calendar = self.env.company.resource_calendar_id
if 'calendar_id' in res:
calendar = self.env['resource.calendar'].browse(res['calendar_id'])
tz = timezone(calendar.tz or 'UTC')
date_from = tz.localize(datetime.combine(today, time.min))
date_to = tz.localize(datetime.combine(today, time.max))
res.update(
date_from=date_from.astimezone(utc).replace(tzinfo=None),
date_to=date_to.astimezone(utc).replace(tzinfo=None)
)
return res
name = fields.Char('Reason')
company_id = fields.Many2one(
'res.company', string="Company", readonly=True, store=True,
default=lambda self: self.env.company, compute='_compute_company_id')
calendar_id = fields.Many2one(
'resource.calendar', "Working Hours",
compute='_compute_calendar_id', store=True, readonly=False,
domain="[('company_id', 'in', [company_id, False])]",
check_company=True, index=True,
)
date_from = fields.Datetime('Start Date', required=True)
date_to = fields.Datetime('End Date', compute="_compute_date_to", readonly=False, required=True, store=True)
resource_id = fields.Many2one(
"resource.resource", 'Resource', index=True,
help="If empty, this is a generic time off for the company. If a resource is set, the time off is only for this resource")
time_type = fields.Selection([('leave', 'Time Off'), ('other', 'Other')], default='leave',
help="Whether this should be computed as a time off or as work time (eg: formation)")
@api.depends('resource_id.calendar_id')
def _compute_calendar_id(self):
for leave in self.filtered('resource_id'):
leave.calendar_id = leave.resource_id.calendar_id
@api.depends('calendar_id')
def _compute_company_id(self):
for leave in self:
leave.company_id = leave.calendar_id.company_id or self.env.company
@api.depends('date_from')
def _compute_date_to(self):
user_tz = self.env.tz
if not (self.env.user.tz or self.env.context.get('tz')):
user_tz = timezone(self.company_id.resource_calendar_id.tz or 'UTC')
for leave in self:
if not leave.date_from or (leave.date_to and leave.date_to > leave.date_from):
continue
local_date_from = utc.localize(leave.date_from).astimezone(user_tz)
local_date_to = local_date_from + relativedelta(hour=23, minute=59, second=59)
leave.date_to = local_date_to.astimezone(utc).replace(tzinfo=None)
@api.constrains('date_from', 'date_to')
def check_dates(self):
if self.filtered(lambda leave: leave.date_from > leave.date_to):
raise ValidationError(_('The start date of the time off must be earlier than the end date.'))
def _copy_leave_vals(self):
self.ensure_one()
return {
'name': self.name,
'date_from': self.date_from,
'date_to': self.date_to,
'time_type': self.time_type,
}

View file

@ -1,30 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from pytz import utc
from odoo import api, fields, models
def timezone_datetime(time):
if not time.tzinfo:
time = time.replace(tzinfo=utc)
return time
from odoo.tools.date_utils import localized
class ResourceMixin(models.AbstractModel):
_name = "resource.mixin"
_name = 'resource.mixin'
_description = 'Resource Mixin'
resource_id = fields.Many2one(
'resource.resource', 'Resource',
auto_join=True, index=True, ondelete='restrict', required=True)
bypass_search_access=True, index=True, ondelete='restrict', required=True)
company_id = fields.Many2one(
'res.company', 'Company',
default=lambda self: self.env.company,
index=True, related='resource_id.company_id', store=True, readonly=False)
index=True, related='resource_id.company_id', precompute=True, store=True, readonly=False)
resource_calendar_id = fields.Many2one(
'resource.calendar', 'Working Hours',
default=lambda self: self.env.company.resource_calendar_id,
@ -67,20 +60,25 @@ class ResourceMixin(models.AbstractModel):
return resource_vals
def copy_data(self, default=None):
if default is None:
default = {}
default = dict(default or {})
vals_list = super().copy_data(default=default)
resource_default = {}
if 'company_id' in default:
resource_default['company_id'] = default['company_id']
if 'resource_calendar_id' in default:
resource_default['calendar_id'] = default['resource_calendar_id']
resource = self.resource_id.copy(resource_default)
resources = [record.resource_id for record in self]
resources_to_copy = self.env['resource.resource'].concat(*resources)
new_resources = resources_to_copy.copy(resource_default)
for resource, vals in zip(new_resources, vals_list):
vals['resource_id'] = resource.id
vals['company_id'] = resource.company_id.id
vals['resource_calendar_id'] = resource.calendar_id.id
return vals_list
default['resource_id'] = resource.id
default['company_id'] = resource.company_id.id
default['resource_calendar_id'] = resource.calendar_id.id
return super(ResourceMixin, self).copy_data(default)
def _get_calendars(self, date_from=None):
return {resource.id: resource.resource_calendar_id for resource in self}
def _get_work_days_data_batch(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None):
"""
@ -98,19 +96,22 @@ class ResourceMixin(models.AbstractModel):
result = {}
# naive datetimes are made explicit in UTC
from_datetime = timezone_datetime(from_datetime)
to_datetime = timezone_datetime(to_datetime)
from_datetime = localized(from_datetime)
to_datetime = localized(to_datetime)
mapped_resources = defaultdict(lambda: self.env['resource.resource'])
for record in self:
mapped_resources[calendar or record.resource_calendar_id] |= record.resource_id
if calendar:
mapped_resources = {calendar: self.resource_id}
else:
calendar_by_resource = self._get_calendars(from_datetime)
mapped_resources = defaultdict(lambda: self.env['resource.resource'])
for resource in self:
mapped_resources[calendar_by_resource[resource.id]] |= resource.resource_id
for calendar, calendar_resources in mapped_resources.items():
if not calendar:
for calendar_resource in calendar_resources:
result[calendar_resource.id] = {'days': 0, 'hours': 0}
continue
day_total = calendar._get_resources_day_total(from_datetime, to_datetime, calendar_resources)
# actual hours per day
if compute_leaves:
@ -119,10 +120,10 @@ class ResourceMixin(models.AbstractModel):
intervals = calendar._attendance_intervals_batch(from_datetime, to_datetime, calendar_resources)
for calendar_resource in calendar_resources:
result[calendar_resource.id] = calendar._get_days_data(intervals[calendar_resource.id], day_total[calendar_resource.id])
result[calendar_resource.id] = calendar._get_attendance_intervals_days_data(intervals[calendar_resource.id])
# convert "resource: result" into "employee: result"
return {mapped_employees[r.id]: result[r.id] for r in resources}
return {mapped_employees[r.id]: result[r.id] for r in resources}
def _get_leave_days_data_batch(self, from_datetime, to_datetime, calendar=None, domain=None):
"""
@ -140,24 +141,30 @@ class ResourceMixin(models.AbstractModel):
result = {}
# naive datetimes are made explicit in UTC
from_datetime = timezone_datetime(from_datetime)
to_datetime = timezone_datetime(to_datetime)
from_datetime = localized(from_datetime)
to_datetime = localized(to_datetime)
mapped_resources = defaultdict(lambda: self.env['resource.resource'])
for record in self:
mapped_resources[calendar or record.resource_calendar_id] |= record.resource_id
for calendar, calendar_resources in mapped_resources.items():
day_total = calendar._get_resources_day_total(from_datetime, to_datetime, calendar_resources)
# handle fully flexible resources by returning the length of the whole interval
# since we do not take into account leaves for fully flexible resources
if not calendar:
days = (to_datetime - from_datetime).days
hours = (to_datetime - from_datetime).total_seconds() / 3600
for calendar_resource in calendar_resources:
result[calendar_resource.id] = {'days': days, 'hours': hours}
continue
# compute actual hours per day
attendances = calendar._attendance_intervals_batch(from_datetime, to_datetime, calendar_resources)
leaves = calendar._leave_intervals_batch(from_datetime, to_datetime, calendar_resources, domain)
for calendar_resource in calendar_resources:
result[calendar_resource.id] = calendar._get_days_data(
attendances[calendar_resource.id] & leaves[calendar_resource.id],
day_total[calendar_resource.id]
result[calendar_resource.id] = calendar._get_attendance_intervals_days_data(
attendances[calendar_resource.id] & leaves[calendar_resource.id]
)
# convert "resource: result" into "employee: result"
@ -171,7 +178,7 @@ class ResourceMixin(models.AbstractModel):
for record in self
}
def list_work_time_per_day(self, from_datetime, to_datetime, calendar=None, domain=None):
def _list_work_time_per_day(self, from_datetime, to_datetime, calendar=None, domain=None):
"""
By default the resource calendar is used, but it can be
changed using the `calendar` argument.
@ -182,21 +189,28 @@ class ResourceMixin(models.AbstractModel):
Returns a list of tuples (day, hours) for each day
containing at least an attendance.
"""
resource = self.resource_id
calendar = calendar or self.resource_calendar_id
result = {}
records_by_calendar = defaultdict(lambda: self.env[self._name])
for record in self:
records_by_calendar[calendar or record.resource_calendar_id or record.company_id.resource_calendar_id] += record
# naive datetimes are made explicit in UTC
if not from_datetime.tzinfo:
from_datetime = from_datetime.replace(tzinfo=utc)
if not to_datetime.tzinfo:
to_datetime = to_datetime.replace(tzinfo=utc)
compute_leaves = self.env.context.get('compute_leaves', True)
intervals = calendar._work_intervals_batch(from_datetime, to_datetime, resource, domain, compute_leaves=compute_leaves)[resource.id]
result = defaultdict(float)
for start, stop, meta in intervals:
result[start.date()] += (stop - start).total_seconds() / 3600
return sorted(result.items())
for calendar, records in records_by_calendar.items():
resources = self.resource_id
all_intervals = calendar._work_intervals_batch(from_datetime, to_datetime, resources, domain, compute_leaves=compute_leaves)
for record in records:
intervals = all_intervals[record.resource_id.id]
record_result = defaultdict(float)
for start, stop, _meta in intervals:
record_result[start.date()] += (stop - start).total_seconds() / 3600
result[record.id] = sorted(record_result.items())
return result
def list_leaves(self, from_datetime, to_datetime, calendar=None, domain=None):
"""

View file

@ -0,0 +1,401 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from copy import deepcopy
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from datetime import datetime, timedelta, time
from pytz import timezone
from odoo import api, fields, models
from odoo.addons.base.models.res_partner import _tz_get
from odoo.tools import get_lang, babel_locale_parse
from odoo.tools.intervals import Intervals
from odoo.tools.date_utils import localized, sum_intervals, to_timezone, weeknumber, weekstart, weekend
class ResourceResource(models.Model):
_name = 'resource.resource'
_description = "Resources"
_order = "name"
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if not res.get('calendar_id') and res.get('company_id'):
company = self.env['res.company'].browse(res['company_id'])
res['calendar_id'] = company.resource_calendar_id.id
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 resource record without removing it.")
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
resource_type = fields.Selection([
('user', 'Human'),
('material', 'Material')], string='Type',
default='user', required=True)
user_id = fields.Many2one('res.users', string='User', index='btree_not_null', help='Related user name for the resource to manage its access.')
avatar_128 = fields.Image(compute='_compute_avatar_128')
share = fields.Boolean(related='user_id.share')
email = fields.Char(related='user_id.email')
phone = fields.Char(related='user_id.phone')
time_efficiency = fields.Float(
'Efficiency Factor', default=100, required=True,
help="This field is used to calculate the expected duration of a work order at this work center. For example, if a work order takes one hour and the efficiency factor is 100%, then the expected duration will be one hour. If the efficiency factor is 200%, however the expected duration will be 30 minutes.")
calendar_id = fields.Many2one(
"resource.calendar", string='Working Time',
default=lambda self: self.env.company.resource_calendar_id,
domain="[('company_id', '=', company_id)]",
help="Define the working schedule of the resource. If not set, the resource will have fully flexible working hours.")
tz = fields.Selection(
_tz_get, string='Timezone', required=True,
default=lambda self: self.env.context.get('tz') or self.env.user.tz or 'UTC')
_check_time_efficiency = models.Constraint(
'CHECK(time_efficiency>0)',
'Time efficiency must be strictly positive',
)
@api.depends('user_id')
def _compute_avatar_128(self):
for resource in self:
resource.avatar_128 = resource.user_id.avatar_128
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
if values.get('company_id') and not 'calendar_id' in values:
values['calendar_id'] = self.env['res.company'].browse(values['company_id']).resource_calendar_id.id
if not values.get('tz'):
# retrieve timezone on user or calendar
tz = (self.env['res.users'].browse(values.get('user_id')).tz or
self.env['resource.calendar'].browse(values.get('calendar_id')).tz)
if tz:
values['tz'] = tz
return super().create(vals_list)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", resource.name)) for resource, vals in zip(self, vals_list)]
def write(self, vals):
if self.env.context.get('check_idempotence') and len(self) == 1:
vals = {
fname: value
for fname, value in vals.items()
if self._fields[fname].convert_to_write(self[fname], self) != value
}
if not vals:
return True
return super().write(vals)
@api.onchange('company_id')
def _onchange_company_id(self):
if self.company_id:
self.calendar_id = self.company_id.resource_calendar_id.id
@api.onchange('user_id')
def _onchange_user_id(self):
if self.user_id:
self.tz = self.user_id.tz
def _get_work_interval(self, start, end):
# Deprecated method. Use `_adjust_to_calendar` instead
return self._adjust_to_calendar(start, end)
def _adjust_to_calendar(self, start, end, compute_leaves=True):
"""Adjust the given start and end datetimes to the closest effective hours encoded
in the resource calendar. Only attendances in the same day as `start` and `end` are
considered (respectively). If no attendance is found during that day, the closest hour
is None.
e.g. simplified example:
given two attendances: 8am-1pm and 2pm-5pm, given start=9am and end=6pm
resource._adjust_to_calendar(start, end)
>>> {resource: (8am, 5pm)}
:return: Closest matching start and end of working periods for each resource
:rtype: dict(resource, tuple(datetime | None, datetime | None))
"""
revert_start_tz = to_timezone(start.tzinfo)
revert_end_tz = to_timezone(end.tzinfo)
start = localized(start)
end = localized(end)
result = {}
for resource in self:
resource_tz = timezone(resource.tz)
start, end = start.astimezone(resource_tz), end.astimezone(resource_tz)
search_range = [
start + relativedelta(hour=0, minute=0, second=0),
end + relativedelta(days=1, hour=0, minute=0, second=0),
]
calendar = resource.calendar_id or resource.company_id.resource_calendar_id or self.env.company.resource_calendar_id
calendar_start = calendar._get_closest_work_time(start, resource=resource, search_range=search_range,
compute_leaves=compute_leaves)
search_range[0] = start
calendar_end = calendar._get_closest_work_time(max(start, end), match_end=True,
resource=resource, search_range=search_range,
compute_leaves=compute_leaves)
result[resource] = (
calendar_start and revert_start_tz(calendar_start),
calendar_end and revert_end_tz(calendar_end),
)
return result
def _get_unavailable_intervals(self, start, end):
""" Compute the intervals during which employee is unavailable with hour granularity between start and end
Note: this method is used in enterprise (forecast and planning)
"""
start_datetime = localized(start)
end_datetime = localized(end)
resource_mapping = {}
calendar_mapping = defaultdict(lambda: self.env['resource.resource'])
for resource in self:
calendar_mapping[resource.calendar_id or resource.company_id.resource_calendar_id] |= resource
for calendar, resources in calendar_mapping.items():
if not calendar:
continue
resources_unavailable_intervals = calendar._unavailable_intervals_batch(start_datetime, end_datetime, resources, tz=timezone(calendar.tz))
resource_mapping.update(resources_unavailable_intervals)
return resource_mapping
def _get_calendars_validity_within_period(self, start, end, default_company=None):
""" Gets a dict of dict with resource's id as first key and resource's calendar as secondary key
The value is the validity interval of the calendar for the given resource.
Here the validity interval for each calendar is the whole interval but it's meant to be overriden in further modules
handling resource's employee contracts.
"""
assert start.tzinfo and end.tzinfo
resource_calendars_within_period = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
default_calendar = default_company and default_company.resource_calendar_id or self.env.company.resource_calendar_id
if not self:
# if no resource, add the company resource calendar.
resource_calendars_within_period[False][default_calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
for resource in self:
calendar = resource.calendar_id or resource.company_id.resource_calendar_id or default_calendar
resource_calendars_within_period[resource.id][calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
return resource_calendars_within_period
def _get_valid_work_intervals(self, start, end, calendars=None, compute_leaves=True):
""" Gets the valid work intervals of the resource following their calendars between ``start`` and ``end``
This methods handle the eventuality of a resource having multiple resource calendars, see _get_calendars_validity_within_period method
for further explanation.
For flexible calendars and fully flexible resources: -> return the whole interval
"""
assert start.tzinfo and end.tzinfo
resource_calendar_validity_intervals = {}
calendar_resources = defaultdict(lambda: self.env['resource.resource'])
resource_work_intervals = defaultdict(Intervals)
calendar_work_intervals = dict()
resource_calendar_validity_intervals = self.sudo()._get_calendars_validity_within_period(start, end)
for resource in self:
# For each resource, retrieve its calendar and their validity intervals
for calendar in resource_calendar_validity_intervals[resource.id]:
calendar_resources[calendar] |= resource
for calendar in (calendars or []):
calendar_resources[calendar] |= self.env['resource.resource']
for calendar, resources in calendar_resources.items():
# for fully flexible resource, return the whole interval
if not calendar:
for resource in resources:
resource_work_intervals[resource.id] |= Intervals([(start, end, self.env['resource.calendar.attendance'])])
continue
# For each calendar used by the resources, retrieve the work intervals for every resources using it
work_intervals_batch = calendar._work_intervals_batch(start, end, resources=resources, compute_leaves=compute_leaves)
for resource in resources:
# Make the conjunction between work intervals and calendar validity
resource_work_intervals[resource.id] |= work_intervals_batch[resource.id] & resource_calendar_validity_intervals[resource.id][calendar]
calendar_work_intervals[calendar.id] = work_intervals_batch[False]
return resource_work_intervals, calendar_work_intervals
def _is_fully_flexible(self):
""" employee has a fully flexible schedule has no working calendar set """
self.ensure_one()
return not self.calendar_id
def _get_calendar_at(self, date_target, tz=False):
return {resource: resource.calendar_id for resource in self}
def _is_flexible(self):
""" An employee is considered flexible if the field flexible_hours is True on the calendar
or the employee is not assigned any calendar, in which case is considered as Fully flexible.
"""
self.ensure_one()
return self._is_fully_flexible() or (self.calendar_id and self.calendar_id.flexible_hours)
def _get_flexible_resources_default_work_intervals(self, start, end):
assert start.tzinfo and end.tzinfo
start_date = start.date()
end_date = end.date()
res = {}
resources_per_tz = defaultdict(list)
for resource in self:
resources_per_tz[timezone((resource or self.env.user).tz)].append(resource)
for tz, resources in resources_per_tz.items():
start = start_date
ranges = []
while start <= end_date:
start_datetime = tz.localize(datetime.combine(start, datetime.min.time()))
end_datetime = tz.localize(datetime.combine(start, datetime.max.time()))
ranges.append((start_datetime, end_datetime, self.env['resource.calendar.attendance']))
start += timedelta(days=1)
for resource in resources:
res[resource.id] = Intervals(ranges)
return res
def _get_flexible_resources_calendars_validity_within_period(self, start, end):
assert start.tzinfo and end.tzinfo
resource_default_work_intervals = self._get_flexible_resources_default_work_intervals(start, end)
calendars_within_period_per_resource = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
for resource in self:
calendars_within_period_per_resource[resource.id][resource.calendar_id] = resource_default_work_intervals[resource.id]
return calendars_within_period_per_resource
def _format_leave(self, leave, resource_hours_per_day, resource_hours_per_week, ranges_to_remove, start_day, end_day, locale):
leave_start_day = leave[0].date()
leave_end_day = leave[1].date()
tz = timezone(self.tz or self.env.user.tz)
while leave_start_day <= leave_end_day:
if not self._is_fully_flexible():
hours = self.calendar_id.hours_per_day
# only days inside the original period
if leave_start_day >= start_day and leave_start_day <= end_day:
resource_hours_per_day[self.id][leave_start_day] -= hours
year_and_week = weeknumber(babel_locale_parse(locale), leave_start_day)
resource_hours_per_week[self.id][year_and_week] -= hours
range_start_datetime = tz.localize(datetime.combine(leave_start_day, datetime.min.time()))
range_end_datetime = tz.localize(datetime.combine(leave_start_day, datetime.max.time()))
ranges_to_remove.append((range_start_datetime, range_end_datetime, self.env['resource.calendar.attendance']))
leave_start_day += timedelta(days=1)
def _get_flexible_resource_valid_work_intervals(self, start, end):
if not self:
return {}, {}, {}
assert all(record._is_flexible() for record in self)
assert start.tzinfo and end.tzinfo
start_day, end_day = start.date(), end.date()
week_start_date, week_end_date = start, end
locale = babel_locale_parse(get_lang(self.env).code)
week_start_date = weekstart(locale, start)
week_end_date = weekend(locale, end)
end_year, end_week = weeknumber(locale, week_end_date)
min_start_date = week_start_date + relativedelta(hour=0, minute=0, second=0, microsecond=0)
max_end_date = week_end_date + relativedelta(days=1, hour=0, minute=0, second=0, microsecond=0)
resource_work_intervals = defaultdict(Intervals)
calendar_resources = defaultdict(lambda: self.env['resource.resource'])
resource_calendar_validity_intervals = self._get_flexible_resources_calendars_validity_within_period(min_start_date, max_end_date)
for resource in self:
# For each resource, retrieve their calendars validity intervals
for calendar, work_intervals in resource_calendar_validity_intervals[resource.id].items():
calendar_resources[calendar] |= resource
resource_work_intervals[resource.id] |= work_intervals
resource_by_id = {resource.id: resource for resource in self}
resource_hours_per_day = defaultdict(lambda: defaultdict(float))
resource_hours_per_week = defaultdict(lambda: defaultdict(float))
locale = get_lang(self.env).code
for resource in self:
if resource._is_fully_flexible():
continue
duration_per_day = defaultdict(float)
resource_intervals = resource_work_intervals.get(resource.id, Intervals())
for interval_start, interval_end, _dummy in resource_intervals:
# thanks to default periods structure, start and end should be in same day (with a same timezone !!)
day = interval_start.date()
# custom timeoff can divide a day to > 1 intervals
duration_per_day[day] += (interval_end - interval_start).total_seconds() / 3600
for day, hours in duration_per_day.items():
day_working_hours = min(hours, resource.calendar_id.hours_per_day)
# only days inside the original period
if day >= start_day and day <= end_day:
resource_hours_per_day[resource.id][day] = day_working_hours
year_week = weeknumber(babel_locale_parse(locale), day)
year, week = year_week
if (year < end_year) or (year == end_year and week <= end_week):
# cap weekly hours to the calendar's configured hours_per_week (not the
# company default full_time_required_hours which does not respect
# part-time schedules).
cap = resource.calendar_id.hours_per_week or resource.calendar_id.full_time_required_hours
resource_hours_per_week[resource.id][year_week] = min(cap, day_working_hours + resource_hours_per_week[resource.id][year_week])
for calendar, resources in calendar_resources.items():
domain = [('calendar_id', '=', False)] if not calendar else None
leave_intervals = (calendar or self.env['resource.calendar'])._leave_intervals_batch(min_start_date, max_end_date, resources, domain)
for resource_id, leaves in leave_intervals.items():
if not resource_id:
continue
ranges_to_remove = []
for leave in leaves._items:
resource_by_id[resource_id]._format_leave(leave, resource_hours_per_day, resource_hours_per_week, ranges_to_remove, start_day, end_day, locale)
resource_work_intervals[resource_id] -= Intervals(ranges_to_remove)
for resource_id, work_intervals in resource_work_intervals.items():
tz = timezone(resource_by_id[resource_id].tz or self.env.user.tz)
resource_work_intervals[resource_id] = work_intervals & Intervals([(start.astimezone(tz), end.astimezone(tz), self.env['resource.calendar.attendance'])])
return resource_work_intervals, resource_hours_per_day, resource_hours_per_week
def _get_flexible_resource_work_hours(self, intervals, flexible_resources_hours_per_day, flexible_resources_hours_per_week, work_hours_per_day=None):
assert self._is_flexible()
if self._is_fully_flexible():
return round(sum_intervals(intervals), 2)
# start and end for each Interval have the same day thanks to schedule_intervals_per_resource_id format for flexible employees
# 2 intervals can cover the same day, in case of custom timeoff at the middle of the day
duration_per_day = deepcopy(flexible_resources_hours_per_day)
duration_per_week = deepcopy(flexible_resources_hours_per_week)
interval_duration_per_day = defaultdict(float)
# days with custom time off can divide a day to many intervals
for start, end, _dummy in intervals:
if end.time() == time.max:
# flex resource intervals are formatted in days, each day from min time to max time, when getting the difference, one microsecond is lost
duration = (end + timedelta(microseconds=1) - start).total_seconds() / 3600
else:
duration = (end - start).total_seconds() / 3600
interval_duration_per_day[start.date()] += duration
work_hours = 0.0
locale = get_lang(self.env).code
for day, hours in interval_duration_per_day.items():
week = weeknumber(babel_locale_parse(locale), day)
day_working_hours = max(0.0, min(
hours,
duration_per_day.get(day, 0.0),
duration_per_week.get(week, 0.0),
))
work_hours += day_working_hours
duration_per_week[week] -= day_working_hours
if work_hours_per_day is not None:
work_hours_per_day[day] += day_working_hours
return work_hours

View file

@ -0,0 +1,53 @@
from odoo.fields import Domain
# Default hour per day value. The one should
# only be used when the one from the calendar
# is not available.
HOURS_PER_DAY = 8
def filter_domain_leaf(domain, field_check, field_name_mapping=None):
"""
filter_domain_lead only keep the leaves of a domain that verify a given check. Logical operators that involves
a leaf that is undetermined (because it does not pass the check) are ignored.
each operator is a logic gate:
- '&' and '|' take two entries and can be ignored if one of them (or the two of them) is undetermined
-'!' takes one entry and can be ignored if this entry is undetermined
params:
- domain: the domain that needs to be filtered
- field_check: the function that the field name used in the leaf needs to verify to keep the leaf
- field_name_mapping: dictionary of the form {'field_name': 'new_field_name', ...}. Occurences of 'field_name'
in the first element of domain leaves will be replaced by 'new_field_name'. This is usefull when adapting a
domain from one model to another when some field names do not match the names of the corresponding fields in
the new model.
returns: The filtered version of the domain
"""
field_name_mapping = field_name_mapping or {}
def adapt_condition(condition, ignored):
field_name = condition.field_expr
if not field_check(field_name):
return ignored
field_name = field_name_mapping.get(field_name)
if field_name is None:
return condition
return Domain(field_name, condition.operator, condition.value)
def adapt_domain(domain: Domain, ignored) -> Domain:
if hasattr(domain, 'OPERATOR'):
if domain.OPERATOR in ('&', '|'):
domain = domain.apply(adapt_domain(d, domain.ZERO) for d in domain.children)
elif domain.OPERATOR == '!':
domain = ~adapt_domain(~domain, ~ignored)
else:
assert False, "domain.OPERATOR = {domain.OPEATOR!r} unhandled"
else:
domain = domain.map_conditions(lambda condition: adapt_condition(condition, ignored))
return ignored if domain.is_true() or domain.is_false() else domain
domain = Domain(domain)
if domain.is_false():
return domain
return adapt_domain(domain, ignored=Domain.TRUE)