diff --git a/addons/hr_payroll/models/hr_payroll.py b/addons/hr_payroll/models/hr_payroll.py index 4be849c9b9a..82cff684b14 100644 --- a/addons/hr_payroll/models/hr_payroll.py +++ b/addons/hr_payroll/models/hr_payroll.py @@ -334,7 +334,7 @@ class HrPayslip(models.Model): day_to = fields.Datetime.from_string(date_to) nb_of_days = (day_to - day_from).days + 1 for day in range(0, nb_of_days): - working_hours_on_day = contract.working_hours.get_work_hours_count(day_from + timedelta(days=day), False) + working_hours_on_day = contract.working_hours.get_work_hours_count(day_from + timedelta(days=day), False, contract.employee_id.resource_id.id) if working_hours_on_day: #the employee had to work leave_type = was_on_leave(contract.employee_id.id, day_from + timedelta(days=day)) diff --git a/addons/mrp/models/mrp_workcenter.py b/addons/mrp/models/mrp_workcenter.py index 91f73ea9c83..cecdeca655c 100644 --- a/addons/mrp/models/mrp_workcenter.py +++ b/addons/mrp/models/mrp_workcenter.py @@ -26,6 +26,7 @@ class MrpWorkcenter(models.Model): time_start = fields.Float('Time before prod.', help="Time in minutes for the setup.") time_stop = fields.Float('Time after prod.', help="Time in minutes for the cleaning.") resource_id = fields.Many2one('resource.resource', 'Resource', ondelete='cascade', required=True) + code = fields.Char('Code', copy=False) routing_line_ids = fields.One2many('mrp.routing.workcenter', 'workcenter_id', "Routing Lines") order_ids = fields.One2many('mrp.workorder', 'workcenter_id', "Orders") @@ -206,7 +207,7 @@ class MrpWorkcenterProductivity(models.Model): d2 = fields.Datetime.from_string(blocktime.date_end) diff = d2 - d1 if (blocktime.loss_type not in ('productive', 'performance')) and blocktime.workcenter_id.calendar_id: - r = blocktime.workcenter_id.calendar_id.get_work_hours_count(d1, d2, resource_id=blocktime.workcenter_id.resource_id.id) + r = blocktime.workcenter_id.calendar_id.get_work_hours_count(d1, d2, blocktime.workcenter_id.resource_id.id) blocktime.duration = round(r * 60, 2) else: blocktime.duration = round(diff.total_seconds() / 60.0, 2) diff --git a/addons/project_issue/models/project_issue.py b/addons/project_issue/models/project_issue.py index 024ab4e81b4..782ea8e9be4 100644 --- a/addons/project_issue/models/project_issue.py +++ b/addons/project_issue/models/project_issue.py @@ -91,8 +91,7 @@ class ProjectIssue(models.Model): issue.day_open = (dt_date_open - dt_create_date).total_seconds() / (24.0 * 3600) if issue.project_id.resource_calendar_id: issue.working_hours_open = issue.project_id.resource_calendar_id.get_work_hours_count( - dt_create_date, dt_date_open, - compute_leaves=True, resource_id=False) + dt_create_date, dt_date_open, False, compute_leaves=True) else: issue.working_hours_open = 0 @@ -101,8 +100,7 @@ class ProjectIssue(models.Model): issue.day_close = (dt_date_closed - dt_create_date).total_seconds() / (24.0 * 3600) if issue.project_id.resource_calendar_id: issue.working_hours_close = issue.project_id.resource_calendar_id.get_work_hours_count( - dt_create_date, dt_date_closed, - compute_leaves=True, resource_id=False) + dt_create_date, dt_date_closed, False, compute_leaves=True) else: issue.working_hours_close = 0 diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 941fce80c96..fbb498bbe10 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import datetime -import itertools +import math import pytz from collections import namedtuple @@ -12,13 +12,30 @@ from dateutil.relativedelta import relativedelta from operator import itemgetter from odoo import api, fields, models, _ +from odoo.addons.base.res.res_partner import _tz_get from odoo.exceptions import ValidationError from odoo.tools.float_utils import float_compare -def seconds(td): - assert isinstance(td, timedelta) - return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10.**6 +def float_to_time(float_hour): + return datetime.time(int(math.modf(float_hour)[1]), int(60 * math.modf(float_hour)[0]), 0) + + +def to_naive_user_tz(datetime, record): + tz_name = record._context.get('tz') or record.env.user.tz + tz = tz_name and pytz.timezone(tz_name) or pytz.UTC + return pytz.UTC.localize(datetime.replace(tzinfo=None), is_dst=False).astimezone(tz).replace(tzinfo=None) + + +def to_naive_utc(datetime, record): + tz_name = record._context.get('tz') or record.env.user.tz + tz = tz_name and pytz.timezone(tz_name) or pytz.UTC + return tz.localize(datetime.replace(tzinfo=None), is_dst=False).astimezone(pytz.UTC).replace(tzinfo=None) + + +def to_tz(datetime, tz_name): + tz = pytz.timezone(tz_name) + return pytz.UTC.localize(datetime.replace(tzinfo=None), is_dst=False).astimezone(tz).replace(tzinfo=None) class ResourceCalendar(models.Model): @@ -169,37 +186,36 @@ class ResourceCalendar(models.Model): return intervals @api.model - def _interval_schedule_hours(self, intervals, hour, remove_at_end=True): + def _interval_schedule_hours(self, intervals, hour, backwards=False): """ Schedule hours in intervals. The last matching interval is truncated - to match the specified hours. + to match the specified hours. This method can be applied backwards meaning + scheduling hours going in the past. In that case truncating last interval + is done accordingly. If number of hours to schedule is greater than possible + scheduling in the given intervals, returned result equals intervals. - It is possible to truncate the last interval at its beginning or ending. - However this does nothing on the given interval order that should be - submitted accordingly. - - :param list intervals: a list of tuples (beginning datetime, ending datetime) + :param list intervals: a list of time intervals :param int/float hours: number of hours to schedule. It will be converted into a timedelta, but should be submitted as an - int or float. - :param boolean remove_at_end: remove extra hours at the end of the last - matching interval. Otherwise, do it at the - beginning. + int or float + :param boolean backwards: schedule starting from last hour - :return list results: a list of intervals. If the number of hours to schedule - is greater than the possible scheduling in the intervals, no extra-scheduling - is done, and results == intervals. """ + :return list results: a list of time intervals """ + if backwards: + intervals.reverse() # first interval is the last working interval of the day results = [] res = timedelta() limit = timedelta(hours=hour) for interval in intervals: res += interval[1] - interval[0] - if res > limit and remove_at_end: - interval = (interval[0], interval[1] + relativedelta(seconds=seconds(limit - res))) + if res > limit and not backwards: + interval = (interval[0], interval[1] + relativedelta(seconds=(limit - res).total_seconds())) elif res > limit: - interval = (interval[0] + relativedelta(seconds=seconds(res - limit)), interval[1]) + interval = (interval[0] + relativedelta(seconds=(res - limit).total_seconds()), interval[1]) results.append(interval) if res > limit: break + if backwards: + results.reverse() # return interval with increasing starting times return results # -------------------------------------------------- @@ -207,17 +223,22 @@ class ResourceCalendar(models.Model): # -------------------------------------------------- @api.multi - def _get_day_attendances(self, day_dt): - """ Given a day datetime, return matching attendances """ + def _get_day_attendances(self, day_date, start_time, end_time): + """ Given a day date, return matching attendances. Those can be limited + by starting and ending time objects. """ self.ensure_one() - weekday = day_dt.weekday() + weekday = day_date.weekday() attendances = self.env['resource.calendar.attendance'] for attendance in self.attendance_ids.filtered( lambda att: int(att.dayofweek) == weekday and - not (att.date_from and fields.Date.from_string(att.date_from) > day_dt.date()) and - not (att.date_to and fields.Date.from_string(att.date_to) < day_dt.date())): + not (att.date_from and fields.Date.from_string(att.date_from) > day_date) and + not (att.date_to and fields.Date.from_string(att.date_to) < day_date)): + if start_time and float_to_time(attendance.hour_to) < start_time: + continue + if end_time and float_to_time(attendance.hour_from) > end_time: + continue attendances |= attendance return attendances @@ -226,28 +247,15 @@ class ResourceCalendar(models.Model): """ Return the list of weekdays that contain at least one working interval. """ self.ensure_one() - weekdays = set(map(int, (self.attendance_ids.mapped('dayofweek')))) - return list(weekdays) + return list(set(map(int, (self.attendance_ids.mapped('dayofweek'))))) @api.multi def _get_next_work_day(self, day_date): - """ Get following date of day_date, based on resource.calendar. If no - calendar is provided, just return the next day. - - :param date day_date: current day as a date - - :return date: next day of calendar, or just next day """ + """ Get following date of day_date, based on resource.calendar. """ self.ensure_one() weekdays = self._get_weekdays() - - base_index = -1 - for weekday in weekdays: - if weekday > day_date.weekday(): - break - base_index += 1 - - new_index = (base_index + 1) % len(weekdays) - days = (weekdays[new_index] - day_date.weekday()) + weekday = next((item for item in weekdays if item > day_date.weekday()), weekdays[0]) + days = weekday - day_date.weekday() if days < 0: days = 7 + days @@ -255,224 +263,281 @@ class ResourceCalendar(models.Model): @api.multi def _get_previous_work_day(self, day_date): - """ Get previous date of day_date, based on resource.calendar. If no - calendar is provided, just return the previous day. - - :param date day_date: current day as a date - - :return date: previous day of calendar, or just previous day """ + """ Get previous date of day_date, based on resource.calendar. """ self.ensure_one() weekdays = self._get_weekdays() weekdays.reverse() - - base_index = -1 - for weekday in weekdays: - if weekday < day_date.weekday(): - break - base_index += 1 - - new_index = (base_index + 1) % len(weekdays) - days = (weekdays[new_index] - day_date.weekday()) + weekday = next((item for item in weekdays if item < day_date.weekday()), weekdays[0]) + days = weekday - day_date.weekday() if days > 0: days = days - 7 return day_date + relativedelta(days=days) @api.multi - def _get_leave_intervals(self, resource_id=None, - start_datetime=None, end_datetime=None): + def _get_leave_intervals(self, resource_id=None, start_datetime=None, end_datetime=None): """Get the leaves of the calendar. Leaves can be filtered on the resource, - the start datetime or the end datetime. + and on a start and end datetime. - :param int resource_id: the id of the resource to take into account when - computing the leaves. If not set, only general - leaves are computed. If set, generic and - specific leaves are computed. - :param datetime start_datetime: if provided, do not take into account leaves - ending before this date. - :param datetime end_datetime: if provided, do not take into account leaves - beginning after this date. + Leaves are encoded from a given timezone given by their tz field. COnverting + them in naive user timezone require to use the leave timezone, not the current + user timezone. For example people managing leaves could be from different + timezones and the correct one is the one used when encoding them. - :return list leaves: list of tuples (start_datetime, end_datetime) of - leave intervals - """ + :return list leaves: list of time intervals """ self.ensure_one() - leaves = [] - for leave in self.leave_ids: - if leave.resource_id and not resource_id == leave.resource_id.id: - continue - date_from = fields.Datetime.from_string(leave.date_from) - if end_datetime and date_from > end_datetime: - continue - date_to = fields.Datetime.from_string(leave.date_to) - if start_datetime and date_to < start_datetime: - continue - leaves.append(self._interval_new(date_from, date_to, {'leaves': leave})) - return leaves + if resource_id: + domain = ['|', ('resource_id', '=', resource_id), ('resource_id', '=', False)] + else: + domain = [('resource_id', '=', False)] + if start_datetime: + # domain += [('date_to', '>', fields.Datetime.to_string(to_naive_utc(start_datetime, self.env.user)))] + domain += [('date_to', '>', fields.Datetime.to_string(start_datetime + timedelta(days=-1)))] + if end_datetime: + # domain += [('date_from', '<', fields.Datetime.to_string(to_naive_utc(end_datetime, self.env.user)))] + domain += [('date_from', '<', fields.Datetime.to_string(start_datetime + timedelta(days=1)))] + leaves = self.env['resource.calendar.leaves'].search(domain + [('calendar_id', '=', self.id)]) + + filtered_leaves = self.env['resource.calendar.leaves'] + for leave in leaves: + if start_datetime: + leave_date_to = to_tz(fields.Datetime.from_string(leave.date_to), leave.tz) + if not leave_date_to >= start_datetime: + continue + if end_datetime: + leave_date_from = to_tz(fields.Datetime.from_string(leave.date_from), leave.tz) + if not leave_date_from <= end_datetime: + continue + filtered_leaves += leave + + return [self._interval_new( + to_tz(fields.Datetime.from_string(leave.date_from), leave.tz), + to_tz(fields.Datetime.from_string(leave.date_to), leave.tz), + {'leaves': leave}) for leave in filtered_leaves] + + def _iter_day_attendance_intervals(self, day_date, start_time, end_time): + """ Get an iterator of all interval of current day attendances. """ + for calendar_working_day in self._get_day_attendances(day_date, start_time, end_time): + from_time = float_to_time(calendar_working_day.hour_from) + to_time = float_to_time(calendar_working_day.hour_to) + + dt_f = datetime.datetime.combine(day_date, max(from_time, start_time)) + dt_t = datetime.datetime.combine(day_date, min(to_time, end_time)) + + yield self._interval_new(dt_f, dt_t, {'attendances': calendar_working_day}) @api.multi - def _get_day_work_intervals(self, start_dt=None, end_dt=None, - leaves=None, compute_leaves=False, resource_id=None): - """ Get the working intervals of the day based on calendar. This method - handle leaves that come directly from the leaves parameter or can be computed. + def _get_day_work_intervals(self, day_date, start_time=None, end_time=None, compute_leaves=False, resource_id=None): + """ Get the working intervals of the day given by day_date based on + current calendar. Input should be given in current user timezone and + output is given in naive UTC, ready to be used by the orm or webclient. - :param datetime start_dt: datetime object that is the beginning hours - for the working intervals computation; any - working interval beginning before start_dt - will be truncated. If not set, set to end_dt - or today() if no end_dt at 00.00.00. - :param datetime end_dt: datetime object that is the ending hour - for the working intervals computation; any - working interval ending after end_dt - will be truncated. If not set, set to start_dt() - at 23.59.59. - :param list leaves: a list of tuples(start_datetime, end_datetime) that - represent leaves. - :param boolean compute_leaves: if set and if leaves is None, compute the + :param time start_time: time object that is the beginning hours in user TZ + :param time end_time: time object that is the ending hours in user TZ + :param boolean compute_leaves: indicates whether to compute the leaves based on calendar and resource. - If leaves is None and compute_leaves false - no leaves are taken into account. :param int resource_id: the id of the resource to take into account when - computing the leaves. If not set, only general - leaves are computed. If set, generic and - specific leaves are computed. + computing the work intervals. Leaves notably are + filtered according to the resource. - :return list intervals: a list of tuples (start_datetime, end_datetime) - of work intervals """ + :return list intervals: list of time intervals in UTC """ self.ensure_one() - # Computes start_dt, end_dt (with default values if not set) + off-interval work limits - work_limits = [] - if start_dt is None and end_dt is not None: - start_dt = end_dt.replace(hour=0, minute=0, second=0) - elif start_dt is None: - start_dt = datetime.datetime.now().replace(hour=0, minute=0, second=0) - else: - work_limits.append(self._interval_new(start_dt.replace(hour=0, minute=0, second=0), start_dt)) - if end_dt is None: - end_dt = start_dt.replace(hour=23, minute=59, second=59) - else: - work_limits.append(self._interval_new(end_dt, end_dt.replace(hour=23, minute=59, second=59))) - assert start_dt.date() == end_dt.date(), '_get_day_work_intervals is restricted to one day' - work_dt = start_dt.replace(hour=0, minute=0, second=0) + if not start_time: + start_time = datetime.time.min + if not end_time: + end_time = datetime.time.max - working_intervals = [] - tz_info = fields.Datetime.context_timestamp(self, work_dt).tzinfo - for calendar_working_day in self._get_day_attendances(start_dt): - dt_f = work_dt.replace(hour=0, minute=0, second=0) + timedelta(seconds=(calendar_working_day.hour_from * 3600)) - dt_t = work_dt.replace(hour=0, minute=0, second=0) + timedelta(seconds=(calendar_working_day.hour_to * 3600)) - - # adapt tz - working_interval = self._interval_new( - dt_f.replace(tzinfo=tz_info).astimezone(pytz.UTC).replace(tzinfo=None), - dt_t.replace(tzinfo=tz_info).astimezone(pytz.UTC).replace(tzinfo=None), - {'attendances': calendar_working_day} - ) - working_intervals += self._interval_remove_leaves(working_interval, work_limits) - - # find leave intervals - if leaves is None and compute_leaves: - leaves = self._get_leave_intervals(resource_id=resource_id) + working_intervals = [att_interval for att_interval in self._iter_day_attendance_intervals(day_date, start_time, end_time)] # filter according to leaves - if leaves: - return list(itertools.chain.from_iterable(map(lambda i: self._interval_remove_leaves(i, leaves), working_intervals))) - else: - return working_intervals + if compute_leaves: + leaves = self._get_leave_intervals( + resource_id=resource_id, + start_datetime=datetime.datetime.combine(day_date, start_time), + end_datetime=datetime.datetime.combine(day_date, end_time)) + working_intervals = [ + sub_interval + for interval in working_intervals + for sub_interval in self._interval_remove_leaves(interval, leaves)] + + # adapt tz + return [self._interval_new( + to_naive_utc(interval[0], self.env.user), + to_naive_utc(interval[1], self.env.user), + interval[2]) for interval in working_intervals] + + def _get_day_leave_intervals(self, day_date, start_time, end_time, resource_id): + """ Get the leave intervals of the day given by day_date based on current + calendar. Input should be given in current user timezone and + output is given in naive UTC, ready to be used by the orm or webclient. + + :param time start_time: time object that is the beginning hours in user TZ + :param time end_time: time object that is the ending hours in user TZ + :param int resource_id: the id of the resource to take into account when + computing the leaves. + + :return list intervals: list of time intervals in UTC """ + self.ensure_one() + + if not start_time: + start_time = datetime.time.min + if not end_time: + end_time = datetime.time.max + + working_intervals = [att_interval for att_interval in self._iter_day_attendance_intervals(day_date, start_time, end_time)] + + leaves_intervals = self._get_leave_intervals( + resource_id=resource_id, + start_datetime=datetime.datetime.combine(day_date, start_time), + end_datetime=datetime.datetime.combine(day_date, end_time)) + + final_intervals = [ + self._interval_and(leave_interval, work_interval) + for leave_interval in leaves_intervals + for work_interval in working_intervals] + + # adapt tz + return [self._interval_new( + to_naive_utc(interval[0], self.env.user), + to_naive_utc(interval[1], self.env.user), + interval[2]) for interval in final_intervals] # -------------------------------------------------- # Main computation API # -------------------------------------------------- - @api.multi - def get_work_hours_count(self, start_dt, end_dt, compute_leaves=False, resource_id=None): + def _iter_work_intervals(self, start_dt, end_dt, resource_id, compute_leaves=True): + """ Lists the current resource's work intervals between the two provided + datetimes (inclusive) expressed in UTC, for each worked day. """ if not end_dt: - end_dt = start_dt.replace(hour=23, minute=59, second=59) - res = timedelta() + end_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max) + + start_dt = to_naive_user_tz(start_dt, self.env.user) + end_dt = to_naive_user_tz(end_dt, self.env.user) + for day in rrule.rrule(rrule.DAILY, dtstart=start_dt, until=end_dt, byweekday=self._get_weekdays()): - day_start_dt = day.replace(hour=0, minute=0, second=0) - if start_dt and day.date() == start_dt.date(): - day_start_dt = start_dt - day_end_dt = day.replace(hour=23, minute=59, second=59) - if end_dt and day.date() == end_dt.date(): - day_end_dt = end_dt + start_time = day.date() == start_dt.date() and start_dt.time() or datetime.time.min + end_time = day.date() == end_dt.date() and end_dt.time() or datetime.time.max + intervals = self._get_day_work_intervals( - start_dt=day_start_dt, end_dt=day_end_dt, - compute_leaves=compute_leaves, resource_id=resource_id) + day.date(), + start_time=start_time, + end_time=end_time, + compute_leaves=compute_leaves, + resource_id=resource_id) + if intervals: + yield intervals + + def _iter_leave_intervals(self, start_dt, end_dt, resource_id): + """ Lists the current resource's leave intervals between the two provided + datetimes (inclusive) expressed in UTC. """ + if not end_dt: + end_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max) + + start_dt = to_naive_user_tz(start_dt, self.env.user) + end_dt = to_naive_user_tz(end_dt, self.env.user) + + for day in rrule.rrule(rrule.DAILY, + dtstart=start_dt, + until=end_dt, + byweekday=self._get_weekdays()): + start_time = day.date() == start_dt.date() and start_dt.time() or datetime.time.min + end_time = day.date() == end_dt.date() and end_dt.time() or datetime.time.max + + intervals = self._get_day_leave_intervals( + day.date(), + start_time, + end_time, + resource_id) + + if intervals: + yield intervals + + def _iter_work_days(self, from_date, to_date, resource_id): + """ Lists the current resource's work days between the two provided + dates (inclusive) expressed in naive UTC. + + Work days are the company or service's open days (as defined by the + resource.calendar) minus the resource's own leaves. + + :param datetime.date from_date: start of the interval to check for + work days (inclusive) + :param datetime.date to_date: end of the interval to check for work + days (inclusive) + :rtype: list(datetime.date) + """ + for interval in self._iter_work_intervals( + datetime.datetime(from_date.year, from_date.month, from_date.day), + datetime.datetime(to_date.year, to_date.month, to_date.day), + resource_id): + yield interval[0][0].date() + + @api.multi + def _is_work_day(self, date, resource_id): + """ Whether the provided date is a work day for the subject resource. + + :type date: datetime.date + :rtype: bool """ + return bool(next(self._iter_work_days(date, date, resource_id), False)) + + @api.multi + def get_work_hours_count(self, start_dt, end_dt, resource_id, compute_leaves=True): + """ Count number of work hours between two datetimes. For compute_leaves, + resource_id: see _get_day_work_intervals. """ + res = timedelta() + for intervals in self._iter_work_intervals(start_dt, end_dt, resource_id, compute_leaves=compute_leaves): for interval in intervals: res += interval[1] - interval[0] - - return seconds(res) / 3600.0 + return res.total_seconds() / 3600.0 # -------------------------------------------------- # Scheduling API # -------------------------------------------------- @api.multi - def _schedule_hours(self, hours, day_dt=None, - compute_leaves=False, resource_id=None): + def _schedule_hours(self, hours, day_dt, compute_leaves=False, resource_id=None): """ Schedule hours of work, using a calendar and an optional resource to compute working and leave days. This method can be used backwards, i.e. - scheduling days before a deadline. + scheduling days before a deadline. For compute_leaves, resource_id: + see _get_day_work_intervals. This method does not use rrule because + rrule does not allow backwards computation. :param int hours: number of hours to schedule. Use a negative number to compute a backwards scheduling. :param datetime day_dt: reference date to compute working days. If days is > 0 date is the starting date. If days is < 0 date is the ending date. - :param boolean compute_leaves: if set, compute the leaves based on calendar - and resource. Otherwise no leaves are taken - into account. - :param int resource_id: the id of the resource to take into account when - computing the leaves. If not set, only general - leaves are computed. If set, generic and - specific leaves are computed. - :return tuple (datetime, intervals): datetime is the beginning/ending date - of the schedulign; intervals are the - working intervals of the scheduling. - - Note: Why not using rrule.rrule ? Because rrule does not seem to allow - getting back in time. - """ + :return list intervals: list of time intervals in naive UTC """ self.ensure_one() - if day_dt is None: - day_dt = datetime.datetime.now() backwards = (hours < 0) - hours = abs(hours) intervals = [] - remaining_hours = hours * 1.0 - iterations = 0 + remaining_hours, iterations = abs(hours * 1.0), 0 current_datetime = day_dt call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id) while float_compare(remaining_hours, 0.0, precision_digits=2) in (1, 0) and iterations < 1000: if backwards: - call_args['end_dt'] = current_datetime + call_args['end_time'] = current_datetime.time() else: - call_args['start_dt'] = current_datetime + call_args['start_time'] = current_datetime.time() - working_intervals = self._get_day_work_intervals(**call_args) + working_intervals = self._get_day_work_intervals(current_datetime.date(), **call_args) if working_intervals: - if backwards: - working_intervals.reverse() - new_working_intervals = self._interval_schedule_hours(working_intervals, remaining_hours, not backwards) - if backwards: - new_working_intervals.reverse() + new_working_intervals = self._interval_schedule_hours(working_intervals, remaining_hours, backwards=backwards) res = timedelta() for interval in working_intervals: res += interval[1] - interval[0] - remaining_hours -= (seconds(res) / 3600.0) - if backwards: - intervals = new_working_intervals + intervals - else: - intervals = intervals + new_working_intervals + remaining_hours -= res.total_seconds() / 3600.0 + + intervals = intervals + new_working_intervals if not backwards else new_working_intervals + intervals # get next day if backwards: current_datetime = datetime.datetime.combine(self._get_previous_work_day(current_datetime), datetime.time(23, 59, 59)) @@ -484,50 +549,34 @@ class ResourceCalendar(models.Model): return intervals @api.multi - def plan_hours(self, hours, day_dt=None, compute_leaves=False, resource_id=None): + def plan_hours(self, hours, day_dt, compute_leaves=False, resource_id=None): """ Return datetime after having planned hours """ res = self._schedule_hours(hours, day_dt, compute_leaves, resource_id) return res and res[0][0] or False @api.multi - def _schedule_days(self, days, day_date=None, compute_leaves=False, - resource_id=None): + def _schedule_days(self, days, day_dt, compute_leaves=False, resource_id=None): """Schedule days of work, using a calendar and an optional resource to compute working and leave days. This method can be used backwards, i.e. - scheduling days before a deadline. + scheduling days before a deadline. For compute_leaves, resource_id: + see _get_day_work_intervals. This method does not use rrule because + rrule does not allow backwards computation. :param int days: number of days to schedule. Use a negative number to compute a backwards scheduling. - :param date day_date: reference date to compute working days. If days is > 0 - date is the starting date. If days is < 0 date is the - ending date. - :param boolean compute_leaves: if set, compute the leaves based on calendar - and resource. Otherwise no leaves are taken - into account. - :param int resource_id: the id of the resource to take into account when - computing the leaves. If not set, only general - leaves are computed. If set, generic and - specific leaves are computed. + :param date day_dt: reference datetime to compute working days. If days is > 0 + date is the starting date. If days is < 0 date is the + ending date. - :return tuple (datetime, intervals): datetime is the beginning/ending date - of the schedulign; intervals are the - working intervals of the scheduling. - - Implementation note: rrule.rrule is not used because rrule it des not seem - to allow getting back in time. - """ - if day_date is None: - day_date = datetime.datetime.now() + :return list intervals: list of time intervals in naive UTC """ backwards = (days < 0) - days = abs(days) intervals = [] - planned_days = 0 - iterations = 0 - current_datetime = day_date.replace(hour=0, minute=0, second=0) + planned_days, iterations = 0, 0 + current_datetime = day_dt.replace(hour=0, minute=0, second=0) - while planned_days < days and iterations < 100: + while planned_days < abs(days) and iterations < 100: working_intervals = self._get_day_work_intervals( - current_datetime, + current_datetime.date(), compute_leaves=compute_leaves, resource_id=resource_id) if not self or working_intervals: # no calendar -> no working hours, but day is considered as worked planned_days += 1 @@ -543,9 +592,9 @@ class ResourceCalendar(models.Model): return intervals @api.multi - def plan_days(self, days, day_date=None, compute_leaves=False, resource_id=None): + def plan_days(self, days, day_dt, compute_leaves=False, resource_id=None): """ Returns the datetime of a days scheduling. """ - res = self._schedule_days(days, day_date, compute_leaves, resource_id) + res = self._schedule_days(days, day_dt, compute_leaves, resource_id) return res and res[-1][1] or False @@ -584,7 +633,6 @@ class ResourceResource(models.Model): return res name = fields.Char(required=True) - code = fields.Char(copy=False) active = fields.Boolean( 'Active', default=True, track_visibility='onchange', help="If the active field is set to False, it will allow you to hide the resource record without removing it.") @@ -623,37 +671,6 @@ class ResourceResource(models.Model): if self.company_id: self.calendar_id = self.company_id.resource_calendar_id.id - def _is_work_day(self, date): - """ Whether the provided date is a work day for the subject resource. - - :type date: datetime.date - :rtype: bool - """ - return bool(next(self._iter_work_days(date, date), False)) - - def _iter_work_days(self, from_date, to_date): - """ Lists the current resource's work days between the two provided - dates (inclusive). - - Work days are the company or service's open days (as defined by the - resource.calendar) minus the resource's own leaves. - - :param datetime.date from_date: start of the interval to check for - work days (inclusive) - :param datetime.date to_date: end of the interval to check for work - days (inclusive) - :rtype: list(datetime.date) - """ - working_intervals = self.calendar_id._get_day_work_intervals - # rrule coerces date inputs to datetimes (with time=0) and yields - # datetimes (with time=0 if freq >= daily) - for dt in rrule.rrule(rrule.DAILY, dtstart=from_date, until=to_date): - intervals = working_intervals(dt, compute_leaves=True, resource_id=self.id) - - # FIXME: get_working_intervals is new-API mapped to return a list of lists of intervals - if intervals and intervals[0]: - yield dt.date() - class ResourceCalendarLeaves(models.Model): _name = "resource.calendar.leaves" @@ -666,6 +683,10 @@ class ResourceCalendarLeaves(models.Model): calendar_id = fields.Many2one('resource.calendar', 'Working Hours') date_from = fields.Datetime('Start Date', required=True) date_to = fields.Datetime('End Date', required=True) + tz = fields.Selection( + _tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz), + help="Timezone used when encoding the leave. It is used to correctly" + "localize leave hours when computing time intervals.") resource_id = fields.Many2one( "resource.resource", 'Resource', help="If empty, this is a generic holiday for the company. If a resource is set, the holiday/leave is only for this resource") diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index def52120c28..33c678492e1 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -3,11 +3,11 @@ import babel.dates -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta, date, time from dateutil.relativedelta import relativedelta from odoo.fields import Date, Datetime -from odoo.addons.resource.models.resource import seconds +from odoo.addons.resource.models.resource import to_naive_utc, to_naive_user_tz from odoo.addons.resource.tests.common import TestResourceCommon @@ -71,13 +71,12 @@ class TestIntervals(TestResourceCommon): def test_interval_schedule_hours_backwards(self): cleaned_intervals = self.env['resource.calendar']._interval_merge(self.intervals) - cleaned_intervals.reverse() - result = self.env['resource.calendar']._interval_schedule_hours(cleaned_intervals, 5.5, remove_at_end=False) + result = self.env['resource.calendar']._interval_schedule_hours(cleaned_intervals, 5.5, backwards=True) self.assertEqual(len(result), 2) # First interval: 03, 8-10 untouched - self.assertEqual(result[0][:2], (Datetime.from_string('2013-02-04 17:00:00'), Datetime.from_string('2013-02-04 21:00:00'))) + self.assertEqual(result[1][:2], (Datetime.from_string('2013-02-04 17:00:00'), Datetime.from_string('2013-02-04 21:00:00'))) # First interval: 04, 08-11:30 - self.assertEqual(result[1][:2], (Datetime.from_string('2013-02-04 12:30:00'), Datetime.from_string('2013-02-04 14:00:00'))) + self.assertEqual(result[0][:2], (Datetime.from_string('2013-02-04 12:30:00'), Datetime.from_string('2013-02-04 14:00:00'))) class TestCalendarBasics(TestResourceCommon): @@ -122,19 +121,19 @@ class TestCalendarBasics(TestResourceCommon): def test_calendar_working_day_intervals_no_leaves(self): # Test: day0 without leaves: 1 interval - intervals = self.calendar._get_day_work_intervals(start_dt=Datetime.from_string('2013-02-12 09:08:07')) + intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-12'), start_time=time(9, 8, 7)) self.assertEqual(len(intervals), 1) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-12 09:08:07'), Datetime.from_string('2013-02-12 16:00:00'))) self.assertEqual(intervals[0][2]['attendances'], self.att_1) # Test: day1, beginning at 10:30 -> work from 10:30 (arrival) until 16:00 - intervals = self.calendar._get_day_work_intervals(start_dt=Datetime.from_string('2013-02-19 10:30:00')) + intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-19'), start_time=time(10, 30, 0)) self.assertEqual(len(intervals), 1) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-19 10:30:00'), Datetime.from_string('2013-02-19 16:00:00'))) self.assertEqual(intervals[0][2]['attendances'], self.att_1) # Test: day3 without leaves: 2 interval - intervals = self.calendar._get_day_work_intervals(start_dt=Datetime.from_string('2013-02-15 10:11:12')) + intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-15'), start_time=time(10, 11, 12)) self.assertEqual(len(intervals), 2) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-15 10:11:12'), Datetime.from_string('2013-02-15 13:00:00'))) self.assertEqual(intervals[1][:2], (Datetime.from_string('2013-02-15 16:00:00'), Datetime.from_string('2013-02-15 23:00:00'))) @@ -143,14 +142,15 @@ class TestCalendarBasics(TestResourceCommon): def test_calendar_working_day_intervals_leaves_generic(self): # Test: day0 with leaves outside range: 1 interval - intervals = self.calendar._get_day_work_intervals(start_dt=Datetime.from_string('2013-02-12 07:00:00'), compute_leaves=True) + intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-12'), start_time=time(7, 0, 0), compute_leaves=True) self.assertEqual(len(intervals), 1) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-12 08:00:00'), Datetime.from_string('2013-02-12 16:00:00'))) # Test: day0 with leaves: 2 intervals because of leave between 9 and 12, ending at 15:45:30 - intervals = self.calendar._get_day_work_intervals(start_dt=Datetime.from_string('2013-02-19 08:15:00'), - end_dt=Datetime.from_string('2013-02-19 15:45:30'), - compute_leaves=True) + intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-19'), + start_time=time(8, 15, 0), + end_time=time(15, 45, 30), + compute_leaves=True) self.assertEqual(len(intervals), 2) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-19 08:15:00'), Datetime.from_string('2013-02-19 09:00:00'))) self.assertEqual(intervals[1][:2], (Datetime.from_string('2013-02-19 12:00:00'), Datetime.from_string('2013-02-19 15:45:30'))) @@ -162,7 +162,8 @@ class TestCalendarBasics(TestResourceCommon): def test_calendar_working_day_intervals_leaves_resource(self): # Test: day1+14 on leave, with resource leave computation intervals = self.calendar._get_day_work_intervals( - Datetime.from_string('2013-02-26 07:00:00'), + Date.from_string('2013-02-26'), + start_time=time(7, 0, 0), compute_leaves=True, resource_id=self.resource1_id ) @@ -177,7 +178,7 @@ class TestCalendarBasics(TestResourceCommon): 'date_from': self.date2 + relativedelta(days=7), 'date_to': False, }) - intervals = self.calendar._get_day_work_intervals(start_dt=self.date2) + intervals = self.calendar._get_day_work_intervals(self.date2.date(), start_time=self.date2.time()) self.assertEqual(len(intervals), 1) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-15 10:11:12'), Datetime.from_string('2013-02-15 13:00:00'))) @@ -185,7 +186,7 @@ class TestCalendarBasics(TestResourceCommon): 'date_from': False, 'date_to': self.date2 - relativedelta(days=7), }) - intervals = self.calendar._get_day_work_intervals(start_dt=self.date2) + intervals = self.calendar._get_day_work_intervals(self.date2.date(), start_time=self.date2.time()) self.assertEqual(len(intervals), 1) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-15 10:11:12'), Datetime.from_string('2013-02-15 13:00:00'))) @@ -193,7 +194,7 @@ class TestCalendarBasics(TestResourceCommon): 'date_from': self.date2 + relativedelta(days=7), 'date_to': self.date2 - relativedelta(days=7), }) - intervals = self.calendar._get_day_work_intervals(start_dt=self.date2) + intervals = self.calendar._get_day_work_intervals(self.date2.date(), start_time=self.date2.time()) self.assertEqual(len(intervals), 1) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-15 10:11:12'), Datetime.from_string('2013-02-15 13:00:00'))) @@ -201,14 +202,14 @@ class TestCalendarBasics(TestResourceCommon): 'date_from': self.date2, 'date_to': self.date2, }) - intervals = self.calendar._get_day_work_intervals(start_dt=self.date2) + intervals = self.calendar._get_day_work_intervals(self.date2.date(), start_time=self.date2.time()) self.assertEqual(len(intervals), 2) self.assertEqual(intervals[0][:2], (Datetime.from_string('2013-02-15 10:11:12'), Datetime.from_string('2013-02-15 13:00:00'))) self.assertEqual(intervals[1][:2], (Datetime.from_string('2013-02-15 16:00:00'), Datetime.from_string('2013-02-15 23:00:00'))) def test_calendar_working_hours_of_date(self): # Test: day1, beginning at 10:30 -> work from 10:30 (arrival) until 16:00 - wh = self.calendar.get_work_hours_count(Datetime.from_string('2013-02-19 10:30:00'), Datetime.from_string('2013-02-19 18:00:00')) + wh = self.calendar.get_work_hours_count(Datetime.from_string('2013-02-19 10:30:00'), Datetime.from_string('2013-02-19 18:00:00'), self.resource1_id, compute_leaves=False) self.assertEqual(wh, 5.5) @@ -220,7 +221,8 @@ class ResourceWorkingHours(TestResourceCommon): res = self.calendar.get_work_hours_count( Datetime.from_string('2013-02-12 06:00:00'), Datetime.from_string('2013-02-22 23:00:00'), - compute_leaves=False, resource_id=self.resource1_id) + self.resource1_id, + compute_leaves=False) self.assertEqual(res, 40.0) def test_calendar_working_hours_leaves(self): @@ -229,9 +231,32 @@ class ResourceWorkingHours(TestResourceCommon): res = self.calendar.get_work_hours_count( Datetime.from_string('2013-02-12 06:00:00'), Datetime.from_string('2013-02-22 23:00:00'), - compute_leaves=True, resource_id=self.resource1_id) + self.resource1_id, + compute_leaves=True) self.assertEqual(res, 33.0) + def test_calendar_timezone(self): + # user in timezone UTC-9 asks for work hours + # Limits: between 2013-02-19 10:00:00 and 2013-02-26 15:30:00 (User TZ) + # between 2013-02-19 19:00:00 and 2013-02-27 00:30:00 (UTC) + # Leaves: between 2013-02-21 10:00:00 and 2013-02-26 12:00:00 (User TZ) + # res: 19/02 (10-16 (beginning)) + 22/02 (0 (leave)) + 26/02 (12-15.30 (leave+ending)) + self.env.user.tz = 'US/Alaska' + (self.leave1 | self.leave2 | self.leave3).unlink() + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'Timezoned Leaves', + 'calendar_id': self.calendar.id, + 'resource_id': self.resource1_id, + 'date_from': to_naive_utc(Datetime.from_string('2013-02-21 10:00:00'), self.env.user), + 'date_to': to_naive_utc(Datetime.from_string('2013-02-26 12:00:00'), self.env.user) + }) + res = self.calendar.get_work_hours_count( + to_naive_utc(Datetime.from_string('2013-02-19 10:00:00'), self.env.user), + to_naive_utc(Datetime.from_string('2013-02-26 15:30:00'), self.env.user), + self.resource1_id, + compute_leaves=True) + self.assertEqual(res, 9.5) + def test_calendar_hours_scheduling_backward(self): res = self.calendar._schedule_hours(-40, day_dt=Datetime.from_string('2013-02-12 09:00:00')) # current day, limited at 09:00 because of day_dt specified -> 1 hour @@ -249,7 +274,7 @@ class ResourceWorkingHours(TestResourceCommon): td = timedelta() for item in res: td += item[1] - item[0] - self.assertEqual(seconds(td) / 3600.0, 40.0) + self.assertEqual(td.total_seconds() / 3600.0, 40.0) def test_calendar_hours_scheduling_forward(self): res = self.calendar._schedule_hours(40, day_dt=Datetime.from_string('2013-02-12 09:00:00')) @@ -264,7 +289,7 @@ class ResourceWorkingHours(TestResourceCommon): td = timedelta() for item in res: td += item[1] - item[0] - self.assertEqual(seconds(td) / 3600.0, 40.0) + self.assertEqual(td.total_seconds() / 3600.0, 40.0) def test_calendar_hours_scheduling_forward_leaves_resource(self): res = self.calendar._schedule_hours( @@ -284,16 +309,16 @@ class ResourceWorkingHours(TestResourceCommon): td = timedelta() for item in res: td += item[1] - item[0] - self.assertEqual(seconds(td) / 3600.0, 40.0) + self.assertEqual(td.total_seconds() / 3600.0, 40.0) def test_calendar_days_scheduling(self): - res = self.calendar.plan_days(5, day_date=Datetime.from_string('2013-02-12 09:08:07') ) + res = self.calendar.plan_days(5, Datetime.from_string('2013-02-12 09:08:07') ) self.assertEqual(res.date(), Datetime.from_string('2013-02-26 00:00:00').date(), 'resource_calendar: wrong days scheduling') - res = self.calendar.plan_days(-2, day_date=Datetime.from_string('2013-02-12 09:08:07') ) + res = self.calendar.plan_days(-2, Datetime.from_string('2013-02-12 09:08:07') ) self.assertEqual(res.date(), Datetime.from_string('2013-02-08 00:00:00').date(), 'resource_calendar: wrong days scheduling') res = self.calendar.plan_days( - 5, day_date=Datetime.from_string('2013-02-12 09:08:07'), + 5, Datetime.from_string('2013-02-12 09:08:07'), compute_leaves=True, resource_id=self.resource1_id) self.assertEqual(res.date(), Datetime.from_string('2013-03-01 00:00:00').date(), 'resource_calendar: wrong days scheduling') @@ -345,7 +370,7 @@ class TestWorkDays(TestResourceCommon): # saturday and sunday self.assertEqual( [d for d in self._days if d.weekday() not in (5, 6)], - list(r._iter_work_days(WAR_START, WAR_END)) + list(r.calendar_id._iter_work_days(WAR_START, WAR_END, r.id)) ) def test_global_leaves(self): @@ -369,8 +394,8 @@ class TestWorkDays(TestResourceCommon): if d.weekday() not in (5, 6) if d < date(1932, 11, 9) or d > date(1932, 11, 12) ] - self.assertEqual(days, list(r1._iter_work_days(WAR_START, WAR_END))) - self.assertEqual(days, list(r2._iter_work_days(WAR_START, WAR_END))) + self.assertEqual(days, list(r1.calendar_id._iter_work_days(WAR_START, WAR_END, r1.id))) + self.assertEqual(days, list(r2.calendar_id._iter_work_days(WAR_START, WAR_END, r2.id))) def test_personal_leaves(self): """ Leaves with a resource_id apply only to that resource @@ -391,10 +416,10 @@ class TestWorkDays(TestResourceCommon): }) weekdays = [d for d in self._days if d.weekday() not in (5, 6)] - self.assertEqual(weekdays, list(r1._iter_work_days(WAR_START, WAR_END))) + self.assertEqual(weekdays, list(r1.calendar_id._iter_work_days(WAR_START, WAR_END, r1.id))) self.assertEqual([ d for d in weekdays if d < date(1932, 11, 9) or d > date(1932, 11, 12)], - list(r2._iter_work_days(WAR_START, WAR_END)) + list(r2.calendar_id._iter_work_days(WAR_START, WAR_END, r2.id)) ) def test_mixed_leaves(self): @@ -419,17 +444,17 @@ class TestWorkDays(TestResourceCommon): if d.weekday() not in (5, 6) if d < date(1932, 11, 9) or d > date(1932, 11, 12) if d < date(1932, 12, 2)], - list(r._iter_work_days(WAR_START, WAR_END)) + list(r.calendar_id._iter_work_days(WAR_START, WAR_END, r.id)) ) # _is_work_day is built on _iter_work_days, but it's probably a good # idea to ensure it does do what it should - self.assertTrue(r._is_work_day(date(1932, 11, 8))) - self.assertTrue(r._is_work_day(date(1932, 11, 14))) - self.assertTrue(r._is_work_day(date(1932, 12, 1))) + self.assertTrue(r.calendar_id._is_work_day(date(1932, 11, 8), r.id)) + self.assertTrue(r.calendar_id._is_work_day(date(1932, 11, 14), r.id)) + self.assertTrue(r.calendar_id._is_work_day(date(1932, 12, 1), r.id)) - self.assertFalse(r._is_work_day(date(1932, 11, 11))) # global leave - self.assertFalse(r._is_work_day(date(1932, 11, 13))) # sun - self.assertFalse(r._is_work_day(date(1932, 11, 19))) # sat - self.assertFalse(r._is_work_day(date(1932, 11, 20))) # sun - self.assertFalse(r._is_work_day(date(1932, 12, 6))) # personal leave + self.assertFalse(r.calendar_id._is_work_day(date(1932, 11, 11), r.id)) # global leave + self.assertFalse(r.calendar_id._is_work_day(date(1932, 11, 13), r.id)) # sun + self.assertFalse(r.calendar_id._is_work_day(date(1932, 11, 19), r.id)) # sat + self.assertFalse(r.calendar_id._is_work_day(date(1932, 11, 20), r.id)) # sun + self.assertFalse(r.calendar_id._is_work_day(date(1932, 12, 6), r.id)) # personal leave