From 2eda54348de1bd42fc2a1bed94cd8b7a3ebf405d Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Thu, 15 Dec 2022 13:39:39 +0100 Subject: [PATCH 1/7] [IMP] 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. TaskID: 3102363 --- addons/hr_contract/tests/test_resource.py | 5 +++++ addons/hr_holidays/models/hr_leave.py | 6 +++++- .../tests/test_accrual_allocations.py | 7 +++++++ .../tests/test_automatic_leave_dates.py | 14 +++++++++++++ .../hr_holidays/tests/test_company_leave.py | 3 +++ .../hr_holidays/tests/test_global_leaves.py | 5 +++++ .../tests/test_holidays_calendar.py | 6 +++--- .../hr_holidays/tests/test_leave_requests.py | 9 +++++++++ addons/hr_work_entry_holidays/tests/common.py | 5 +++++ .../tests/test_timesheet_global_time_off.py | 20 +++++++++++-------- addons/resource/data/resource_data.xml | 10 ++++++++++ addons/resource/models/resource.py | 20 +++++++++++++++---- 12 files changed, 94 insertions(+), 16 deletions(-) diff --git a/addons/hr_contract/tests/test_resource.py b/addons/hr_contract/tests/test_resource.py index 108a688841e..d81152edb3c 100644 --- a/addons/hr_contract/tests/test_resource.py +++ b/addons/hr_contract/tests/test_resource.py @@ -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'}) ], }) diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index a6c2255d3d7..625fc331886 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -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'], diff --git a/addons/hr_holidays/tests/test_accrual_allocations.py b/addons/hr_holidays/tests/test_accrual_allocations.py index f719709cc8f..96e58fd312b 100644 --- a/addons/hr_holidays/tests/test_accrual_allocations.py +++ b/addons/hr_holidays/tests/test_accrual_allocations.py @@ -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, diff --git a/addons/hr_holidays/tests/test_automatic_leave_dates.py b/addons/hr_holidays/tests/test_automatic_leave_dates.py index 2c47d3d95a6..275aaa0a0a2 100644 --- a/addons/hr_holidays/tests/test_automatic_leave_dates.py +++ b/addons/hr_holidays/tests/test_automatic_leave_dates.py @@ -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, diff --git a/addons/hr_holidays/tests/test_company_leave.py b/addons/hr_holidays/tests/test_company_leave.py index f13e296981d..2fddbf66d7f 100644 --- a/addons/hr_holidays/tests/test_company_leave.py +++ b/addons/hr_holidays/tests/test_company_leave.py @@ -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'}) ]}) diff --git a/addons/hr_holidays/tests/test_global_leaves.py b/addons/hr_holidays/tests/test_global_leaves.py index 09ee427455c..b7c21840278 100644 --- a/addons/hr_holidays/tests/test_global_leaves.py +++ b/addons/hr_holidays/tests/test_global_leaves.py @@ -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'}) ] }) diff --git a/addons/hr_holidays/tests/test_holidays_calendar.py b/addons/hr_holidays/tests/test_holidays_calendar.py index ae223e82b22..c48db97460f 100644 --- a/addons/hr_holidays/tests/test_holidays_calendar.py +++ b/addons/hr_holidays/tests/test_holidays_calendar.py @@ -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') diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py index fa68ea0ab91..0b5fb4c5989 100644 --- a/addons/hr_holidays/tests/test_leave_requests.py +++ b/addons/hr_holidays/tests/test_leave_requests.py @@ -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, { diff --git a/addons/hr_work_entry_holidays/tests/common.py b/addons/hr_work_entry_holidays/tests/common.py index 8d3be5a1584..0419daa6196 100644 --- a/addons/hr_work_entry_holidays/tests/common.py +++ b/addons/hr_work_entry_holidays/tests/common.py @@ -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'}) ] }) diff --git a/addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py b/addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py index 814f35d9903..ca255df3c14 100644 --- a/addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py +++ b/addons/project_timesheet_holidays/tests/test_timesheet_global_time_off.py @@ -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({ diff --git a/addons/resource/data/resource_data.xml b/addons/resource/data/resource_data.xml index 2563fb17706..94d7dddaff2 100644 --- a/addons/resource/data/resource_data.xml +++ b/addons/resource/data/resource_data.xml @@ -27,14 +27,19 @@ @@ -46,14 +51,19 @@ diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 9e6af3fb477..a29e00dd634 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -63,7 +63,7 @@ def _boundaries(intervals, opening, closing): 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`` @@ -183,14 +183,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 @@ -280,13 +285,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 @@ -400,6 +408,7 @@ class ResourceCalendar(models.Model): ('calendar_id', '=', self.id), ('resource_id', 'in', resource_ids), ('display_type', '=', False), + ('day_period', '!=', 'lunch'), ]]) attendances = self.env['resource.calendar.attendance'].search(domain) @@ -826,7 +835,7 @@ 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()) @@ -853,7 +862,10 @@ class ResourceCalendarAttendance(models.Model): "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') + 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'), From 7bce5f3f95429a4d4ba034a66c350ee2a5868567 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Mon, 19 Dec 2022 09:14:42 +0100 Subject: [PATCH 2/7] [MOV] resource: Split files according to coding guidelines --- addons/hr/models/resource.py | 3 - addons/hr/tests/test_resource.py | 7 +- .../hr_contract/models/resource_resource.py | 2 +- addons/hr_contract/tests/test_resource.py | 2 +- addons/hr_holidays/models/hr_employee.py | 2 +- addons/hr_holidays/models/hr_leave.py | 2 +- .../hr_holidays/models/hr_leave_allocation.py | 2 +- addons/hr_holidays/models/hr_leave_type.py | 2 +- .../models/hr_contract.py | 6 +- addons/mrp/models/mrp_workcenter.py | 2 +- addons/resource/__manifest__.py | 6 +- addons/resource/models/__init__.py | 7 +- .../{resource.py => resource_calendar.py} | 470 +----------------- .../models/resource_calendar_attendance.py | 89 ++++ .../models/resource_calendar_leaves.py | 70 +++ addons/resource/models/resource_mixin.py | 10 +- addons/resource/models/resource_resource.py | 195 ++++++++ addons/resource/models/utils.py | 137 +++++ addons/resource/views/menuitems.xml | 18 + .../resource_calendar_attendance_views.xml | 45 ++ .../views/resource_calendar_leaves_views.xml | 110 ++++ .../views/resource_calendar_views.xml | 84 ++++ .../views/resource_resource_views.xml | 92 ++++ addons/test_resource/tests/test_resource.py | 2 +- .../models/event_sponsor.py | 2 +- 25 files changed, 877 insertions(+), 490 deletions(-) rename addons/resource/models/{resource.py => resource_calendar.py} (61%) create mode 100644 addons/resource/models/resource_calendar_attendance.py create mode 100644 addons/resource/models/resource_calendar_leaves.py create mode 100644 addons/resource/models/resource_resource.py create mode 100644 addons/resource/views/menuitems.xml create mode 100644 addons/resource/views/resource_calendar_attendance_views.xml create mode 100644 addons/resource/views/resource_calendar_leaves_views.xml create mode 100644 addons/resource/views/resource_calendar_views.xml create mode 100644 addons/resource/views/resource_resource_views.xml diff --git a/addons/hr/models/resource.py b/addons/hr/models/resource.py index 64b5f52db31..05af1c176cf 100644 --- a/addons/hr/models/resource.py +++ b/addons/hr/models/resource.py @@ -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): diff --git a/addons/hr/tests/test_resource.py b/addons/hr/tests/test_resource.py index 19074b3f75b..82fabb58554 100644 --- a/addons/hr/tests/test_resource.py +++ b/addons/hr/tests/test_resource.py @@ -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 diff --git a/addons/hr_contract/models/resource_resource.py b/addons/hr_contract/models/resource_resource.py index 953106496aa..c2c9f9fb2c9 100644 --- a/addons/hr_contract/models/resource_resource.py +++ b/addons/hr_contract/models/resource_resource.py @@ -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' diff --git a/addons/hr_contract/tests/test_resource.py b/addons/hr_contract/tests/test_resource.py index d81152edb3c..3549124aeed 100644 --- a/addons/hr_contract/tests/test_resource.py +++ b/addons/hr_contract/tests/test_resource.py @@ -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 diff --git a/addons/hr_holidays/models/hr_employee.py b/addons/hr_holidays/models/hr_employee.py index 0e2e065c419..16a318988a2 100644 --- a/addons/hr_holidays/models/hr_employee.py +++ b/addons/hr_holidays/models/hr_employee.py @@ -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): diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 625fc331886..0d63291c8ef 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -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 diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py index ac1c5cf4ad0..16731bc551a 100644 --- a/addons/hr_holidays/models/hr_leave_allocation.py +++ b/addons/hr_holidays/models/hr_leave_allocation.py @@ -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 _ diff --git a/addons/hr_holidays/models/hr_leave_type.py b/addons/hr_holidays/models/hr_leave_type.py index c764b8a6a0e..437df216546 100644 --- a/addons/hr_holidays/models/hr_leave_type.py +++ b/addons/hr_holidays/models/hr_leave_type.py @@ -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__) diff --git a/addons/hr_work_entry_contract/models/hr_contract.py b/addons/hr_work_entry_contract/models/hr_contract.py index 7b620c24e23..ac317ea7158 100644 --- a/addons/hr_work_entry_contract/models/hr_contract.py +++ b/addons/hr_work_entry_contract/models/hr_contract.py @@ -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 diff --git a/addons/mrp/models/mrp_workcenter.py b/addons/mrp/models/mrp_workcenter.py index e2e6f6b21ad..d402dee5eef 100644 --- a/addons/mrp/models/mrp_workcenter.py +++ b/addons/mrp/models/mrp_workcenter.py @@ -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 diff --git a/addons/resource/__manifest__.py b/addons/resource/__manifest__.py index 2d310c58a7a..1cb2807e6e7 100644 --- a/addons/resource/__manifest__.py +++ b/addons/resource/__manifest__.py @@ -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': [ diff --git a/addons/resource/models/__init__.py b/addons/resource/models/__init__.py index 43ace13cef0..9778b6b83ca 100644 --- a/addons/resource/models/__init__.py +++ b/addons/resource/models/__init__.py @@ -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 diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource_calendar.py similarity index 61% rename from addons/resource/models/resource.py rename to addons/resource/models/resource_calendar.py index a29e00dd634..1449499e522 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource_calendar.py @@ -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, - } diff --git a/addons/resource/models/resource_calendar_attendance.py b/addons/resource/models/resource_calendar_attendance.py new file mode 100644 index 00000000000..859673f5f8d --- /dev/null +++ b/addons/resource/models/resource_calendar_attendance.py @@ -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, + } diff --git a/addons/resource/models/resource_calendar_leaves.py b/addons/resource/models/resource_calendar_leaves.py new file mode 100644 index 00000000000..4a20960e7f5 --- /dev/null +++ b/addons/resource/models/resource_calendar_leaves.py @@ -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, + } diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index 934ce18260a..8b45c405442 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -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): """ diff --git a/addons/resource/models/resource_resource.py b/addons/resource/models/resource_resource.py new file mode 100644 index 00000000000..ed86c885564 --- /dev/null +++ b/addons/resource/models/resource_resource.py @@ -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 diff --git a/addons/resource/models/utils.py b/addons/resource/models/utils.py index 8db355daeba..8cce56d4afa 100644 --- a/addons/resource/models/utils.py +++ b/addons/resource/models/utils.py @@ -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 diff --git a/addons/resource/views/menuitems.xml b/addons/resource/views/menuitems.xml new file mode 100644 index 00000000000..44953339ff8 --- /dev/null +++ b/addons/resource/views/menuitems.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/addons/resource/views/resource_calendar_attendance_views.xml b/addons/resource/views/resource_calendar_attendance_views.xml new file mode 100644 index 00000000000..d9c53eac6a3 --- /dev/null +++ b/addons/resource/views/resource_calendar_attendance_views.xml @@ -0,0 +1,45 @@ + + + + resource.calendar.attendance.tree + resource.calendar.attendance + + + + + + + + + + + + + + + + + + + resource.calendar.attendance.form + resource.calendar.attendance + +
+ + + + + + + + +
+
+
+
diff --git a/addons/resource/views/resource_calendar_leaves_views.xml b/addons/resource/views/resource_calendar_leaves_views.xml new file mode 100644 index 00000000000..4027b1ef843 --- /dev/null +++ b/addons/resource/views/resource_calendar_leaves_views.xml @@ -0,0 +1,110 @@ + + + + resource.calendar.leaves.search + resource.calendar.leaves + + + + + + + + + + + + + + + + + + resource.calendar.leaves.calendar + resource.calendar.leaves + + + + + + + + + + + resource.calendar.leaves.form + resource.calendar.leaves + +
+ + + + + + + + + + + + + + + + +
+
+ + + resource.calendar.leaves.tree + resource.calendar.leaves + 1 + + + + + + + + + + + + + + Resource Time Off + resource.calendar.leaves + tree,form,calendar + + + + + Resource Time Off + resource.calendar.leaves + tree,form,calendar + { + 'default_calendar_id': active_id, + 'search_default_calendar_id': active_id} + + + + + Closing Days + resource.calendar.leaves + calendar,tree,form + [('calendar_id','=',active_id), ('resource_id','=',False)] + {'default_calendar_id': active_id} + + form + + + + Resources Time Off + resource.calendar.leaves + calendar,tree,form + [('calendar_id','=',active_id), ('resource_id','!=',False)] + {'default_calendar_id': active_id} + + form + +
diff --git a/addons/resource/views/resource_calendar_views.xml b/addons/resource/views/resource_calendar_views.xml new file mode 100644 index 00000000000..2c918523153 --- /dev/null +++ b/addons/resource/views/resource_calendar_views.xml @@ -0,0 +1,84 @@ + + + + resource.calendar.search + resource.calendar + + + + + + + + + + + + resource.calendar.form + resource.calendar + +
+ +
+
+ + +
+
+

+ +

+ + + + + + + + + + + + + + + +
+ +
+
+ + + resource.calendar.tree + resource.calendar + + + + + + + + + + Working Times + resource.calendar + tree,form + + + +

+ Define working hours and time table that could be scheduled to your project members +

+
+
+
diff --git a/addons/resource/views/resource_resource_views.xml b/addons/resource/views/resource_resource_views.xml new file mode 100644 index 00000000000..b54ac282f28 --- /dev/null +++ b/addons/resource/views/resource_resource_views.xml @@ -0,0 +1,92 @@ + + + + + + resource.resource.search + resource.resource + + + + + + + + + + + + + + + + + + + + + + + resource.resource.form + resource.resource + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + resource.resource.tree + resource.resource + + + + + + + + + + + + + + + + Resources + resource.resource + tree,form + {} + + 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. + + + + Resources + resource.resource + tree,form + { + 'default_calendar_id': active_id, + 'search_default_calendar_id': active_id} + + 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. + +
diff --git a/addons/test_resource/tests/test_resource.py b/addons/test_resource/tests/test_resource.py index e47a7cf8e7e..03cb1e1f311 100644 --- a/addons/test_resource/tests/test_resource.py +++ b/addons/test_resource/tests/test_resource.py @@ -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 diff --git a/addons/website_event_exhibitor/models/event_sponsor.py b/addons/website_event_exhibitor/models/event_sponsor.py index 0f70e8b3621..293854d1646 100644 --- a/addons/website_event_exhibitor/models/event_sponsor.py +++ b/addons/website_event_exhibitor/models/event_sponsor.py @@ -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 From 5b6b1e0b18bc946f6a40ecabebdf04dc3df107d5 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Wed, 21 Dec 2022 11:46:48 +0100 Subject: [PATCH 3/7] [IMP] hr_work_entry: Add overtime work entry type --- addons/hr_work_entry/data/hr_work_entry_data.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/addons/hr_work_entry/data/hr_work_entry_data.xml b/addons/hr_work_entry/data/hr_work_entry_data.xml index f4d2a6fb1fd..d4dd187ccdd 100644 --- a/addons/hr_work_entry/data/hr_work_entry_data.xml +++ b/addons/hr_work_entry/data/hr_work_entry_data.xml @@ -9,5 +9,10 @@ WORK100 + + Overtime Hours + 4 + OVERTIME + From 2e4f399e90bf01c893aa92105443afb74942238c Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Tue, 13 Dec 2022 14:13:59 +0100 Subject: [PATCH 4/7] [IMP] hr_work_entry_contract: Add method to ease inheritance --- .../models/hr_contract.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/addons/hr_work_entry_contract/models/hr_contract.py b/addons/hr_work_entry_contract/models/hr_contract.py index ac317ea7158..d3c4eff144f 100644 --- a/addons/hr_work_entry_contract/models/hr_contract.py +++ b/addons/hr_work_entry_contract/models/hr_contract.py @@ -90,6 +90,16 @@ class HrContract(models.Model): )) 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._get_default_work_entry_type() + + 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 +148,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 +190,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)), From 394014f6791f47e11935f5b3f53718bc220104ad Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Wed, 21 Dec 2022 13:56:55 +0100 Subject: [PATCH 5/7] [IMP] hr_work_entry_contract: Add method to retrieve lunch intervals --- .../hr_work_entry_contract/models/hr_contract.py | 16 ++++++++++++++++ addons/resource/models/resource_calendar.py | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/addons/hr_work_entry_contract/models/hr_contract.py b/addons/hr_work_entry_contract/models/hr_contract.py index d3c4eff144f..bcbe9701d51 100644 --- a/addons/hr_work_entry_contract/models/hr_contract.py +++ b/addons/hr_work_entry_contract/models/hr_contract.py @@ -90,6 +90,22 @@ 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]: diff --git a/addons/resource/models/resource_calendar.py b/addons/resource/models/resource_calendar.py index 1449499e522..16fcfee5068 100644 --- a/addons/resource/models/resource_calendar.py +++ b/addons/resource/models/resource_calendar.py @@ -269,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() @@ -284,7 +284,7 @@ class ResourceCalendar(models.Model): ('calendar_id', '=', self.id), ('resource_id', 'in', resource_ids), ('display_type', '=', False), - ('day_period', '!=', 'lunch'), + ('day_period', '!=' if not lunch else '=', 'lunch'), ]]) attendances = self.env['resource.calendar.attendance'].search(domain) From 3bdfbe31c026b55179a8b505334433827a5c4623 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Wed, 21 Dec 2022 15:27:43 +0100 Subject: [PATCH 6/7] [IMP] hr_attendance: Take lunch time into account on work hours count --- addons/hr_attendance/models/hr_attendance.py | 21 ++++++++++++++++--- .../tests/test_timesheet_attendance.py | 4 ++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/addons/hr_attendance/models/hr_attendance.py b/addons/hr_attendance/models/hr_attendance.py index a38aef128d9..5584833c98c 100644 --- a/addons/hr_attendance/models/hr_attendance.py +++ b/addons/hr_attendance/models/hr_attendance.py @@ -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 diff --git a/addons/hr_timesheet_attendance/tests/test_timesheet_attendance.py b/addons/hr_timesheet_attendance/tests/test_timesheet_attendance.py index 775edb5b603..699e7445bc5 100644 --- a/addons/hr_timesheet_attendance/tests/test_timesheet_attendance.py +++ b/addons/hr_timesheet_attendance/tests/test_timesheet_attendance.py @@ -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) From 3ee6b735fe6d69968c53a1a0a210ccc255c89850 Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Wed, 21 Dec 2022 16:32:10 +0100 Subject: [PATCH 7/7] [FIX] hr_work_entry_contract: Improve work entries generation perf --- addons/hr_work_entry_contract/models/hr_contract.py | 9 ++++++--- .../hr_work_entry_contract/tests/test_global_time_off.py | 2 +- addons/hr_work_entry_holidays/tests/test_performance.py | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/addons/hr_work_entry_contract/models/hr_contract.py b/addons/hr_work_entry_contract/models/hr_contract.py index bcbe9701d51..fb0b75e5d4f 100644 --- a/addons/hr_work_entry_contract/models/hr_contract.py +++ b/addons/hr_work_entry_contract/models/hr_contract.py @@ -11,6 +11,7 @@ from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ 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) @@ -110,7 +113,7 @@ class HrContract(models.Model): 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._get_default_work_entry_type() + 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() diff --git a/addons/hr_work_entry_contract/tests/test_global_time_off.py b/addons/hr_work_entry_contract/tests/test_global_time_off.py index c4b7f925b14..bd388a0c04f 100644 --- a/addons/hr_work_entry_contract/tests/test_global_time_off.py +++ b/addons/hr_work_entry_contract/tests/test_global_time_off.py @@ -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 diff --git a/addons/hr_work_entry_holidays/tests/test_performance.py b/addons/hr_work_entry_holidays/tests/test_performance.py index 2b1844012ae..461a907e964 100644 --- a/addons/hr_work_entry_holidays/tests/test_performance.py +++ b/addons/hr_work_entry_holidays/tests/test_performance.py @@ -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