diff --git a/addons/hr/models/hr.py b/addons/hr/models/hr.py index c7ef36659ab..cb84b0eaca6 100644 --- a/addons/hr/models/hr.py +++ b/addons/hr/models/hr.py @@ -229,6 +229,11 @@ class Employee(models.Model): if self.user_id: self.update(self._sync_user(self.user_id)) + @api.onchange('user_id', 'resource_calendar_id') + def _onchange_timezone(self): + if self.user_id or self.resource_calendar_id: + self.tz = self.user_id.tz or self.resource_calendar_id.tz + def _sync_user(self, user): return dict( name=user.name, diff --git a/addons/hr/views/hr_views.xml b/addons/hr/views/hr_views.xml index 6011c5b1edb..94aa370d792 100644 --- a/addons/hr/views/hr_views.xml +++ b/addons/hr/views/hr_views.xml @@ -72,6 +72,7 @@ + diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 4884b481da9..7fca30f50f8 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -223,7 +223,7 @@ class HolidaysRequest(models.Model): if employee_id: employee = self.env['hr.employee'].browse(employee_id) - return employee.get_work_days_count(from_dt, to_dt) + return employee.get_work_days_data(from_dt, to_dt)['days'] time_delta = to_dt - from_dt return math.ceil(time_delta.days + float(time_delta.seconds) / 86400) diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index f99591d51e1..19692265971 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -1,12 +1,10 @@ # -*- coding:utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import time -from datetime import datetime -from datetime import time as datetime_time -from dateutil import relativedelta - import babel +from datetime import date, datetime, time +from dateutil.relativedelta import relativedelta +from pytz import timezone from odoo import api, fields, models, tools, _ from odoo.addons import decimal_precision as dp @@ -29,9 +27,9 @@ class HrPayslip(models.Model): employee_id = fields.Many2one('hr.employee', string='Employee', required=True, readonly=True, states={'draft': [('readonly', False)]}) date_from = fields.Date(string='Date From', readonly=True, required=True, - default=time.strftime('%Y-%m-01'), states={'draft': [('readonly', False)]}) + default=lambda self: fields.Date.to_string(date.today().replace(day=1)), states={'draft': [('readonly', False)]}) date_to = fields.Date(string='Date To', readonly=True, required=True, - default=str(datetime.now() + relativedelta.relativedelta(months=+1, day=1, days=-1))[:10], + default=lambda self: fields.Date.to_string((datetime.now() + relativedelta(months=+1, day=1, days=-1)).date()), states={'draft': [('readonly', False)]}) # this is chaos: 4 states are defined, 3 are used ('verify' isn't) and 5 exist ('confirm' seems to have existed) state = fields.Selection([ @@ -170,28 +168,32 @@ class HrPayslip(models.Model): res = [] # fill only if the contract as a working schedule linked for contract in contracts.filtered(lambda contract: contract.resource_calendar_id): - day_from = datetime.combine(fields.Date.from_string(date_from), datetime_time.min) - day_to = datetime.combine(fields.Date.from_string(date_to), datetime_time.max) + day_from = datetime.combine(fields.Date.from_string(date_from), time.min) + day_to = datetime.combine(fields.Date.from_string(date_to), time.max) # compute leave days leaves = {} - day_leave_intervals = contract.employee_id.iter_leaves(day_from, day_to, calendar=contract.resource_calendar_id) - for day_intervals in day_leave_intervals: - for interval in day_intervals: - holiday = interval[2]['leaves'].holiday_id - current_leave_struct = leaves.setdefault(holiday.holiday_status_id, { - 'name': holiday.holiday_status_id.name, - 'sequence': 5, - 'code': holiday.holiday_status_id.name, - 'number_of_days': 0.0, - 'number_of_hours': 0.0, - 'contract_id': contract.id, - }) - leave_time = (interval[1] - interval[0]).seconds / 3600 - current_leave_struct['number_of_hours'] += leave_time - work_hours = contract.employee_id.get_day_work_hours_count(interval[0].date(), calendar=contract.resource_calendar_id) - if work_hours: - current_leave_struct['number_of_days'] += leave_time / work_hours + calendar = contract.resource_calendar_id + tz = timezone(calendar.tz) + day_leave_intervals = contract.employee_id.list_leaves(day_from, day_to, calendar=contract.resource_calendar_id) + for day, hours, leave in day_leave_intervals: + holiday = leave.holiday_id + current_leave_struct = leaves.setdefault(holiday.holiday_status_id, { + 'name': holiday.holiday_status_id.name, + 'sequence': 5, + 'code': holiday.holiday_status_id.name, + 'number_of_days': 0.0, + 'number_of_hours': 0.0, + 'contract_id': contract.id, + }) + current_leave_struct['number_of_hours'] += hours + work_hours = calendar.get_work_hours_count( + tz.localize(datetime.combine(day, time.min)), + tz.localize(datetime.combine(day, time.max)), + compute_leaves=False, + ) + if work_hours: + current_leave_struct['number_of_days'] += hours / work_hours # compute worked days work_data = contract.employee_id.get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id) @@ -394,7 +396,7 @@ class HrPayslip(models.Model): } if (not employee_id) or (not date_from) or (not date_to): return res - ttyme = datetime.fromtimestamp(time.mktime(time.strptime(date_from, "%Y-%m-%d"))) + ttyme = datetime.combine(fields.Date.from_string(date_from), time.min) employee = self.env['hr.employee'].browse(employee_id) locale = self.env.context.get('lang') or 'en_US' res['value'].update({ @@ -446,7 +448,7 @@ class HrPayslip(models.Model): date_to = self.date_to contract_ids = [] - ttyme = datetime.fromtimestamp(time.mktime(time.strptime(date_from, "%Y-%m-%d"))) + ttyme = datetime.combine(fields.Date.from_string(date_from), time.min) locale = self.env.context.get('lang') or 'en_US' self.name = _('Salary Slip of %s for %s') % (employee.name, tools.ustr(babel.dates.format_date(date=ttyme, format='MMMM-y', locale=locale))) self.company_id = employee.company_id @@ -566,10 +568,10 @@ class HrPayslipRun(models.Model): ('close', 'Close'), ], string='Status', index=True, readonly=True, copy=False, default='draft') date_start = fields.Date(string='Date From', required=True, readonly=True, - states={'draft': [('readonly', False)]}, default=time.strftime('%Y-%m-01')) + states={'draft': [('readonly', False)]}, default=lambda self: fields.Date.to_string(date.today().replace(day=1))) date_end = fields.Date(string='Date To', required=True, readonly=True, states={'draft': [('readonly', False)]}, - default=str(datetime.now() + relativedelta.relativedelta(months=+1, day=1, days=-1))[:10]) + default=lambda self: fields.Date.to_string((datetime.now() + relativedelta(months=+1, day=1, days=-1)).date())) credit_note = fields.Boolean(string='Credit Note', readonly=True, states={'draft': [('readonly', False)]}, help="If its checked, indicates that all payslips generated from here are refund payslips.") diff --git a/addons/mrp/models/mrp_workcenter.py b/addons/mrp/models/mrp_workcenter.py index 9ec22553053..af78cad1ac9 100644 --- a/addons/mrp/models/mrp_workcenter.py +++ b/addons/mrp/models/mrp_workcenter.py @@ -212,7 +212,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.resource_calendar_id: - r = blocktime.workcenter_id.resource_calendar_id.get_work_hours_count(d1, d2, blocktime.workcenter_id.resource_id.id) + r = blocktime.workcenter_id.get_work_days_data(d1, d2)['hours'] blocktime.duration = round(r * 60, 2) else: blocktime.duration = round(diff.total_seconds() / 60.0, 2) diff --git a/addons/mrp/tests/test_oee.py b/addons/mrp/tests/test_oee.py index 3fb3ff3850b..4cf8a31a1a9 100644 --- a/addons/mrp/tests/test_oee.py +++ b/addons/mrp/tests/test_oee.py @@ -2,9 +2,10 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime, timedelta, time +from pytz import timezone, utc +from odoo import fields from odoo.addons.mrp.tests.common import TestMrpCommon -from odoo.addons.resource.models.resource import to_naive_utc class TestOee(TestMrpCommon): @@ -24,8 +25,12 @@ class TestOee(TestMrpCommon): if day.weekday() in (5, 6): day -= timedelta(days=2) + tz = timezone(self.workcenter_1.resource_calendar_id.tz) + def time_to_string_utc_datetime(time): - return str(to_naive_utc(datetime.combine(day, time), self.workcenter_1.resource_id)) + return fields.Datetime.to_string( + tz.localize(datetime.combine(day, time)).astimezone(utc) + ) start_time = time_to_string_utc_datetime(time(10, 43, 22)) end_time = time_to_string_utc_datetime(time(10, 56, 22)) diff --git a/addons/project/models/project.py b/addons/project/models/project.py index cf2c6a941d4..d0564c78a28 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -546,13 +546,13 @@ class Task(models.Model): if task.date_assign: dt_date_assign = fields.Datetime.from_string(task.date_assign) task.working_hours_open = task.project_id.resource_calendar_id.get_work_hours_count( - dt_create_date, dt_date_assign, False, compute_leaves=True) + dt_create_date, dt_date_assign, compute_leaves=True) task.working_days_open = task.working_hours_open / 24.0 if task.date_end: dt_date_end = fields.Datetime.from_string(task.date_end) task.working_hours_close = task.project_id.resource_calendar_id.get_work_hours_count( - dt_create_date, dt_date_end, False, compute_leaves=True) + dt_create_date, dt_date_end, compute_leaves=True) task.working_days_close = task.working_hours_close / 24.0 (self - task_linked_to_calendar).update(dict.fromkeys( diff --git a/addons/project_timesheet_holidays/models/hr_holidays.py b/addons/project_timesheet_holidays/models/hr_holidays.py index 3e4f3122c3c..75d4f47026b 100644 --- a/addons/project_timesheet_holidays/models/hr_holidays.py +++ b/addons/project_timesheet_holidays/models/hr_holidays.py @@ -56,7 +56,10 @@ class Holidays(models.Model): holiday_project = holiday.holiday_status_id.timesheet_project_id holiday_task = holiday.holiday_status_id.timesheet_task_id - work_hours_data = [item for item in holiday.employee_id.iter_work_hours_count(fields.Datetime.from_string(holiday.date_from), fields.Datetime.from_string(holiday.date_to))] + work_hours_data = holiday.employee_id.list_work_time_per_day( + fields.Datetime.from_string(holiday.date_from), + fields.Datetime.from_string(holiday.date_to), + ) for index, (day_date, work_hours_count) in enumerate(work_hours_data): self.env['account.analytic.line'].create({ 'name': "%s (%s/%s)" % (holiday.name or '', index + 1, len(work_hours_data)), diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 0cc6406783a..85f0aac9c46 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -1,20 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import datetime import math -import pytz - -from collections import namedtuple -from datetime import timedelta -from dateutil import rrule -from dateutil.relativedelta import relativedelta -from operator import itemgetter +from datetime import datetime, time, timedelta +from dateutil.rrule import rrule, DAILY +from functools import partial +from itertools import chain +from pytz import timezone, utc from odoo import api, fields, models, _ from odoo.addons.base.models.res_partner import _tz_get from odoo.exceptions import ValidationError -from odoo.tools.float_utils import float_compare, float_round +from odoo.tools.float_utils import float_round # Default hour per day value. The one should # only be used when the one from the calendar @@ -22,25 +19,104 @@ from odoo.tools.float_utils import float_compare, float_round HOURS_PER_DAY = 8 -def float_to_time(float_hour): - return datetime.time(int(math.modf(float_hour)[1]), int(60 * math.modf(float_hour)[0]), 0) +def string_to_datetime(value): + """ Convert the given string value to a datetime in UTC. """ + return utc.localize(fields.Datetime.from_string(value)) -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 datetime_to_string(dt): + """ Convert the given datetime (converted in UTC) to a string value. """ + return fields.Datetime.to_string(dt.astimezone(utc)) -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 float_to_time(hours): + """ Convert a number of hours into a time object. """ + fractional, integral = math.modf(hours) + return time(int(integral), int(60 * fractional), 0) -def to_tz(datetime, tz_name): - tz = pytz.timezone(tz_name) if tz_name else pytz.UTC - return pytz.UTC.localize(datetime.replace(tzinfo=None), is_dst=False).astimezone(tz).replace(tzinfo=None) +def _boundaries(intervals, opening, closing): + """ Iterate on the boundaries of intervals. """ + for start, stop, recs in intervals: + if start < stop: + yield (start, opening, recs) + yield (stop, closing, recs) + + +class Intervals(object): + """ Collection of ordered disjoint intervals with some associated records. + Each interval is a triple ``(start, stop, records)``, where ``records`` + is a recordset. + """ + def __init__(self, intervals=()): + self._items = [] + if intervals: + # normalize the representation of intervals + append = self._items.append + starts = [] + recses = [] + for value, flag, recs in sorted(_boundaries(intervals, 'start', 'stop')): + if flag == 'start': + starts.append(value) + recses.append(recs) + else: + start = starts.pop() + if not starts: + append((start, value, recses[0].union(*recses))) + recses.clear() + + def __bool__(self): + return bool(self._items) + + def __len__(self): + return len(self._items) + + def __iter__(self): + return iter(self._items) + + def __reversed__(self): + return reversed(self._items) + + def __or__(self, other): + """ Return the union of two sets of intervals. """ + return Intervals(chain(self._items, other._items)) + + def __and__(self, other): + """ Return the intersection of two sets of intervals. """ + return self._merge(other, False) + + def __sub__(self, other): + """ Return the difference of two sets of intervals. """ + return self._merge(other, True) + + def _merge(self, other, difference): + """ Return the difference or intersection of two sets of intervals. """ + result = Intervals() + append = result._items.append + + # using 'self' and 'other' below forces normalization + bounds1 = _boundaries(self, 'start', 'stop') + bounds2 = _boundaries(other, 'switch', 'switch') + + start = None # set by start/stop + recs1 = None # set by start + enabled = difference # changed by switch + for value, flag, recs in sorted(chain(bounds1, bounds2)): + if flag == 'start': + start = value + recs1 = recs + elif flag == 'stop': + if enabled and start < value: + append((start, value, recs1)) + start = None + else: + if not enabled and start is not None: + start = value + if enabled and start is not None and start < value: + append((start, value, recs1)) + enabled = not enabled + + return result class ResourceCalendar(models.Model): @@ -56,7 +132,6 @@ class ResourceCalendar(models.Model): tuples, holding several intervals of work or leaves. """ _name = "resource.calendar" _description = "Resource Calendar" - _interval_obj = namedtuple('Interval', ('start_datetime', 'end_datetime', 'data')) @api.model def default_get(self, fields): @@ -92,7 +167,12 @@ class ResourceCalendar(models.Model): 'resource.calendar.leaves', 'calendar_id', 'Global Leaves', domain=[('resource_id', '=', False)] ) - hours_per_day = fields.Float("Average hour per day", default=HOURS_PER_DAY, help="Average hours per day a resource is supposed to work with this calendar.") + hours_per_day = fields.Float("Average hour per day", default=HOURS_PER_DAY, + help="Average hours per day a resource is supposed to work with this calendar.") + tz = fields.Selection( + _tz_get, string='Timezone', required=True, + default=lambda self: self._context.get('tz') or self.env.user.tz, + help="This field is used in order to define in which timezone the resources will work.") @api.onchange('attendance_ids') def _onchange_hours_per_day(self): @@ -103,550 +183,196 @@ class ResourceCalendar(models.Model): self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2) # -------------------------------------------------- - # Utility methods + # Computation API # -------------------------------------------------- - - def _merge_kw(self, kw, kw_ext): - new_kw = dict(kw, **kw_ext) - new_kw.update( - attendances=kw.get('attendances', self.env['resource.calendar.attendance']) | kw_ext.get('attendances', self.env['resource.calendar.attendance']), - leaves=kw.get('leaves', self.env['resource.calendar.leaves']) | kw_ext.get('leaves', self.env['resource.calendar.leaves']) - ) - return new_kw - - def _interval_new(self, start_datetime, end_datetime, kw=None): - kw = kw if kw is not None else dict() - kw.setdefault('attendances', self.env['resource.calendar.attendance']) - kw.setdefault('leaves', self.env['resource.calendar.leaves']) - return self._interval_obj(start_datetime, end_datetime, kw) - - def _interval_exclude_left(self, interval, interval_dst): - return self._interval_obj( - interval.start_datetime > interval_dst.end_datetime and interval.start_datetime or interval_dst.end_datetime, - interval.end_datetime, - self._merge_kw(interval.data, interval_dst.data) - ) - - def _interval_exclude_right(self, interval, interval_dst): - return self._interval_obj( - interval.start_datetime, - interval.end_datetime < interval_dst.start_datetime and interval.end_datetime or interval_dst.start_datetime, - self._merge_kw(interval.data, interval_dst.data) - ) - - def _interval_or(self, interval, interval_dst): - return self._interval_obj( - interval.start_datetime < interval_dst.start_datetime and interval.start_datetime or interval_dst.start_datetime, - interval.end_datetime > interval_dst.end_datetime and interval.end_datetime or interval_dst.end_datetime, - self._merge_kw(interval.data, interval_dst.data) - ) - - def _interval_and(self, interval, interval_dst): - if interval.start_datetime > interval_dst.end_datetime or interval.end_datetime < interval_dst.start_datetime: - return None - return self._interval_obj( - interval.start_datetime > interval_dst.start_datetime and interval.start_datetime or interval_dst.start_datetime, - interval.end_datetime < interval_dst.end_datetime and interval.end_datetime or interval_dst.end_datetime, - self._merge_kw(interval.data, interval_dst.data) - ) - - def _interval_merge(self, intervals): - """ Sort intervals based on starting datetime and merge overlapping intervals. - - :return list cleaned: sorted intervals merged without overlap """ - intervals = sorted(intervals, key=itemgetter(0)) # sort on first datetime - cleaned = [] - working_interval = None - while intervals: - current_interval = intervals.pop(0) - if not working_interval: # init - working_interval = self._interval_new(*current_interval) - elif working_interval[1] < current_interval[0]: # interval is disjoint - cleaned.append(working_interval) - working_interval = self._interval_new(*current_interval) - elif working_interval[1] < current_interval[1]: # union of greater intervals - working_interval = self._interval_or(working_interval, current_interval) - if working_interval: # handle void lists - cleaned.append(working_interval) - return cleaned - - @api.model - def _interval_remove_leaves(self, interval, leave_intervals): - """ Remove leave intervals from a base interval - - :param tuple interval: an interval (see above) that is the base interval - from which the leave intervals will be removed - :param list leave_intervals: leave intervals to remove - :return list intervals: ordered intervals with leaves removed """ - intervals = [] - leave_intervals = self._interval_merge(leave_intervals) - current_interval = interval - for leave in leave_intervals: - # skip if ending before the current start datetime - if leave[1] <= current_interval[0]: - continue - # skip if starting after current end datetime; break as leaves are ordered and - # are therefore all out of range - if leave[0] >= current_interval[1]: - break - # begins within current interval: close current interval and begin a new one - # that begins at the leave end datetime - if current_interval[0] < leave[0] < current_interval[1]: - intervals.append(self._interval_exclude_right(current_interval, leave)) - current_interval = self._interval_exclude_left(interval, leave) - # ends within current interval: set current start datetime as leave end datetime - if current_interval[0] <= leave[1]: - current_interval = self._interval_exclude_left(interval, leave) - if current_interval and current_interval[0] < interval[1]: # remove intervals moved outside base interval due to leaves - intervals.append(current_interval) - return intervals - - @api.model - def _interval_schedule_hours(self, intervals, hour, backwards=False): - """ Schedule hours in intervals. The last matching interval is truncated - 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. - - :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 backwards: schedule starting from last hour - - :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 not backwards: - interval = (interval[0], interval[1] + relativedelta(seconds=(limit - res).total_seconds())) - elif res > limit: - 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 - - # -------------------------------------------------- - # Date and hours computation - # -------------------------------------------------- - - @api.multi - 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_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_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 - - @api.multi - def _get_weekdays(self): - """ Return the list of weekdays that contain at least one working - interval. """ - self.ensure_one() - return list({int(d) for d in 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. """ - self.ensure_one() - weekdays = self._get_weekdays() - 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 - - return day_date + relativedelta(days=days) - - @api.multi - def _get_previous_work_day(self, day_date): - """ Get previous date of day_date, based on resource.calendar. """ - self.ensure_one() - weekdays = self._get_weekdays() - weekdays.reverse() - 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, domain=None): - """Get the leaves of the calendar. Leaves can be filtered on the resource, - and on a start and end datetime. - - 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 time intervals """ - self.ensure_one() - if resource_id: - search_domain = ['|', ('resource_id', '=', resource_id), ('resource_id', '=', False)] - else: - search_domain = [('resource_id', '=', False)] - if start_datetime: - # domain += [('date_to', '>', fields.Datetime.to_string(to_naive_utc(start_datetime, self.env.user)))] - search_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)))] - search_domain += [('date_from', '<', fields.Datetime.to_string(end_datetime + timedelta(days=1)))] - if domain: - search_domain += domain - if domain is None: - search_domain += [('time_type', '=', 'leave')] - leaves = self.env['resource.calendar.leaves'].search(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, day_date, start_time=None, end_time=None, compute_leaves=False, resource_id=None, domain=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 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. - :param int resource_id: the id of the resource to take into account when - computing the work intervals. Leaves notably are - filtered according to the resource. - - :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)] - - # filter according to leaves - 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), - domain=domain) - 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, domain=None): - """ 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), - domain=domain) - - final_intervals = [i for i in - [self._interval_and(leave_interval, work_interval) - for leave_interval in leaves_intervals - for work_interval in working_intervals] if i] - - # 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 - # -------------------------------------------------- - - def _iter_work_intervals(self, start_dt, end_dt, resource_id, compute_leaves=True, domain=None): - """ 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 = 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 = datetime.time.min - if day.date() == start_dt.date(): - start_time = start_dt.time() - end_time = datetime.time.max - if day.date() == end_dt.date() and end_dt.time() != datetime.time(): - end_time = end_dt.time() - - intervals = self._get_day_work_intervals( - day.date(), - start_time=start_time, - end_time=end_time, - compute_leaves=compute_leaves, - resource_id=resource_id, - domain=domain) - if intervals: - yield intervals - - def _iter_leave_intervals(self, start_dt, end_dt, resource_id, domain=None): - """ 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 = datetime.time.min - if day.date() == start_dt.date(): - start_time = start_dt.time() - end_time = datetime.time.max - if day.date() == end_dt.date() and end_dt.time() != datetime.time(): - end_time = end_dt.time() - - intervals = self._get_day_leave_intervals( - day.date(), - start_time, - end_time, - resource_id, - domain=domain) - - if intervals: - yield intervals - - def _iter_work_hours_count(self, from_datetime, to_datetime, resource_id, domain=None): - """ Lists the current resource's work hours count between the two provided - datetime expressed in naive UTC. """ - - for interval in self._iter_work_intervals(from_datetime, to_datetime, resource_id, domain=domain): - td = timedelta() - for work_interval in interval: - td += work_interval[1] - work_interval[0] - yield (interval[0][0].date(), td.total_seconds() / 3600.0) - - def _iter_work_days(self, from_date, to_date, resource_id, domain=None): - """ 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) + def _attendance_intervals(self, start_dt, end_dt, resource=None): + """ Return the attendance intervals in the given datetime range. + The returned intervals are expressed in the resource's timezone. """ - 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, domain=domain): - yield interval[0][0].date() + assert start_dt.tzinfo and end_dt.tzinfo + combine = datetime.combine - @api.multi - def _is_work_day(self, date, resource_id): - """ Whether the provided date is a work day for the subject resource. + # express all dates and times in the resource's timezone + tz = timezone((resource or self).tz) + start_dt = start_dt.astimezone(tz) + end_dt = end_dt.astimezone(tz) - :type date: datetime.date - :rtype: bool """ - return bool(next(self._iter_work_days(date, date, resource_id), False)) + # for each attendance spec, generate the intervals in the date range + result = [] + for attendance in self.attendance_ids: + start = start_dt.date() + if attendance.date_from: + start = max(start, attendance.date_from) + until = end_dt.date() + if attendance.date_to: + until = min(until, attendance.date_to) + weekday = int(attendance.dayofweek) - @api.multi - def get_work_hours_count(self, start_dt, end_dt, resource_id, compute_leaves=True, domain=None): - """ 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, domain=domain): - for interval in intervals: - res += interval[1] - interval[0] - return res.total_seconds() / 3600.0 + for day in rrule(DAILY, start, until=until, byweekday=weekday): + # attendance hours are interpreted in the resource's timezone + dt0 = tz.localize(combine(day, float_to_time(attendance.hour_from))) + dt1 = tz.localize(combine(day, float_to_time(attendance.hour_to))) + result.append((max(start_dt, dt0), min(end_dt, dt1), attendance)) - # -------------------------------------------------- - # Scheduling API - # -------------------------------------------------- + return Intervals(result) - @api.multi - def _schedule_hours(self, hours, day_dt, compute_leaves=False, resource_id=None, domain=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. 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. - - :return list intervals: list of time intervals in naive UTC """ + def _leave_intervals(self, start_dt, end_dt, resource=None, domain=None): + """ Return the leave intervals in the given datetime range. + The returned intervals are expressed in the calendar's timezone. + """ + assert start_dt.tzinfo and end_dt.tzinfo self.ensure_one() - backwards = (hours < 0) - intervals = [] - remaining_hours, iterations = abs(hours * 1.0), 0 - day_dt_tz = to_naive_user_tz(day_dt, self.env.user) - current_datetime = day_dt_tz + # for the computation, express all datetimes in UTC + resource_ids = [resource.id, False] if resource else [False] + if domain is None: + domain = [('time_type', '=', 'leave')] + domain = domain + [ + ('calendar_id', '=', self.id), + ('resource_id', 'in', resource_ids), + ('date_from', '<=', datetime_to_string(end_dt)), + ('date_to', '>=', datetime_to_string(start_dt)), + ] - call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id, domain=domain) + # retrieve leave intervals in (start_dt, end_dt) + tz = timezone((resource or self).tz) + start_dt = start_dt.astimezone(tz) + end_dt = end_dt.astimezone(tz) + result = [] + for leave in self.env['resource.calendar.leaves'].search(domain): + dt0 = string_to_datetime(leave.date_from).astimezone(tz) + dt1 = string_to_datetime(leave.date_to).astimezone(tz) + result.append((max(start_dt, dt0), min(end_dt, dt1), leave)) - while float_compare(remaining_hours, 0.0, precision_digits=2) in (1, 0) and iterations < 1000: - if backwards: - call_args['end_time'] = current_datetime.time() - else: - call_args['start_time'] = current_datetime.time() + return Intervals(result) - working_intervals = self._get_day_work_intervals(current_datetime.date(), **call_args) + def _work_intervals(self, start_dt, end_dt, resource=None, domain=None): + """ Return the effective work intervals between the given datetimes. """ + return (self._attendance_intervals(start_dt, end_dt, resource) - + self._leave_intervals(start_dt, end_dt, resource, domain)) - if working_intervals: - 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 -= 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)) - else: - current_datetime = datetime.datetime.combine(self._get_next_work_day(current_datetime), datetime.time()) - # avoid infinite loops - iterations += 1 - - return intervals + # -------------------------------------------------- + # External API + # -------------------------------------------------- @api.multi - def plan_hours(self, hours, day_dt, compute_leaves=False, resource_id=None, domain=None): - """ Return datetime after having planned hours """ - res = self._schedule_hours(hours, day_dt, compute_leaves, resource_id, domain) - if res and hours < 0.0: - return res[0][0] - elif res: - return res[-1][1] - return False + def get_work_hours_count(self, start_dt, end_dt, compute_leaves=True, domain=None): + """ + `compute_leaves` controls whether or not this method is taking into + account the global leaves. + + `domain` controls the way leaves are recognized. + None means default value ('time_type', '=', 'leave') + + Counts the number of work hours between two datetimes. + """ + # Set timezone in UTC if no timezone is explicitly given + if not start_dt.tzinfo: + start_dt = start_dt.replace(tzinfo=utc) + if not end_dt.tzinfo: + end_dt = end_dt.replace(tzinfo=utc) + + if compute_leaves: + intervals = self._work_intervals(start_dt, end_dt, domain=domain) + else: + intervals = self._attendance_intervals(start_dt, end_dt) + + return sum( + (stop - start).total_seconds() / 3600 + for start, stop, meta in intervals + ) @api.multi - def _schedule_days(self, days, day_dt, compute_leaves=False, resource_id=None, domain=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. For compute_leaves, resource_id: - see _get_day_work_intervals. This method does not use rrule because - rrule does not allow backwards computation. + def plan_hours(self, hours, day_dt, compute_leaves=False, domain=None): + """ + `compute_leaves` controls whether or not this method is taking into + account the global leaves. - :param int days: number of days to schedule. Use a negative number to - compute a backwards scheduling. - :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. + `domain` controls the way leaves are recognized. + None means default value ('time_type', '=', 'leave') - :return list intervals: list of time intervals in naive UTC """ - backwards = (days < 0) - intervals = [] - planned_days, iterations = 0, 0 + Return datetime after having planned hours + """ + if not day_dt.tzinfo: + day_dt = day_dt.replace(tzinfo=utc) - day_dt_tz = to_naive_user_tz(day_dt, self.env.user) - current_datetime = day_dt_tz.replace(hour=0, minute=0, second=0, microsecond=0) + # which method to use for retrieving intervals + if compute_leaves: + get_intervals = partial(self._work_intervals, domain=domain) + else: + get_intervals = self._attendance_intervals - while planned_days < abs(days) and iterations < 100: - working_intervals = self._get_day_work_intervals( - current_datetime.date(), - compute_leaves=compute_leaves, resource_id=resource_id, - domain=domain) - if not self or working_intervals: # no calendar -> no working hours, but day is considered as worked - planned_days += 1 - intervals += working_intervals - # get next day - if backwards: - current_datetime = self._get_previous_work_day(current_datetime) - else: - current_datetime = self._get_next_work_day(current_datetime) - # avoid infinite loops - iterations += 1 + if hours > 0: + delta = timedelta(days=14) + for n in range(100): + dt = day_dt + delta * n + for start, stop, meta in get_intervals(dt, dt + delta): + interval_hours = (stop - start).total_seconds() / 3600 + if hours <= interval_hours: + return (start + timedelta(hours=hours)).astimezone(day_dt.tzinfo) + hours -= interval_hours + return False - return intervals + elif hours < 0: + hours = abs(hours) + delta = timedelta(days=14) + for n in range(100): + dt = day_dt - delta * n + for start, stop, meta in reversed(get_intervals(dt - delta, dt)): + interval_hours = (stop - start).total_seconds() / 3600 + if hours <= interval_hours: + return (stop - timedelta(hours=hours)).astimezone(day_dt.tzinfo) + hours -= interval_hours + return False + + else: + return day_dt @api.multi - def plan_days(self, days, day_dt, compute_leaves=False, resource_id=None, domain=None): - """ Returns the datetime of a days scheduling. """ - res = self._schedule_days(days, day_dt, compute_leaves, resource_id, domain) - return res and res[-1][1] or False + def plan_days(self, days, day_dt, compute_leaves=False, domain=None): + """ + `compute_leaves` controls whether or not this method is taking into + account the global leaves. + + `domain` controls the way leaves are recognized. + None means default value ('time_type', '=', 'leave') + + Returns the datetime of a days scheduling. + """ + if not day_dt.tzinfo: + day_dt = day_dt.replace(tzinfo=utc) + + # which method to use for retrieving intervals + if compute_leaves: + get_intervals = partial(self._work_intervals, domain=domain) + else: + get_intervals = self._attendance_intervals + + if days > 0: + found = set() + delta = timedelta(days=14) + for n in range(100): + dt = day_dt + delta * n + for start, stop, meta in get_intervals(dt, dt + delta): + found.add(start.date()) + if len(found) == days: + return stop.astimezone(day_dt.tzinfo) + return False + + elif days < 0: + days = abs(days) + found = set() + delta = timedelta(days=14) + for n in range(100): + dt = day_dt - delta * n + for start, stop, meta in reversed(get_intervals(dt - delta, dt)): + found.add(start.date()) + if len(found) == days: + return start.astimezone(day_dt.tzinfo) + return False + + else: + return day_dt class ResourceCalendarAttendance(models.Model): @@ -712,6 +438,10 @@ class ResourceResource(models.Model): default=lambda self: self.env['res.company']._company_default_get().resource_calendar_id, required=True, help="Define the schedule of resource") + tz = fields.Selection( + _tz_get, string='Timezone', required=True, + default=lambda self: self._context.get('tz') or self.env.user.tz, + help="This field is used in order to define in which timezone the resources will work.") _sql_constraints = [ ('check_time_efficiency', 'CHECK(time_efficiency>0)', 'Time efficiency must be strictly positive'), @@ -728,6 +458,12 @@ class ResourceResource(models.Model): def create(self, values): if values.get('company_id') and not values.get('calendar_id'): values['calendar_id'] = self.env['res.company'].browse(values['company_id']).resource_calendar_id.id + if not values.get('tz'): + # retrieve timezone on user or calendar + tz = (self.env['res.users'].browse(values.get('user_id')).tz or + self.env['resource.calendar'].browse(values.get('calendar_id')).tz) + if tz: + values['tz'] = tz return super(ResourceResource, self).create(values) @api.multi @@ -745,6 +481,11 @@ class ResourceResource(models.Model): if self.company_id: self.calendar_id = self.company_id.resource_calendar_id.id + @api.onchange('user_id') + def _onchange_user_id(self): + if self.user_id: + self.tz = self.user_id.tz + class ResourceCalendarLeaves(models.Model): _name = "resource.calendar.leaves" @@ -757,10 +498,6 @@ 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') or self.env.user.tz or 'UTC', - 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/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index c08bd66d628..d5aa1cee51f 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -1,11 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict from datetime import timedelta +from pytz import utc from odoo import api, fields, models from odoo.tools import float_utils +# This will generate quarter of days +ROUNDING_FACTOR = 4 + + class ResourceMixin(models.AbstractModel): _name = "resource.mixin" _description = 'Resource Mixin' @@ -21,13 +27,19 @@ class ResourceMixin(models.AbstractModel): 'resource.calendar', 'Working Hours', default=lambda self: self.env['res.company']._company_default_get().resource_calendar_id, index=True, related='resource_id.calendar_id', store=True) + tz = fields.Selection( + string='Timezone', related='resource_id.tz', + help="This field is used in order to define in which timezone the resources will work.") @api.model def create(self, values): if not values.get('resource_id'): - resource = self.env['resource.resource'].create({ - 'name': values.get(self._rec_name) - }) + resource_vals = {'name': values.get(self._rec_name)} + tz = (values.pop('tz', False) or + self.env['resource.calendar'].browse(values.get('resource_calendar_id')).tz) + if tz: + resource_vals['tz'] = tz + resource = self.env['resource.resource'].create(resource_vals) values['resource_id'] = resource.id return super(ResourceMixin, self).create(values) @@ -41,87 +53,150 @@ class ResourceMixin(models.AbstractModel): default['resource_calendar_id'] = resource.calendar_id.id return super(ResourceMixin, self).copy_data(default) - def get_work_days_count(self, from_datetime, to_datetime, calendar=None, domain=None): - """ Return the number of work days for the resource, taking into account - leaves. An optional calendar can be given in case multiple calendars can - be used on the resource. """ - return self.get_work_days_data(from_datetime, to_datetime, calendar=calendar, domain=domain)['days'] + def get_work_days_data(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None): + """ + By default the resource calendar is used, but it can be + changed using the `calendar` argument. - def get_work_hours_count(self, from_datetime, to_datetime, calendar=None, domain=None): - """ Return the number of work hours for the resource, taking into account - leaves. An optional calendar can be given in case multiple calendars can - be used on the resource. """ - return self.get_work_days_data(from_datetime, to_datetime, calendar=calendar, domain=domain)['hours'] + `domain` is used in order to recognise the leaves to take, + None means default value ('time_type', '=', 'leave') - def get_work_days_data(self, from_datetime, to_datetime, calendar=None, domain=None): - days_count = 0.0 - total_work_time = timedelta() + Returns a dict {'days': n, 'hours': h} containing the + quantity of working time expressed as days and as hours. + """ + resource = self.resource_id calendar = calendar or self.resource_calendar_id - for day_intervals in calendar._iter_work_intervals( - from_datetime, to_datetime, self.resource_id.id, - compute_leaves=True, domain=domain): - theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) - work_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) - total_work_time += work_time - if theoric_hours: - days_count += float_utils.round((work_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 + + # naive datetimes are made explicit in UTC + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) + + # 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) + to_full = to_datetime + timedelta(days=1) + intervals = calendar._attendance_intervals(from_full, to_full, resource) + day_total = defaultdict(float) + for start, stop, meta in intervals: + day_total[start.date()] += (stop - start).total_seconds() / 3600 + + # actual hours per day + if compute_leaves: + intervals = calendar._work_intervals(from_datetime, to_datetime, resource, domain) + else: + intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource) + day_hours = defaultdict(float) + for start, stop, meta in intervals: + day_hours[start.date()] += (stop - start).total_seconds() / 3600 + + # compute number of days as quarters + days = sum( + float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR + for day in day_hours + ) return { - 'days': days_count, - 'hours': total_work_time.total_seconds() / 3600, + 'days': days, + 'hours': sum(day_hours.values()), } - def iter_works(self, from_datetime, to_datetime, calendar=None, domain=None): + def get_leave_days_data(self, from_datetime, to_datetime, calendar=None, domain=None): + """ + By default the resource calendar is used, but it can be + changed using the `calendar` argument. + + `domain` is used in order to recognise the leaves to take, + None means default value ('time_type', '=', 'leave') + + Returns a dict {'days': n, 'hours': h} containing the number of leaves + expressed as days and as hours. + """ + resource = self.resource_id calendar = calendar or self.resource_calendar_id - return calendar._iter_work_intervals(from_datetime, to_datetime, self.resource_id.id, domain=domain) - def iter_work_hours_count(self, from_datetime, to_datetime, calendar=None, domain=None): - calendar = calendar or self.resource_calendar_id - return calendar._iter_work_hours_count(from_datetime, to_datetime, self.resource_id.id, domain=domain) + # naive datetimes are made explicit in UTC + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) - def get_leaves_day_count(self, from_datetime, to_datetime, calendar=None, domain=None): - """ Return the number of leave days for the resource, taking into account - attendances. An optional calendar can be given in case multiple calendars - can be used on the resource. """ - return self.get_leaves_days_data(from_datetime, to_datetime, calendar=calendar, domain=domain)['days'] + # 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) + to_full = to_datetime + timedelta(days=1) + intervals = calendar._attendance_intervals(from_full, to_full, resource) + day_total = defaultdict(float) + for start, stop, meta in intervals: + day_total[start.date()] += (stop - start).total_seconds() / 3600 - def get_leaves_hours_count(self, from_datetime, to_datetime, calendar=None, domain=None): - """ Return the number of leave hours for the resource, taking into account - attendances. An optional calendar can be given in case multiple calendars can - be used on the resource. """ - return self.get_leaves_days_data(from_datetime, to_datetime, calendar=calendar, domain=domain)['hours'] + # compute actual hours per day + attendances = calendar._attendance_intervals(from_datetime, to_datetime, resource) + leaves = calendar._leave_intervals(from_datetime, to_datetime, resource, domain) + day_hours = defaultdict(float) + for start, stop, meta in (attendances & leaves): + day_hours[start.date()] += (stop - start).total_seconds() / 3600 - def get_leaves_days_data(self, from_datetime, to_datetime, calendar=None, domain=None): - days_count = 0.0 - total_leave_time = timedelta() - calendar = calendar or self.resource_calendar_id - for day_intervals in calendar._iter_leave_intervals(from_datetime, to_datetime, self.resource_id.id, domain=domain): - theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) - leave_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) - total_leave_time += leave_time - if theoric_hours: - days_count += float_utils.round((leave_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 + # compute number of days as quarters + days = sum( + float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR + for day in day_hours + ) return { - 'days': days_count, - 'hours': total_leave_time.total_seconds() / 3600, + 'days': days, + 'hours': sum(day_hours.values()), } - def iter_leaves(self, from_datetime, to_datetime, calendar=None, domain=None): - calendar = calendar or self.resource_calendar_id - return calendar._iter_leave_intervals(from_datetime, to_datetime, self.resource_id.id, domain=domain) + 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 + changed using the `calendar` argument. - def get_start_work_hour(self, day_dt, calendar=None, domain=None): - calendar = calendar or self.resource_calendar_id - work_intervals = calendar._get_day_work_intervals(day_dt, resource_id=self.resource_id.id, domain=domain) - return work_intervals and work_intervals[0][0] + `domain` is used in order to recognise the leaves to take, + None means default value ('time_type', '=', 'leave') - def get_end_work_hour(self, day_dt, calendar=None, domain=None): + Returns a list of tuples (day, hours) for each day + containing at least an attendance. + """ + resource = self.resource_id calendar = calendar or self.resource_calendar_id - work_intervals = calendar._get_day_work_intervals(day_dt, resource_id=self.resource_id.id, domain=domain) - return work_intervals and work_intervals[-1][1] - def get_day_work_hours_count(self, day_date, calendar=None): + # naive datetimes are made explicit in UTC + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) + + intervals = calendar._work_intervals(from_datetime, to_datetime, resource, domain) + result = defaultdict(float) + for start, stop, meta in intervals: + result[start.date()] += (stop - start).total_seconds() / 3600 + return sorted(result.items()) + + def list_leaves(self, from_datetime, to_datetime, calendar=None, domain=None): + """ + By default the resource calendar is used, but it can be + changed using the `calendar` argument. + + `domain` is used in order to recognise the leaves to take, + None means default value ('time_type', '=', 'leave') + + Returns a list of tuples (day, hours, resource.calendar.leaves) + for each leave in the calendar. + """ + resource = self.resource_id calendar = calendar or self.resource_calendar_id - attendances = calendar._get_day_attendances(day_date, False, False) - if not attendances: - return 0 - return sum(float(i.hour_to) - float(i.hour_from) for i in attendances) + + # naive datetimes are made explicit in UTC + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) + + attendances = calendar._attendance_intervals(from_datetime, to_datetime, resource) + leaves = calendar._leave_intervals(from_datetime, to_datetime, resource, domain) + result = [] + for start, stop, leave in (leaves & attendances): + hours = (stop - start).total_seconds() / 3600 + result.append((start.date(), hours, leave)) + return result diff --git a/addons/resource/tests/common.py b/addons/resource/tests/common.py index 0fd6f9f7d9b..fdcc6b80a10 100644 --- a/addons/resource/tests/common.py +++ b/addons/resource/tests/common.py @@ -1,74 +1,46 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.fields import Datetime from odoo.tests.common import TransactionCase class TestResourceCommon(TransactionCase): + def _define_calendar(self, name, attendances, tz): + return self.env['resource.calendar'].create({ + 'name': name, + 'tz': tz, + 'attendance_ids': [ + (0, 0, { + 'name': '%s_%d' % (name, index), + 'hour_from': att[0], + 'hour_to': att[1], + 'dayofweek': str(att[2]), + }) + for index, att in enumerate(attendances) + ], + }) + def setUp(self): super(TestResourceCommon, self).setUp() - self.env.user.tz = 'UTC' - # Some demo data - self.date1 = Datetime.from_string('2013-02-12 09:08:07') # weekday() returns 1, isoweekday() returns 2 - self.date2 = Datetime.from_string('2013-02-15 10:11:12') # weekday() returns 4, isoweekday() returns 5 + # UTC+1 winter, UTC+2 summer + self.calendar_jean = self._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') + # 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') - # Resource data - # Calendar working days: 1 (8-16 -> 8hours), 4 (8-13, 16-23 -> 12hours) - self.calendar = self.env['resource.calendar'].create({ - 'name': 'TestCalendar', - 'attendance_ids': [(5, 0, 0)] + # Employee is linked to a resource.resource via resource.mixin + self.jean = self.env['resource.test'].create({ + 'name': 'Jean', + 'resource_calendar_id': self.calendar_jean.id, }) - self.att_1 = self.env['resource.calendar.attendance'].create({ - 'name': 'Att1', - 'calendar_id': self.calendar.id, - 'dayofweek': '1', - 'hour_from': 8, - 'hour_to': 16 + self.patel = self.env['resource.test'].create({ + 'name': 'Patel', + 'resource_calendar_id': self.calendar_patel.id, }) - self.att_2 = self.env['resource.calendar.attendance'].create({ - 'name': 'Att2', - 'calendar_id': self.calendar.id, - 'dayofweek': '4', - 'hour_from': 8, - 'hour_to': 13 + self.john = self.env['resource.test'].create({ + 'name': 'John', + 'resource_calendar_id': self.calendar_john.id, }) - self.att_3 = self.env['resource.calendar.attendance'].create({ - 'name': 'Att3', - 'calendar_id': self.calendar.id, - 'dayofweek': '4', - 'hour_from': 16, - 'hour_to': 23 - }) - - self.resource1_id = self.env['resource.resource'].create( - { - 'name': 'TestResource1', - 'resource_type': 'user', - 'time_efficiency': 150.0, - 'calendar_id': self.calendar.id, - } - ).id - - # Leave1: 19/02/2013, from 9 to 12, is a day 1 - self.leave1 = self.env['resource.calendar.leaves'].create({ - 'name': 'GenericLeave', - 'calendar_id': self.calendar.id, - 'date_from': Datetime.from_string('2013-02-19 09:00:00'), - 'date_to': Datetime.from_string('2013-02-19 12:00:00')}) - # Leave2: 22/02/2013, from 9 to 15, is a day 4 - self.leave2 = self.env['resource.calendar.leaves'].create({ - 'name': 'ResourceLeave', - 'calendar_id': self.calendar.id, - 'resource_id': self.resource1_id, - 'date_from': Datetime.from_string('2013-02-22 09:00:00'), - 'date_to': Datetime.from_string('2013-02-22 15:00:00')}) - # Leave3: 25/02/2013 (day0) -> 01/03/2013 (day4) - self.leave3 = self.env['resource.calendar.leaves'].create({ - 'name': 'ResourceLeave2', - 'calendar_id': self.calendar.id, - 'resource_id': self.resource1_id, - 'date_from': Datetime.from_string('2013-02-25 13:00:00'), - 'date_to': Datetime.from_string('2013-03-01 11:30:00')}) diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py index 8e8c5b8015d..a190878e730 100644 --- a/addons/resource/tests/test_resource.py +++ b/addons/resource/tests/test_resource.py @@ -1,849 +1,876 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import babel.dates +from datetime import date, datetime +from pytz import timezone, utc -from datetime import datetime, timedelta, date, time - -from dateutil import rrule -from dateutil.relativedelta import relativedelta - -from odoo.fields import Date, Datetime -from odoo.addons.resource.models.resource import to_naive_utc, to_naive_user_tz, to_tz +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.addons.resource.models.resource import Intervals from odoo.addons.resource.tests.common import TestResourceCommon +from odoo.tests.common import TransactionCase -class TestIntervals(TestResourceCommon): - - def setUp(self): - super(TestIntervals, self).setUp() - # Some data intervals - # - first one is included in second one - # - second one is extended by third one - # - sixth one is included in fourth one - # - fifth one is prior to other one - # Once cleaned: 1 interval 03/02 8-10), 2 intervals 04/02 (8-14 + 17-21) - self.intervals = [ - self.calendar._interval_new( - Datetime.from_string('2013-02-04 09:00:00'), - Datetime.from_string('2013-02-04 11:00:00') - ), self.calendar._interval_new( - Datetime.from_string('2013-02-04 08:00:00'), - Datetime.from_string('2013-02-04 12:00:00') - ), self.calendar._interval_new( - Datetime.from_string('2013-02-04 11:00:00'), - Datetime.from_string('2013-02-04 14:00:00') - ), self.calendar._interval_new( - Datetime.from_string('2013-02-04 17:00:00'), - Datetime.from_string('2013-02-04 21:00:00') - ), self.calendar._interval_new( - Datetime.from_string('2013-02-03 08:00:00'), - Datetime.from_string('2013-02-03 10:00:00') - ), self.calendar._interval_new( - Datetime.from_string('2013-02-04 18:00:00'), - Datetime.from_string('2013-02-04 19:00:00') - ) - ] - - def test_interval_merge(self): - cleaned_intervals = self.env['resource.calendar']._interval_merge(self.intervals) - self.assertEqual(len(cleaned_intervals), 3) - # First interval: 03, unchanged - self.assertEqual(cleaned_intervals[0][:2], (Datetime.from_string('2013-02-03 08:00:00'), Datetime.from_string('2013-02-03 10:00:00'))) - # Second interval: 04, 08-14, combining 08-12 and 11-14, 09-11 being inside 08-12 - self.assertEqual(cleaned_intervals[1][:2], (Datetime.from_string('2013-02-04 08:00:00'), Datetime.from_string('2013-02-04 14:00:00'))) - # Third interval: 04, 17-21, 18-19 being inside 17-21 - self.assertEqual(cleaned_intervals[2][:2], (Datetime.from_string('2013-02-04 17:00:00'), Datetime.from_string('2013-02-04 21:00:00'))) - - def test_interval_and(self): - self.assertEqual(self.env['resource.calendar']._interval_and(self.intervals[0], self.intervals[1]), - self.calendar._interval_new(Datetime.from_string('2013-02-04 09:00:00'), Datetime.from_string('2013-02-04 11:00:00'))) - self.assertEqual(self.env['resource.calendar']._interval_and(self.intervals[2], self.intervals[3]), - None) - - def test_interval_remove(self): - working_interval = self.calendar._interval_new(Datetime.from_string('2013-02-04 08:00:00'), Datetime.from_string('2013-02-04 18:00:00')) - result = self.env['resource.calendar']._interval_remove_leaves(working_interval, self.intervals) - self.assertEqual(len(result), 1) - # First interval: 04, 14-17 - self.assertEqual(result[0][:2], (Datetime.from_string('2013-02-04 14:00:00'), Datetime.from_string('2013-02-04 17:00:00'))) - - def test_interval_schedule_hours(self): - cleaned_intervals = self.env['resource.calendar']._interval_merge(self.intervals) - result = self.env['resource.calendar']._interval_schedule_hours(cleaned_intervals, 5.5) - self.assertEqual(len(result), 2) - # First interval: 03, 8-10 untouched - self.assertEqual(result[0][:2], (Datetime.from_string('2013-02-03 08:00:00'), Datetime.from_string('2013-02-03 10:00:00'))) - # First interval: 04, 08-11:30 - self.assertEqual(result[1][:2], (Datetime.from_string('2013-02-04 08:00:00'), Datetime.from_string('2013-02-04 11:30:00'))) - - def test_interval_schedule_hours_backwards(self): - cleaned_intervals = self.env['resource.calendar']._interval_merge(self.intervals) - 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[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[0][:2], (Datetime.from_string('2013-02-04 12:30:00'), Datetime.from_string('2013-02-04 14:00:00'))) +def datetime_tz(year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): + """ Return a `datetime` object with a given timezone (if given). """ + dt = datetime(year, month, day, hour, minute, second, microsecond) + return timezone(tzinfo).localize(dt) if tzinfo else dt -class TestCalendarBasics(TestResourceCommon): +def datetime_str(year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): + """ Return a fields.Datetime value with the given timezone. """ + dt = datetime(year, month, day, hour, minute, second, microsecond) + if tzinfo: + dt = timezone(tzinfo).localize(dt).astimezone(utc) + return fields.Datetime.to_string(dt) - def test_calendar_weekdays(self): - weekdays = self.calendar._get_weekdays() - self.assertEqual(weekdays, [1, 4]) - def test_calendar_next_day(self): - # Test: next day: next day after day1 is day4 - date = self.calendar._get_next_work_day(day_date=Date.from_string('2013-02-12')) - self.assertEqual(date, self.date2.date()) +class TestIntervals(TransactionCase): - # Test: next day: next day after day4 is (day1+7) - date = self.calendar._get_next_work_day(day_date=Date.from_string('2013-02-15')) - self.assertEqual(date, self.date1.date() + relativedelta(days=7)) + def ints(self, pairs): + recs = self.env['base'] + return [(a, b, recs) for a, b in pairs] - # Test: next day: next day after day4+1 is (day1+7) - date = self.calendar._get_next_work_day(day_date=Date.from_string('2013-02-15') + relativedelta(days=1)) - self.assertEqual(date, self.date1.date() + relativedelta(days=7)) + def test_union(self): + def check(a, b): + a, b = self.ints(a), self.ints(b) + self.assertEqual(list(Intervals(a)), b) - # Test: next day: next day after day1-1 is day1 - date = self.calendar._get_next_work_day(day_date=Date.from_string('2013-02-12') + relativedelta(days=-1)) - self.assertEqual(date, self.date1.date()) + check([(1, 2), (3, 4)], [(1, 2), (3, 4)]) + check([(1, 2), (2, 4)], [(1, 4)]) + check([(1, 3), (2, 4)], [(1, 4)]) + check([(1, 4), (2, 3)], [(1, 4)]) + check([(3, 4), (1, 2)], [(1, 2), (3, 4)]) + check([(2, 4), (1, 2)], [(1, 4)]) + check([(2, 4), (1, 3)], [(1, 4)]) + check([(2, 3), (1, 4)], [(1, 4)]) - def test_calendar_previous_day(self): - # Test: previous day: previous day before day1 is (day4-7) - date = self.calendar._get_previous_work_day(day_date=Date.from_string('2013-02-12')) - self.assertEqual(date, self.date2.date() + relativedelta(days=-7)) + def test_intersection(self): + def check(a, b, c): + a, b, c = self.ints(a), self.ints(b), self.ints(c) + self.assertEqual(list(Intervals(a) & Intervals(b)), c) - # Test: previous day: previous day before day4 is day1 - date = self.calendar._get_previous_work_day(day_date=Date.from_string('2013-02-15')) - self.assertEqual(date, self.date1.date()) - - # Test: previous day: previous day before day4+1 is day4 - date = self.calendar._get_previous_work_day(day_date=Date.from_string('2013-02-15') + relativedelta(days=1)) - self.assertEqual(date, self.date2.date()) - - # Test: previous day: previous day before day1-1 is (day4-7) - date = self.calendar._get_previous_work_day(day_date=Date.from_string('2013-02-12') + relativedelta(days=-1)) - self.assertEqual(date, self.date2.date() + relativedelta(days=-7)) - - def test_calendar_working_day_intervals_no_leaves(self): - # Test: day0 without leaves: 1 interval - 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(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(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'))) - self.assertEqual(intervals[0][2]['attendances'], self.att_2) - self.assertEqual(intervals[1][2]['attendances'], self.att_3) - - def test_calendar_working_day_intervals_leaves_generic(self): - # Test: day0 with leaves outside range: 1 interval - 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(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'))) - self.assertEqual(intervals[0][2]['attendances'], self.att_1) - self.assertEqual(intervals[0][2]['leaves'], self.leave1) - self.assertEqual(intervals[1][2]['attendances'], self.att_1) - self.assertEqual(intervals[0][2]['leaves'], self.leave1) - - 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( - Date.from_string('2013-02-26'), - start_time=time(7, 0, 0), - compute_leaves=True, - resource_id=self.resource1_id + check([(10, 20)], [(5, 8)], []) + check([(10, 20)], [(5, 10)], []) + check([(10, 20)], [(5, 15)], [(10, 15)]) + check([(10, 20)], [(5, 20)], [(10, 20)]) + check([(10, 20)], [(5, 25)], [(10, 20)]) + check([(10, 20)], [(10, 15)], [(10, 15)]) + check([(10, 20)], [(10, 20)], [(10, 20)]) + check([(10, 20)], [(10, 25)], [(10, 20)]) + check([(10, 20)], [(15, 18)], [(15, 18)]) + check([(10, 20)], [(15, 20)], [(15, 20)]) + check([(10, 20)], [(15, 25)], [(15, 20)]) + check([(10, 20)], [(20, 25)], []) + check( + [(0, 5), (10, 15), (20, 25), (30, 35)], + [(6, 7), (9, 12), (13, 17), (22, 23), (24, 40)], + [(10, 12), (13, 15), (22, 23), (24, 25), (30, 35)], ) - # Result: nothing, because on leave - self.assertEqual(len(intervals), 0) - def test_calendar_working_day_intervals_limited_attendances(self): - """ Test attendances limited in time. """ - attendance = self.env['resource.calendar.attendance'].search( - [('name', '=', 'Att3')]) - attendance.write({ - 'date_from': self.date2 + relativedelta(days=7), - 'date_to': False, + def test_difference(self): + def check(a, b, c): + a, b, c = self.ints(a), self.ints(b), self.ints(c) + self.assertEqual(list(Intervals(a) - Intervals(b)), c) + + check([(10, 20)], [(5, 8)], [(10, 20)]) + check([(10, 20)], [(5, 10)], [(10, 20)]) + check([(10, 20)], [(5, 15)], [(15, 20)]) + check([(10, 20)], [(5, 20)], []) + check([(10, 20)], [(5, 25)], []) + check([(10, 20)], [(10, 15)], [(15, 20)]) + check([(10, 20)], [(10, 20)], []) + check([(10, 20)], [(10, 25)], []) + check([(10, 20)], [(15, 18)], [(10, 15), (18, 20)]) + check([(10, 20)], [(15, 20)], [(10, 15)]) + check([(10, 20)], [(15, 25)], [(10, 15)]) + check([(10, 20)], [(20, 25)], [(10, 20)]) + check( + [(0, 5), (10, 15), (20, 25), (30, 35)], + [(6, 7), (9, 12), (13, 17), (22, 23), (24, 40)], + [(0, 5), (12, 13), (20, 22), (23, 24)], + ) + + +class TestErrors(TestResourceCommon): + def setUp(self): + super(TestErrors, self).setUp() + + def test_create_negative_leave(self): + # from > to + with self.assertRaises(ValidationError): + self.env['resource.calendar.leaves'].create({ + 'name': 'error cannot return in the past', + 'resource_id': False, + 'calendar_id': self.calendar_jean.id, + 'date_from': datetime_str(2018, 4, 3, 20, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.jean.tz), + }) + + with self.assertRaises(ValidationError): + self.env['resource.calendar.leaves'].create({ + 'name': 'error caused by timezones', + 'resource_id': False, + 'calendar_id': self.calendar_jean.id, + 'date_from': datetime_str(2018, 4, 3, 10, 0, 0, tzinfo='UTC'), + 'date_to': datetime_str(2018, 4, 3, 12, 0, 0, tzinfo='Etc/GMT-6') + }) + + +class TestCalendar(TestResourceCommon): + def setUp(self): + super(TestCalendar, self).setUp() + + def test_get_work_hours_count(self): + self.env['resource.calendar.leaves'].create({ + 'name': 'Global Leave', + 'resource_id': False, + 'calendar_id': self.calendar_jean.id, + 'date_from': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 3, 23, 59, 59, tzinfo=self.jean.tz), }) - 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'))) - attendance.write({ - 'date_from': False, - 'date_to': self.date2 - relativedelta(days=7), + self.env['resource.calendar.leaves'].create({ + 'name': 'leave for Jean', + 'calendar_id': self.calendar_jean.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 5, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 5, 23, 59, 59, tzinfo=self.jean.tz), }) - 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'))) - attendance.write({ - 'date_from': self.date2 + relativedelta(days=7), - 'date_to': self.date2 - relativedelta(days=7), + hours = self.calendar_jean.get_work_hours_count( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jean.tz), + ) + self.assertEqual(hours, 32) + + hours = self.calendar_jean.get_work_hours_count( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.jean.tz), + compute_leaves=False, + ) + self.assertEqual(hours, 40) + + # leave of size 0 + self.env['resource.calendar.leaves'].create({ + 'name': 'zero_length', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), + 'date_to': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), }) - 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'))) - attendance.write({ - 'date_from': self.date2, - 'date_to': 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'))) + hours = self.calendar_patel.get_work_hours_count( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 35) - 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'), self.resource1_id, compute_leaves=False) - self.assertEqual(wh, 5.5) - - -class ResourceWorkingHours(TestResourceCommon): - - def test_calendar_working_hours(self): - # new API: resource without leaves - # res: 2 weeks -> 40 hours - 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'), - self.resource1_id, - compute_leaves=False) - self.assertEqual(res, 40.0) - - def test_calendar_working_hours_leaves(self): - # new API: resource and leaves - # res: 2 weeks -> 40 hours - (3+4) leave hours - 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'), - 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 of medium size 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) + 'name': 'zero_length', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 3, 9, 0, 0, tzinfo=self.patel.tz), + 'date_to': datetime_str(2018, 4, 3, 12, 0, 0, tzinfo=self.patel.tz), }) - 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_leave_intervals_timezone(self): - # _iter_leave_intervals takes UTC and outputs UTC - # Leave is taken by user in US/Alaska timezone - # It should be visible at the right time for every timezone - self.env.user.tz = 'US/Alaska' - (self.leave1 | self.leave2 | self.leave3).unlink() + hours = self.calendar_patel.get_work_hours_count( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 32) + + leave.unlink() + + # leave of very small size 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) + 'name': 'zero_length', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 3, 0, 0, 0, tzinfo=self.patel.tz), + 'date_to': datetime_str(2018, 4, 3, 0, 0, 10, tzinfo=self.patel.tz), }) - def to_tuple(interval): - return (interval.start_datetime, interval.end_datetime) + hours = self.calendar_patel.get_work_hours_count( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 35) - # Checking for 'Europe/Brussels' - self.env.user.tz = 'Europe/Brussels' - leaves = list(self.calendar._iter_leave_intervals( - to_naive_utc(Datetime.from_string('2013-02-26 14:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-26 15:30:00'), self.env.user), - self.resource1_id)) - self.assertEqual(len(leaves), 0) + leave.unlink() - leaves = list(self.calendar._iter_leave_intervals( - to_naive_utc(Datetime.from_string('2013-02-26 08:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-26 11:00:00'), self.env.user), - self.resource1_id))[0] + # no timezone given should be converted to UTC + # Should equal to a leave between 2018/04/03 10:00:00 and 2018/04/04 10:00:00 + self.env['resource.calendar.leaves'].create({ + 'name': 'no timezone', + 'calendar_id': self.calendar_patel.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 3, 4, 0, 0), + 'date_to': datetime_str(2018, 4, 4, 4, 0, 0), + }) + + hours = self.calendar_patel.get_work_hours_count( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 6, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 28) + + hours = self.calendar_patel.get_work_hours_count( + datetime_tz(2018, 4, 2, 23, 59, 59, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + ) + self.assertEqual(hours, 0) + + leave.unlink() + + def test_plan_hours(self): + self.env['resource.calendar.leaves'].create({ + 'name': 'global', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 11, 23, 59, 59, tzinfo=self.jean.tz), + }) + + time = self.calendar_jean.plan_hours(2, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, datetime_tz(2018, 4, 10, 10, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_hours(20, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, datetime_tz(2018, 4, 12, 12, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_hours(5, datetime_tz(2018, 4, 10, 15, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, datetime_tz(2018, 4, 12, 12, 0, 0, tzinfo=self.jean.tz)) + + # negative planning + time = self.calendar_jean.plan_hours(-10, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, datetime_tz(2018, 4, 6, 14, 0, 0, tzinfo=self.jean.tz)) + + # zero planning + time = self.calendar_jean.plan_hours(0, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz)) + + # very small planning + time = self.calendar_jean.plan_hours(0.0002, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, datetime_tz(2018, 4, 10, 8, 0, 0, 720000, tzinfo=self.jean.tz)) + + # huge planning + time = self.calendar_jean.plan_hours(3000, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, datetime_tz(2019, 9, 16, 16, 0, 0, tzinfo=self.jean.tz)) + + def test_plan_days(self): + self.env['resource.calendar.leaves'].create({ + 'name': 'global', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 11, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 11, 23, 59, 59, tzinfo=self.jean.tz), + }) + + time = self.calendar_jean.plan_days(1, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, datetime_tz(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_days(3, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=False) + self.assertEqual(time, datetime_tz(2018, 4, 12, 16, 0, 0, tzinfo=self.jean.tz)) + + time = self.calendar_jean.plan_days(4, datetime_tz(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, datetime_tz(2018, 4, 17, 16, 0, 0, tzinfo=self.jean.tz)) + + # negative planning + time = self.calendar_jean.plan_days(-10, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, datetime_tz(2018, 3, 27, 8, 0, 0, tzinfo=self.jean.tz)) + + # zero planning + time = self.calendar_jean.plan_days(0, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz)) + + # very small planning returns False in this case + # TODO: decide if this behaviour is alright + time = self.calendar_jean.plan_days(0.0002, datetime_tz(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), compute_leaves=True) + self.assertEqual(time, False) + + # huge planning + # TODO: Same as above + # NOTE: Maybe allow to set a max limit to the method + 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) + + +class TestResMixin(TestResourceCommon): + def setUp(self): + super(TestResMixin, self).setUp() + + def test_work_days_data(self): + # Looking at Jean's calendar + + # Viewing it as Jean + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data, {'days': 5, 'hours': 40}) + + # Viewing it as Patel + # Views from 2018/04/01 20:00:00 to 2018/04/06 12:00:00 + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.patel.tz), + ) + self.assertEqual(data, {'days': 4.5, 'hours': 36}) # We see only 36 hours + + # Viewing it as John + # Views from 2018/04/02 09:00:00 to 2018/04/07 02:00:00 + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + datetime_tz(2018, 4, 6, 16, 0, 0, tzinfo=self.john.tz), + ) + # still showing as 5 days because of rounding, but we see only 39 hours + self.assertEqual(data, {'days': 5, 'hours': 39}) + + # Looking at John's calendar + + # Viewing it as Jean + # Views from 2018/04/01 15:00:00 to 2018/04/06 14:00:00 + data = self.john.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data, {'days': 1.5, 'hours': 13}) + + # Viewing it as Patel + # Views from 2018/04/01 11:00:00 to 2018/04/06 10:00:00 + data = self.john.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.patel.tz), + ) + self.assertEqual(data, {'days': 1.25, 'hours': 10}) + + # Viewing it as John + data = self.john.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), + ) + self.assertEqual(data, {'days': 2, 'hours': 20}) + + # using Jean as a timezone reference + data = self.john.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), + calendar=self.calendar_jean, + ) + self.assertEqual(data, {'days': 5, 'hours': 40}) + + # half days + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'half', + 'calendar_id': self.calendar_jean.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data, {'days': 4.5, 'hours': 36}) + + # using John as a timezone reference, leaves are outside attendances + data = self.john.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.john.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.john.tz), + calendar=self.calendar_jean, + ) + self.assertEqual(data, {'days': 5, 'hours': 40}) + + leave.unlink() + + # leave size 0 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data, {'days': 5, 'hours': 40}) + + leave.unlink() + + # leave very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) + + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data['days'], 5) + self.assertAlmostEqual(data['hours'], 40, 2) + + def test_leaves_days_data(self): + # Jean takes a leave + self.env['resource.calendar.leaves'].create({ + 'name': 'Jean is visiting India', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 10, 8, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 10, 16, 0, 0, tzinfo=self.jean.tz), + }) + + # John takes a leave for Jean + self.env['resource.calendar.leaves'].create({ + 'name': 'Jean is comming in USA', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 12, 8, 0, 0, tzinfo=self.john.tz), + 'date_to': datetime_str(2018, 4, 12, 16, 0, 0, tzinfo=self.john.tz), + }) + + # Jean asks to see how much leave he has taken + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jean.tz), + ) + # Sees only 1 day and 8 hours because, as john is in UTC-7 the second leave is not in + # the attendances of Jean + self.assertEqual(data, {'days': 1, 'hours': 8}) + + # Patel Asks to see when Jean has taken some leaves + # Patel should see the same + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.patel.tz), + ) + self.assertEqual(data, {'days': 1, 'hours': 8}) + + # use Patel as a resource, jean's leaves are not visible + datas = self.patel.get_leave_days_data( + datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.patel.tz), + datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.patel.tz), + calendar=self.calendar_jean, + ) + self.assertEqual(datas['days'], 0) + self.assertEqual(datas['hours'], 0) + + # Jean takes a leave for John + # Gives 3 hours (3/8 of a day) + self.env['resource.calendar.leaves'].create({ + 'name': 'John is sick', + 'calendar_id': self.john.resource_calendar_id.id, + 'resource_id': self.john.resource_id.id, + 'date_from': datetime_str(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 10, 20, 0, 0, tzinfo=self.jean.tz), + }) + + # John takes a leave + # Gives all day (12 hours) + self.env['resource.calendar.leaves'].create({ + 'name': 'John goes to holywood', + 'calendar_id': self.john.resource_calendar_id.id, + 'resource_id': self.john.resource_id.id, + 'date_from': datetime_str(2018, 4, 13, 7, 0, 0, tzinfo=self.john.tz), + 'date_to': datetime_str(2018, 4, 13, 18, 0, 0, tzinfo=self.john.tz), + }) + + # John asks how much leaves he has + # He sees that he has only 15 hours of leave in his attendances + data = self.john.get_leave_days_data( + datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), + datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), + ) + self.assertEqual(data, {'days': 1, 'hours': 10}) + + # half days + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'half', + 'calendar_id': self.calendar_jean.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data, {'days': 0.5, 'hours': 4}) + + leave.unlink() + + # leave size 0 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + }) + + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data, {'days': 0, 'hours': 0}) + + leave.unlink() + + # leave very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.calendar_jean.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) + + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(data['days'], 0) + self.assertAlmostEqual(data['hours'], 0, 2) + + leave.unlink() + + def test_list_leaves(self): + jean_leave = self.env['resource.calendar.leaves'].create({ + 'name': "Jean's son is sick", + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': False, + 'date_from': datetime_str(2018, 4, 10, 0, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 10, 23, 59, 59, tzinfo=self.jean.tz), + }) + + leaves = self.jean.list_leaves( + datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.jean.tz), + ) + self.assertEqual(leaves, [(date(2018, 4, 10), 8, jean_leave)]) + + # half days + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'half', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), + }) + + leaves = self.jean.list_leaves( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(leaves, [(date(2018, 4, 2), 4, leave)]) + + leave.unlink() + + # very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) + + leaves = self.jean.list_leaves( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) self.assertEqual(len(leaves), 1) - self.assertEqual(to_tuple(leaves[0]), (Datetime.from_string('2013-02-26 07:00:00'), Datetime.from_string('2013-02-26 10:00:00'))) + self.assertEqual(leaves[0][0], date(2018, 4, 2)) + self.assertAlmostEqual(leaves[0][1], 0, 2) + self.assertEqual(leaves[0][2].id, leave.id) - leaves = list(self.calendar._iter_leave_intervals( - to_naive_utc(Datetime.from_string('2013-02-22 08:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-22 20:00:00'), self.env.user), - self.resource1_id))[0] - self.assertEqual(len(leaves), 2) - self.assertEqual(to_tuple(leaves[0]), (Datetime.from_string('2013-02-22 07:00:00'), Datetime.from_string('2013-02-22 12:00:00'))) + leave.unlink() - # Checking for 'Japan' - self.env.user.tz = 'Japan' - leaves = list(self.calendar._iter_leave_intervals( - to_naive_utc(Datetime.from_string('2013-02-26 14:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-26 15:30:00'), self.env.user), - self.resource1_id)) - self.assertEqual(len(leaves), 0) - - leaves = list(self.calendar._iter_leave_intervals( - to_naive_utc(Datetime.from_string('2013-02-26 08:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-26 11:00:00'), self.env.user), - self.resource1_id))[0] - self.assertEqual(len(leaves), 1) - self.assertEqual(to_tuple(leaves[0]), (Datetime.from_string('2013-02-25 23:00:00'), Datetime.from_string('2013-02-26 02:00:00'))) - - leaves = list(self.calendar._iter_leave_intervals( - to_naive_utc(Datetime.from_string('2013-02-22 08:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-22 20:00:00'), self.env.user), - self.resource1_id))[0] - self.assertEqual(len(leaves), 2) - self.assertEqual(to_tuple(leaves[0]), (Datetime.from_string('2013-02-21 23:00:00'), Datetime.from_string('2013-02-22 04:00:00'))) - - def test_calendar_work_days_intervals_timezone(self): - # _get_day_work_intervals converts from the timezone - # of the user into UTC - self.env.user.tz = 'US/Alaska' - (self.leave1 | self.leave2 | self.leave3).unlink() + # size 0 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) + 'name': 'zero', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), }) - def to_tuple(interval): - return (interval.start_datetime, interval.end_datetime) + leaves = self.jean.list_leaves( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(leaves, []) - intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-26'), time(10), time(18), False, self.resource1_id) - self.assertEqual(to_tuple(intervals[0]), (Datetime.from_string('2013-02-26 19:00:00'), Datetime.from_string('2013-02-27 01:00:00'))) - intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-26'), time(10), time(18), True, self.resource1_id) - self.assertEqual(to_tuple(intervals[0]), (Datetime.from_string('2013-02-26 21:00:00'), Datetime.from_string('2013-02-27 01:00:00'))) + leave.unlink() - self.env.user.tz = 'Japan' + def test_list_work_time_per_day(self): + working_time = self.john.list_work_time_per_day( + datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), + datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), + ) + self.assertEqual(working_time, [ + (date(2018, 4, 10), 8), + (date(2018, 4, 13), 12), + ]) - intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-26'), time(10), time(18), False, self.resource1_id) - self.assertEqual(to_tuple(intervals[0]), (Datetime.from_string('2013-02-26 01:00:00'), Datetime.from_string('2013-02-26 07:00:00'))) - intervals = self.calendar._get_day_work_intervals(Date.from_string('2013-02-26'), time(10), time(18), True, self.resource1_id) - self.assertEqual(to_tuple(intervals[0]), (Datetime.from_string('2013-02-26 03:00:00'), Datetime.from_string('2013-02-26 07:00:00'))) + # change john's resource's timezone + self.john.resource_id.tz = 'Europe/Brussels' + self.assertEqual(self.john.tz, 'Europe/Brussels') + self.assertEqual(self.calendar_john.tz, 'America/Los_Angeles') + working_time = self.john.list_work_time_per_day( + datetime_tz(2018, 4, 9, 0, 0, 0, tzinfo=self.john.tz), + datetime_tz(2018, 4, 13, 23, 59, 59, tzinfo=self.john.tz), + ) + self.assertEqual(working_time, [ + (date(2018, 4, 10), 8), + (date(2018, 4, 13), 12), + ]) - def test_calendar_leave_days_intervals_timezone(self): - # _get_day_leave_intervals converts from the timezone - # of the user into UTC - self.env.user.tz = 'US/Alaska' - (self.leave1 | self.leave2 | self.leave3).unlink() + # half days 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) + 'name': 'small', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 14, 0, 0, tzinfo=self.jean.tz), }) - def to_tuple(interval): - return (interval.start_datetime, interval.end_datetime) - - intervals = self.calendar._get_day_leave_intervals(Date.from_string('2013-02-26'), time(10), time(18), self.resource1_id) - self.assertEqual(to_tuple(intervals[0]), (Datetime.from_string('2013-02-26 19:00:00'), Datetime.from_string('2013-02-26 21:00:00'))) - - self.env.user.tz = 'Japan' - - intervals = self.calendar._get_day_leave_intervals(Date.from_string('2013-02-26'), time(10), time(18), self.resource1_id) - self.assertEqual(to_tuple(intervals[0]), (Datetime.from_string('2013-02-26 01:00:00'), Datetime.from_string('2013-02-26 03:00:00'))) - - 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 - self.assertEqual(res[-1][:2], (Datetime.from_string('2013-02-12 08:00:00'), Datetime.from_string('2013-02-12 09:00:00'))) - # previous days: 5+7 hours / 8 hours / 5+7 hours -> 32 hours - self.assertEqual(res[-2][:2], (Datetime.from_string('2013-02-08 16:00:00'), Datetime.from_string('2013-02-08 23:00:00'))) - self.assertEqual(res[-3][:2], (Datetime.from_string('2013-02-08 08:00:00'), Datetime.from_string('2013-02-08 13:00:00'))) - self.assertEqual(res[-4][:2], (Datetime.from_string('2013-02-05 08:00:00'), Datetime.from_string('2013-02-05 16:00:00'))) - self.assertEqual(res[-5][:2], (Datetime.from_string('2013-02-01 16:00:00'), Datetime.from_string('2013-02-01 23:00:00'))) - self.assertEqual(res[-6][:2], (Datetime.from_string('2013-02-01 08:00:00'), Datetime.from_string('2013-02-01 13:00:00'))) - # 7 hours remaining - self.assertEqual(res[-7][:2], (Datetime.from_string('2013-01-29 09:00:00'), Datetime.from_string('2013-01-29 16:00:00'))) - - # Compute scheduled hours - td = timedelta() - for item in res: - td += item[1] - item[0] - self.assertEqual(td.total_seconds() / 3600.0, 40.0) - - res = self.calendar.plan_hours(-40, day_dt=Datetime.from_string('2013-02-12 09:00:00')) - self.assertEqual(res, Datetime.from_string('2013-01-29 09:00:00')) - - def test_calendar_hours_scheduling_forward(self): - res = self.calendar._schedule_hours(40, day_dt=Datetime.from_string('2013-02-12 09:00:00')) - self.assertEqual(res[0][:2], (Datetime.from_string('2013-02-12 09:00:00'), Datetime.from_string('2013-02-12 16:00:00'))) - self.assertEqual(res[1][:2], (Datetime.from_string('2013-02-15 08:00:00'), Datetime.from_string('2013-02-15 13:00:00'))) - self.assertEqual(res[2][:2], (Datetime.from_string('2013-02-15 16:00:00'), Datetime.from_string('2013-02-15 23:00:00'))) - self.assertEqual(res[3][:2], (Datetime.from_string('2013-02-19 08:00:00'), Datetime.from_string('2013-02-19 16:00:00'))) - self.assertEqual(res[4][:2], (Datetime.from_string('2013-02-22 08:00:00'), Datetime.from_string('2013-02-22 13:00:00'))) - self.assertEqual(res[5][:2], (Datetime.from_string('2013-02-22 16:00:00'), Datetime.from_string('2013-02-22 23:00:00'))) - self.assertEqual(res[6][:2], (Datetime.from_string('2013-02-26 08:00:00'), Datetime.from_string('2013-02-26 09:00:00'))) - - td = timedelta() - for item in res: - td += item[1] - item[0] - self.assertEqual(td.total_seconds() / 3600.0, 40.0) - - res = self.calendar.plan_hours(40, day_dt=Datetime.from_string('2013-02-12 09:00:00')) - self.assertEqual(res, Datetime.from_string('2013-02-26 09:00:00')) - - def test_calendar_hours_scheduling_timezone(self): - # user in timezone UTC-9 asks for work hours - self.env.user.tz = 'US/Alaska' - res = self.calendar.plan_hours( - 42, - to_naive_utc(Datetime.from_string('2013-02-12 09:25:00'), self.env.user)) - self.assertEqual(res, to_naive_utc(Datetime.from_string('2013-02-26 11:25:00'), self.env.user)) - - def test_calendar_hours_scheduling_timezone_2(self): - # Call schedule_hours for a user in Autralia, Sydney (GMT+10) - # Two cases: - # - start at 2013-02-15 08:00:00 => 2013-02-14 21:00:00 in UTC - # - start at 2013-02-15 11:00:00 => 2013-02-15 00:00:00 in UTC - self.env.user.tz = 'Australia/Sydney' - self.env['resource.calendar.attendance'].create({ - 'name': 'Day3 - 1', - 'dayofweek': '3', - 'hour_from': 8, - 'hour_to': 12, - 'calendar_id': self.calendar.id, - }) - self.env['resource.calendar.attendance'].create({ - 'name': 'Day3 - 2', - 'dayofweek': '3', - 'hour_from': 13, - 'hour_to': 17, - 'calendar_id': self.calendar.id, - }) - hours = 1.0/60.0 - for test_date in ['2013-02-15 08:00:00', '2013-02-15 11:00:00']: - start_dt = Datetime.from_string(test_date) - start_dt_utc = to_naive_utc(start_dt, self.env.user) - res = self.calendar._schedule_hours(hours, start_dt_utc) - self.assertEqual( - [(start_dt_utc, start_dt_utc.replace(minute=1))], res, - 'resource_calendar: wrong schedule_hours computation') - - def test_calendar_hours_scheduling_forward_leaves_resource(self): - res = self.calendar._schedule_hours( - 40, day_dt=Datetime.from_string('2013-02-12 09:00:00'), - compute_leaves=True, resource_id=self.resource1_id + working_time = self.jean.list_work_time_per_day( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), ) - self.assertEqual(res[0][:2], (Datetime.from_string('2013-02-12 09:00:00'), Datetime.from_string('2013-02-12 16:00:00'))) - self.assertEqual(res[1][:2], (Datetime.from_string('2013-02-15 08:00:00'), Datetime.from_string('2013-02-15 13:00:00'))) - self.assertEqual(res[2][:2], (Datetime.from_string('2013-02-15 16:00:00'), Datetime.from_string('2013-02-15 23:00:00'))) - self.assertEqual(res[3][:2], (Datetime.from_string('2013-02-19 08:00:00'), Datetime.from_string('2013-02-19 09:00:00'))) - self.assertEqual(res[4][:2], (Datetime.from_string('2013-02-19 12:00:00'), Datetime.from_string('2013-02-19 16:00:00'))) - self.assertEqual(res[5][:2], (Datetime.from_string('2013-02-22 08:00:00'), Datetime.from_string('2013-02-22 09:00:00'))) - self.assertEqual(res[6][:2], (Datetime.from_string('2013-02-22 16:00:00'), Datetime.from_string('2013-02-22 23:00:00'))) - self.assertEqual(res[7][:2], (Datetime.from_string('2013-03-01 11:30:00'), Datetime.from_string('2013-03-01 13:00:00'))) - self.assertEqual(res[8][:2], (Datetime.from_string('2013-03-01 16:00:00'), Datetime.from_string('2013-03-01 22:30:00'))) + self.assertEqual(working_time, [ + (date(2018, 4, 2), 4), + (date(2018, 4, 3), 8), + (date(2018, 4, 4), 8), + (date(2018, 4, 5), 8), + (date(2018, 4, 6), 8), + ]) - td = timedelta() - for item in res: - td += item[1] - item[0] - self.assertEqual(td.total_seconds() / 3600.0, 40.0) + leave.unlink() - def test_calendar_days_scheduling(self): - 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, 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') + # very small size + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'small', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 1, tzinfo=self.jean.tz), + }) - res = self.calendar.plan_days( - 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') + working_time = self.jean.list_work_time_per_day( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(len(working_time), 5) + self.assertEqual(working_time[0][0], date(2018, 4, 2)) + self.assertAlmostEqual(working_time[0][1], 8, 2) - def test_calendar_days_scheduling_timezone(self): - self.env.user.tz = 'US/Alaska' - res = self.calendar.plan_days(5, to_naive_utc(Datetime.from_string('2013-02-12 09:08:07'), self.env.user)) - self.assertEqual(to_naive_user_tz(res, self.env.user).date(), Datetime.from_string('2013-02-26 00:00:00').date()) + leave.unlink() + + # size 0 + leave = self.env['resource.calendar.leaves'].create({ + 'name': 'zero', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + 'date_to': datetime_str(2018, 4, 2, 10, 0, 0, tzinfo=self.jean.tz), + }) + + working_time = self.jean.list_work_time_per_day( + datetime_tz(2018, 4, 2, 0, 0, 0, tzinfo=self.jean.tz), + datetime_tz(2018, 4, 6, 23, 0, 0, tzinfo=self.jean.tz), + ) + self.assertEqual(working_time, [ + (date(2018, 4, 2), 8), + (date(2018, 4, 3), 8), + (date(2018, 4, 4), 8), + (date(2018, 4, 5), 8), + (date(2018, 4, 6), 8), + ]) + + leave.unlink() -WAR_START = date(1932, 11, 2) -WAR_END = date(1932, 12, 10) - - -class TestWorkDays(TestResourceCommon): - - def _make_attendance(self, weekday, **kw): - data = { - 'name': babel.dates.get_day_names()[weekday], - 'dayofweek': str(weekday), - 'hour_from': 9, - 'hour_to': 17, - } - data.update(kw) - return data - +class TestTimezones(TestResourceCommon): def setUp(self): - super(TestWorkDays, self).setUp() - # trivial 5/7 9-17 resource calendar - self.calendar.write({ - 'attendance_ids': [ - (0, 0, self._make_attendance(i)) - for i in range(5) - ] - }) + super(TestTimezones, self).setUp() - self._days = [dt.date() for dt in rrule.rrule(rrule.DAILY, dtstart=WAR_START, until=WAR_END)] + self.tz1 = 'Etc/GMT+6' + self.tz2 = 'Europe/Brussels' + self.tz3 = 'Etc/GMT-10' + self.tz4 = 'Etc/GMT+10' - def test_trivial_calendar_no_leaves(self): - """ If leaves are not involved, only calendar attendances (basic - company configuration) are taken in account - """ - r = self.env['resource.resource'].create({ - 'name': "Trivial Calendar", - 'calendar_id': self.calendar.id - }) - - # with the trivial calendar, all days are work days except for - # saturday and sunday - self.assertEqual( - [d for d in self._days if d.weekday() not in (5, 6)], - list(r.calendar_id._iter_work_days(WAR_START, WAR_END, r.id)) + def test_work_hours_count(self): + # When no timezone => UTC + count = self.calendar_jean.get_work_hours_count( + datetime_tz(2018, 4, 10, 8, 0, 0), + datetime_tz(2018, 4, 10, 12, 0, 0), ) + self.assertEqual(count, 4) - def test_global_leaves(self): + # This timezone is not the same as the calendar's one + count = self.calendar_jean.get_work_hours_count( + datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz1), + datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz1), + ) + self.assertEqual(count, 0) + + # Using two different timezones + # 10-04-2018 06:00:00 - 10-04-2018 02:00:00 + count = self.calendar_jean.get_work_hours_count( + datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz2), + datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz3), + ) + self.assertEqual(count, 0) + + # Using two different timezones + # 2018-4-10 06:00:00 - 2018-4-10 22:00:00 + count = self.calendar_jean.get_work_hours_count( + datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz2), + datetime_tz(2018, 4, 10, 12, 0, 0, tzinfo=self.tz4), + ) + self.assertEqual(count, 8) + + def test_plan_hours(self): + dt = self.calendar_jean.plan_hours(10, datetime_tz(2018, 4, 10, 8, 0, 0)) + self.assertEqual(dt, datetime_tz(2018, 4, 11, 12, 0, 0, tzinfo=self.tz2)) + + dt = self.calendar_jean.plan_hours(10, datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz4)) + self.assertEqual(dt, datetime_tz(2018, 4, 12, 10, 0, 0, tzinfo=self.tz2)) + + def test_plan_days(self): + dt = self.calendar_jean.plan_days(2, datetime_tz(2018, 4, 10, 8, 0, 0)) + self.assertEqual(dt, datetime_tz(2018, 4, 11, 16, 0, 0, tzinfo=self.tz2)) + + # We lose one day because of timezone + dt = self.calendar_jean.plan_days(2, datetime_tz(2018, 4, 10, 8, 0, 0, tzinfo=self.tz4)) + self.assertEqual(dt, datetime_tz(2018, 4, 12, 16, 0, 0, tzinfo=self.tz2)) + + def test_work_data(self): + # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 9, 8, 0, 0), + datetime_tz(2018, 4, 13, 16, 0, 0), + ) + self.assertEqual(data, {'days': 4.75, 'hours': 38}) + + # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), + ) + self.assertEqual(data, {'days': 4, 'hours': 32}) + + # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 + data = self.jean.get_work_days_data( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), + ) + self.assertEqual(data, {'days': 5, 'hours': 40}) + + def test_leave_data(self): self.env['resource.calendar.leaves'].create({ - 'calendar_id': self.calendar.id, - 'date_from': '1932-11-09 00:00:00', - 'date_to': '1932-11-12 23:59:59', + 'name': '', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + 'date_to': datetime_str(2018, 4, 9, 14, 0, 0, tzinfo=self.tz2), }) - r1 = self.env['resource.resource'].create({ - 'name': "Resource 1", - 'calendar_id': self.calendar.id - }) - r2 = self.env['resource.resource'].create({ - 'name': "Resource 2", - 'calendar_id': self.calendar.id - }) - - days = [ - d for d in self._days - if d.weekday() not in (5, 6) - if d < date(1932, 11, 9) or d > date(1932, 11, 12) - ] - 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 - """ - r1 = self.env['resource.resource'].create({ - 'name': "Resource 1", - 'calendar_id': self.calendar.id - }) - r2 = self.env['resource.resource'].create({ - 'name': "Resource 2", - 'calendar_id': self.calendar.id - }) - self.env['resource.calendar.leaves'].create({ - 'calendar_id': self.calendar.id, - 'date_from': '1932-11-09 00:00:00', - 'date_to': '1932-11-12 23:59:59', - 'resource_id': r2.id - }) - - weekdays = [d for d in self._days if d.weekday() not in (5, 6)] - 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.calendar_id._iter_work_days(WAR_START, WAR_END, r2.id)) + # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 9, 8, 0, 0), + datetime_tz(2018, 4, 13, 16, 0, 0), ) + self.assertEqual(data, {'days': 0.5, 'hours': 4}) - def test_mixed_leaves(self): - r = self.env['resource.resource'].create({ - 'name': "Resource 1", - 'calendar_id': self.calendar.id - }) - self.env['resource.calendar.leaves'].create({ - 'calendar_id': self.calendar.id, - 'date_from': '1932-11-09 00:00:00', - 'date_to': '1932-11-12 23:59:59', - }) - self.env['resource.calendar.leaves'].create({ - 'calendar_id': self.calendar.id, - 'date_from': '1932-12-02 00:00:00', - 'date_to': '1932-12-31 23:59:59', - 'resource_id': r.id - }) - - self.assertEqual([ - d for d in self._days - 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.calendar_id._iter_work_days(WAR_START, WAR_END, r.id)) + # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), ) + self.assertEqual(data, {'days': 0.75, 'hours': 6}) - # _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.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.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 - - -class TestResourceMixin(TestResourceCommon): - - def setUp(self): - super(TestResourceMixin, self).setUp() - self.lost_user = self.env['res.users'].with_context( - no_reset_password=True, - mail_create_nosubscribe=True - ).create({ - 'name': 'Désiré Boideladodo', - 'login': 'desire', - 'tz': 'Indian/Reunion', - 'groups_id': [(6, 0, [self.env.ref('base.group_user').id])] - }) - self.test = self.env['resource.test'].with_context(default_resource_calendar_id=self.calendar.id).create({'name': 'Test'}) - - def test_basics(self): - self.assertEqual(self.env['resource.test'].create({'name': 'Test'}).resource_calendar_id, self.env.user.company_id.resource_calendar_id) - self.assertEqual(self.test.resource_calendar_id, self.calendar) - - def test_work_days_count(self): - # user in timezone UTC-9 asks for work hours - self.env.user.tz = 'US/Alaska' - - res = self.test.get_work_days_count( - to_naive_utc(Datetime.from_string('2013-02-12 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-22 23:00:00'), self.env.user)) - self.assertEqual(res, 3.75) # generic leaves, 3 hours - - res = self.test.get_work_days_count( - to_naive_utc(Datetime.from_string('2013-02-12 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-22 20:00:00'), self.env.user)) - self.assertEqual(res, 3.5) # last day is truncated of 3 hours on 12) - - self.env['resource.calendar.leaves'].create({ - 'name': 'Timezoned Leaves', - 'calendar_id': self.test.resource_calendar_id.id, - 'resource_id': self.test.resource_id.id, - 'date_from': to_naive_utc(Datetime.from_string('2013-02-13 10:00:00'), self.env.user), - 'date_to': to_naive_utc(Datetime.from_string('2013-02-17 12:00:00'), self.env.user) - }) - - res = self.test.get_work_days_count( - to_naive_utc(Datetime.from_string('2013-02-12 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-22 20:00:00'), self.env.user)) - self.assertEqual(res, 2.5) # one day is on leave and last day is truncated of 3 hours on 12) - - def test_work_days_count_timezones_ultra(self): - # user in timezone UTC+4 is attached to the resource and create leaves - self.test.resource_id.write({ - 'user_id': self.lost_user.id, - }) - reunion_leave = self.env['resource.calendar.leaves'].sudo(self.lost_user).create({ - 'name': 'Timezoned Leaves', - 'calendar_id': self.test.resource_calendar_id.id, - 'resource_id': self.test.resource_id.id, - 'date_from': to_naive_utc(Datetime.from_string('2013-02-12 10:00:00'), self.lost_user), - 'date_to': to_naive_utc(Datetime.from_string('2013-02-12 12:00:00'), self.lost_user) - }) - self.assertEqual(reunion_leave.tz, 'Indian/Reunion') - - # user in timezone UTC-9 read and manipulate leaves - self.env.user.tz = 'US/Alaska' - res = self.test.get_work_days_data( - to_naive_utc(Datetime.from_string('2013-02-12 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-12 20:00:00'), self.env.user)) - self.assertEqual(res['days'], 0.75) - self.assertEqual(res['hours'], 6.0) - - # user in timezone UTC+4 read and manipulate leaves - res = self.test.sudo(self.lost_user).get_work_days_data( - to_naive_utc(Datetime.from_string('2013-02-12 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2013-02-12 20:00:00'), self.env.user)) - self.assertEqual(res['days'], 0.75) - self.assertEqual(res['hours'], 6.0) - - def test_days_count_with_domain(self): - self.test.resource_id.write({ - 'user_id': self.lost_user.id, - }) - - self.env['resource.calendar.leaves'].sudo(self.lost_user).create({ - 'name': 'first', - 'calendar_id': self.test.resource_calendar_id.id, - 'resource_id': self.test.resource_id.id, - 'date_from': to_naive_utc(Datetime.from_string('2018-02-05 08:00:00'), self.lost_user), - 'date_to': to_naive_utc(Datetime.from_string('2018-02-09 18:00:00'), self.lost_user) - }) - - self.env['resource.calendar.leaves'].sudo(self.lost_user).create({ - 'name': 'second', - 'calendar_id': self.test.resource_calendar_id.id, - 'resource_id': self.test.resource_id.id, - 'date_from': to_naive_utc(Datetime.from_string('2018-01-12 08:00:00'), self.lost_user), - 'date_to': to_naive_utc(Datetime.from_string('2018-01-13 18:00:00'), self.lost_user) - }) - - res = self.test.get_work_days_count( - to_naive_utc(Datetime.from_string('2018-02-04 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2018-02-14 20:00:00'), self.env.user), - domain=[('name', '=', '')] + # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 + data = self.jean.get_leave_days_data( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), ) - self.assertEqual(res, 3.0) + self.assertEqual(data, {'days': 0.75, 'hours': 6}) - res = self.test.get_work_days_count( - to_naive_utc(Datetime.from_string('2018-02-04 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2018-02-14 20:00:00'), self.env.user), - domain=[('name', '=', 'first')] + def test_leaves(self): + leave = self.env['resource.calendar.leaves'].create({ + 'name': '', + 'calendar_id': self.jean.resource_calendar_id.id, + 'resource_id': self.jean.resource_id.id, + 'date_from': datetime_str(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + 'date_to': datetime_str(2018, 4, 9, 14, 0, 0, tzinfo=self.tz2), + }) + + # 09-04-2018 10:00:00 - 13-04-2018 18:00:00 + leaves = self.jean.list_leaves( + datetime_tz(2018, 4, 9, 8, 0, 0), + datetime_tz(2018, 4, 13, 16, 0, 0), ) - self.assertEqual(res, 1.0) + self.assertEqual(leaves, [(date(2018, 4, 9), 4, leave)]) - res = self.test.get_leaves_day_count( - to_naive_utc(Datetime.from_string('2018-02-04 06:00:00'), self.env.user), - to_naive_utc(Datetime.from_string('2018-02-14 20:00:00'), self.env.user), - domain=[('name', '=', 'first')] + # 09-04-2018 00:00:00 - 13-04-2018 08:00:00 + leaves = self.jean.list_leaves( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), ) - self.assertEqual(res, 2.0) + self.assertEqual(leaves, [(date(2018, 4, 9), 6, leave)]) -class TestGlobalLeaves(TestResourceCommon): + # 09-04-2018 08:00:00 - 14-04-2018 12:00:00 + leaves = self.jean.list_leaves( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), + ) + self.assertEqual(leaves, [(date(2018, 4, 9), 6, leave)]) - def setUp(self): - super(TestGlobalLeaves, self).setUp() + def test_works(self): + work = self.jean.list_work_time_per_day( + datetime_tz(2018, 4, 9, 8, 0, 0), + datetime_tz(2018, 4, 13, 16, 0, 0), + ) + self.assertEqual(work, [ + (date(2018, 4, 9), 6), + (date(2018, 4, 10), 8), + (date(2018, 4, 11), 8), + (date(2018, 4, 12), 8), + (date(2018, 4, 13), 8), + ]) - self.Leave = self.env['resource.calendar.leaves'] + work = self.jean.list_work_time_per_day( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz3), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz3), + ) + self.assertEqual(len(work), 4) + self.assertEqual(work, [ + (date(2018, 4, 9), 8), + (date(2018, 4, 10), 8), + (date(2018, 4, 11), 8), + (date(2018, 4, 12), 8), + ]) - # This leave has a timezone of False : UTC should be used as default - self.global_no_tz = self.Leave.create({ - 'name': 'global no timezone', - 'calendar_id': self.calendar.id, - 'date_from': to_naive_utc(Datetime.from_string('2018-12-25 08:00:00'), self.env.user), - 'date_to': to_naive_utc(Datetime.from_string('2018-12-25 23:00:00'), self.env.user), - 'tz': False, - }) - - # This leave has no timezone defined : should default to user timezone (here UTC) - self.global_user_tz = self.Leave.create({ - 'name': 'global user timezone', - 'calendar_id': self.calendar.id, - 'date_from': to_naive_utc(Datetime.from_string('2018-05-01 08:00:00'), self.env.user), - 'date_to': to_naive_utc(Datetime.from_string('2018-05-01 23:00:00'), self.env.user), - }) - - # This leave has been taken in Japan timezone - # Leave from 8:00 to 13:00 but we have to adapt cause we define it in UTC - self.global_spec_tz = self.Leave.create({ - 'name': 'global specific timezone', - 'calendar_id': self.calendar.id, - 'date_from': Datetime.from_string('2018-04-05 23:00:00'), - 'date_to': Datetime.from_string('2018-04-06 04:00:00'), - 'tz' : 'Japan', # UTC+9 - }) - - def test_timezones(self): - self.assertEqual(self.global_no_tz.tz, False) - self.assertEqual(self.global_user_tz.tz, self.env.user.tz) - self.assertEqual(self.global_spec_tz.tz, 'Japan') - - def test_leave(self): - def to_tuple(interval): - return (interval.start_datetime, interval.end_datetime) - - # Here we test if the leaves are correctly computed in the case our user is in UTC - # NB: Beware of winter/spring time - work_no_tz = [to_tuple(x) for x in self.calendar._get_day_leave_intervals(Date.from_string('2018-12-25'), time(0), time(23, 59, 59), self.resource1_id)] - self.assertEqual(work_no_tz[0], (Datetime.from_string('2018-12-25 08:00:00'), Datetime.from_string('2018-12-25 16:00:00'))) - - work_user_tz = [to_tuple(x) for x in self.calendar._get_day_leave_intervals(Date.from_string('2018-05-01'), time(0), time(23, 59, 59), self.resource1_id)] - self.assertEqual(work_user_tz[0], (Datetime.from_string('2018-05-01 08:00:00'), Datetime.from_string('2018-05-01 16:00:00'))) - - work_spec = [to_tuple(x) for x in self.calendar._get_day_leave_intervals(Date.from_string('2018-04-06'), time(0), time(23, 59, 59), self.resource1_id)] - self.assertEqual(work_spec[0], (Datetime.from_string('2018-04-06 08:00:00'), Datetime.from_string('2018-04-06 13:00:00'))) - - def test_leaves_timezones(self): - self.env.user.tz = 'US/Alaska' # UTC-9 - - def to_tuple(interval): - return (interval.start_datetime, interval.end_datetime) - - # Here we test if the leaves are correctly computed in the case our user is in tz Alaska - # NB: Beware of winter/spring time - work_no_tz = [to_tuple(x) for x in self.calendar._get_day_leave_intervals(Date.from_string('2018-12-25'), time(0), time(23, 59, 59), self.resource1_id)] - self.assertEqual(work_no_tz[0], (Datetime.from_string('2018-12-25 17:00:00'), Datetime.from_string('2018-12-26 01:00:00'))) - - work_user_tz = [to_tuple(x) for x in self.calendar._get_day_leave_intervals(Date.from_string('2018-05-01'), time(0), time(23, 59, 59), self.resource1_id)] - self.assertEqual(work_user_tz[0], (Datetime.from_string('2018-05-01 16:00:00'), Datetime.from_string('2018-05-02 00:00:00'))) - - work_spec = [to_tuple(x) for x in self.calendar._get_day_leave_intervals(Date.from_string('2018-04-06'), time(0), time(23, 59, 59), self.resource1_id)] - self.assertEqual(work_spec[0], (Datetime.from_string('2018-04-06 16:00:00'), Datetime.from_string('2018-04-06 21:00:00'))) - - -class TestTimeType(TestResourceCommon): - def setUp(self): - super(TestTimeType, self).setUp() - Leave = self.env['resource.calendar.leaves'] - - self.other1 = Leave.create({ - 'name': 'Formation', - 'calendar_id': self.calendar.id, - 'resource_id': self.resource1_id, - 'date_from': Datetime.from_string('2013-02-15 09:00:00'), - 'date_to': Datetime.from_string('2013-02-15 19:00:00'), - 'time_type': 'other', - }) - self.other2 = Leave.create({ - 'name': 'Homeworking', - 'calendar_id': self.calendar.id, - 'resource_id': self.resource1_id, - 'date_from': Datetime.from_string('2013-02-12 09:00:00'), - 'date_to': Datetime.from_string('2013-02-12 15:00:00'), - 'time_type': 'other', - }) - - def test_leaves_leaves(self): - # Here we test if setting the value to leave has the effect intended - leave = self.calendar._get_day_leave_intervals(Date.from_string('2013-02-26'), time(0), time(23, 59, 59), self.resource1_id, [('time_type', '=', 'leave')]) - self.assertEqual(len(leave), 1, "When asking for leave should compute the leaves") - leave = self.calendar._get_day_leave_intervals(Date.from_string('2013-02-15'), time(0), time(23, 59, 59), self.resource1_id, [('time_type', '=', 'leave')]) - self.assertEqual(len(leave), 0, "When asking for leave should ignore others") - - def test_leaves_other(self): - # Here we test if setting the value to other has the effect intended - leave = self.calendar._get_day_leave_intervals(Date.from_string('2013-02-26'), time(0), time(23, 59, 59), self.resource1_id, [('time_type', '=', 'other')]) - self.assertEqual(len(leave), 0, "When asking for other should ignore leaves") - leave = self.calendar._get_day_leave_intervals(Date.from_string('2013-02-15'), time(0), time(23, 59, 59), self.resource1_id, [('time_type', '=', 'other')]) - self.assertEqual(len(leave), 2, "When asking for other should compute others") + work = self.jean.list_work_time_per_day( + datetime_tz(2018, 4, 9, 8, 0, 0, tzinfo=self.tz2), + datetime_tz(2018, 4, 13, 16, 0, 0, tzinfo=self.tz4), + ) + self.assertEqual(work, [ + (date(2018, 4, 9), 8), + (date(2018, 4, 10), 8), + (date(2018, 4, 11), 8), + (date(2018, 4, 12), 8), + (date(2018, 4, 13), 8), + ]) diff --git a/addons/resource/tests/test_resource_model.py b/addons/resource/tests/test_resource_model.py index dad7e3cb10a..d1379510ec6 100644 --- a/addons/resource/tests/test_resource_model.py +++ b/addons/resource/tests/test_resource_model.py @@ -4,7 +4,7 @@ from odoo import fields, models -class MailTest(models.Model): +class ResourceTest(models.Model): _description = 'Test Resource Model' _name = 'resource.test' _inherit = ['resource.mixin'] diff --git a/addons/resource/views/resource_views.xml b/addons/resource/views/resource_views.xml index dda1c55e11f..4d39092d801 100644 --- a/addons/resource/views/resource_views.xml +++ b/addons/resource/views/resource_views.xml @@ -38,6 +38,7 @@ + @@ -250,6 +251,7 @@ + @@ -261,7 +263,6 @@ -