diff --git a/addons/hr/models/resource.py b/addons/hr/models/resource.py
index 64b5f52db31..05af1c176cf 100644
--- a/addons/hr/models/resource.py
+++ b/addons/hr/models/resource.py
@@ -1,10 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from pytz import utc, timezone
-from datetime import datetime
from odoo import fields, models
-from odoo.addons.resource.models.resource import Intervals
class ResourceResource(models.Model):
diff --git a/addons/hr/tests/test_resource.py b/addons/hr/tests/test_resource.py
index 19074b3f75b..82fabb58554 100644
--- a/addons/hr/tests/test_resource.py
+++ b/addons/hr/tests/test_resource.py
@@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from datetime import datetime
-from pytz import timezone, utc
-from odoo.addons.resource.models.resource import Intervals, sum_intervals
+from datetime import datetime
+from pytz import utc
+
+from odoo.addons.resource.models.utils import Intervals
from .common import TestHrCommon
diff --git a/addons/hr_contract/models/resource_resource.py b/addons/hr_contract/models/resource_resource.py
index 953106496aa..c2c9f9fb2c9 100644
--- a/addons/hr_contract/models/resource_resource.py
+++ b/addons/hr_contract/models/resource_resource.py
@@ -5,7 +5,7 @@ from datetime import datetime
from pytz import timezone
from odoo import models
-from odoo.addons.resource.models.resource import Intervals
+from odoo.addons.resource.models.utils import Intervals
class ResourceResource(models.Model):
_inherit = 'resource.resource'
diff --git a/addons/hr_contract/tests/test_resource.py b/addons/hr_contract/tests/test_resource.py
index d81152edb3c..3549124aeed 100644
--- a/addons/hr_contract/tests/test_resource.py
+++ b/addons/hr_contract/tests/test_resource.py
@@ -3,7 +3,7 @@
from datetime import date, datetime
from pytz import utc, timezone
-from odoo.addons.resource.models.resource import Intervals, sum_intervals
+from odoo.addons.resource.models.utils import Intervals, sum_intervals
from odoo.fields import Date
from .common import TestContractCommon
diff --git a/addons/hr_holidays/models/hr_employee.py b/addons/hr_holidays/models/hr_employee.py
index 0e2e065c419..16a318988a2 100644
--- a/addons/hr_holidays/models/hr_employee.py
+++ b/addons/hr_holidays/models/hr_employee.py
@@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.tools.float_utils import float_round
-from odoo.addons.resource.models.resource import HOURS_PER_DAY
+from odoo.addons.resource.models.utils import HOURS_PER_DAY
class HrEmployeeBase(models.AbstractModel):
diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py
index 625fc331886..0d63291c8ef 100644
--- a/addons/hr_holidays/models/hr_leave.py
+++ b/addons/hr_holidays/models/hr_leave.py
@@ -14,7 +14,7 @@ from odoo.tools import date_utils
from odoo import api, Command, fields, models, tools
from odoo.addons.base.models.res_partner import _tz_get
-from odoo.addons.resource.models.resource import float_to_time, HOURS_PER_DAY
+from odoo.addons.resource.models.utils import float_to_time, HOURS_PER_DAY
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.tools import float_compare, format_date
from odoo.tools.float_utils import float_round
diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py
index ac1c5cf4ad0..16731bc551a 100644
--- a/addons/hr_holidays/models/hr_leave_allocation.py
+++ b/addons/hr_holidays/models/hr_leave_allocation.py
@@ -10,7 +10,7 @@ from datetime import datetime, time
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
-from odoo.addons.resource.models.resource import HOURS_PER_DAY
+from odoo.addons.resource.models.utils import HOURS_PER_DAY
from odoo.addons.hr_holidays.models.hr_leave import get_employee_from_context
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.tools.translate import _
diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py
index c764b8a6a0e..437df216546 100644
--- a/addons/hr_holidays/models/hr_leave_type.py
+++ b/addons/hr_holidays/models/hr_leave_type.py
@@ -14,7 +14,7 @@ from odoo.osv import expression
from odoo.tools import format_date
from odoo.tools.translate import _
from odoo.tools.float_utils import float_round
-from odoo.addons.resource.models.resource import Intervals
+from odoo.addons.resource.models.utils import Intervals
_logger = logging.getLogger(__name__)
diff --git a/addons/hr_work_entry_contract/models/hr_contract.py b/addons/hr_work_entry_contract/models/hr_contract.py
index 7b620c24e23..ac317ea7158 100644
--- a/addons/hr_work_entry_contract/models/hr_contract.py
+++ b/addons/hr_work_entry_contract/models/hr_contract.py
@@ -2,14 +2,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
-import pytz
-
from collections import defaultdict
from datetime import datetime, date
+import pytz
+
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
-from odoo.addons.resource.models.resource import datetime_to_string, string_to_datetime, Intervals
+from odoo.addons.resource.models.utils import string_to_datetime, Intervals
from odoo.osv import expression
from odoo.exceptions import UserError
diff --git a/addons/mrp/models/mrp_workcenter.py b/addons/mrp/models/mrp_workcenter.py
index e2e6f6b21ad..d402dee5eef 100644
--- a/addons/mrp/models/mrp_workcenter.py
+++ b/addons/mrp/models/mrp_workcenter.py
@@ -9,7 +9,7 @@ from random import randint
from odoo import api, exceptions, fields, models, _
from odoo.exceptions import UserError, ValidationError
-from odoo.addons.resource.models.resource import make_aware, Intervals
+from odoo.addons.resource.models.utils import make_aware, Intervals
from odoo.tools.float_utils import float_compare
diff --git a/addons/resource/__manifest__.py b/addons/resource/__manifest__.py
index 2d310c58a7a..1cb2807e6e7 100644
--- a/addons/resource/__manifest__.py
+++ b/addons/resource/__manifest__.py
@@ -18,7 +18,11 @@ associated to every resource. It also manages the leaves of every resource.
'data/resource_data.xml',
'security/ir.model.access.csv',
'security/resource_security.xml',
- 'views/resource_views.xml',
+ 'views/resource_resource_views.xml',
+ 'views/resource_calendar_leaves_views.xml',
+ 'views/resource_calendar_attendance_views.xml',
+ 'views/resource_calendar_views.xml',
+ 'views/menuitems.xml',
],
'assets': {
'web.assets_backend': [
diff --git a/addons/resource/models/__init__.py b/addons/resource/models/__init__.py
index 43ace13cef0..9778b6b83ca 100644
--- a/addons/resource/models/__init__.py
+++ b/addons/resource/models/__init__.py
@@ -1,8 +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
diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource_calendar.py
similarity index 61%
rename from addons/resource/models/resource.py
rename to addons/resource/models/resource_calendar.py
index a29e00dd634..1449499e522 100644
--- a/addons/resource/models/resource.py
+++ b/addons/resource/models/resource_calendar.py
@@ -1,14 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from collections import defaultdict
import itertools
-import math
-from datetime import datetime, time, timedelta
-from dateutil.relativedelta import relativedelta
-from dateutil.rrule import rrule, DAILY, WEEKLY
+
+from collections import defaultdict
+from datetime import datetime, timedelta
from functools import partial
from itertools import chain
+
+from dateutil.relativedelta import relativedelta
+from dateutil.rrule import rrule, DAILY
from pytz import timezone, utc
from odoo import api, fields, models, _
@@ -18,133 +19,8 @@ from odoo.osv import expression
from odoo.tools.float_utils import float_round
from odoo.tools import date_utils, float_utils
-from .resource_mixin import timezone_datetime
+from .utils import Intervals, float_to_time, make_aware, datetime_to_string, string_to_datetime, ROUNDING_FACTOR
-# Default hour per day value. The one should
-# only be used when the one from the calendar
-# is not available.
-HOURS_PER_DAY = 8
-# This will generate 16th of days
-ROUNDING_FACTOR = 16
-
-
-def make_aware(dt):
- """ Return ``dt`` with an explicit timezone, together with a function to
- convert a datetime to the same (naive or aware) timezone as ``dt``.
- """
- if dt.tzinfo:
- return dt, lambda val: val.astimezone(dt.tzinfo)
- else:
- return dt.replace(tzinfo=utc), lambda val: val.astimezone(utc).replace(tzinfo=None)
-
-
-def string_to_datetime(value):
- """ Convert the given string value to a datetime in UTC. """
- return utc.localize(fields.Datetime.from_string(value))
-
-
-def datetime_to_string(dt):
- """ Convert the given datetime (converted in UTC) to a string value. """
- return fields.Datetime.to_string(dt.astimezone(utc))
-
-
-def float_to_time(hours):
- """ Convert a number of hours into a time object. """
- if hours == 24.0:
- return time.max
- fractional, integral = math.modf(hours)
- return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
-
-
-def _boundaries(intervals, opening, closing):
- """ Iterate on the boundaries of intervals. """
- for start, stop, recs in intervals:
- if start < stop:
- yield (start, opening, recs)
- yield (stop, closing, recs)
-
-# YTI TODO LAST SPLIT FILES PLIIIIZ
-class Intervals(object):
- """ Collection of ordered disjoint intervals with some associated records.
- Each interval is a triple ``(start, stop, records)``, where ``records``
- is a recordset.
- """
- def __init__(self, intervals=()):
- self._items = []
- if intervals:
- # normalize the representation of intervals
- append = self._items.append
- starts = []
- recses = []
- for value, flag, recs in sorted(_boundaries(intervals, 'start', 'stop')):
- if flag == 'start':
- starts.append(value)
- recses.append(recs)
- else:
- start = starts.pop()
- if not starts:
- append((start, value, recses[0].union(*recses)))
- recses.clear()
-
- def __bool__(self):
- return bool(self._items)
-
- def __len__(self):
- return len(self._items)
-
- def __iter__(self):
- return iter(self._items)
-
- def __reversed__(self):
- return reversed(self._items)
-
- def __or__(self, other):
- """ Return the union of two sets of intervals. """
- return Intervals(chain(self._items, other._items))
-
- def __and__(self, other):
- """ Return the intersection of two sets of intervals. """
- return self._merge(other, False)
-
- def __sub__(self, other):
- """ Return the difference of two sets of intervals. """
- return self._merge(other, True)
-
- def _merge(self, other, difference):
- """ Return the difference or intersection of two sets of intervals. """
- result = Intervals()
- append = result._items.append
-
- # using 'self' and 'other' below forces normalization
- bounds1 = _boundaries(self, 'start', 'stop')
- bounds2 = _boundaries(other, 'switch', 'switch')
-
- start = None # set by start/stop
- recs1 = None # set by start
- enabled = difference # changed by switch
- for value, flag, recs in sorted(chain(bounds1, bounds2)):
- if flag == 'start':
- start = value
- recs1 = recs
- elif flag == 'stop':
- if enabled and start < value:
- append((start, value, recs1))
- start = None
- else:
- if not enabled and start is not None:
- start = value
- if enabled and start is not None and start < value:
- append((start, value, recs1))
- enabled = not enabled
-
- return result
-
-def sum_intervals(intervals):
- """ Sum the intervals duration (unit : hour)"""
- return sum(
- (stop - start).total_seconds() / 3600
- for start, stop, meta in intervals
- )
class ResourceCalendar(models.Model):
""" Calendar model for a resource. It has
@@ -162,7 +38,7 @@ class ResourceCalendar(models.Model):
@api.model
def default_get(self, fields):
- res = super(ResourceCalendar, self).default_get(fields)
+ res = super().default_get(fields)
if not res.get('name') and res.get('company_id'):
res['name'] = _('Working Hours of %s', self.env['res.company'].browse(res['company_id']).name)
if 'attendance_ids' in fields and not res.get('attendance_ids'):
@@ -263,7 +139,7 @@ class ResourceCalendar(models.Model):
default = {}
if not default.get('name'):
default.update(name=_('%s (copy)') % (self.name))
- return super(ResourceCalendar, self).copy(default)
+ return super().copy(default)
@api.constrains('attendance_ids')
def _check_attendance_ids(self):
@@ -838,331 +714,3 @@ class ResourceCalendar(models.Model):
for attendance in self.attendance_ids.filtered(lambda a: a.day_period != 'lunch' and ((not a.date_from or not a.date_to) or (a.date_from <= end.date() and a.date_to >= start.date()))):
mapped_data[(attendance.week_type, attendance.dayofweek)] += attendance.hour_to - attendance.hour_from
return max(mapped_data.values())
-
-
-class ResourceCalendarAttendance(models.Model):
- _name = "resource.calendar.attendance"
- _description = "Work Detail"
- _order = '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')
- date_from = fields.Date(string='Starting Date')
- date_to = fields.Date(string='End Date')
- hour_from = fields.Float(string='Work from', 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', required=True)
- calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
- day_period = fields.Selection([
- ('morning', 'Morning'),
- ('lunch', 'Lunch'),
- ('afternoon', 'Afternoon')], required=True, default='morning')
- resource_id = fields.Many2one('resource.resource', 'Resource')
- 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.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)
-
- 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': _('First week'), '1': _('Second week')}
- section_info = {True: _('this week'), False: _('other week')}
- for record in self.filtered(lambda l: l.display_type == 'line_section'):
- section_name = "%s (%s)" % (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,
- 'date_from': self.date_from,
- 'date_to': self.date_to,
- '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,
- }
-
-
-class ResourceResource(models.Model):
- _name = "resource.resource"
- _description = "Resources"
- _order = "name"
-
- @api.model
- def default_get(self, fields):
- res = super(ResourceResource, self).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', help='Related user name for the resource to manage its access.')
- 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,
- required=True, domain="[('company_id', '=', company_id)]")
- tz = fields.Selection(
- _tz_get, string='Timezone', required=True,
- default=lambda self: self._context.get('tz') or self.env.user.tz or 'UTC')
-
- _sql_constraints = [
- ('check_time_efficiency', 'CHECK(time_efficiency>0)', 'Time efficiency must be strictly positive'),
- ]
-
- @api.model_create_multi
- def create(self, vals_list):
- for values in vals_list:
- if values.get('company_id') and not values.get('calendar_id'):
- 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(ResourceResource, self).create(vals_list)
-
- @api.returns('self', lambda value: value.id)
- def copy(self, default=None):
- self.ensure_one()
- if default is None:
- default = {}
- if not default.get('name'):
- default.update(name=_('%s (copy)') % (self.name))
- return super(ResourceResource, self).copy(default)
-
- def write(self, values):
- if self.env.context.get('check_idempotence') and len(self) == 1:
- values = {
- fname: value
- for fname, value in values.items()
- if self._fields[fname].convert_to_write(self[fname], self) != value
- }
- if not values:
- return True
- return super().write(values)
-
- @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))
- """
- start, revert_start_tz = make_aware(start)
- end, revert_end_tz = make_aware(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_start = resource.calendar_id._get_closest_work_time(start, resource=resource, search_range=search_range,
- compute_leaves=compute_leaves)
- search_range[0] = start
- calendar_end = resource.calendar_id._get_closest_work_time(end if end > start else start, 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 = timezone_datetime(start)
- end_datetime = timezone_datetime(end)
- resource_mapping = {}
- calendar_mapping = defaultdict(lambda: self.env['resource.resource'])
- for resource in self:
- calendar_mapping[resource.calendar_id] |= resource
-
- for calendar, resources in calendar_mapping.items():
- 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):
- """ 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.
- """
- 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 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)
- 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
-
-class ResourceCalendarLeaves(models.Model):
- _name = "resource.calendar.leaves"
- _description = "Resource Time Off Detail"
- _order = "date_from"
-
- def default_get(self, fields_list):
- res = super().default_get(fields_list)
- if 'date_from' in fields_list and 'date_to' in fields_list 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 = fields.Datetime.now()
- user_tz = timezone(self.env.user.tz or self._context.get('tz') or self.company_id.resource_calendar_id.tz or 'UTC')
- date_from = user_tz.localize(datetime.combine(today, time.min))
- date_to = user_tz.localize(datetime.combine(today, time.max))
- intervals = self.env.company.resource_calendar_id._work_intervals_batch(date_from.replace(tzinfo=utc), date_to.replace(tzinfo=utc))[False]
- if intervals: # Then we stop and return the dates given in parameter
- list_intervals = [(start, stop) for start, stop, records in intervals] # Convert intervals in interval list
- date_from = list_intervals[0][0] # We take the first date in the interval list
- date_to = list_intervals[-1][1] # We take the last date in the interval list
- 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', 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', required=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('calendar_id')
- def _compute_company_id(self):
- for leave in self:
- leave.company_id = leave.calendar_id.company_id or self.env.company
-
- @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.'))
-
- @api.onchange('resource_id')
- def onchange_resource(self):
- if self.resource_id:
- self.calendar_id = self.resource_id.calendar_id
-
- 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,
- }
diff --git a/addons/resource/models/resource_calendar_attendance.py b/addons/resource/models/resource_calendar_attendance.py
new file mode 100644
index 00000000000..859673f5f8d
--- /dev/null
+++ b/addons/resource/models/resource_calendar_attendance.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import math
+
+from odoo import api, fields, models, _
+
+
+class ResourceCalendarAttendance(models.Model):
+ _name = "resource.calendar.attendance"
+ _description = "Work Detail"
+ _order = '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')
+ date_from = fields.Date(string='Starting Date')
+ date_to = fields.Date(string='End Date')
+ hour_from = fields.Float(string='Work from', 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', required=True)
+ calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
+ day_period = fields.Selection([
+ ('morning', 'Morning'),
+ ('lunch', 'Lunch'),
+ ('afternoon', 'Afternoon')], required=True, default='morning')
+ resource_id = fields.Many2one('resource.resource', 'Resource')
+ 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.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)
+
+ 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': _('First week'), '1': _('Second week')}
+ section_info = {True: _('this week'), False: _('other week')}
+ for record in self.filtered(lambda l: l.display_type == 'line_section'):
+ section_name = "%s (%s)" % (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,
+ 'date_from': self.date_from,
+ 'date_to': self.date_to,
+ '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,
+ }
diff --git a/addons/resource/models/resource_calendar_leaves.py b/addons/resource/models/resource_calendar_leaves.py
new file mode 100644
index 00000000000..4a20960e7f5
--- /dev/null
+++ b/addons/resource/models/resource_calendar_leaves.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, time
+from pytz import timezone, utc
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class ResourceCalendarLeaves(models.Model):
+ _name = "resource.calendar.leaves"
+ _description = "Resource Time Off Detail"
+ _order = "date_from"
+
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ if 'date_from' in fields_list and 'date_to' in fields_list 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 = fields.Datetime.now()
+ user_tz = timezone(self.env.user.tz or self._context.get('tz') or self.company_id.resource_calendar_id.tz or 'UTC')
+ date_from = user_tz.localize(datetime.combine(today, time.min))
+ date_to = user_tz.localize(datetime.combine(today, time.max))
+ intervals = self.env.company.resource_calendar_id._work_intervals_batch(date_from.replace(tzinfo=utc), date_to.replace(tzinfo=utc))[False]
+ if intervals: # Then we stop and return the dates given in parameter
+ list_intervals = [(start, stop) for start, stop, records in intervals] # Convert intervals in interval list
+ date_from = list_intervals[0][0] # We take the first date in the interval list
+ date_to = list_intervals[-1][1] # We take the last date in the interval list
+ 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', 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', required=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('calendar_id')
+ def _compute_company_id(self):
+ for leave in self:
+ leave.company_id = leave.calendar_id.company_id or self.env.company
+
+ @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.'))
+
+ @api.onchange('resource_id')
+ def onchange_resource(self):
+ if self.resource_id:
+ self.calendar_id = self.resource_id.calendar_id
+
+ 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,
+ }
diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py
index 934ce18260a..8b45c405442 100644
--- a/addons/resource/models/resource_mixin.py
+++ b/addons/resource/models/resource_mixin.py
@@ -2,16 +2,10 @@
# 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 .utils import timezone_datetime
class ResourceMixin(models.AbstractModel):
@@ -80,7 +74,7 @@ class ResourceMixin(models.AbstractModel):
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)
+ return super().copy_data(default)
def _get_work_days_data_batch(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None):
"""
diff --git a/addons/resource/models/resource_resource.py b/addons/resource/models/resource_resource.py
new file mode 100644
index 00000000000..ed86c885564
--- /dev/null
+++ b/addons/resource/models/resource_resource.py
@@ -0,0 +1,195 @@
+# -*- 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 timezone
+
+from odoo import api, fields, models, _
+from odoo.addons.base.models.res_partner import _tz_get
+
+from .utils import timezone_datetime, make_aware, Intervals
+
+
+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', help='Related user name for the resource to manage its access.')
+ 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,
+ required=True, domain="[('company_id', '=', company_id)]")
+ tz = fields.Selection(
+ _tz_get, string='Timezone', required=True,
+ default=lambda self: self._context.get('tz') or self.env.user.tz or 'UTC')
+
+ _sql_constraints = [
+ ('check_time_efficiency', 'CHECK(time_efficiency>0)', 'Time efficiency must be strictly positive'),
+ ]
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for values in vals_list:
+ if values.get('company_id') and not values.get('calendar_id'):
+ 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)
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ self.ensure_one()
+ if default is None:
+ default = {}
+ if not default.get('name'):
+ default.update(name=_('%s (copy)') % (self.name))
+ return super().copy(default)
+
+ def write(self, values):
+ if self.env.context.get('check_idempotence') and len(self) == 1:
+ values = {
+ fname: value
+ for fname, value in values.items()
+ if self._fields[fname].convert_to_write(self[fname], self) != value
+ }
+ if not values:
+ return True
+ return super().write(values)
+
+ @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))
+ """
+ start, revert_start_tz = make_aware(start)
+ end, revert_end_tz = make_aware(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_start = resource.calendar_id._get_closest_work_time(start, resource=resource, search_range=search_range,
+ compute_leaves=compute_leaves)
+ search_range[0] = start
+ calendar_end = resource.calendar_id._get_closest_work_time(end if end > start else start, 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 = timezone_datetime(start)
+ end_datetime = timezone_datetime(end)
+ resource_mapping = {}
+ calendar_mapping = defaultdict(lambda: self.env['resource.resource'])
+ for resource in self:
+ calendar_mapping[resource.calendar_id] |= resource
+
+ for calendar, resources in calendar_mapping.items():
+ 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):
+ """ 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.
+ """
+ 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 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)
+ 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
diff --git a/addons/resource/models/utils.py b/addons/resource/models/utils.py
index 8db355daeba..8cce56d4afa 100644
--- a/addons/resource/models/utils.py
+++ b/addons/resource/models/utils.py
@@ -1,6 +1,56 @@
+# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import math
+from datetime import time
+from itertools import chain
+from pytz import utc
+
+from odoo import fields
from odoo.osv.expression import normalize_domain, NOT_OPERATOR, DOMAIN_OPERATORS
+from odoo.tools.float_utils import float_round
+
+# Default hour per day value. The one should
+# only be used when the one from the calendar
+# is not available.
+HOURS_PER_DAY = 8
+# This will generate 16th of days
+ROUNDING_FACTOR = 16
+
+
+def make_aware(dt):
+ """ Return ``dt`` with an explicit timezone, together with a function to
+ convert a datetime to the same (naive or aware) timezone as ``dt``.
+ """
+ if dt.tzinfo:
+ return dt, lambda val: val.astimezone(dt.tzinfo)
+ return dt.replace(tzinfo=utc), lambda val: val.astimezone(utc).replace(tzinfo=None)
+
+
+def string_to_datetime(value):
+ """ Convert the given string value to a datetime in UTC. """
+ return utc.localize(fields.Datetime.from_string(value))
+
+
+def datetime_to_string(dt):
+ """ Convert the given datetime (converted in UTC) to a string value. """
+ return fields.Datetime.to_string(dt.astimezone(utc))
+
+
+def float_to_time(hours):
+ """ Convert a number of hours into a time object. """
+ if hours == 24.0:
+ return time.max
+ fractional, integral = math.modf(hours)
+ return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
+
+
+def _boundaries(intervals, opening, closing):
+ """ Iterate on the boundaries of intervals. """
+ for start, stop, recs in intervals:
+ if start < stop:
+ yield (start, opening, recs)
+ yield (stop, closing, recs)
def filter_domain_leaf(domain, leaf_check):
"""
@@ -41,3 +91,90 @@ def filter_domain_leaf(domain, leaf_check):
operator = domain[0] if len(domain) > 1 else False
domain = domain[1:] if len(domain) > 1 else domain
return _filter_domain_leaf_recursive(domain, leaf_check, operator=operator)[0]
+
+class Intervals(object):
+ """ Collection of ordered disjoint intervals with some associated records.
+ Each interval is a triple ``(start, stop, records)``, where ``records``
+ is a recordset.
+ """
+ def __init__(self, intervals=()):
+ self._items = []
+ if intervals:
+ # normalize the representation of intervals
+ append = self._items.append
+ starts = []
+ recses = []
+ for value, flag, recs in sorted(_boundaries(intervals, 'start', 'stop')):
+ if flag == 'start':
+ starts.append(value)
+ recses.append(recs)
+ else:
+ start = starts.pop()
+ if not starts:
+ append((start, value, recses[0].union(*recses)))
+ recses.clear()
+
+ def __bool__(self):
+ return bool(self._items)
+
+ def __len__(self):
+ return len(self._items)
+
+ def __iter__(self):
+ return iter(self._items)
+
+ def __reversed__(self):
+ return reversed(self._items)
+
+ def __or__(self, other):
+ """ Return the union of two sets of intervals. """
+ return Intervals(chain(self._items, other._items))
+
+ def __and__(self, other):
+ """ Return the intersection of two sets of intervals. """
+ return self._merge(other, False)
+
+ def __sub__(self, other):
+ """ Return the difference of two sets of intervals. """
+ return self._merge(other, True)
+
+ def _merge(self, other, difference):
+ """ Return the difference or intersection of two sets of intervals. """
+ result = Intervals()
+ append = result._items.append
+
+ # using 'self' and 'other' below forces normalization
+ bounds1 = _boundaries(self, 'start', 'stop')
+ bounds2 = _boundaries(other, 'switch', 'switch')
+
+ start = None # set by start/stop
+ recs1 = None # set by start
+ enabled = difference # changed by switch
+ for value, flag, recs in sorted(chain(bounds1, bounds2)):
+ if flag == 'start':
+ start = value
+ recs1 = recs
+ elif flag == 'stop':
+ if enabled and start < value:
+ append((start, value, recs1))
+ start = None
+ else:
+ if not enabled and start is not None:
+ start = value
+ if enabled and start is not None and start < value:
+ append((start, value, recs1))
+ enabled = not enabled
+
+ return result
+
+def sum_intervals(intervals):
+ """ Sum the intervals duration (unit : hour)"""
+ return sum(
+ (stop - start).total_seconds() / 3600
+ for start, stop, meta in intervals
+ )
+
+def timezone_datetime(time):
+ if not time.tzinfo:
+ time = time.replace(tzinfo=utc)
+ return time
diff --git a/addons/resource/views/menuitems.xml b/addons/resource/views/menuitems.xml
new file mode 100644
index 00000000000..44953339ff8
--- /dev/null
+++ b/addons/resource/views/menuitems.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/addons/resource/views/resource_calendar_attendance_views.xml b/addons/resource/views/resource_calendar_attendance_views.xml
new file mode 100644
index 00000000000..d9c53eac6a3
--- /dev/null
+++ b/addons/resource/views/resource_calendar_attendance_views.xml
@@ -0,0 +1,45 @@
+
+
+
+ resource.calendar.attendance.tree
+ resource.calendar.attendance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ resource.calendar.attendance.form
+ resource.calendar.attendance
+
+
+
+
+
diff --git a/addons/resource/views/resource_calendar_leaves_views.xml b/addons/resource/views/resource_calendar_leaves_views.xml
new file mode 100644
index 00000000000..4027b1ef843
--- /dev/null
+++ b/addons/resource/views/resource_calendar_leaves_views.xml
@@ -0,0 +1,110 @@
+
+
+
+ resource.calendar.leaves.search
+ resource.calendar.leaves
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ resource.calendar.leaves.calendar
+ resource.calendar.leaves
+
+
+
+
+
+
+
+
+
+
+ resource.calendar.leaves.form
+ resource.calendar.leaves
+
+
+
+
+
+
+ resource.calendar.leaves.tree
+ resource.calendar.leaves
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Resource Time Off
+ resource.calendar.leaves
+ tree,form,calendar
+
+
+
+
+ Resource Time Off
+ resource.calendar.leaves
+ tree,form,calendar
+ {
+ 'default_calendar_id': active_id,
+ 'search_default_calendar_id': active_id}
+
+
+
+
+ Closing Days
+ resource.calendar.leaves
+ calendar,tree,form
+ [('calendar_id','=',active_id), ('resource_id','=',False)]
+ {'default_calendar_id': active_id}
+
+ form
+
+
+
+ Resources Time Off
+ resource.calendar.leaves
+ calendar,tree,form
+ [('calendar_id','=',active_id), ('resource_id','!=',False)]
+ {'default_calendar_id': active_id}
+
+ form
+
+
diff --git a/addons/resource/views/resource_calendar_views.xml b/addons/resource/views/resource_calendar_views.xml
new file mode 100644
index 00000000000..2c918523153
--- /dev/null
+++ b/addons/resource/views/resource_calendar_views.xml
@@ -0,0 +1,84 @@
+
+
+
+ resource.calendar.search
+ resource.calendar
+
+
+
+
+
+
+
+
+
+
+
+ resource.calendar.form
+ resource.calendar
+
+
+
+
+
+
+ resource.calendar.tree
+ resource.calendar
+
+
+
+
+
+
+
+
+
+ Working Times
+ resource.calendar
+ tree,form
+
+
+
+
+ Define working hours and time table that could be scheduled to your project members
+
+
+
+
diff --git a/addons/resource/views/resource_resource_views.xml b/addons/resource/views/resource_resource_views.xml
new file mode 100644
index 00000000000..b54ac282f28
--- /dev/null
+++ b/addons/resource/views/resource_resource_views.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+ resource.resource.search
+ resource.resource
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ resource.resource.form
+ resource.resource
+
+
+
+
+
+
+ resource.resource.tree
+ resource.resource
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Resources
+ resource.resource
+ tree,form
+ {}
+
+ Resources allow you to create and manage resources that should be involved in a specific project phase. You can also set their efficiency level and workload based on their weekly working hours.
+
+
+
+ Resources
+ resource.resource
+ tree,form
+ {
+ 'default_calendar_id': active_id,
+ 'search_default_calendar_id': active_id}
+
+ Resources allow you to create and manage resources that should be involved in a specific project phase. You can also set their efficiency level and workload based on their weekly working hours.
+
+
diff --git a/addons/test_resource/tests/test_resource.py b/addons/test_resource/tests/test_resource.py
index e47a7cf8e7e..03cb1e1f311 100644
--- a/addons/test_resource/tests/test_resource.py
+++ b/addons/test_resource/tests/test_resource.py
@@ -7,7 +7,7 @@ from pytz import timezone, utc
from odoo import fields
from odoo.exceptions import ValidationError
-from odoo.addons.resource.models.resource import Intervals, sum_intervals
+from odoo.addons.resource.models.utils import Intervals, sum_intervals
from odoo.addons.test_resource.tests.common import TestResourceCommon
from odoo.tests.common import TransactionCase
diff --git a/addons/website_event_exhibitor/models/event_sponsor.py b/addons/website_event_exhibitor/models/event_sponsor.py
index 0f70e8b3621..293854d1646 100644
--- a/addons/website_event_exhibitor/models/event_sponsor.py
+++ b/addons/website_event_exhibitor/models/event_sponsor.py
@@ -6,7 +6,7 @@ from pytz import timezone, utc
from odoo import api, fields, models, _
from odoo.addons.http_routing.models.ir_http import slug
-from odoo.addons.resource.models.resource import float_to_time
+from odoo.addons.resource.models.utils import float_to_time
from odoo.modules.module import get_resource_path
from odoo.tools import is_html_empty
from odoo.tools.translate import html_translate