From b674e4401d1e4b4ae15940db84f04f822293f0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre?= Date: Thu, 2 Apr 2020 14:40:09 +0000 Subject: [PATCH] [FIX] resource: Adjust time span to calendar The method `_get_work_interval` has several problems: 1) The name is not self explanatory in any way 2) It should be defined on the `resource.mixin` 3) It simply does not work (as described in its docstring). Here is a simplified example (only concerned about hours): Currently, given two attendances: 8-12 and 13-17, with parameters start=9 and end=18 it returns (9, 17) while it should return (8, 17). This leads to strange behaviors in the planning app: Given employee A with calendar 8-17 and employee B 8-16. Create a planning slot and assign employee A: the start and end times are set to 8-17. Then assign employee B, the start and end times are correcly set to 8-16. Now reassign employee A: the start and end times are not set to 8-17. Task 2229296 closes odoo/odoo#50804 X-original-commit: 7dfab89bc0025cf6424f00b6992fef1f42463ffe Related: odoo/enterprise#10426 Signed-off-by: Yannick Tivisse (yti) Signed-off-by: lul-odoo --- addons/resource/models/resource.py | 79 ++++++++++++---- addons/resource/models/resource_mixin.py | 9 ++ addons/resource/tests/test_resource.py | 111 ++++++++++++++++++++++- 3 files changed, 180 insertions(+), 19 deletions(-) diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index fb79c8819ae..2f8029f3547 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -3,6 +3,7 @@ from collections import defaultdict import math +import logging from datetime import datetime, time, timedelta from dateutil.relativedelta import relativedelta from dateutil.rrule import rrule, DAILY, WEEKLY @@ -19,6 +20,8 @@ from odoo.tools.float_utils import float_round from odoo.tools import date_utils, float_utils from .resource_mixin import timezone_datetime +_logger = logging.getLogger(__name__) + # Default hour per day value. The one should # only be used when the one from the calendar # is not available. @@ -457,6 +460,35 @@ class ResourceCalendar(models.Model): day_total[start.date()] += (stop - start).total_seconds() / 3600 return day_total + def _get_closest_work_time(self, dt, match_end=False, resource=None, search_range=None): + """Return the closest work interval boundary within the search range. + Consider only starts of intervals unless `match_end` is True. It will then only consider + ends of intervals. + :param dt: reference datetime + :param match_end: wether to search for the begining of an interval or the end. + :param search_range: time interval considered. Defaults to the entire day of `dt` + :rtype: datetime | None + """ + def interval_dt(interval): + return interval[1 if match_end else 0] + + if not search_range: + range_start = dt + relativedelta(hour=0, minute=0, second=0) + range_end = dt + relativedelta(days=1, hour=0, minute=0, second=0) + else: + range_start, range_end = search_range + + if not (dt.tzinfo and range_start.tzinfo and range_end.tzinfo): + raise ValueError('Provided datetimes needs to be timezoned') + + if not range_start <= dt <= range_end: + return None + work_intervals = sorted( + self._work_intervals(range_start, range_end, resource), + key=lambda i: abs(interval_dt(i) - dt), + ) + return interval_dt(work_intervals[0]) if work_intervals else None + # -------------------------------------------------- # External API # -------------------------------------------------- @@ -721,27 +753,40 @@ class ResourceResource(models.Model): self.tz = self.user_id.tz def _get_work_interval(self, start, end): - """ Return interval's start datetime for interval closest to start. And interval's end datetime for interval closest to end. - If none is found return None - Note: this method is used in enterprise (forecast and planning) + # Deprecated method. Use `_adjust_to_calendar` instead + return self._adjust_to_calendar(start, end) - :start: datetime - :end: datetime - :return: (datetime|None, datetime|None) + def _adjust_to_calendar(self, start, end): + """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_datetime = timezone_datetime(start) - end_datetime = timezone_datetime(end) - resource_mapping = {} + start, revert_start_tz = make_aware(start) + end, revert_end_tz = make_aware(end) + result = {} for resource in self: - work_intervals = sorted( - resource.calendar_id._work_intervals(start_datetime, end_datetime, resource), - key=lambda x: x[0] + calendar_start = resource.calendar_id._get_closest_work_time(start, resource=resource) + search_range = None + if calendar_start and start.date() == end.date(): + # Make sure to only search end after start + search_range = ( + start, + end + relativedelta(days=1, hour=0, minute=0, second=0), + ) + calendar_end = resource.calendar_id._get_closest_work_time(end, match_end=True, resource=resource, search_range=search_range) + result[resource] = ( + calendar_start and revert_start_tz(calendar_start), + calendar_end and revert_end_tz(calendar_end), ) - if work_intervals: - resource_mapping[resource.id] = (work_intervals[0][0].astimezone(utc), work_intervals[-1][1].astimezone(utc)) - else: - resource_mapping[resource.id] = (None, None) - return resource_mapping + return result + def _get_unavailable_intervals(self, start, end): """ Compute the intervals during which employee is unavailable with hour granularity between start and end diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index 1358ffd1f7a..368528bf61a 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -2,6 +2,7 @@ # 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 @@ -107,6 +108,14 @@ class ResourceMixin(models.AbstractModel): return calendar._get_days_data(attendances & leaves, day_total) + def _adjust_to_calendar(self, start, end): + resource_results = self.resource_id._adjust_to_calendar(start, end) + # change dict keys from resources to associated records. + return { + record: resource_results[record.resource_id] + for record in self + } + 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 diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index 3a73ac366dd..123648855be 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -375,10 +375,117 @@ class TestCalendar(TestResourceCommon): time = self.calendar_jean.plan_days(3000, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) self.assertEqual(time, False) + def test_closest_time(self): + # Calendar: + # Tuesdays 8-16 + # Fridays 8-13 and 16-23 + dt = datetime_tz(2020, 4, 2, 7, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertFalse(calendar_dt, "It should not return any value for unattended days") + + dt = datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) + range_start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + range_end = datetime_tz(2020, 4, 3, 19, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, search_range=(range_start, range_end)) + self.assertFalse(calendar_dt, "It should not return any value if dt outside of range") + + dt = datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) # before + start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertEqual(calendar_dt, start, "It should return the start of the day") + + dt = datetime_tz(2020, 4, 3, 10, 0, 0, tzinfo=self.john.tz) # after + start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertEqual(calendar_dt, start, "It should return the start of the closest attendance") + + dt = datetime_tz(2020, 4, 3, 7, 0, 0, tzinfo=self.john.tz) # before + end = datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) + self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") + + dt = datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz) # after + end = datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) + self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") + + dt = datetime_tz(2020, 4, 3, 0, 0, 0, tzinfo=self.john.tz) + start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertEqual(calendar_dt, start, "It should return the start of the closest attendance") + + dt = datetime_tz(2020, 4, 3, 23, 59, 59, tzinfo=self.john.tz) + end = datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, match_end=True) + self.assertEqual(calendar_dt, end, "It should return the end of the closest attendance") + + # with a resource specific attendance + self.env['resource.calendar.attendance'].create({ + 'name': 'Att4', + 'calendar_id': self.calendar_john.id, + 'dayofweek': '4', + 'hour_from': 5, + 'hour_to': 6, + 'resource_id': self.john.resource_id.id, + }) + dt = datetime_tz(2020, 4, 3, 5, 0, 0, tzinfo=self.john.tz) + start = datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt) + self.assertEqual(calendar_dt, start, "It should not take into account resouce specific attendances") + + dt = datetime_tz(2020, 4, 3, 5, 0, 0, tzinfo=self.john.tz) + start = datetime_tz(2020, 4, 3, 5, 0, 0, tzinfo=self.john.tz) + calendar_dt = self.calendar_john._get_closest_work_time(dt, resource=self.john.resource_id) + self.assertEqual(calendar_dt, start, "It should have taken john's specific attendances") + class TestResMixin(TestResourceCommon): - def setUp(self): - super(TestResMixin, self).setUp() + + def test_adjust_calendar(self): + # Calendar: + # Tuesdays 8-16 + # Fridays 8-13 and 16-23 + result = self.john._adjust_to_calendar( + datetime_tz(2020, 4, 3, 9, 0, 0, tzinfo=self.john.tz), + datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john],( + datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), + datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz), + )) + + result = self.john._adjust_to_calendar( + datetime_tz(2020, 4, 3, 13, 1, 0, tzinfo=self.john.tz), + datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john],( + datetime_tz(2020, 4, 3, 16, 0, 0, tzinfo=self.john.tz), + datetime_tz(2020, 4, 3, 23, 0, 0, tzinfo=self.john.tz), + )) + + result = self.john._adjust_to_calendar( + datetime_tz(2020, 4, 4, 9, 0, 0, tzinfo=self.john.tz), # both a day without attendance + datetime_tz(2020, 4, 4, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john], (None, None)) + + result = self.john._adjust_to_calendar( + datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), + datetime_tz(2020, 4, 4, 14, 0, 0, tzinfo=self.john.tz), # day without attendance + ) + self.assertEqual(result[self.john], ( + datetime_tz(2020, 4, 3, 8, 0, 0, tzinfo=self.john.tz), + None, + )) + + result = self.john._adjust_to_calendar( + datetime_tz(2020, 4, 2, 8, 0, 0, tzinfo=self.john.tz), # day without attendance + datetime_tz(2020, 4, 3, 14, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(result[self.john], ( + None, + datetime_tz(2020, 4, 3, 13, 0, 0, tzinfo=self.john.tz), + )) def test_work_days_data(self): # Looking at Jean's calendar