[MERGE] resource: Add lunch attendance type

Purpose
=======

Add a new type on the working schedule, adding "Lunch" to Morning and Afternoon. It's mandatory, because, if you don't do that, you can't assume the difference between a time that should be recorded as extra hours and a time that should not be counted because it's lunch time.

A company is not another, so to be clear, they will define what are the imposed lunch time where the employee is not paid during the week.

Specification
=============

Lunch between Morning and Afternoon

When a time period is under Lunch :
- There is no working entry generated for that period of time.
- If you work before the lunch time and after it, it's not considered as extra time.
- The employee is not paid for the Lunch time.

closes odoo/odoo#108061

Taskid: 3102363
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-02-03 12:55:39 +01:00
committed by GitHub
39 changed files with 1034 additions and 517 deletions
-3
View File
@@ -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):
+4 -3
View File
@@ -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
+18 -3
View File
@@ -1,12 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import pytz
from collections import defaultdict
from datetime import datetime, timedelta
from operator import itemgetter
from pytz import timezone
import pytz
from odoo import models, fields, api, exceptions, _
from odoo.addons.resource.models.utils import Intervals
from odoo.tools import format_datetime
from odoo.osv.expression import AND, OR
from odoo.tools.float_utils import float_is_zero
@@ -43,12 +46,24 @@ class HrAttendance(models.Model):
}))
return result
def _get_employee_calendar(self):
self.ensure_one()
return self.employee_id.resource_calendar_id
@api.depends('check_in', 'check_out')
def _compute_worked_hours(self):
for attendance in self:
if attendance.check_out and attendance.check_in:
delta = attendance.check_out - attendance.check_in
attendance.worked_hours = delta.total_seconds() / 3600.0
calendar = attendance._get_employee_calendar()
resource = attendance.employee_id.resource_id
tz = timezone(calendar.tz)
check_in_tz = attendance.check_in.astimezone(tz)
check_out_tz = attendance.check_out.astimezone(tz)
lunch_intervals = calendar._attendance_intervals_batch(
check_in_tz, check_out_tz, resource, lunch=True)
attendance_intervals = Intervals([(check_in_tz, check_out_tz, attendance)]) - lunch_intervals[resource.id]
delta = sum((i[1] - i[0]).total_seconds() for i in attendance_intervals)
attendance.worked_hours = delta / 3600.0
else:
attendance.worked_hours = False
@@ -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'
+6 -1
View File
@@ -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
@@ -20,14 +20,19 @@ class TestResource(TestContractCommon):
'name': '35h calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Evening', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Evening', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Evening', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Evening', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Evening', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'})
],
})
+1 -1
View File
@@ -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):
+6 -2
View File
@@ -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
@@ -1692,7 +1692,11 @@ class HolidaysRequest(models.Model):
def _get_attendances(self, employee, request_date_from, request_date_to):
resource_calendar_id = employee.resource_calendar_id or self.env.company.resource_calendar_id
domain = [('calendar_id', '=', resource_calendar_id.id), ('display_type', '=', False)]
domain = [
('calendar_id', '=', resource_calendar_id.id),
('display_type', '=', False),
('day_period', '!=', 'lunch'),
]
attendances = self.env['resource.calendar.attendance'].read_group(domain,
['ids:array_agg(id)', 'hour_from:min(hour_from)', 'hour_to:max(hour_to)',
'week_type', 'dayofweek', 'day_period'],
@@ -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 _
+1 -1
View File
@@ -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__)
@@ -295,6 +295,13 @@ class TestAccrualAllocations(TestHrHolidaysCommon):
'dayofweek': str(index),
'day_period': 'morning'
}))
attendances.append((0, 0, {
'name': '%s_%d' % ('40 Hours', index),
'hour_from': 12,
'hour_to': 13,
'dayofweek': str(index),
'day_period': 'lunch'
}))
attendances.append((0, 0, {
'name': '%s_%d' % ('40 Hours', index),
'hour_from': 13,
@@ -49,6 +49,13 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon):
'day_period': 'morning',
'dayofweek': '0',
}),
(0, 0, {
'name': 'monday lunch',
'hour_from': 12,
'hour_to': 13,
'day_period': 'lunch',
'dayofweek': '0',
}),
(0, 0, {
'name': 'monday afternoon',
'hour_from': 13,
@@ -94,6 +101,13 @@ class TestAutomaticLeaveDates(TestHrHolidaysCommon):
'day_period': 'morning',
'dayofweek': '0',
}),
(0, 0, {
'name': 'monday lunch',
'hour_from': 12.25,
'hour_to': 13,
'day_period': 'lunch',
'dayofweek': '0',
}),
(0, 0, {
'name': 'monday afternoon',
'hour_from': 13,
@@ -241,10 +241,13 @@ class TestCompanyLeave(TransactionCase):
self.employee.resource_calendar_id.write({'attendance_ids': [
(5, 0, 0),
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'})
]})
@@ -20,14 +20,19 @@ class TestGlobalLeaves(TestHrHolidaysCommon):
'hours_per_day': 8.0,
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'})
]
})
@@ -8,7 +8,7 @@ from odoo.tests.common import users
from odoo.addons.hr_holidays.tests.common import TestHrHolidaysCommon
@tagged('post_install', '-at_install')
@tagged('post_install', '-at_install', 'holiday_calendar')
class TestHolidaysCalendar(HttpCase, TestHrHolidaysCommon):
@users('admin')
@@ -19,8 +19,8 @@ class TestHolidaysCalendar(HttpCase, TestHrHolidaysCommon):
"""
self.env.user.tz = 'UTC'
calendar = self.env.user.employee_id.resource_calendar_id.attendance_ids
expected_start_thursday = calendar[6].hour_from
expected_end_thursday = calendar[7].hour_to
expected_start_thursday = calendar[9].hour_from
expected_end_thursday = calendar[11].hour_to
self.start_tour('/', 'time_off_request_calendar_view', login='admin')
@@ -336,12 +336,16 @@ class TestLeaveRequests(TestHrHolidaysCommon):
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8.5, 'hour_to': 12.5, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8.5, 'hour_to': 12.5, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12.5, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17.5, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8.5, 'hour_to': 12.5, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12.5, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17.5, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8.5, 'hour_to': 12.5, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12.5, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17.5, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8.5, 'hour_to': 12.5, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12.5, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17.5, 'day_period': 'afternoon'})
],
})
@@ -403,14 +407,19 @@ class TestLeaveRequests(TestHrHolidaysCommon):
'hours_per_day': 8.0,
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'})
],
'global_leave_ids': [(0, 0, {
@@ -31,5 +31,5 @@ class TestTimesheetAttendance(TestCommonTimesheet):
['user_id'],
)[0]
self.assertEqual(report_data['total_timesheet'], 6.0, "Total timesheet in report should be 4.0")
self.assertEqual(report_data['total_attendance'], 8.0, "Total attendance in report should be 8.0")
self.assertEqual(report_data['total_attendance'] - report_data['total_timesheet'], 2)
self.assertEqual(report_data['total_attendance'], 7.0, "Total attendance in report should be 8.0")
self.assertEqual(report_data['total_attendance'] - report_data['total_timesheet'], 1)
@@ -9,5 +9,10 @@
<field name="code">WORK100</field>
</record>
<record id="overtime_work_entry_type" model="hr.work.entry.type">
<field name="name">Overtime Hours</field>
<field name="color">4</field>
<field name="code">OVERTIME</field>
</record>
</data>
</odoo>
@@ -2,15 +2,16 @@
# 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.tools import ormcache
from odoo.exceptions import UserError
@@ -32,8 +33,10 @@ class HrContract(models.Model):
'''
)
def _get_default_work_entry_type(self):
return self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
@ormcache('self.structure_type_id')
def _get_default_work_entry_type_id(self):
attendance = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
return attendance.id if attendance else False
def _get_leave_work_entry_type_dates(self, leave, date_from, date_to, employee):
return self._get_leave_work_entry_type(leave)
@@ -90,6 +93,32 @@ class HrContract(models.Model):
))
return result
def _get_lunch_intervals(self, start_dt, end_dt):
# {resource: intervals}
employees_by_calendar = defaultdict(lambda: self.env['hr.employee'])
for contract in self:
employees_by_calendar[contract.resource_calendar_id] |= contract.employee_id
result = {}
for calendar, employees in employees_by_calendar.items():
result.update(calendar._attendance_intervals_batch(
start_dt,
end_dt,
resources=employees.resource_id,
tz=pytz.timezone(calendar.tz),
lunch=True,
))
return result
def _get_interval_work_entry_type(self, interval):
self.ensure_one()
if 'work_entry_type_id' in interval[2] and interval[2].work_entry_type_id[:1]:
return interval[2].work_entry_type_id[:1]
return self.env['hr.work.entry.type'].browse(self._get_default_work_entry_type_id())
def _get_valid_leave_intervals(self, attendances, interval):
self.ensure_one()
return [interval]
def _get_contract_work_entries_values(self, date_start, date_stop):
start_dt = pytz.utc.localize(date_start) if not date_start.tzinfo else date_start
end_dt = pytz.utc.localize(date_stop) if not date_stop.tzinfo else date_stop
@@ -138,7 +167,12 @@ class HrContract(models.Model):
tz_dates[(tz, end_dt)] = end
dt0 = string_to_datetime(leave.date_from).astimezone(tz)
dt1 = string_to_datetime(leave.date_to).astimezone(tz)
result[resource.id].append((max(start, dt0), min(end, dt1), leave))
leave_start_dt = max(start, dt0)
leave_end_dt = min(end, dt1)
leave_interval = (leave_start_dt, leave_end_dt, leave)
leave_interval = contract._get_valid_leave_intervals(attendances, leave_interval)
if leave_interval:
result[resource.id] += leave_interval
mapped_leaves = {r.id: Intervals(result[r.id]) for r in resources_list}
leaves = mapped_leaves[resource.id]
@@ -175,10 +209,8 @@ class HrContract(models.Model):
leaves = split_leaves
# Attendances
default_work_entry_type = contract._get_default_work_entry_type()
for interval in real_attendances:
work_entry_type = 'work_entry_type_id' in interval[2] and interval[2].work_entry_type_id[:1]\
or default_work_entry_type
work_entry_type = contract._get_interval_work_entry_type(interval)
# All benefits generated here are using datetimes converted from the employee's timezone
contract_vals += [dict([
('name', "%s: %s" % (work_entry_type.name, employee.name)),
@@ -29,7 +29,7 @@ class TestGlobalTimeOff(TestWorkEntryBase):
contract.date_generated_from = start
contract.date_generated_to = start
work_entries = contract._generate_work_entries(start, end)
self.assertEqual(work_entries.work_entry_type_id, contract._get_default_work_entry_type())
self.assertEqual(work_entries.work_entry_type_id.id, contract._get_default_work_entry_type_id())
work_entries.unlink()
contract.date_generated_from = start
contract.date_generated_to = start
@@ -36,14 +36,19 @@ class TestWorkEntryHolidaysBase(TestWorkEntryBase):
'name': '35h calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Evening', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Evening', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Evening', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Evening', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Evening', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'})
]
})
@@ -6,7 +6,7 @@ from odoo.addons.hr_work_entry_holidays.tests.common import TestWorkEntryHoliday
from odoo.tests.common import users, warmup, tagged
@tagged('work_entry_perf')
@tagged('work_entry_holidays_perf')
class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
@classmethod
@@ -32,7 +32,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2))
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
with self.assertQueryCount(__system__=91, admin=93):
with self.assertQueryCount(__system__=91, admin=95):
leave.action_validate()
leave.action_refuse()
@@ -112,7 +112,7 @@ class TestWorkEntryHolidaysPerformancesBigData(TestWorkEntryHolidaysBase):
def test_work_entries_generation_perf(self):
# Test Case 7: Try to generate work entries for
# a hundred employees over a month
with self.assertQueryCount(__system__=500, admin=2807):
with self.assertQueryCount(__system__=406, admin=406):
work_entries = self.contracts._generate_work_entries(date(2020, 7, 1), date(2020, 8, 31))
# Original work entries to generate when we don't adapt date_generated_from and
+1 -1
View File
@@ -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
@@ -21,14 +21,18 @@ class TestTimesheetGlobalTimeOff(common.TransactionCase):
})
attendance_ids = [
Command.create({'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
Command.create({'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
Command.create({'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
Command.create({'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
Command.create({'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'})
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'})
]
self.part_time_calendar = self.env['resource.calendar'].create({
+5 -1
View File
@@ -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': [
+10
View File
@@ -27,14 +27,19 @@
<field name="attendance_ids"
eval="[(5, 0, 0),
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16, 'day_period': 'afternoon'})
]"
/>
@@ -46,14 +51,19 @@
<field name="attendance_ids"
eval="[(5, 0, 0),
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Lunch', 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 16.6, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Lunch', 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 16.6, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Lunch', 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 16.6, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Lunch', 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 16.6, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Lunch', 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 16.6, 'day_period': 'afternoon'})
]"
/>
+5 -2
View File
@@ -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)
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'):
@@ -183,14 +59,19 @@ class ResourceCalendar(models.Model):
else:
res['attendance_ids'] = [
(0, 0, {'name': _('Monday Morning'), 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': _('Monday Lunch'), 'dayofweek': '0', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': _('Monday Afternoon'), 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': _('Tuesday Morning'), 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': _('Tuesday Lunch'), 'dayofweek': '1', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': _('Tuesday Afternoon'), 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': _('Wednesday Morning'), 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': _('Wednesday Lunch'), 'dayofweek': '2', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': _('Wednesday Afternoon'), 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': _('Thursday Morning'), 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': _('Thursday Lunch'), 'dayofweek': '3', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': _('Thursday Afternoon'), 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': _('Friday Morning'), 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': _('Friday Lunch'), 'dayofweek': '4', 'hour_from': 12, 'hour_to': 13, 'day_period': 'lunch'}),
(0, 0, {'name': _('Friday Afternoon'), 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'})
]
return res
@@ -258,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):
@@ -280,13 +161,16 @@ class ResourceCalendar(models.Model):
def _get_global_attendances(self):
return self.attendance_ids.filtered(lambda attendance:
not attendance.date_from and not attendance.date_to
attendance.day_period != 'lunch'
and not attendance.date_from and not attendance.date_to
and not attendance.resource_id and not attendance.display_type)
def _get_hours_per_day(self, attendances):
if not attendances:
return 0
attendances = attendances.filtered(lambda a: a.day_period != 'lunch')
hour_count = 0.0
for attendance in attendances:
hour_count += attendance.hour_to - attendance.hour_from
@@ -385,7 +269,7 @@ class ResourceCalendar(models.Model):
# Computation API
# --------------------------------------------------
def _attendance_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None):
def _attendance_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None, lunch=False):
assert start_dt.tzinfo and end_dt.tzinfo
self.ensure_one()
@@ -400,6 +284,7 @@ class ResourceCalendar(models.Model):
('calendar_id', '=', self.id),
('resource_id', 'in', resource_ids),
('display_type', '=', False),
('day_period', '!=' if not lunch else '=', 'lunch'),
]])
attendances = self.env['resource.calendar.attendance'].search(domain)
@@ -826,331 +711,6 @@ class ResourceCalendar(models.Model):
if not self.attendance_ids:
return 0
mapped_data = defaultdict(lambda: 0)
for attendance in self.attendance_ids.filtered(lambda a: (not a.date_from or not a.date_to) or (a.date_from <= end.date() and a.date_to >= start.date())):
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'), ('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 -8
View File
@@ -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):
"""
+195
View File
@@ -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
+137
View File
@@ -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
+18
View File
@@ -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>
+1 -1
View File
@@ -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