[REF] resource calendar (#24717)

Refactor resource calendar and resource mixin:
 - add a required timezone field on models 'resource.calendar' and 'resource.resource';
 - resource calendar attendances are now computed in the timezone of the resource
   or the calendar;
 - improve API of 'resource.calendar' and 'resource.mixin';
 - avoid other modules from using the implementation methods of 'resource.calendar';
 - refactor implementation of 'resource.calendar' to make it more efficient.
This commit is contained in:
Raphael Collet
2018-05-31 20:28:12 +02:00
committed by GitHub
parent f5af0714e2
commit 975c6c8639
14 changed files with 1317 additions and 1489 deletions
+5
View File
@@ -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,
+1
View File
@@ -72,6 +72,7 @@
<field name="parent_id"/>
<field name="coach_id"/>
<field name="resource_calendar_id" required="1"/>
<field name="tz"/>
</group>
</group>
<field name="notes" placeholder="Other Information ..." colspan="4"/>
+1 -1
View File
@@ -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)
+32 -30
View File
@@ -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.")
+1 -1
View File
@@ -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)
+7 -2
View File
@@ -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))
+2 -2
View File
@@ -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(
@@ -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)),
+285 -548
View File
@@ -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")
+143 -68
View File
@@ -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
+31 -59
View File
@@ -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')})
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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']
+2 -1
View File
@@ -38,6 +38,7 @@
<group>
<field name="resource_type" />
<field name="calendar_id"/>
<field name="tz"/>
<field name="time_efficiency"/>
</group>
</group>
@@ -250,6 +251,7 @@
<group>
<field name="company_id" groups="base.group_multi_company"/>
<field name="hours_per_day"/>
<field name="tz"/>
</group>
<notebook>
<page string="Working Hours">
@@ -261,7 +263,6 @@
<field name="name"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="tz"/>
</tree>
</field>
</page>