From 6d41b7ec7e33d6b50e64ae2a1c938a463a12ee20 Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Tue, 28 Sep 2021 15:27:43 +0200 Subject: [PATCH 1/3] [IMP] resource: add the false key to work intervals batch The False key is always added in attendance and leave intervals batch but is not added in the work_intervals batch. To give a more smooth API, this commit aligns work_intervals behavior with the two other methods behavior, regarding the added False key. Furthermore, we avoid to deal with a resource_list of two empty recordsets, in the case where the resources parameter is None or empty recordset. PR: #77362 task-2646630 --- addons/resource/models/resource.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index e1050d4a5a2..16c0a8dac42 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -382,13 +382,15 @@ class ResourceCalendar(models.Model): """ Return the attendance intervals in the given datetime range. The returned intervals are expressed in specified tz or in the resource's timezone. """ - self.ensure_one() - resources = self.env['resource.resource'] if not resources else resources assert start_dt.tzinfo and end_dt.tzinfo self.ensure_one() combine = datetime.combine - resources_list = list(resources) + [self.env['resource.resource']] + if not resources: + resources = self.env['resource.resource'] + resources_list = [resources] + else: + resources_list = list(resources) + [self.env['resource.resource']] resource_ids = [r.id for r in resources_list] domain = domain if domain is not None else [] domain = expression.AND([domain, [ @@ -472,15 +474,18 @@ class ResourceCalendar(models.Model): """ Return the leave intervals in the given datetime range. The returned intervals are expressed in specified tz or in the calendar's timezone. """ - resources = self.env['resource.resource'] if not resources else resources assert start_dt.tzinfo and end_dt.tzinfo self.ensure_one() - # for the computation, express all datetimes in UTC - resources_list = list(resources) + [self.env['resource.resource']] + if not resources: + resources = self.env['resource.resource'] + resources_list = [resources] + else: + resources_list = list(resources) + [self.env['resource.resource']] resource_ids = [r.id for r in resources_list] if domain is None: domain = [('time_type', '=', 'leave')] + # for the computation, express all datetimes in UTC domain = domain + [ ('calendar_id', 'in', [False, self.id]), ('resource_id', 'in', resource_ids), @@ -518,7 +523,7 @@ class ResourceCalendar(models.Model): resources = self.env['resource.resource'] resources_list = [resources] else: - resources_list = list(resources) + resources_list = list(resources) + [self.env['resource.resource']] attendance_intervals = self._attendance_intervals_batch(start_dt, end_dt, resources, tz=tz) if compute_leaves: @@ -588,8 +593,11 @@ class ResourceCalendar(models.Model): @return dict with hours of attendance in each day between `from_datetime` and `to_datetime` """ self.ensure_one() - resources = self.env['resource.resource'] if not resources else resources - resources_list = list(resources) + [self.env['resource.resource']] + if not resources: + resources = self.env['resource.resource'] + resources_list = [resources] + else: + resources_list = list(resources) + [self.env['resource.resource']] # total hours per day: retrieve attendances with one extra day margin, # in order to compute the total hours on the first and last days from_full = from_datetime - timedelta(days=1) From be23e7436d5239063e9ae1537a1b419a4de14c09 Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Mon, 11 Oct 2021 15:07:26 +0200 Subject: [PATCH 2/3] [IMP] resource: speedup tests This commit replaces the setUp method by a classmethod in order to speed up the tests. PR: #77362 task-2646630 --- addons/resource/tests/common.py | 46 ++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/addons/resource/tests/common.py b/addons/resource/tests/common.py index d4b19ccd701..ee2dee6dff7 100644 --- a/addons/resource/tests/common.py +++ b/addons/resource/tests/common.py @@ -6,8 +6,9 @@ from odoo.tests.common import TransactionCase class TestResourceCommon(TransactionCase): - def _define_calendar(self, name, attendances, tz): - return self.env['resource.calendar'].create({ + @classmethod + def _define_calendar(cls, name, attendances, tz): + return cls.env['resource.calendar'].create({ 'name': name, 'tz': tz, 'attendance_ids': [ @@ -20,8 +21,10 @@ class TestResourceCommon(TransactionCase): for index, att in enumerate(attendances) ], }) - def _define_calendar_2_weeks(self, name, attendances, tz): - return self.env['resource.calendar'].create({ + + @classmethod + def _define_calendar_2_weeks(cls, name, attendances, tz): + return cls.env['resource.calendar'].create({ 'name': name, 'tz': tz, 'two_weeks_calendar': True, @@ -39,42 +42,43 @@ class TestResourceCommon(TransactionCase): ], }) - def setUp(self): - super(TestResourceCommon, self).setUp() + @classmethod + def setUpClass(cls): + super(TestResourceCommon, cls).setUpClass() # UTC+1 winter, UTC+2 summer - self.calendar_jean = self._define_calendar('40 Hours', [(8, 16, i) for i in range(5)], 'Europe/Brussels') + cls.calendar_jean = cls._define_calendar('40 Hours', [(8, 16, i) for i in range(5)], 'Europe/Brussels') # UTC+6 - self.calendar_patel = self._define_calendar('38 Hours', sum([((9, 12, i), (13, 17, i)) for i in range(5)], ()), 'Etc/GMT-6') + cls.calendar_patel = cls._define_calendar('38 Hours', sum([((9, 12, i), (13, 17, i)) for i in range(5)], ()), 'Etc/GMT-6') # UTC-8 winter, UTC-7 summer - self.calendar_john = self._define_calendar('8+12 Hours', [(8, 16, 1), (8, 13, 4), (16, 23, 4)], 'America/Los_Angeles') + cls.calendar_john = cls._define_calendar('8+12 Hours', [(8, 16, 1), (8, 13, 4), (16, 23, 4)], 'America/Los_Angeles') # UTC+1 winter, UTC+2 summer - self.calendar_jules = self._define_calendar_2_weeks('Week 1: 30 Hours - Week 2: 16 Hours', [ + cls.calendar_jules = cls._define_calendar_2_weeks('Week 1: 30 Hours - Week 2: 16 Hours', [ (0, 0, 0, '0', 'line_section', 0), (8, 16, 0, '0', False, 1), (9, 17, 1, '0', False, 2), (0, 0, 0, '1', 'line_section', 10), (8, 16, 0, '1', False, 11), (7, 15, 2, '1', False, 12), (8, 16, 3, '1', False, 13), (10, 16, 4, '1', False, 14)], 'Europe/Brussels') - self.calendar_paul = self._define_calendar('Morning and evening shifts', sum([((2, 7, i), (10, 16, i)) for i in range(5)], ()), 'Brazil/DeNoronha') + cls.calendar_paul = cls._define_calendar('Morning and evening shifts', sum([((2, 7, i), (10, 16, i)) for i in range(5)], ()), 'Brazil/DeNoronha') # Employee is linked to a resource.resource via resource.mixin - self.jean = self.env['resource.test'].create({ + cls.jean = cls.env['resource.test'].create({ 'name': 'Jean', - 'resource_calendar_id': self.calendar_jean.id, + 'resource_calendar_id': cls.calendar_jean.id, }) - self.patel = self.env['resource.test'].create({ + cls.patel = cls.env['resource.test'].create({ 'name': 'Patel', - 'resource_calendar_id': self.calendar_patel.id, + 'resource_calendar_id': cls.calendar_patel.id, }) - self.john = self.env['resource.test'].create({ + cls.john = cls.env['resource.test'].create({ 'name': 'John', - 'resource_calendar_id': self.calendar_john.id, + 'resource_calendar_id': cls.calendar_john.id, }) - self.jules = self.env['resource.test'].create({ + cls.jules = cls.env['resource.test'].create({ 'name': 'Jules', - 'resource_calendar_id': self.calendar_jules.id, + 'resource_calendar_id': cls.calendar_jules.id, }) - self.paul = self.env['resource.test'].create({ + cls.paul = cls.env['resource.test'].create({ 'name': 'Paul', - 'resource_calendar_id': self.calendar_paul.id, + 'resource_calendar_id': cls.calendar_paul.id, }) From 74624f43ee56c5c059eb0c043f2fa509308a57d1 Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Fri, 15 Oct 2021 14:46:01 +0200 Subject: [PATCH 3/3] [REF] resource,hr{,_contract}: obtain calendar validity per resource This commit adds methods to easily obtain calendar validity per resource for a given search period and get the work intervals per resource, taking into account the validity of their calendars. In hr, the create and departure date of an employee are taken into account to compute the calendars validity. In hr_contract, it looks for every contract of human resources with employee of type student or employee existing in the search period and consider the specific calendar of the contract as valid during the contract-lifetime. PR: #77362 task-2646630 --- addons/hr/models/resource.py | 27 +++++ addons/hr/tests/__init__.py | 1 + addons/hr/tests/test_resource.py | 86 ++++++++++++++ addons/hr_contract/models/__init__.py | 1 + addons/hr_contract/models/resource.py | 1 - .../hr_contract/models/resource_resource.py | 39 +++++++ addons/hr_contract/tests/__init__.py | 1 + .../hr_contract/tests/test_calendar_sync.py | 2 +- addons/hr_contract/tests/test_resource.py | 109 ++++++++++++++++++ addons/resource/models/resource.py | 53 ++++++++- addons/resource/tests/test_resource.py | 71 +++++++++++- 11 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 addons/hr/tests/test_resource.py create mode 100644 addons/hr_contract/models/resource_resource.py create mode 100644 addons/hr_contract/tests/test_resource.py diff --git a/addons/hr/models/resource.py b/addons/hr/models/resource.py index b59767f6254..d0db7c76b82 100644 --- a/addons/hr/models/resource.py +++ b/addons/hr/models/resource.py @@ -1,10 +1,37 @@ # -*- 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): _inherit = "resource.resource" user_id = fields.Many2one(copy=False) + employee_id = fields.One2many('hr.employee', 'resource_id', domain="[('company_id', '=', company_id)]") + + def _get_calendars_validity_within_period(self, start, end, default_company=None): + """ + Returns 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. + + The validity interval of the employee resource calendar is the lifetime of the employee, from creation to departure. + """ + assert start.tzinfo and end.tzinfo + calendars_within_period_per_resource = super()._get_calendars_validity_within_period(start, end, default_company=default_company) + for resource in self: + if not resource.employee_id: + continue + create_date = max(start, utc.localize(resource.employee_id.create_date)) + if resource.employee_id.departure_date and resource.employee_id.departure_date <= end.date(): + departure_datetime = timezone(resource.tz).localize(datetime.combine(resource.employee_id.departure_date, datetime.max.time())) + departure_datetime = min(departure_datetime, end) + else: + departure_datetime = end + interval = Intervals([(create_date, departure_datetime, self.env['resource.calendar.attendance'])]) + for calendar in calendars_within_period_per_resource[resource.id]: + calendars_within_period_per_resource[resource.id][calendar] &= interval + return calendars_within_period_per_resource diff --git a/addons/hr/tests/__init__.py b/addons/hr/tests/__init__.py index c46b7cc336f..17a4487628d 100644 --- a/addons/hr/tests/__init__.py +++ b/addons/hr/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_channel from . import test_hr_flow from . import test_self_user_access from . import test_multi_company +from . import test_resource diff --git a/addons/hr/tests/test_resource.py b/addons/hr/tests/test_resource.py new file mode 100644 index 00000000000..490fe343694 --- /dev/null +++ b/addons/hr/tests/test_resource.py @@ -0,0 +1,86 @@ +# -*- 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 .common import TestHrCommon + + +class TestResource(TestHrCommon): + + @classmethod + def setUpClass(cls): + super(TestResource, cls).setUpClass() + cls.calendar_40h = cls.env['resource.calendar'].create({'name': 'Default calendar'}) + cls.employee_niv = cls.env['hr.employee'].create({ + 'name': 'Sharlene Rhodes', + 'departure_date': '2022-06-01', + 'resource_calendar_id': cls.calendar_40h.id, + }) + cls.employee_niv_create_date = '2021-01-01 10:00:00' + cls.env.cr.execute("UPDATE hr_employee SET create_date=%s WHERE id=%s", + (cls.employee_niv_create_date, cls.employee_niv.id)) + + def test_calendars_validity_within_period_default(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[self.employee_niv.resource_id.id]), "Niv should only have one calendar") + niv_entry = calendars[self.employee_niv.resource_id.id] + niv_calendar = next(iter(niv_entry)) + self.assertEqual(niv_calendar, self.calendar_40h, "It should be Niv's Calendar") + self.assertFalse(niv_entry[niv_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - niv_entry[niv_calendar], "Calendar validity should cover all interval") + + def test_calendars_validity_within_period_creation(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2020, 12, 1, 8, 0, 0)), + utc.localize(datetime(2021, 1, 31, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2021, 1, 1, 10, 0, 0)), + utc.localize(datetime(2021, 1, 31, 17, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + niv_entry = calendars[self.employee_niv.resource_id.id] + self.assertFalse(niv_entry[self.calendar_40h] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - niv_entry[self.calendar_40h], "Calendar validity should cover all interval") + + def test_calendars_validity_within_period_before_creation(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2020, 12, 1, 8, 0, 0)), + utc.localize(datetime(2020, 12, 31, 17, 0, 0)), + ) + niv_entry = calendars[self.employee_niv.resource_id.id] + self.assertFalse(niv_entry[self.calendar_40h], "Interval should be empty") + + def test_calendars_validity_within_period_before_departure(self): + calendars = self.employee_niv.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2022, 5, 1, 8, 0, 0)), + utc.localize(datetime(2022, 6, 30, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2022, 5, 1, 8, 0, 0)), + timezone(self.employee_niv.tz).localize(datetime(2022, 6, 1, 23, 59, 59, 999999)), + self.env['resource.calendar.attendance'] + )]) + niv_entry = calendars[self.employee_niv.resource_id.id] + self.assertFalse(niv_entry[self.calendar_40h] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - niv_entry[self.calendar_40h], "Calendar validity should cover all interval") + + def test_get_valid_work_intervals(self): + start = utc.localize(datetime(2022, 5, 29, 0, 0, 0)) + end = utc.localize(datetime(2022, 6, 4, 23, 59, 59)) + work_intervals, _ = self.employee_niv.resource_id._get_valid_work_intervals(start, end) + sum_work_intervals = sum_intervals(work_intervals[self.employee_niv.resource_id.id]) + self.assertEqual(24, sum_work_intervals, "Sum of the work intervals for the resource niv should be 24h as he left his work after wednesday.") diff --git a/addons/hr_contract/models/__init__.py b/addons/hr_contract/models/__init__.py index ce5e380e01d..0c4bf551ae3 100644 --- a/addons/hr_contract/models/__init__.py +++ b/addons/hr_contract/models/__init__.py @@ -6,4 +6,5 @@ from . import hr_employee_public from . import hr_contract from . import res_users from . import resource +from . import resource_resource from . import hr_contract_type diff --git a/addons/hr_contract/models/resource.py b/addons/hr_contract/models/resource.py index ad4079e2f65..ba000c4de18 100644 --- a/addons/hr_contract/models/resource.py +++ b/addons/hr_contract/models/resource.py @@ -1,6 +1,5 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - from datetime import datetime from odoo import fields, models diff --git a/addons/hr_contract/models/resource_resource.py b/addons/hr_contract/models/resource_resource.py new file mode 100644 index 00000000000..5ac7e078eb2 --- /dev/null +++ b/addons/hr_contract/models/resource_resource.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict +from datetime import datetime +from pytz import timezone + +from odoo import models +from odoo.addons.resource.models.resource import Intervals + +class ResourceResource(models.Model): + _inherit = 'resource.resource' + + def _get_calendars_validity_within_period(self, start, end, default_company=None): + assert start.tzinfo and end.tzinfo + if not self: + return super()._get_calendars_validity_within_period(start, end, default_company=default_company) + calendars_within_period_per_resource = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']] + resource_without_contract = self.filtered(lambda r: not r.employee_id or r.employee_id.employee_type not in ['employee', 'student']) + if resource_without_contract: + calendars_within_period_per_resource.update( + super(ResourceResource, resource_without_contract)._get_calendars_validity_within_period(start, end, default_company=default_company) + ) + resource_with_contract = self - resource_without_contract + if not resource_with_contract: + return calendars_within_period_per_resource + timezones = {resource.tz for resource in resource_with_contract} + date_start = min(start.astimezone(timezone(tz)).date() for tz in timezones) + date_end = max(end.astimezone(timezone(tz)).date() for tz in timezones) + contracts = resource_with_contract.employee_id._get_contracts( + date_start, date_end, states=['open', 'draft', 'close'] + ).filtered(lambda c: c.state in ['open', 'close'] or c.kanban_state == 'done') + for contract in contracts: + tz = timezone(contract.employee_id.tz) + calendars_within_period_per_resource[contract.employee_id.resource_id.id][contract.resource_calendar_id] |= Intervals([( + tz.localize(datetime.combine(contract.date_start, datetime.min.time())) if contract.date_start > start.astimezone(tz).date() else start, + tz.localize(datetime.combine(contract.date_end, datetime.max.time())) if contract.date_end and contract.date_end < end.astimezone(tz).date() else end, + self.env['resource.calendar.attendance'] + )]) + return calendars_within_period_per_resource diff --git a/addons/hr_contract/tests/__init__.py b/addons/hr_contract/tests/__init__.py index d798e53356b..6f1137322aa 100644 --- a/addons/hr_contract/tests/__init__.py +++ b/addons/hr_contract/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_auto_status from . import test_contract from . import test_calendar_sync +from . import test_resource diff --git a/addons/hr_contract/tests/test_calendar_sync.py b/addons/hr_contract/tests/test_calendar_sync.py index cecbe399225..40493ddaf9e 100644 --- a/addons/hr_contract/tests/test_calendar_sync.py +++ b/addons/hr_contract/tests/test_calendar_sync.py @@ -66,4 +66,4 @@ class TestContractCalendars(TestContractCommon): # Transfer global leaves self.calendar_richard.transfer_leaves_to(self.calendar_35h, resources=None, from_date=Date.to_date('2015-11-21')) - self.assertEqual(leave3.calendar_id, self.calendar_35h, "Global leave should be transfered") \ No newline at end of file + self.assertEqual(leave3.calendar_id, self.calendar_35h, "Global leave should be transfered") diff --git a/addons/hr_contract/tests/test_resource.py b/addons/hr_contract/tests/test_resource.py new file mode 100644 index 00000000000..0a6021c6051 --- /dev/null +++ b/addons/hr_contract/tests/test_resource.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import date, datetime +from pytz import utc, timezone + +from odoo.addons.resource.models.resource import Intervals, sum_intervals +from odoo.fields import Date + +from .common import TestContractCommon + +class TestResource(TestContractCommon): + + @classmethod + def setUpClass(cls): + super(TestResource, cls).setUpClass() + cls.calendar_richard = cls.env['resource.calendar'].create({'name': 'Calendar of Richard'}) + cls.employee.resource_calendar_id = cls.calendar_richard + + cls.calendar_35h = cls.env['resource.calendar'].create({ + 'name': '35h calendar', + 'attendance_ids': [ + (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Monday Evening', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday Evening', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday Evening', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday Evening', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}), + (0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday Evening', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}) + ], + }) + cls.calendar_35h._onchange_hours_per_day() # update hours/day + + cls.contract_cdd = cls.env['hr.contract'].create({ + 'date_start': Date.to_date('2021-09-01'), + 'date_end': Date.to_date('2021-10-31'), + 'name': 'First CDD Contract for Richard', + 'resource_calendar_id': cls.calendar_35h.id, + 'wage': 5000.0, + 'employee_id': cls.employee.id, + 'state': 'open', + }) + cls.contract_cdi = cls.env['hr.contract'].create({ + 'date_start': Date.to_date('2021-11-01'), + 'name': 'CDI Contract for Richard', + 'resource_calendar_id': cls.calendar_richard.id, + 'wage': 5000.0, + 'employee_id': cls.employee.id, + 'state': 'draft', + 'kanban_state': 'done', + }) + + def test_calendars_validity_within_period(self): + tz = timezone(self.employee.tz) + calendars = self.employee.resource_id._get_calendars_validity_within_period( + tz.localize(datetime(2021, 10, 1, 0, 0, 0)), + tz.localize(datetime(2021, 12, 1, 0, 0, 0)), + ) + interval_35h = Intervals([( + tz.localize(datetime(2021, 10, 1, 0, 0, 0)), + tz.localize(datetime.combine(date(2021, 10, 31), datetime.max.time())), + self.env['resource.calendar.attendance'] + )]) + interval_40h = Intervals([( + tz.localize(datetime(2021, 11, 1, 0, 0, 0)), + tz.localize(datetime(2021, 12, 1, 0, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(2, len(calendars[self.employee.resource_id.id]), "Jean should only have one calendar") + richard_entries = calendars[self.employee.resource_id.id] + for calendar in richard_entries: + self.assertTrue(calendar in (self.calendar_35h | self.calendar_richard), "Each calendar should be listed") + if calendar == self.calendar_35h: + self.assertFalse(richard_entries[calendar] - interval_35h, "Interval 35h should cover all calendar 35h validity") + self.assertFalse(interval_35h - richard_entries[calendar], "Calendar 35h validity should cover all interval 35h") + elif calendar == self.calendar_richard: + self.assertFalse(richard_entries[calendar] - interval_40h, "Interval 40h should cover all calendar 40h validity") + self.assertFalse(interval_40h - richard_entries[calendar], "Calendar 40h validity should cover all interval 40h") + + def test_queries(self): + employees_test = self.env['hr.employee'].create([{ + 'name': 'Employee ' + str(i), + } for i in range(0, 50)]) + for emp in employees_test: + new_contract = self.contract_cdd.copy() + new_contract.employee_id = emp + new_contract.state = 'open' + new_contract = self.contract_cdi.copy() + new_contract.employee_id = emp + new_contract.state = 'draft' + new_contract.kanban_state = 'done' + + start = utc.localize(datetime(2021, 9, 1, 0, 0, 0)) + end = utc.localize(datetime(2021, 11, 30, 23, 59, 59)) + with self.assertQueryCount(14): + work_intervals, _ = (employees_test | self.employee).resource_id._get_valid_work_intervals(start, end) + + self.assertEqual(len(work_intervals), 51) + + def test_get_valid_work_intervals(self): + start = timezone(self.employee.tz).localize(datetime(2021, 10, 24, 2, 0, 0)) + end = timezone(self.employee.tz).localize(datetime(2021, 11, 6, 23, 59, 59)) + work_intervals, _ = self.employee.resource_id._get_valid_work_intervals(start, end) + sum_work_intervals = sum_intervals(work_intervals[self.employee.resource_id.id]) + self.assertEqual(75, sum_work_intervals, "Sum of the work intervals for the employee should be 35h+40h = 75h") diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 16c0a8dac42..9c815aacb1a 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -138,6 +138,12 @@ class Intervals(object): 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 @@ -1002,7 +1008,6 @@ class ResourceResource(models.Model): ) 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) @@ -1020,6 +1025,52 @@ class ResourceResource(models.Model): 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._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" diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index f15058c26ce..27127b630e3 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -6,7 +6,7 @@ from pytz import timezone, utc from odoo import fields from odoo.exceptions import ValidationError -from odoo.addons.resource.models.resource import Intervals +from odoo.addons.resource.models.resource import Intervals, sum_intervals from odoo.addons.resource.tests.common import TestResourceCommon from odoo.tests.common import TransactionCase @@ -1148,3 +1148,72 @@ class TestTimezones(TestResourceCommon): (date(2018, 4, 12), 8), (date(2018, 4, 13), 8), ]) + +class TestResource(TestResourceCommon): + + def test_calendars_validity_within_period(self): + calendars = self.jean.resource_id._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + interval = Intervals([( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + self.env['resource.calendar.attendance'] + )]) + + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[self.jean.resource_id.id]), "Jean should only have one calendar") + jean_entry = calendars[self.jean.resource_id.id] + jean_calendar = next(iter(jean_entry)) + self.assertEqual(self.jean.resource_calendar_id, jean_calendar, "It should be Jean's Calendar") + self.assertFalse(jean_entry[jean_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - jean_entry[jean_calendar], "Calendar validity should cover all interval") + + calendars = self.env['resource.resource']._get_calendars_validity_within_period( + utc.localize(datetime(2021, 7, 1, 8, 0, 0)), + utc.localize(datetime(2021, 7, 30, 17, 0, 0)), + ) + self.assertEqual(1, len(calendars), "The dict returned by calendars validity should only have 1 entry") + self.assertEqual(1, len(calendars[False]), "False (default) should only have one calendar") + false_entry = calendars[False] + false_calendar = next(iter(false_entry)) + self.assertEqual(self.env.company.resource_calendar_id, false_calendar, "It should be company calendar Calendar") + self.assertFalse(false_entry[false_calendar] - interval, "Interval should cover all calendar's validity") + self.assertFalse(interval - false_entry[false_calendar], "Calendar validity should cover all interval") + + def test_performance(self): + calendars = [self.calendar_jean, self.calendar_john, self.calendar_jules, self.calendar_patel] + calendars_len = len(calendars) + self.resources_test = self.env['resource.test'].create([{ + 'name': 'resource ' + str(i), + 'resource_calendar_id': calendars[i % calendars_len].id, + } for i in range(0, 50)]) + + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + with self.assertQueryCount(12): + work_intervals, _ = self.resources_test.resource_id._get_valid_work_intervals(start, end) + + self.assertEqual(len(work_intervals), 50) + + def test_get_valid_work_intervals(self): + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + work_intervals, _ = self.jean.resource_id._get_valid_work_intervals(start, end) + sum_work_intervals = sum_intervals(work_intervals[self.jean.resource_id.id]) + self.assertEqual(58, sum_work_intervals, "Sum of the work intervals for the resource jean should be 40h+18h = 58h") + + def test_get_valid_work_intervals_calendars_only(self): + calendars = [self.calendar_jean, self.calendar_john, self.calendar_jules, self.calendar_patel] + start = utc.localize(datetime(2021, 7, 7, 12, 0, 0)) + end = utc.localize(datetime(2021, 7, 16, 23, 59, 59)) + _, calendars_intervals = self.env['resource.resource']._get_valid_work_intervals(start, end, calendars) + sum_work_intervals_jean = sum_intervals(calendars_intervals[self.calendar_jean.id]) + self.assertEqual(58, sum_work_intervals_jean, "Sum of the work intervals for the calendar of jean should be 40h+18h = 58h") + sum_work_intervals_john = sum_intervals(calendars_intervals[self.calendar_john.id]) + self.assertEqual(26 - 1 / 3600, sum_work_intervals_john, "Sum of the work intervals for the calendar of john should be 20h+6h-1s = 25h59m59s") + sum_work_intervals_jules = sum_intervals(calendars_intervals[self.calendar_jules.id]) + self.assertEqual(31, sum_work_intervals_jules, "Sum of the work intervals for the calendar of jules should be Wodd:15h+Wpair:16h = 31h") + sum_work_intervals_patel = sum_intervals(calendars_intervals[self.calendar_patel.id]) + self.assertEqual(49, sum_work_intervals_patel, "Sum of the work intervals for the calendar of patel should be 14+35h = 49h")