[MOV] resource: Split files according to coding guidelines
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from pytz import utc, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.addons.resource.models.resource import Intervals
|
||||
|
||||
|
||||
class ResourceResource(models.Model):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import datetime
|
||||
from pytz import timezone, utc
|
||||
|
||||
from odoo.addons.resource.models.resource import Intervals, sum_intervals
|
||||
from datetime import datetime
|
||||
from pytz import utc
|
||||
|
||||
from odoo.addons.resource.models.utils import Intervals
|
||||
|
||||
from .common import TestHrCommon
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
from pytz import timezone
|
||||
|
||||
from odoo import models
|
||||
from odoo.addons.resource.models.resource import Intervals
|
||||
from odoo.addons.resource.models.utils import Intervals
|
||||
|
||||
class ResourceResource(models.Model):
|
||||
_inherit = 'resource.resource'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import date, datetime
|
||||
from pytz import utc, timezone
|
||||
|
||||
from odoo.addons.resource.models.resource import Intervals, sum_intervals
|
||||
from odoo.addons.resource.models.utils import Intervals, sum_intervals
|
||||
from odoo.fields import Date
|
||||
|
||||
from .common import TestContractCommon
|
||||
|
||||
@@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.tools.float_utils import float_round
|
||||
from odoo.addons.resource.models.resource import HOURS_PER_DAY
|
||||
from odoo.addons.resource.models.utils import HOURS_PER_DAY
|
||||
|
||||
|
||||
class HrEmployeeBase(models.AbstractModel):
|
||||
|
||||
@@ -14,7 +14,7 @@ from odoo.tools import date_utils
|
||||
|
||||
from odoo import api, Command, fields, models, tools
|
||||
from odoo.addons.base.models.res_partner import _tz_get
|
||||
from odoo.addons.resource.models.resource import float_to_time, HOURS_PER_DAY
|
||||
from odoo.addons.resource.models.utils import float_to_time, HOURS_PER_DAY
|
||||
from odoo.exceptions import AccessError, UserError, ValidationError
|
||||
from odoo.tools import float_compare, format_date
|
||||
from odoo.tools.float_utils import float_round
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime, time
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.resource.models.resource import HOURS_PER_DAY
|
||||
from odoo.addons.resource.models.utils import HOURS_PER_DAY
|
||||
from odoo.addons.hr_holidays.models.hr_leave import get_employee_from_context
|
||||
from odoo.exceptions import AccessError, UserError, ValidationError
|
||||
from odoo.tools.translate import _
|
||||
|
||||
@@ -14,7 +14,7 @@ from odoo.osv import expression
|
||||
from odoo.tools import format_date
|
||||
from odoo.tools.translate import _
|
||||
from odoo.tools.float_utils import float_round
|
||||
from odoo.addons.resource.models.resource import Intervals
|
||||
from odoo.addons.resource.models.utils import Intervals
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import itertools
|
||||
import pytz
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, date
|
||||
import pytz
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.resource.models.resource import datetime_to_string, string_to_datetime, Intervals
|
||||
from odoo.addons.resource.models.utils import string_to_datetime, Intervals
|
||||
from odoo.osv import expression
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from random import randint
|
||||
|
||||
from odoo import api, exceptions, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.addons.resource.models.resource import make_aware, Intervals
|
||||
from odoo.addons.resource.models.utils import make_aware, Intervals
|
||||
from odoo.tools.float_utils import float_compare
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ associated to every resource. It also manages the leaves of every resource.
|
||||
'data/resource_data.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'security/resource_security.xml',
|
||||
'views/resource_views.xml',
|
||||
'views/resource_resource_views.xml',
|
||||
'views/resource_calendar_leaves_views.xml',
|
||||
'views/resource_calendar_attendance_views.xml',
|
||||
'views/resource_calendar_views.xml',
|
||||
'views/menuitems.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import resource
|
||||
from . import resource_mixin
|
||||
from . import res_company
|
||||
from . import res_users
|
||||
from . import resource_calendar
|
||||
from . import resource_calendar_attendance
|
||||
from . import resource_calendar_leaves
|
||||
from . import resource_mixin
|
||||
from . import resource_resource
|
||||
from . import utils
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
import itertools
|
||||
import math
|
||||
from datetime import datetime, time, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil.rrule import rrule, DAILY, WEEKLY
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil.rrule import rrule, DAILY
|
||||
from pytz import timezone, utc
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
@@ -18,133 +19,8 @@ from odoo.osv import expression
|
||||
from odoo.tools.float_utils import float_round
|
||||
|
||||
from odoo.tools import date_utils, float_utils
|
||||
from .resource_mixin import timezone_datetime
|
||||
from .utils import Intervals, float_to_time, make_aware, datetime_to_string, string_to_datetime, ROUNDING_FACTOR
|
||||
|
||||
# Default hour per day value. The one should
|
||||
# only be used when the one from the calendar
|
||||
# is not available.
|
||||
HOURS_PER_DAY = 8
|
||||
# This will generate 16th of days
|
||||
ROUNDING_FACTOR = 16
|
||||
|
||||
|
||||
def make_aware(dt):
|
||||
""" Return ``dt`` with an explicit timezone, together with a function to
|
||||
convert a datetime to the same (naive or aware) timezone as ``dt``.
|
||||
"""
|
||||
if dt.tzinfo:
|
||||
return dt, lambda val: val.astimezone(dt.tzinfo)
|
||||
else:
|
||||
return dt.replace(tzinfo=utc), lambda val: val.astimezone(utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def string_to_datetime(value):
|
||||
""" Convert the given string value to a datetime in UTC. """
|
||||
return utc.localize(fields.Datetime.from_string(value))
|
||||
|
||||
|
||||
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 float_to_time(hours):
|
||||
""" Convert a number of hours into a time object. """
|
||||
if hours == 24.0:
|
||||
return time.max
|
||||
fractional, integral = math.modf(hours)
|
||||
return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# YTI TODO LAST SPLIT FILES PLIIIIZ
|
||||
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
|
||||
|
||||
def sum_intervals(intervals):
|
||||
""" Sum the intervals duration (unit : hour)"""
|
||||
return sum(
|
||||
(stop - start).total_seconds() / 3600
|
||||
for start, stop, meta in intervals
|
||||
)
|
||||
|
||||
class ResourceCalendar(models.Model):
|
||||
""" Calendar model for a resource. It has
|
||||
@@ -162,7 +38,7 @@ class ResourceCalendar(models.Model):
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super(ResourceCalendar, self).default_get(fields)
|
||||
res = super().default_get(fields)
|
||||
if not res.get('name') and res.get('company_id'):
|
||||
res['name'] = _('Working Hours of %s', self.env['res.company'].browse(res['company_id']).name)
|
||||
if 'attendance_ids' in fields and not res.get('attendance_ids'):
|
||||
@@ -263,7 +139,7 @@ class ResourceCalendar(models.Model):
|
||||
default = {}
|
||||
if not default.get('name'):
|
||||
default.update(name=_('%s (copy)') % (self.name))
|
||||
return super(ResourceCalendar, self).copy(default)
|
||||
return super().copy(default)
|
||||
|
||||
@api.constrains('attendance_ids')
|
||||
def _check_attendance_ids(self):
|
||||
@@ -838,331 +714,3 @@ class ResourceCalendar(models.Model):
|
||||
for attendance in self.attendance_ids.filtered(lambda a: a.day_period != 'lunch' and ((not a.date_from or not a.date_to) or (a.date_from <= end.date() and a.date_to >= start.date()))):
|
||||
mapped_data[(attendance.week_type, attendance.dayofweek)] += attendance.hour_to - attendance.hour_from
|
||||
return max(mapped_data.values())
|
||||
|
||||
|
||||
class ResourceCalendarAttendance(models.Model):
|
||||
_name = "resource.calendar.attendance"
|
||||
_description = "Work Detail"
|
||||
_order = 'week_type, dayofweek, hour_from'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
dayofweek = fields.Selection([
|
||||
('0', 'Monday'),
|
||||
('1', 'Tuesday'),
|
||||
('2', 'Wednesday'),
|
||||
('3', 'Thursday'),
|
||||
('4', 'Friday'),
|
||||
('5', 'Saturday'),
|
||||
('6', 'Sunday')
|
||||
], 'Day of Week', required=True, index=True, default='0')
|
||||
date_from = fields.Date(string='Starting Date')
|
||||
date_to = fields.Date(string='End Date')
|
||||
hour_from = fields.Float(string='Work from', required=True, index=True,
|
||||
help="Start and End time of working.\n"
|
||||
"A specific value of 24:00 is interpreted as 23:59:59.999999.")
|
||||
hour_to = fields.Float(string='Work to', required=True)
|
||||
calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
|
||||
day_period = fields.Selection([
|
||||
('morning', 'Morning'),
|
||||
('lunch', 'Lunch'),
|
||||
('afternoon', 'Afternoon')], required=True, default='morning')
|
||||
resource_id = fields.Many2one('resource.resource', 'Resource')
|
||||
week_type = fields.Selection([
|
||||
('1', 'Second'),
|
||||
('0', 'First')
|
||||
], 'Week Number', default=False)
|
||||
two_weeks_calendar = fields.Boolean("Calendar in 2 weeks mode", related='calendar_id.two_weeks_calendar')
|
||||
display_type = fields.Selection([
|
||||
('line_section', "Section")], default=False, help="Technical field for UX purpose.")
|
||||
sequence = fields.Integer(default=10,
|
||||
help="Gives the sequence of this line when displaying the resource calendar.")
|
||||
|
||||
@api.onchange('hour_from', 'hour_to')
|
||||
def _onchange_hours(self):
|
||||
# avoid negative or after midnight
|
||||
self.hour_from = min(self.hour_from, 23.99)
|
||||
self.hour_from = max(self.hour_from, 0.0)
|
||||
self.hour_to = min(self.hour_to, 24)
|
||||
self.hour_to = max(self.hour_to, 0.0)
|
||||
|
||||
# avoid wrong order
|
||||
self.hour_to = max(self.hour_to, self.hour_from)
|
||||
|
||||
@api.model
|
||||
def get_week_type(self, date):
|
||||
# week_type is defined by
|
||||
# * counting the number of days from January 1 of year 1
|
||||
# (extrapolated to dates prior to the first adoption of the Gregorian calendar)
|
||||
# * converted to week numbers and then the parity of this number is asserted.
|
||||
# It ensures that an even week number always follows an odd week number. With classical week number,
|
||||
# some years have 53 weeks. Therefore, two consecutive odd week number follow each other (53 --> 1).
|
||||
return int(math.floor((date.toordinal() - 1) / 7) % 2)
|
||||
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
this_week_type = str(self.get_week_type(fields.Date.context_today(self)))
|
||||
section_names = {'0': _('First week'), '1': _('Second week')}
|
||||
section_info = {True: _('this week'), False: _('other week')}
|
||||
for record in self.filtered(lambda l: l.display_type == 'line_section'):
|
||||
section_name = "%s (%s)" % (section_names[record.week_type], section_info[this_week_type == record.week_type])
|
||||
record.display_name = section_name
|
||||
|
||||
def _copy_attendance_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'dayofweek': self.dayofweek,
|
||||
'date_from': self.date_from,
|
||||
'date_to': self.date_to,
|
||||
'hour_from': self.hour_from,
|
||||
'hour_to': self.hour_to,
|
||||
'day_period': self.day_period,
|
||||
'week_type': self.week_type,
|
||||
'display_type': self.display_type,
|
||||
'sequence': self.sequence,
|
||||
}
|
||||
|
||||
|
||||
class ResourceResource(models.Model):
|
||||
_name = "resource.resource"
|
||||
_description = "Resources"
|
||||
_order = "name"
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super(ResourceResource, self).default_get(fields)
|
||||
if not res.get('calendar_id') and res.get('company_id'):
|
||||
company = self.env['res.company'].browse(res['company_id'])
|
||||
res['calendar_id'] = company.resource_calendar_id.id
|
||||
return res
|
||||
|
||||
name = fields.Char(required=True)
|
||||
active = fields.Boolean(
|
||||
'Active', default=True,
|
||||
help="If the active field is set to False, it will allow you to hide the resource record without removing it.")
|
||||
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
|
||||
resource_type = fields.Selection([
|
||||
('user', 'Human'),
|
||||
('material', 'Material')], string='Type',
|
||||
default='user', required=True)
|
||||
user_id = fields.Many2one('res.users', string='User', help='Related user name for the resource to manage its access.')
|
||||
time_efficiency = fields.Float(
|
||||
'Efficiency Factor', default=100, required=True,
|
||||
help="This field is used to calculate the expected duration of a work order at this work center. For example, if a work order takes one hour and the efficiency factor is 100%, then the expected duration will be one hour. If the efficiency factor is 200%, however the expected duration will be 30 minutes.")
|
||||
calendar_id = fields.Many2one(
|
||||
"resource.calendar", string='Working Time',
|
||||
default=lambda self: self.env.company.resource_calendar_id,
|
||||
required=True, domain="[('company_id', '=', company_id)]")
|
||||
tz = fields.Selection(
|
||||
_tz_get, string='Timezone', required=True,
|
||||
default=lambda self: self._context.get('tz') or self.env.user.tz or 'UTC')
|
||||
|
||||
_sql_constraints = [
|
||||
('check_time_efficiency', 'CHECK(time_efficiency>0)', 'Time efficiency must be strictly positive'),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
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(vals_list)
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
self.ensure_one()
|
||||
if default is None:
|
||||
default = {}
|
||||
if not default.get('name'):
|
||||
default.update(name=_('%s (copy)') % (self.name))
|
||||
return super(ResourceResource, self).copy(default)
|
||||
|
||||
def write(self, values):
|
||||
if self.env.context.get('check_idempotence') and len(self) == 1:
|
||||
values = {
|
||||
fname: value
|
||||
for fname, value in values.items()
|
||||
if self._fields[fname].convert_to_write(self[fname], self) != value
|
||||
}
|
||||
if not values:
|
||||
return True
|
||||
return super().write(values)
|
||||
|
||||
@api.onchange('company_id')
|
||||
def _onchange_company_id(self):
|
||||
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
|
||||
|
||||
def _get_work_interval(self, start, end):
|
||||
# Deprecated method. Use `_adjust_to_calendar` instead
|
||||
return self._adjust_to_calendar(start, end)
|
||||
|
||||
def _adjust_to_calendar(self, start, end, compute_leaves=True):
|
||||
"""Adjust the given start and end datetimes to the closest effective hours encoded
|
||||
in the resource calendar. Only attendances in the same day as `start` and `end` are
|
||||
considered (respectively). If no attendance is found during that day, the closest hour
|
||||
is None.
|
||||
e.g. simplified example:
|
||||
given two attendances: 8am-1pm and 2pm-5pm, given start=9am and end=6pm
|
||||
resource._adjust_to_calendar(start, end)
|
||||
>>> {resource: (8am, 5pm)}
|
||||
:return: Closest matching start and end of working periods for each resource
|
||||
:rtype: dict(resource, tuple(datetime | None, datetime | None))
|
||||
"""
|
||||
start, revert_start_tz = make_aware(start)
|
||||
end, revert_end_tz = make_aware(end)
|
||||
result = {}
|
||||
for resource in self:
|
||||
resource_tz = timezone(resource.tz)
|
||||
start, end = start.astimezone(resource_tz), end.astimezone(resource_tz)
|
||||
search_range = [
|
||||
start + relativedelta(hour=0, minute=0, second=0),
|
||||
end + relativedelta(days=1, hour=0, minute=0, second=0),
|
||||
]
|
||||
calendar_start = resource.calendar_id._get_closest_work_time(start, resource=resource, search_range=search_range,
|
||||
compute_leaves=compute_leaves)
|
||||
search_range[0] = start
|
||||
calendar_end = resource.calendar_id._get_closest_work_time(end if end > start else start, match_end=True,
|
||||
resource=resource, search_range=search_range,
|
||||
compute_leaves=compute_leaves)
|
||||
result[resource] = (
|
||||
calendar_start and revert_start_tz(calendar_start),
|
||||
calendar_end and revert_end_tz(calendar_end),
|
||||
)
|
||||
return result
|
||||
|
||||
def _get_unavailable_intervals(self, start, end):
|
||||
""" Compute the intervals during which employee is unavailable with hour granularity between start and end
|
||||
Note: this method is used in enterprise (forecast and planning)
|
||||
|
||||
"""
|
||||
start_datetime = timezone_datetime(start)
|
||||
end_datetime = timezone_datetime(end)
|
||||
resource_mapping = {}
|
||||
calendar_mapping = defaultdict(lambda: self.env['resource.resource'])
|
||||
for resource in self:
|
||||
calendar_mapping[resource.calendar_id] |= resource
|
||||
|
||||
for calendar, resources in calendar_mapping.items():
|
||||
resources_unavailable_intervals = calendar._unavailable_intervals_batch(start_datetime, end_datetime, resources, tz=timezone(calendar.tz))
|
||||
resource_mapping.update(resources_unavailable_intervals)
|
||||
return resource_mapping
|
||||
|
||||
def _get_calendars_validity_within_period(self, start, end, default_company=None):
|
||||
""" Gets a dict of dict with resource's id as first key and resource's calendar as secondary key
|
||||
The value is the validity interval of the calendar for the given resource.
|
||||
|
||||
Here the validity interval for each calendar is the whole interval but it's meant to be overriden in further modules
|
||||
handling resource's employee contracts.
|
||||
"""
|
||||
assert start.tzinfo and end.tzinfo
|
||||
resource_calendars_within_period = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
|
||||
default_calendar = default_company and default_company.resource_calendar_id or self.env.company.resource_calendar_id
|
||||
if not self:
|
||||
# if no resource, add the company resource calendar.
|
||||
resource_calendars_within_period[False][default_calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
|
||||
for resource in self:
|
||||
calendar = resource.calendar_id or resource.company_id.resource_calendar_id or default_calendar
|
||||
resource_calendars_within_period[resource.id][calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
|
||||
return resource_calendars_within_period
|
||||
|
||||
def _get_valid_work_intervals(self, start, end, calendars=None):
|
||||
""" Gets the valid work intervals of the resource following their calendars between ``start`` and ``end``
|
||||
|
||||
This methods handle the eventuality of a resource having multiple resource calendars, see _get_calendars_validity_within_period method
|
||||
for further explanation.
|
||||
"""
|
||||
assert start.tzinfo and end.tzinfo
|
||||
resource_calendar_validity_intervals = {}
|
||||
calendar_resources = defaultdict(lambda: self.env['resource.resource'])
|
||||
resource_work_intervals = defaultdict(Intervals)
|
||||
calendar_work_intervals = dict()
|
||||
|
||||
resource_calendar_validity_intervals = self.sudo()._get_calendars_validity_within_period(start, end)
|
||||
for resource in self:
|
||||
# For each resource, retrieve its calendar and their validity intervals
|
||||
for calendar in resource_calendar_validity_intervals[resource.id]:
|
||||
calendar_resources[calendar] |= resource
|
||||
for calendar in (calendars or []):
|
||||
calendar_resources[calendar] |= self.env['resource.resource']
|
||||
for calendar, resources in calendar_resources.items():
|
||||
# For each calendar used by the resources, retrieve the work intervals for every resources using it
|
||||
work_intervals_batch = calendar._work_intervals_batch(start, end, resources=resources)
|
||||
for resource in resources:
|
||||
# Make the conjunction between work intervals and calendar validity
|
||||
resource_work_intervals[resource.id] |= work_intervals_batch[resource.id] & resource_calendar_validity_intervals[resource.id][calendar]
|
||||
calendar_work_intervals[calendar.id] = work_intervals_batch[False]
|
||||
|
||||
return resource_work_intervals, calendar_work_intervals
|
||||
|
||||
class ResourceCalendarLeaves(models.Model):
|
||||
_name = "resource.calendar.leaves"
|
||||
_description = "Resource Time Off Detail"
|
||||
_order = "date_from"
|
||||
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if 'date_from' in fields_list and 'date_to' in fields_list and not res.get('date_from') and not res.get('date_to'):
|
||||
# Then we give the current day and we search the begin and end hours for this day in resource.calendar of the current company
|
||||
today = fields.Datetime.now()
|
||||
user_tz = timezone(self.env.user.tz or self._context.get('tz') or self.company_id.resource_calendar_id.tz or 'UTC')
|
||||
date_from = user_tz.localize(datetime.combine(today, time.min))
|
||||
date_to = user_tz.localize(datetime.combine(today, time.max))
|
||||
intervals = self.env.company.resource_calendar_id._work_intervals_batch(date_from.replace(tzinfo=utc), date_to.replace(tzinfo=utc))[False]
|
||||
if intervals: # Then we stop and return the dates given in parameter
|
||||
list_intervals = [(start, stop) for start, stop, records in intervals] # Convert intervals in interval list
|
||||
date_from = list_intervals[0][0] # We take the first date in the interval list
|
||||
date_to = list_intervals[-1][1] # We take the last date in the interval list
|
||||
res.update(
|
||||
date_from=date_from.astimezone(utc).replace(tzinfo=None),
|
||||
date_to=date_to.astimezone(utc).replace(tzinfo=None)
|
||||
)
|
||||
return res
|
||||
|
||||
name = fields.Char('Reason')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string="Company", readonly=True, store=True,
|
||||
default=lambda self: self.env.company, compute='_compute_company_id')
|
||||
calendar_id = fields.Many2one('resource.calendar', 'Working Hours', domain="[('company_id', 'in', [company_id, False])]", check_company=True, index=True)
|
||||
date_from = fields.Datetime('Start Date', required=True)
|
||||
date_to = fields.Datetime('End Date', required=True)
|
||||
resource_id = fields.Many2one(
|
||||
"resource.resource", 'Resource', index=True,
|
||||
help="If empty, this is a generic time off for the company. If a resource is set, the time off is only for this resource")
|
||||
time_type = fields.Selection([('leave', 'Time Off'), ('other', 'Other')], default='leave',
|
||||
help="Whether this should be computed as a time off or as work time (eg: formation)")
|
||||
|
||||
@api.depends('calendar_id')
|
||||
def _compute_company_id(self):
|
||||
for leave in self:
|
||||
leave.company_id = leave.calendar_id.company_id or self.env.company
|
||||
|
||||
@api.constrains('date_from', 'date_to')
|
||||
def check_dates(self):
|
||||
if self.filtered(lambda leave: leave.date_from > leave.date_to):
|
||||
raise ValidationError(_('The start date of the time off must be earlier than the end date.'))
|
||||
|
||||
@api.onchange('resource_id')
|
||||
def onchange_resource(self):
|
||||
if self.resource_id:
|
||||
self.calendar_id = self.resource_id.calendar_id
|
||||
|
||||
def _copy_leave_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'date_from': self.date_from,
|
||||
'date_to': self.date_to,
|
||||
'time_type': self.time_type,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import math
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ResourceCalendarAttendance(models.Model):
|
||||
_name = "resource.calendar.attendance"
|
||||
_description = "Work Detail"
|
||||
_order = 'week_type, dayofweek, hour_from'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
dayofweek = fields.Selection([
|
||||
('0', 'Monday'),
|
||||
('1', 'Tuesday'),
|
||||
('2', 'Wednesday'),
|
||||
('3', 'Thursday'),
|
||||
('4', 'Friday'),
|
||||
('5', 'Saturday'),
|
||||
('6', 'Sunday')
|
||||
], 'Day of Week', required=True, index=True, default='0')
|
||||
date_from = fields.Date(string='Starting Date')
|
||||
date_to = fields.Date(string='End Date')
|
||||
hour_from = fields.Float(string='Work from', required=True, index=True,
|
||||
help="Start and End time of working.\n"
|
||||
"A specific value of 24:00 is interpreted as 23:59:59.999999.")
|
||||
hour_to = fields.Float(string='Work to', required=True)
|
||||
calendar_id = fields.Many2one("resource.calendar", string="Resource's Calendar", required=True, ondelete='cascade')
|
||||
day_period = fields.Selection([
|
||||
('morning', 'Morning'),
|
||||
('lunch', 'Lunch'),
|
||||
('afternoon', 'Afternoon')], required=True, default='morning')
|
||||
resource_id = fields.Many2one('resource.resource', 'Resource')
|
||||
week_type = fields.Selection([
|
||||
('1', 'Second'),
|
||||
('0', 'First')
|
||||
], 'Week Number', default=False)
|
||||
two_weeks_calendar = fields.Boolean("Calendar in 2 weeks mode", related='calendar_id.two_weeks_calendar')
|
||||
display_type = fields.Selection([
|
||||
('line_section', "Section")], default=False, help="Technical field for UX purpose.")
|
||||
sequence = fields.Integer(default=10,
|
||||
help="Gives the sequence of this line when displaying the resource calendar.")
|
||||
|
||||
@api.onchange('hour_from', 'hour_to')
|
||||
def _onchange_hours(self):
|
||||
# avoid negative or after midnight
|
||||
self.hour_from = min(self.hour_from, 23.99)
|
||||
self.hour_from = max(self.hour_from, 0.0)
|
||||
self.hour_to = min(self.hour_to, 24)
|
||||
self.hour_to = max(self.hour_to, 0.0)
|
||||
|
||||
# avoid wrong order
|
||||
self.hour_to = max(self.hour_to, self.hour_from)
|
||||
|
||||
@api.model
|
||||
def get_week_type(self, date):
|
||||
# week_type is defined by
|
||||
# * counting the number of days from January 1 of year 1
|
||||
# (extrapolated to dates prior to the first adoption of the Gregorian calendar)
|
||||
# * converted to week numbers and then the parity of this number is asserted.
|
||||
# It ensures that an even week number always follows an odd week number. With classical week number,
|
||||
# some years have 53 weeks. Therefore, two consecutive odd week number follow each other (53 --> 1).
|
||||
return int(math.floor((date.toordinal() - 1) / 7) % 2)
|
||||
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
this_week_type = str(self.get_week_type(fields.Date.context_today(self)))
|
||||
section_names = {'0': _('First week'), '1': _('Second week')}
|
||||
section_info = {True: _('this week'), False: _('other week')}
|
||||
for record in self.filtered(lambda l: l.display_type == 'line_section'):
|
||||
section_name = "%s (%s)" % (section_names[record.week_type], section_info[this_week_type == record.week_type])
|
||||
record.display_name = section_name
|
||||
|
||||
def _copy_attendance_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'dayofweek': self.dayofweek,
|
||||
'date_from': self.date_from,
|
||||
'date_to': self.date_to,
|
||||
'hour_from': self.hour_from,
|
||||
'hour_to': self.hour_to,
|
||||
'day_period': self.day_period,
|
||||
'week_type': self.week_type,
|
||||
'display_type': self.display_type,
|
||||
'sequence': self.sequence,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, time
|
||||
from pytz import timezone, utc
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class ResourceCalendarLeaves(models.Model):
|
||||
_name = "resource.calendar.leaves"
|
||||
_description = "Resource Time Off Detail"
|
||||
_order = "date_from"
|
||||
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
if 'date_from' in fields_list and 'date_to' in fields_list and not res.get('date_from') and not res.get('date_to'):
|
||||
# Then we give the current day and we search the begin and end hours for this day in resource.calendar of the current company
|
||||
today = fields.Datetime.now()
|
||||
user_tz = timezone(self.env.user.tz or self._context.get('tz') or self.company_id.resource_calendar_id.tz or 'UTC')
|
||||
date_from = user_tz.localize(datetime.combine(today, time.min))
|
||||
date_to = user_tz.localize(datetime.combine(today, time.max))
|
||||
intervals = self.env.company.resource_calendar_id._work_intervals_batch(date_from.replace(tzinfo=utc), date_to.replace(tzinfo=utc))[False]
|
||||
if intervals: # Then we stop and return the dates given in parameter
|
||||
list_intervals = [(start, stop) for start, stop, records in intervals] # Convert intervals in interval list
|
||||
date_from = list_intervals[0][0] # We take the first date in the interval list
|
||||
date_to = list_intervals[-1][1] # We take the last date in the interval list
|
||||
res.update(
|
||||
date_from=date_from.astimezone(utc).replace(tzinfo=None),
|
||||
date_to=date_to.astimezone(utc).replace(tzinfo=None)
|
||||
)
|
||||
return res
|
||||
|
||||
name = fields.Char('Reason')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string="Company", readonly=True, store=True,
|
||||
default=lambda self: self.env.company, compute='_compute_company_id')
|
||||
calendar_id = fields.Many2one('resource.calendar', 'Working Hours', domain="[('company_id', 'in', [company_id, False])]", check_company=True, index=True)
|
||||
date_from = fields.Datetime('Start Date', required=True)
|
||||
date_to = fields.Datetime('End Date', required=True)
|
||||
resource_id = fields.Many2one(
|
||||
"resource.resource", 'Resource', index=True,
|
||||
help="If empty, this is a generic time off for the company. If a resource is set, the time off is only for this resource")
|
||||
time_type = fields.Selection([('leave', 'Time Off'), ('other', 'Other')], default='leave',
|
||||
help="Whether this should be computed as a time off or as work time (eg: formation)")
|
||||
|
||||
@api.depends('calendar_id')
|
||||
def _compute_company_id(self):
|
||||
for leave in self:
|
||||
leave.company_id = leave.calendar_id.company_id or self.env.company
|
||||
|
||||
@api.constrains('date_from', 'date_to')
|
||||
def check_dates(self):
|
||||
if self.filtered(lambda leave: leave.date_from > leave.date_to):
|
||||
raise ValidationError(_('The start date of the time off must be earlier than the end date.'))
|
||||
|
||||
@api.onchange('resource_id')
|
||||
def onchange_resource(self):
|
||||
if self.resource_id:
|
||||
self.calendar_id = self.resource_id.calendar_id
|
||||
|
||||
def _copy_leave_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'date_from': self.date_from,
|
||||
'date_to': self.date_to,
|
||||
'time_type': self.time_type,
|
||||
}
|
||||
@@ -2,16 +2,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from pytz import utc
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
def timezone_datetime(time):
|
||||
if not time.tzinfo:
|
||||
time = time.replace(tzinfo=utc)
|
||||
return time
|
||||
from .utils import timezone_datetime
|
||||
|
||||
|
||||
class ResourceMixin(models.AbstractModel):
|
||||
@@ -80,7 +74,7 @@ class ResourceMixin(models.AbstractModel):
|
||||
default['resource_id'] = resource.id
|
||||
default['company_id'] = resource.company_id.id
|
||||
default['resource_calendar_id'] = resource.calendar_id.id
|
||||
return super(ResourceMixin, self).copy_data(default)
|
||||
return super().copy_data(default)
|
||||
|
||||
def _get_work_days_data_batch(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from pytz import timezone
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.base.models.res_partner import _tz_get
|
||||
|
||||
from .utils import timezone_datetime, make_aware, Intervals
|
||||
|
||||
|
||||
class ResourceResource(models.Model):
|
||||
_name = "resource.resource"
|
||||
_description = "Resources"
|
||||
_order = "name"
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super().default_get(fields)
|
||||
if not res.get('calendar_id') and res.get('company_id'):
|
||||
company = self.env['res.company'].browse(res['company_id'])
|
||||
res['calendar_id'] = company.resource_calendar_id.id
|
||||
return res
|
||||
|
||||
name = fields.Char(required=True)
|
||||
active = fields.Boolean(
|
||||
'Active', default=True,
|
||||
help="If the active field is set to False, it will allow you to hide the resource record without removing it.")
|
||||
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
|
||||
resource_type = fields.Selection([
|
||||
('user', 'Human'),
|
||||
('material', 'Material')], string='Type',
|
||||
default='user', required=True)
|
||||
user_id = fields.Many2one('res.users', string='User', help='Related user name for the resource to manage its access.')
|
||||
time_efficiency = fields.Float(
|
||||
'Efficiency Factor', default=100, required=True,
|
||||
help="This field is used to calculate the expected duration of a work order at this work center. For example, if a work order takes one hour and the efficiency factor is 100%, then the expected duration will be one hour. If the efficiency factor is 200%, however the expected duration will be 30 minutes.")
|
||||
calendar_id = fields.Many2one(
|
||||
"resource.calendar", string='Working Time',
|
||||
default=lambda self: self.env.company.resource_calendar_id,
|
||||
required=True, domain="[('company_id', '=', company_id)]")
|
||||
tz = fields.Selection(
|
||||
_tz_get, string='Timezone', required=True,
|
||||
default=lambda self: self._context.get('tz') or self.env.user.tz or 'UTC')
|
||||
|
||||
_sql_constraints = [
|
||||
('check_time_efficiency', 'CHECK(time_efficiency>0)', 'Time efficiency must be strictly positive'),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
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().create(vals_list)
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
self.ensure_one()
|
||||
if default is None:
|
||||
default = {}
|
||||
if not default.get('name'):
|
||||
default.update(name=_('%s (copy)') % (self.name))
|
||||
return super().copy(default)
|
||||
|
||||
def write(self, values):
|
||||
if self.env.context.get('check_idempotence') and len(self) == 1:
|
||||
values = {
|
||||
fname: value
|
||||
for fname, value in values.items()
|
||||
if self._fields[fname].convert_to_write(self[fname], self) != value
|
||||
}
|
||||
if not values:
|
||||
return True
|
||||
return super().write(values)
|
||||
|
||||
@api.onchange('company_id')
|
||||
def _onchange_company_id(self):
|
||||
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
|
||||
|
||||
def _get_work_interval(self, start, end):
|
||||
# Deprecated method. Use `_adjust_to_calendar` instead
|
||||
return self._adjust_to_calendar(start, end)
|
||||
|
||||
def _adjust_to_calendar(self, start, end, compute_leaves=True):
|
||||
"""Adjust the given start and end datetimes to the closest effective hours encoded
|
||||
in the resource calendar. Only attendances in the same day as `start` and `end` are
|
||||
considered (respectively). If no attendance is found during that day, the closest hour
|
||||
is None.
|
||||
e.g. simplified example:
|
||||
given two attendances: 8am-1pm and 2pm-5pm, given start=9am and end=6pm
|
||||
resource._adjust_to_calendar(start, end)
|
||||
>>> {resource: (8am, 5pm)}
|
||||
:return: Closest matching start and end of working periods for each resource
|
||||
:rtype: dict(resource, tuple(datetime | None, datetime | None))
|
||||
"""
|
||||
start, revert_start_tz = make_aware(start)
|
||||
end, revert_end_tz = make_aware(end)
|
||||
result = {}
|
||||
for resource in self:
|
||||
resource_tz = timezone(resource.tz)
|
||||
start, end = start.astimezone(resource_tz), end.astimezone(resource_tz)
|
||||
search_range = [
|
||||
start + relativedelta(hour=0, minute=0, second=0),
|
||||
end + relativedelta(days=1, hour=0, minute=0, second=0),
|
||||
]
|
||||
calendar_start = resource.calendar_id._get_closest_work_time(start, resource=resource, search_range=search_range,
|
||||
compute_leaves=compute_leaves)
|
||||
search_range[0] = start
|
||||
calendar_end = resource.calendar_id._get_closest_work_time(end if end > start else start, match_end=True,
|
||||
resource=resource, search_range=search_range,
|
||||
compute_leaves=compute_leaves)
|
||||
result[resource] = (
|
||||
calendar_start and revert_start_tz(calendar_start),
|
||||
calendar_end and revert_end_tz(calendar_end),
|
||||
)
|
||||
return result
|
||||
|
||||
def _get_unavailable_intervals(self, start, end):
|
||||
""" Compute the intervals during which employee is unavailable with hour granularity between start and end
|
||||
Note: this method is used in enterprise (forecast and planning)
|
||||
|
||||
"""
|
||||
start_datetime = timezone_datetime(start)
|
||||
end_datetime = timezone_datetime(end)
|
||||
resource_mapping = {}
|
||||
calendar_mapping = defaultdict(lambda: self.env['resource.resource'])
|
||||
for resource in self:
|
||||
calendar_mapping[resource.calendar_id] |= resource
|
||||
|
||||
for calendar, resources in calendar_mapping.items():
|
||||
resources_unavailable_intervals = calendar._unavailable_intervals_batch(start_datetime, end_datetime, resources, tz=timezone(calendar.tz))
|
||||
resource_mapping.update(resources_unavailable_intervals)
|
||||
return resource_mapping
|
||||
|
||||
def _get_calendars_validity_within_period(self, start, end, default_company=None):
|
||||
""" Gets a dict of dict with resource's id as first key and resource's calendar as secondary key
|
||||
The value is the validity interval of the calendar for the given resource.
|
||||
|
||||
Here the validity interval for each calendar is the whole interval but it's meant to be overriden in further modules
|
||||
handling resource's employee contracts.
|
||||
"""
|
||||
assert start.tzinfo and end.tzinfo
|
||||
resource_calendars_within_period = defaultdict(lambda: defaultdict(Intervals)) # keys are [resource id:integer][calendar:self.env['resource.calendar']]
|
||||
default_calendar = default_company and default_company.resource_calendar_id or self.env.company.resource_calendar_id
|
||||
if not self:
|
||||
# if no resource, add the company resource calendar.
|
||||
resource_calendars_within_period[False][default_calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
|
||||
for resource in self:
|
||||
calendar = resource.calendar_id or resource.company_id.resource_calendar_id or default_calendar
|
||||
resource_calendars_within_period[resource.id][calendar] = Intervals([(start, end, self.env['resource.calendar.attendance'])])
|
||||
return resource_calendars_within_period
|
||||
|
||||
def _get_valid_work_intervals(self, start, end, calendars=None):
|
||||
""" Gets the valid work intervals of the resource following their calendars between ``start`` and ``end``
|
||||
|
||||
This methods handle the eventuality of a resource having multiple resource calendars, see _get_calendars_validity_within_period method
|
||||
for further explanation.
|
||||
"""
|
||||
assert start.tzinfo and end.tzinfo
|
||||
resource_calendar_validity_intervals = {}
|
||||
calendar_resources = defaultdict(lambda: self.env['resource.resource'])
|
||||
resource_work_intervals = defaultdict(Intervals)
|
||||
calendar_work_intervals = dict()
|
||||
|
||||
resource_calendar_validity_intervals = self.sudo()._get_calendars_validity_within_period(start, end)
|
||||
for resource in self:
|
||||
# For each resource, retrieve its calendar and their validity intervals
|
||||
for calendar in resource_calendar_validity_intervals[resource.id]:
|
||||
calendar_resources[calendar] |= resource
|
||||
for calendar in (calendars or []):
|
||||
calendar_resources[calendar] |= self.env['resource.resource']
|
||||
for calendar, resources in calendar_resources.items():
|
||||
# For each calendar used by the resources, retrieve the work intervals for every resources using it
|
||||
work_intervals_batch = calendar._work_intervals_batch(start, end, resources=resources)
|
||||
for resource in resources:
|
||||
# Make the conjunction between work intervals and calendar validity
|
||||
resource_work_intervals[resource.id] |= work_intervals_batch[resource.id] & resource_calendar_validity_intervals[resource.id][calendar]
|
||||
calendar_work_intervals[calendar.id] = work_intervals_batch[False]
|
||||
|
||||
return resource_work_intervals, calendar_work_intervals
|
||||
@@ -1,6 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import math
|
||||
from datetime import time
|
||||
from itertools import chain
|
||||
from pytz import utc
|
||||
|
||||
from odoo import fields
|
||||
from odoo.osv.expression import normalize_domain, NOT_OPERATOR, DOMAIN_OPERATORS
|
||||
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
|
||||
# is not available.
|
||||
HOURS_PER_DAY = 8
|
||||
# This will generate 16th of days
|
||||
ROUNDING_FACTOR = 16
|
||||
|
||||
|
||||
def make_aware(dt):
|
||||
""" Return ``dt`` with an explicit timezone, together with a function to
|
||||
convert a datetime to the same (naive or aware) timezone as ``dt``.
|
||||
"""
|
||||
if dt.tzinfo:
|
||||
return dt, lambda val: val.astimezone(dt.tzinfo)
|
||||
return dt.replace(tzinfo=utc), lambda val: val.astimezone(utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def string_to_datetime(value):
|
||||
""" Convert the given string value to a datetime in UTC. """
|
||||
return utc.localize(fields.Datetime.from_string(value))
|
||||
|
||||
|
||||
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 float_to_time(hours):
|
||||
""" Convert a number of hours into a time object. """
|
||||
if hours == 24.0:
|
||||
return time.max
|
||||
fractional, integral = math.modf(hours)
|
||||
return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def filter_domain_leaf(domain, leaf_check):
|
||||
"""
|
||||
@@ -41,3 +91,90 @@ def filter_domain_leaf(domain, leaf_check):
|
||||
operator = domain[0] if len(domain) > 1 else False
|
||||
domain = domain[1:] if len(domain) > 1 else domain
|
||||
return _filter_domain_leaf_recursive(domain, leaf_check, operator=operator)[0]
|
||||
|
||||
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
|
||||
|
||||
def sum_intervals(intervals):
|
||||
""" Sum the intervals duration (unit : hour)"""
|
||||
return sum(
|
||||
(stop - start).total_seconds() / 3600
|
||||
for start, stop, meta in intervals
|
||||
)
|
||||
|
||||
def timezone_datetime(time):
|
||||
if not time.tzinfo:
|
||||
time = time.replace(tzinfo=utc)
|
||||
return time
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem id="menu_resource_config" name="Resource"
|
||||
parent="base.menu_custom"
|
||||
sequence="30"/>
|
||||
<menuitem id="menu_resource_calendar"
|
||||
parent="menu_resource_config"
|
||||
action="action_resource_calendar_form"
|
||||
sequence="1"/>
|
||||
<menuitem id="menu_view_resource_calendar_leaves_search"
|
||||
parent="menu_resource_config"
|
||||
action="action_resource_calendar_leave_tree"
|
||||
sequence="2"/>
|
||||
<menuitem id="menu_resource_resource"
|
||||
parent="menu_resource_config"
|
||||
action="action_resource_resource_tree"
|
||||
sequence="3"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_resource_calendar_attendance_tree" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.attendance.tree</field>
|
||||
<field name="model">resource.calendar.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Working Time" editable="top">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="display_type" invisible="1"/>
|
||||
<field name="display_name" width="1" string=" " attrs="{'invisible': [('display_type', '!=', 'line_section')]}"/>
|
||||
<field name="name" attrs="{'invisible': [('display_type', '=', 'line_section')]}"/>
|
||||
<field name="dayofweek"/>
|
||||
<field name="day_period"/>
|
||||
<field name="hour_from" widget="float_time"/>
|
||||
<field name="hour_to" widget="float_time"/>
|
||||
<field name="date_from" optional="hide"/>
|
||||
<field name="date_to" optional="hide"/>
|
||||
<field name="week_type" readonly="1" force_save="1" groups="base.group_no_one"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_resource_calendar_attendance_form" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.attendance.form</field>
|
||||
<field name="model">resource.calendar.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Working Time">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="dayofweek"/>
|
||||
<label for="hour_from" string="Hours"/>
|
||||
<div class="o_row">
|
||||
<field name="hour_from" widget="float_time"/> -
|
||||
<field name="hour_to" widget="float_time"/>
|
||||
</div>
|
||||
<field name="day_period"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_resource_calendar_leaves_search" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.leaves.search</field>
|
||||
<field name="model">resource.calendar.leaves</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Working Period Time Off">
|
||||
<field name="name"/>
|
||||
<field name="resource_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="calendar_id"/>
|
||||
<filter name="filter_date" date="date_from" default_period="this_year" string="Period"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Resource" name="resource" domain="[]" context="{'group_by':'resource_id'}"/>
|
||||
<filter string="Company" name="company" domain="[]" context="{'group_by':'company_id'}" groups="base.group_multi_company"/>
|
||||
<filter string="Time Off Date" name="leave_month" domain="[]" context="{'group_by':'date_from'}" help="Starting Date of Time Off"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_resource_calendar" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.leaves.calendar</field>
|
||||
<field name="model">resource.calendar.leaves</field>
|
||||
<field name="arch" type="xml">
|
||||
<calendar date_start="date_from" date_stop="date_to" mode="month" string="Resource" color="resource_id" event_limit="5">
|
||||
<field name="resource_id" avatar_field="image_128" filters="1"/>
|
||||
<field name="company_id"/>
|
||||
<field name="name"/>
|
||||
</calendar>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="resource_calendar_leave_form" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.leaves.form</field>
|
||||
<field name="model">resource.calendar.leaves</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Time Off Detail">
|
||||
<field name="company_id" invisible="1"/>
|
||||
<sheet>
|
||||
<group>
|
||||
<group name="leave_details">
|
||||
<field name="name" string="Reason"/>
|
||||
<field name="calendar_id"/>
|
||||
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company" attrs="{'invisible':[('calendar_id','=',False)]}"/>
|
||||
<field name="resource_id"/>
|
||||
</group>
|
||||
<group name="leave_dates">
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="resource_calendar_leave_tree" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.leaves.tree</field>
|
||||
<field name="model">resource.calendar.leaves</field>
|
||||
<field name="priority">1</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Time Off Detail">
|
||||
<field name="name" string="Reason"/>
|
||||
<field name="resource_id" />
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="calendar_id"/>
|
||||
<field name="date_from" />
|
||||
<field name="date_to" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_resource_calendar_leave_tree" model="ir.actions.act_window">
|
||||
<field name="name">Resource Time Off</field>
|
||||
<field name="res_model">resource.calendar.leaves</field>
|
||||
<field name="view_mode">tree,form,calendar</field>
|
||||
<field name="search_view_id" ref="view_resource_calendar_leaves_search"/>
|
||||
</record>
|
||||
|
||||
<record id="resource_calendar_leaves_action_from_calendar" model="ir.actions.act_window">
|
||||
<field name="name">Resource Time Off</field>
|
||||
<field name="res_model">resource.calendar.leaves</field>
|
||||
<field name="view_mode">tree,form,calendar</field>
|
||||
<field name="context">{
|
||||
'default_calendar_id': active_id,
|
||||
'search_default_calendar_id': active_id}</field>
|
||||
<field name="search_view_id" ref="view_resource_calendar_leaves_search"/>
|
||||
</record>
|
||||
|
||||
<record id="resource_calendar_closing_days" model="ir.actions.act_window">
|
||||
<field name="name">Closing Days</field>
|
||||
<field name="res_model">resource.calendar.leaves</field>
|
||||
<field name="view_mode">calendar,tree,form</field>
|
||||
<field name="domain">[('calendar_id','=',active_id), ('resource_id','=',False)]</field>
|
||||
<field name="context">{'default_calendar_id': active_id}</field>
|
||||
<field name="binding_model_id" ref="model_resource_calendar"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
</record>
|
||||
|
||||
<record id="resource_calendar_resources_leaves" model="ir.actions.act_window">
|
||||
<field name="name">Resources Time Off</field>
|
||||
<field name="res_model">resource.calendar.leaves</field>
|
||||
<field name="view_mode">calendar,tree,form</field>
|
||||
<field name="domain">[('calendar_id','=',active_id), ('resource_id','!=',False)]</field>
|
||||
<field name="context">{'default_calendar_id': active_id}</field>
|
||||
<field name="binding_model_id" ref="model_resource_calendar"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_resource_calendar_search" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.search</field>
|
||||
<field name="model">resource.calendar</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Working Time">
|
||||
<field name="name" string="Working Time"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="resource_calendar_form" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.form</field>
|
||||
<field name="model">resource.calendar</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Working Time">
|
||||
<field name="two_weeks_calendar" invisible="1"/>
|
||||
<header>
|
||||
<button name="switch_calendar_type" attrs="{'invisible':[('two_weeks_calendar', '=', True)]}" string="Switch to 2 weeks calendar" type="object" confirm="Are you sure you want to switch this calendar to 2 weeks calendar ? All entries will be lost"/>
|
||||
<button name="switch_calendar_type" attrs="{'invisible':[('two_weeks_calendar', '=', False)]}" string="Switch to 1 week calendar" type="object" confirm="Are you sure you want to switch this calendar to 1 week calendar ? All entries will be lost"/>
|
||||
</header>
|
||||
<sheet string="Working Time">
|
||||
<widget name="web_ribbon" text="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="%(resource_calendar_leaves_action_from_calendar)d" type="action"
|
||||
string="Time Off" icon="fa-plane"
|
||||
class="oe_stat_button"
|
||||
groups="base.group_no_one"/>
|
||||
<button name="%(resource_resource_action_from_calendar)d" type="action"
|
||||
string="Work Resources" icon="fa-cogs"
|
||||
class="oe_stat_button"
|
||||
groups="base.group_user"/>
|
||||
</div>
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
<group name="resource_details">
|
||||
<group>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="hours_per_day" widget="float_time"/>
|
||||
<field name="tz" widget="timezone_mismatch" options="{'tz_offset_field': 'tz_offset', 'mismatch_title': 'Timezone Mismatch : This timezone is different from that of your browser.\nPlease, be mindful of this when setting the working hours or the time off.'}" />
|
||||
<field name="tz_offset" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Working Hours" name="working_hours">
|
||||
<field name="two_weeks_explanation" attrs="{'invisible':[('two_weeks_calendar', '=', False)]}"/>
|
||||
<field name="attendance_ids" widget="section_one2many"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_resource_calendar_tree" model="ir.ui.view">
|
||||
<field name="name">resource.calendar.tree</field>
|
||||
<field name="model">resource.calendar</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Working Time">
|
||||
<field name="name" string="Working Time"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_resource_calendar_form" model="ir.actions.act_window">
|
||||
<field name="name">Working Times</field>
|
||||
<field name="res_model">resource.calendar</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" eval="False"/>
|
||||
<field name="search_view_id" ref="view_resource_calendar_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Define working hours and time table that could be scheduled to your project members
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- RESOURCE.RESOURCE -->
|
||||
<record id="view_resource_resource_search" model="ir.ui.view">
|
||||
<field name="name">resource.resource.search</field>
|
||||
<field name="model">resource.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Resource">
|
||||
<field name="name" />
|
||||
<field name="resource_type"/>
|
||||
<field name="user_id"/>
|
||||
<field name="calendar_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<filter string="Human" name="human" domain="[('resource_type','=', 'user')]"/>
|
||||
<filter string="Material" name="material" domain="[('resource_type','=', 'material')]"/>
|
||||
<separator />
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="User" name="user" domain="[]" context="{'group_by':'user_id'}"/>
|
||||
<filter string="Type" name="type" domain="[]" context="{'group_by':'resource_type'}"/>
|
||||
<filter string="Company" name="company" domain="[]" context="{'group_by':'company_id'}" groups="base.group_multi_company"/>
|
||||
<filter string="Working Time" name="working_period" domain="[]" context="{'group_by':'calendar_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="resource_resource_form" model="ir.ui.view">
|
||||
<field name="name">resource.resource.form</field>
|
||||
<field name="model">resource.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Resource">
|
||||
<sheet>
|
||||
<field name="active" invisible="1" />
|
||||
<field name="company_id" invisible="1"/>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
|
||||
<group>
|
||||
<group name="user_details">
|
||||
<field name="name"/>
|
||||
<field name="user_id" attrs="{'required':[('resource_type','=','user')], 'invisible': [('resource_type','=','material')]}"/>
|
||||
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
|
||||
<field name="resource_type" />
|
||||
</group>
|
||||
<group name="resource_details">
|
||||
<field name="calendar_id"/>
|
||||
<field name="tz"/>
|
||||
<field name="time_efficiency"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="resource_resource_tree" model="ir.ui.view">
|
||||
<field name="name">resource.resource.tree</field>
|
||||
<field name="model">resource.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Resources" multi_edit="1" default_order="name">
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="name" />
|
||||
<field name="user_id" />
|
||||
<field name="company_id" groups="base.group_multi_company" optional="show"/>
|
||||
<field name="calendar_id" optional="show" />
|
||||
<field name="tz" optional="hide" />
|
||||
<field name="resource_type" optional="show" />
|
||||
<field name="time_efficiency"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_resource_resource_tree" model="ir.actions.act_window">
|
||||
<field name="name">Resources</field>
|
||||
<field name="res_model">resource.resource</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{}</field>
|
||||
<field name="search_view_id" ref="view_resource_resource_search"/>
|
||||
<field name="help">Resources allow you to create and manage resources that should be involved in a specific project phase. You can also set their efficiency level and workload based on their weekly working hours.</field>
|
||||
</record>
|
||||
|
||||
<record id="resource_resource_action_from_calendar" model="ir.actions.act_window">
|
||||
<field name="name">Resources</field>
|
||||
<field name="res_model">resource.resource</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{
|
||||
'default_calendar_id': active_id,
|
||||
'search_default_calendar_id': active_id}</field>
|
||||
<field name="search_view_id" ref="view_resource_resource_search"/>
|
||||
<field name="help">Resources allow you to create and manage resources that should be involved in a specific project phase. You can also set their efficiency level and workload based on their weekly working hours.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -7,7 +7,7 @@ from pytz import timezone, utc
|
||||
|
||||
from odoo import fields
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.addons.resource.models.resource import Intervals, sum_intervals
|
||||
from odoo.addons.resource.models.utils import Intervals, sum_intervals
|
||||
from odoo.addons.test_resource.tests.common import TestResourceCommon
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from pytz import timezone, utc
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.http_routing.models.ir_http import slug
|
||||
from odoo.addons.resource.models.resource import float_to_time
|
||||
from odoo.addons.resource.models.utils import float_to_time
|
||||
from odoo.modules.module import get_resource_path
|
||||
from odoo.tools import is_html_empty
|
||||
from odoo.tools.translate import html_translate
|
||||
|
||||
Reference in New Issue
Block a user