From c5f2031d728095890741659b48650addda79b819 Mon Sep 17 00:00:00 2001 From: Carmen Bianca BAKKER Date: Fri, 30 Aug 2024 13:10:05 +0200 Subject: [PATCH] fixup! WIP: Write implementation Signed-off-by: Carmen Bianca BAKKER --- resource_multi_week_calendar/__init__.py | 1 - resource_multi_week_calendar/__manifest__.py | 1 - resource_multi_week_calendar/hooks.py | 135 ------------------ .../models/resource_calendar.py | 57 ++++++-- .../tests/test_calendar.py | 88 ++++++++++++ 5 files changed, 130 insertions(+), 152 deletions(-) delete mode 100644 resource_multi_week_calendar/hooks.py diff --git a/resource_multi_week_calendar/__init__.py b/resource_multi_week_calendar/__init__.py index ecaeb671684..3eb78877c5b 100644 --- a/resource_multi_week_calendar/__init__.py +++ b/resource_multi_week_calendar/__init__.py @@ -3,4 +3,3 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from . import models -from .hooks import post_load_hook diff --git a/resource_multi_week_calendar/__manifest__.py b/resource_multi_week_calendar/__manifest__.py index a10e413e4f7..f1f018a28a9 100644 --- a/resource_multi_week_calendar/__manifest__.py +++ b/resource_multi_week_calendar/__manifest__.py @@ -19,5 +19,4 @@ "data": [ "views/resource_calendar_views.xml", ], - "post_load": "post_load_hook", } diff --git a/resource_multi_week_calendar/hooks.py b/resource_multi_week_calendar/hooks.py deleted file mode 100644 index 502e54563c4..00000000000 --- a/resource_multi_week_calendar/hooks.py +++ /dev/null @@ -1,135 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Coop IT Easy SC -# SPDX-FileCopyrightText: Odoo SA -# -# SPDX-License-Identifier: AGPL-3.0-or-later AND LGPL-3.0-or-later - -# flake8: noqa - -import datetime -import itertools -from collections import defaultdict - -from dateutil.rrule import DAILY, rrule -from pytz import timezone, utc - -from odoo.osv import expression - -from odoo.addons.resource.models.resource import ( - Intervals, - ResourceCalendar, - float_to_time, -) - - -def post_load_hook(): - if not hasattr(ResourceCalendar, "_attendance_intervals_batch_original"): - ResourceCalendar._attendance_intervals_batch_original = ( - ResourceCalendar._attendance_intervals_batch - ) - - # fmt: off - def _new_attendance_intervals_batch(self, start_dt, end_dt, resources=None, domain=None, tz=None): - assert start_dt.tzinfo and end_dt.tzinfo - self.ensure_one() - - if not resources: - resources = self.env['resource.resource'] - resources_list = [resources] - else: - resources_list = list(resources) + [self.env['resource.resource']] - resource_ids = [r.id for r in resources_list] - domain = domain if domain is not None else [] - domain = expression.AND([domain, [ - ('calendar_id', '=', self.id), - ('resource_id', 'in', resource_ids), - ('display_type', '=', False), - ]]) - - attendances = self.env['resource.calendar.attendance'].search(domain) - # Since we only have one calendar to take in account - # Group resources per tz they will all have the same result - resources_per_tz = defaultdict(list) - for resource in resources_list: - resources_per_tz[tz or timezone((resource or self).tz)].append(resource) - # Resource specific attendances - attendance_per_resource = defaultdict(lambda: self.env['resource.calendar.attendance']) - # Calendar attendances per day of the week - # * 7 days per week * 2 for two week calendars - attendances_per_day = [self.env['resource.calendar.attendance']] * 7 * 2 - weekdays = set() - for attendance in attendances: - if attendance.resource_id: - attendance_per_resource[attendance.resource_id] |= attendance - weekday = int(attendance.dayofweek) - weekdays.add(weekday) - if self.two_weeks_calendar: - weektype = int(attendance.week_type) - attendances_per_day[weekday + 7 * weektype] |= attendance - else: - attendances_per_day[weekday] |= attendance - attendances_per_day[weekday + 7] |= attendance - - start = start_dt.astimezone(utc) - end = end_dt.astimezone(utc) - bounds_per_tz = { - tz: (start_dt.astimezone(tz), end_dt.astimezone(tz)) - for tz in resources_per_tz.keys() - } - # Use the outer bounds from the requested timezones - for tz, bounds in bounds_per_tz.items(): - start = min(start, bounds[0].replace(tzinfo=utc)) - end = max(end, bounds[1].replace(tzinfo=utc)) - # Generate once with utc as timezone - days = rrule(DAILY, start.date(), until=end.date(), byweekday=weekdays) - ResourceCalendarAttendance = self.env['resource.calendar.attendance'] - base_result = [] - per_resource_result = defaultdict(list) - for day in days: - # begin change - if not self._day_in_calendar(day): - continue - # end change - week_type = ResourceCalendarAttendance.get_week_type(day) - attendances = attendances_per_day[day.weekday() + 7 * week_type] - for attendance in attendances: - if (attendance.date_from and day.date() < attendance.date_from) or\ - (attendance.date_to and attendance.date_to < day.date()): - continue - day_from = datetime.combine(day, float_to_time(attendance.hour_from)) - day_to = datetime.combine(day, float_to_time(attendance.hour_to)) - if attendance.resource_id: - per_resource_result[attendance.resource_id].append((day_from, day_to, attendance)) - else: - base_result.append((day_from, day_to, attendance)) - - # Copy the result localized once per necessary timezone - # Strictly speaking comparing start_dt < time or start_dt.astimezone(tz) < time - # should always yield the same result. however while working with dates it is easier - # if all dates have the same format - result_per_tz = { - tz: [(max(bounds_per_tz[tz][0], tz.localize(val[0])), - min(bounds_per_tz[tz][1], tz.localize(val[1])), - val[2]) - for val in base_result] - for tz in resources_per_tz.keys() - } - result_per_resource_id = dict() - for tz, resources in resources_per_tz.items(): - res = result_per_tz[tz] - res_intervals = Intervals(res) - for resource in resources: - if resource in per_resource_result: - resource_specific_result = [(max(bounds_per_tz[tz][0], tz.localize(val[0])), min(bounds_per_tz[tz][1], tz.localize(val[1])), val[2]) - for val in per_resource_result[resource]] - result_per_resource_id[resource.id] = Intervals(itertools.chain(res, resource_specific_result)) - else: - result_per_resource_id[resource.id] = res_intervals - return result_per_resource_id - - # fmt: on - - def _day_in_calendar(self, day): - return True - - ResourceCalendar._attendance_intervals_batch = _new_attendance_intervals_batch - ResourceCalendar._day_in_calendar = _day_in_calendar diff --git a/resource_multi_week_calendar/models/resource_calendar.py b/resource_multi_week_calendar/models/resource_calendar.py index f6cf94e3c4b..4d05e817861 100644 --- a/resource_multi_week_calendar/models/resource_calendar.py +++ b/resource_multi_week_calendar/models/resource_calendar.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import math -from datetime import timedelta +from datetime import datetime, timedelta from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -130,6 +130,8 @@ def _get_week_number(self, day=None): self.ensure_one() if day is None: day = fields.Date.today() + if isinstance(day, datetime): + day = day.date() family_size = len(self.family_calendar_ids) weeks_since_epoch = math.floor( (day - self._get_first_day_of_epoch_week()).days / 7 @@ -231,6 +233,27 @@ def _check_epoch_date_matches_parent(self): % calendar.name ) + @api.model + def _split_into_weeks(self, start_dt, end_dt): + current_start = start_dt + + while current_start <= end_dt: + # Calculate the end of the week (Sunday, 23:59:59) + days_until_sunday = 6 - current_start.weekday() + week_end = current_start + timedelta(days=days_until_sunday) + week_end = week_end.replace( + hour=23, minute=59, second=59, microsecond=999999 + ) + + current_end = min(week_end, end_dt) + yield (current_start, current_end) + + # Move to the next week (start of next Monday) + current_start = current_end + timedelta(days=1) + current_start = current_start.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + def _attendance_intervals_batch( self, start_dt, end_dt, resources=None, domain=None, tz=None ): @@ -239,37 +262,41 @@ def _attendance_intervals_batch( return super()._attendance_intervals_batch( start_dt, end_dt, resources=resources, domain=domain, tz=tz ) + if self.parent_calendar_id: return self.parent_calendar_id._attendance_intervals_batch( start_dt, end_dt, resources=resources, domain=domain, tz=tz ) - calendars = self | self.child_calendar_ids + calendars_by_week = { + calendar.week_number: calendar + for calendar in self | self.child_calendar_ids + } results = [] - for calendar in calendars: + + # Calculate each week separately, choosing the correct calendar for each + # week. + for week_start, week_end in self._split_into_weeks(start_dt, end_dt): results.append( super( ResourceCalendar, - # This context isn't used here, but could be used by - # dependencies to prevent loops. - calendar.with_context(recursive_multi_week=True), + calendars_by_week[self._get_week_number(week_start)].with_context( + # This context is not used here, but could possibly be + # used by other modules that use this module. I am not + # sure how useful it is. + recursive_multi_week=True + ), )._attendance_intervals_batch( - start_dt, end_dt, resources=resources, domain=domain, tz=tz + week_start, week_end, resources=resources, domain=domain, tz=tz ) ) + # Aggregate the results from each week. result = {} for item in results: for resource, intervals in item.items(): if resource not in result: result[resource] = intervals else: - result[resource] += intervals + result[resource] |= intervals return result - - # See hooks.py for where this is used. - def _day_in_calendar(self, day): - self.ensure_one() - if not self.is_multi_week: - return super()._day_in_calendar(day) - return self._get_week_number(day) == self.week_number diff --git a/resource_multi_week_calendar/tests/test_calendar.py b/resource_multi_week_calendar/tests/test_calendar.py index 5544eb99bbb..20cf6c3f7c9 100644 --- a/resource_multi_week_calendar/tests/test_calendar.py +++ b/resource_multi_week_calendar/tests/test_calendar.py @@ -7,6 +7,7 @@ from freezegun import freeze_time from odoo.exceptions import ValidationError +from odoo.fields import Command from odoo.tests.common import TransactionCase @@ -236,3 +237,90 @@ def test_compute_current_week_when_day_changes(self): child._compute_current_week() self.assertEqual(child.current_week_number, 2) self.assertEqual(child.current_calendar_id, child) + + +class TestMultiCalendar(CalendarCase): + def setUp(self): + super().setUpClass() + # The parent calendar has attendances by default: Every weekday from 8 + # to 12, and 13 to 17. + self.child_calendar = self.create_simple_child() + # In the child calendar, only work the mornings. + self.child_calendar.attendance_ids = False + self.child_calendar.attendance_ids = [ + Command.create( + { + "name": "Monday Morning", + "dayofweek": "0", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + } + ), + Command.create( + { + "name": "Tuesday Morning", + "dayofweek": "1", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + } + ), + Command.create( + { + "name": "Wednesday Morning", + "dayofweek": "2", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + } + ), + Command.create( + { + "name": "Thursday Morning", + "dayofweek": "3", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + } + ), + Command.create( + { + "name": "Friday Morning", + "dayofweek": "4", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + } + ), + ] + + def test_count_work_hours_two_weeks(self): + hours = self.parent_calendar.get_work_hours_count( + # 1st of July is a Monday. + datetime.datetime.fromisoformat("2024-07-01T00:00:00+00:00"), + datetime.datetime.fromisoformat("2024-07-14T23:59:59+00:00"), + ) + # 40 from the parent, 20 from the child + self.assertEqual(hours, 60) + + def test_count_work_hours_from_child(self): + # It doesn't matter whether you call the method from the child. + hours = self.child_calendar.get_work_hours_count( + datetime.datetime.fromisoformat("2024-07-01T00:00:00+00:00"), + datetime.datetime.fromisoformat("2024-07-14T23:59:59+00:00"), + ) + self.assertEqual(hours, 60) + + def test_count_work_hours_weeks_separately(self): + self.parent_calendar.multi_week_epoch_date = "2024-07-01" + hours = self.parent_calendar.get_work_hours_count( + datetime.datetime.fromisoformat("2024-07-01T00:00:00+00:00"), + datetime.datetime.fromisoformat("2024-07-07T23:59:59+00:00"), + ) + self.assertEqual(hours, 40) + hours = self.parent_calendar.get_work_hours_count( + datetime.datetime.fromisoformat("2024-07-08T00:00:00+00:00"), + datetime.datetime.fromisoformat("2024-07-14T23:59:59+00:00"), + ) + self.assertEqual(hours, 20)