[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) <yti@odoo.com>
Signed-off-by: lul-odoo <LucasLefevre@users.noreply.github.com>
This commit is contained in:
Lucas Lefèvre
2020-05-07 08:22:22 +00:00
committed by Yannick Tivisse
parent 0180510c20
commit b674e4401d
3 changed files with 180 additions and 19 deletions
+62 -17
View File
@@ -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
+9
View File
@@ -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
+109 -2
View File
@@ -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