[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:
committed by
Yannick Tivisse
parent
0180510c20
commit
b674e4401d
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user