diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index 6b49780a..bf49ba1b 100644 --- a/resource_booking/__manifest__.py +++ b/resource_booking/__manifest__.py @@ -33,6 +33,7 @@ "security/ir.model.access.csv", "templates/portal.xml", "views/calendar_event_views.xml", + "views/res_partner_views.xml", "views/resource_booking_combination_views.xml", "views/resource_booking_type_views.xml", "views/resource_booking_views.xml", diff --git a/resource_booking/migrations/16.0.1.0.0/post-migration.py b/resource_booking/migrations/16.0.1.0.0/post-migration.py new file mode 100644 index 00000000..1da234bb --- /dev/null +++ b/resource_booking/migrations/16.0.1.0.0/post-migration.py @@ -0,0 +1,14 @@ +from openupgradelib import openupgrade + + +def _update_attendance_hour_to(env): + # 23:59 -> 24:00 + lines = env["resource.calendar.attendance"].search( + [("hour_to", ">=", 23 + 59 / 60)] + ) + lines.hour_to = 24.0 + + +@openupgrade.migrate() +def migrate(env, version): + _update_attendance_hour_to(env) diff --git a/resource_booking/migrations/16.0.1.0.0/pre-migration.py b/resource_booking/migrations/16.0.1.0.0/pre-migration.py new file mode 100644 index 00000000..b614ea3c --- /dev/null +++ b/resource_booking/migrations/16.0.1.0.0/pre-migration.py @@ -0,0 +1,33 @@ +from openupgradelib import openupgrade + +xmlids_spec = [ + ( + "resource_booking.menu_resource_resource", + "resource_booking.resource_resource_menu", + ), + ( + "resource_booking.menu_resource_calendar", + "resource_booking.resource_calendar_menu", + ), + ( + "resource_booking.menu_view_resource_calendar_leaves_search", + "resource_booking.resource_calendar_leaves_menu", + ), + ( + "resource_booking.resource_booking_combination_form", + "resource_booking.resource_booking_combination_view_form", + ), + ( + "resource_booking.resource_booking_type_form", + "resource_booking.resource_booking_type_view_form", + ), + ( + "resource_booking.resource_booking_form", + "resource_booking.resource_booking_view_form", + ), +] + + +@openupgrade.migrate(use_env=False) +def migrate(cr, version): + openupgrade.rename_xmlids(cr, xmlids_spec) diff --git a/resource_booking/models/__init__.py b/resource_booking/models/__init__.py index 38ad3480..c75cebc1 100644 --- a/resource_booking/models/__init__.py +++ b/resource_booking/models/__init__.py @@ -1,4 +1,5 @@ from . import calendar_event +from . import res_partner from . import resource_booking from . import resource_booking_combination from . import resource_booking_type diff --git a/resource_booking/models/res_partner.py b/resource_booking/models/res_partner.py new file mode 100644 index 00000000..180ea2aa --- /dev/null +++ b/resource_booking/models/res_partner.py @@ -0,0 +1,26 @@ +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + resource_booking_count = fields.Integer( + compute="_compute_resource_booking_count", string="Resource booking count" + ) + resource_booking_ids = fields.One2many( + "resource.booking", "partner_id", string="Bookings" + ) + + def _compute_resource_booking_count(self): + for p in self: + p.resource_booking_count = len(p.resource_booking_ids) + + def action_view_resource_booking(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "resource_booking.resource_booking_action" + ) + action["context"] = { + "default_partner_id": self.id, + } + return action diff --git a/resource_booking/models/resource_booking.py b/resource_booking/models/resource_booking.py index 3fcc0abc..a5e0a40b 100644 --- a/resource_booking/models/resource_booking.py +++ b/resource_booking/models/resource_booking.py @@ -3,7 +3,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import calendar -from datetime import datetime, timedelta +from datetime import timedelta from dateutil.relativedelta import relativedelta @@ -13,7 +13,42 @@ from odoo.addons.resource.models.resource import Intervals -def _availability_is_fitting(available_intervals, start_dt, end_dt): +def _merge_intervals(intervals): + # Merge intervals where start of current interval == stop of previous interval, + # assuming that the intervals are ordererd. + intervals = [list(tup) for tup in intervals._items] + # Handle 23:59:59:99999 + for i in range(len(intervals)): + stop = intervals[i][1] + if ( + stop.hour == 23 + and stop.minute == 59 + and stop.second == 59 + and stop.microsecond == 999999 + ): + intervals[i][1] += timedelta(microseconds=1) + # Begin with the last interval, to safely delete it if needed. + for i in range(len(intervals) - 1, 0, -1): + current_start = intervals[i][0] + current_stop = intervals[i][1] + previous_stop = intervals[i - 1][1] + if current_start == previous_stop: + intervals[i - 1][1] = current_stop + del intervals[i] + return Intervals([tuple(interval) for interval in intervals]) + + +def _availability_is_fitting(available_intervals, start_dt, stop_dt): + available_intervals = _merge_intervals(available_intervals) + for item in available_intervals._items: + available_start, available_stop = item[0], item[1] + if start_dt >= available_start and stop_dt <= available_stop: + return True + return False + + +def _availability_is_fitting_legacy(available_intervals, start_dt, end_dt): + """I keep the old method, since part of it may be needed in the new method.""" # Test whether the stretch between start_dt and end_dt is an uninterrupted # stretch of time as determined by `available_intervals`. # @@ -131,6 +166,14 @@ class ResourceBooking(models.Model): tracking=True, help="Who requested this booking?", ) + partner_ids = fields.Many2many( + "res.partner", + string="Contacts", + store=True, + compute="_compute_partner_ids", + inverse="_inverse_partner_ids", + help="E.g. multiple people in a room. Used by sale_resource_booking_period", + ) user_id = fields.Many2one( comodel_name="res.users", default=lambda self: self._default_user_id(), @@ -324,6 +367,15 @@ def _compute_stop(self): # Either value is False: no stop date record.stop = False + @api.depends("partner_id") + def _compute_partner_ids(self): + for record in self: + if record.partner_id: + record.partner_ids = [(6, 0, [record.partner_id.id])] + + def _inverse_partner_ids(self): + pass + @api.depends("meeting_id.user_id") def _compute_user_id(self): """Get user from related meeting, if available.""" @@ -436,17 +488,22 @@ def _get_calendar_context(self, year=None, month=None, now=None): :param datetime now: Represents the current datetime. """ month1 = relativedelta(months=1) - now = now or fields.Datetime.now() + now = fields.Datetime.context_timestamp(self, now or fields.Datetime.now()) year = year or now.year month = month or now.month - start = datetime(year, month, 1) - start, now = ( - fields.Datetime.context_timestamp(self, dt) for dt in (start, now) + start = now.replace( + year=year, + month=month, + day=1, + hour=0, + minute=0, + second=0, + microsecond=0, ) - start = start.replace(hour=0, minute=0, second=0, microsecond=0) lang = self.env["res.lang"]._lang_get(self.env.lang or self.env.user.lang) weekday_names = dict(lang.fields_get(["week_start"])["week_start"]["selection"]) - slots = self._get_available_slots(start, start + month1) + booking_duration = timedelta(hours=self.duration) + slots = self._get_available_slots(start, start + month1 + booking_duration) return { "booking": self, "calendar": calendar.Calendar(int(lang.week_start) - 1), @@ -495,30 +552,39 @@ def _get_best_combination(self): def _get_available_slots(self, start_dt, end_dt): """Return available slots for scheduling current booking.""" result = {} - now = fields.Datetime.context_timestamp(self, fields.Datetime.now()) - slot_duration = timedelta(hours=self.type_id.duration) + slot_duration = timedelta(hours=self.type_id.slot_duration) booking_duration = timedelta(hours=self.duration) - current = max( + now = fields.Datetime.context_timestamp(self, fields.Datetime.now()) + start_dt = max( start_dt, now + timedelta(hours=self.type_id.modifications_deadline) ) - available_intervals = self._get_intervals(current, end_dt) - while current and current < end_dt: - slot_start = self.type_id._get_next_slot_start(current) - if current != slot_start: - current = slot_start - continue - current_interval = Intervals([(current, current + booking_duration, self)]) - for start, end, _meta in available_intervals & current_interval: - if end - start == booking_duration: - result.setdefault(current.date(), []) - result[current.date()].append(current) - # I actually only care about the 1st interval, if any - break - current += slot_duration + # available_intervals should start with the beginning of the work day, + # to compute each slot based on the beginning of the work day. + workday_min = start_dt.replace(hour=0, minute=0, second=0, microsecond=0) + available_intervals = self._get_intervals(workday_min, end_dt) + available_intervals = _merge_intervals(available_intervals) + # Loop through available times and append tested start/stop to the result. + test_start = False + for item in available_intervals._items: + available_start, available_stop = item[0], item[1] + test_start = available_start + while test_start and test_start < available_stop: + test_stop = test_start + booking_duration + if ( + test_start >= start_dt + and test_start >= available_start + and test_stop <= available_stop + ): + if not result.get(test_start.date()): + result.setdefault(test_start.date(), []) + result[test_start.date()].append(test_start) + test_start += slot_duration return result def _get_intervals(self, start_dt, end_dt, combination=None): - """Get available intervals for this booking.""" + """Get available intervals for this booking, + based on the calendar of the booking type + and the calendar(s) of the relevant resource combination(s).""" # Get all intervals except those from current booking try: booking_id = self.id or self._origin.id or -1 @@ -529,10 +595,12 @@ def _get_intervals(self, start_dt, end_dt, combination=None): analyzing_booking=booking_id, exclude_public_holidays=True ) # RBT calendar uses no resources to restrict bookings - resource = self.env["resource.resource"] - result = booking.type_id.resource_calendar_id._work_intervals_batch( - start_dt, end_dt - )[resource.id] + if booking.type_id: + result = booking.type_id.resource_calendar_id._work_intervals_batch( + start_dt, end_dt + )[False] + else: + result = Intervals([]) # Restrict with the chosen combination, or to at least one of the # available ones combinations = ( diff --git a/resource_booking/models/resource_booking_combination.py b/resource_booking/models/resource_booking_combination.py index a7cf2b1b..f8b3d390 100644 --- a/resource_booking/models/resource_booking_combination.py +++ b/resource_booking/models/resource_booking_combination.py @@ -101,6 +101,7 @@ def action_open_bookings(self): "res_model": "resource.booking", "type": "ir.actions.act_window", "view_mode": "calendar,tree,form", + "context": {"default_combination_id": self.id}, } def action_open_resource_booking_types(self): diff --git a/resource_booking/models/resource_booking_type.py b/resource_booking/models/resource_booking_type.py index 077a5045..caad7631 100644 --- a/resource_booking/models/resource_booking_type.py +++ b/resource_booking/models/resource_booking_type.py @@ -1,8 +1,6 @@ # Copyright 2021 Tecnativa - Jairo Llopis # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from datetime import timedelta -from math import ceil from random import random from odoo import _, api, fields, models @@ -59,10 +57,12 @@ class ResourceBookingType(models.Model): duration = fields.Float( required=True, default=0.5, # 30 minutes - help=( - "Interval offered to start each resource booking. " - "Also used as booking default duration." - ), + help=("Booking default duration."), + ) + slot_duration = fields.Float( + required=True, + default=0.5, # 30 minutes + help=("Interval offered to start each resource booking."), ) location = fields.Char() modifications_deadline = fields.Float( @@ -127,46 +127,8 @@ def _get_combinations_priorized(self): combinations = rels.mapped("combination_id") return combinations - def _get_next_slot_start(self, start_dt): - """Slot start as it would come from the beginning of work hours. - - Returns a `datetime` object indicating the next slot start (which could - be the same as `start_dt` if it matches), or `False` if no slot is - found in the next 2 weeks. - - If the RBT doesn't have a calendar, it returns `start_dt`, unaltered, - because there's no way to know when a slot would start. - """ - duration_delta = timedelta(hours=self.duration) - end_dt = start_dt + duration_delta - workday_min = start_dt.replace(hour=0, minute=0, second=0, microsecond=0) - # Detached compatibility with hr_holidays_public - res_calendar = self.resource_calendar_id.with_context( - exclude_public_holidays=True - ) - resource = self.env["resource.resource"] - attendance_intervals = res_calendar._attendance_intervals_batch( - workday_min, end_dt - )[resource.id] - try: - workday_start, valid_end, _meta = attendance_intervals._items[-1] - if valid_end != end_dt: - # Interval found, but without enough time; same as no interval - raise IndexError - except IndexError: - try: - # Returns `False` if no slot is found in the next 2 weeks - return ( - res_calendar.plan_hours(self.duration, end_dt, compute_leaves=True) - - duration_delta - ) - except TypeError: - return False - time_passed = valid_end - duration_delta - workday_start - return workday_start + duration_delta * ceil(time_passed / duration_delta) - def action_open_bookings(self): - FloatTimeParser = self.env["ir.qweb.field.float_time"] + DurationParser = self.env["ir.qweb.field.duration"] return { "context": dict( self.env.context, @@ -175,8 +137,12 @@ def action_open_bookings(self): default_duration=self.duration, default_type_id=self.id, # Context used by web_calendar_slot_duration module - calendar_slot_duration=FloatTimeParser.value_to_html( - self.duration, False + calendar_slot_duration=DurationParser.value_to_html( + self.slot_duration, + { + "unit": "hour", + "digital": True, + }, ), ), "domain": [("type_id", "=", self.id)], diff --git a/resource_booking/tests/common.py b/resource_booking/tests/common.py index 335e6e4c..c94ee2ab 100644 --- a/resource_booking/tests/common.py +++ b/resource_booking/tests/common.py @@ -42,7 +42,7 @@ def create_test_data(obj): "name": "Fridays", "dayofweek": "4", "hour_from": 0, - "hour_to": 23.99, + "hour_to": 24, "day_period": "morning", }, ), @@ -53,7 +53,7 @@ def create_test_data(obj): "name": "Saturdays", "dayofweek": "5", "hour_from": 0, - "hour_to": 23.99, + "hour_to": 24, "day_period": "morning", }, ), @@ -64,7 +64,7 @@ def create_test_data(obj): "name": "Sunday", "dayofweek": "6", "hour_from": 0, - "hour_to": 23.99, + "hour_to": 24, "day_period": "morning", }, ), diff --git a/resource_booking/tests/test_backend.py b/resource_booking/tests/test_backend.py index 88ae41fb..2c1a077b 100644 --- a/resource_booking/tests/test_backend.py +++ b/resource_booking/tests/test_backend.py @@ -201,8 +201,16 @@ def test_availability_is_fitting_malformed_date_skip(self): """ recset = self.env["resource.booking"] tuples = [ - (datetime(2021, 3, 1, 18, 0), datetime(2021, 3, 1, 23, 59), recset), - (datetime(2021, 3, 2, 0, 0), datetime(2021, 3, 2, 23, 59), recset), + ( + datetime(2021, 3, 1, 18, 0), + datetime(2021, 3, 1, 23, 59, 59, 999999), + recset, + ), + ( + datetime(2021, 3, 2, 0, 0), + datetime(2021, 3, 2, 23, 59, 59, 999999), + recset, + ), (datetime(2021, 3, 3, 0, 0), datetime(2021, 3, 3, 18, 0), recset), ] available_intervals = Intervals(tuples) @@ -722,7 +730,7 @@ def test_suggested_and_subscribed_recipients(self): # Requester and combination must be suggested self.assertEqual( rb._message_get_suggested_recipients(), - {rb.id: [(rb.partner_id.id, "some customer", "Requester")]}, + {rb.id: [(rb.partner_id.id, "some customer", None, "Requester")]}, ) def test_creating_rbt_has_tags(self): diff --git a/resource_booking/views/menus.xml b/resource_booking/views/menus.xml index 78ed311f..df3df927 100644 --- a/resource_booking/views/menus.xml +++ b/resource_booking/views/menus.xml @@ -39,13 +39,13 @@ groups="group_manager" /> + + + res.partner + form + + +
+ +
+
+
+
diff --git a/resource_booking/views/resource_booking_combination_views.xml b/resource_booking/views/resource_booking_combination_views.xml index b20af153..ca778086 100644 --- a/resource_booking/views/resource_booking_combination_views.xml +++ b/resource_booking/views/resource_booking_combination_views.xml @@ -3,7 +3,7 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). --> - + Resource booking combination form resource.booking.combination diff --git a/resource_booking/views/resource_booking_type_views.xml b/resource_booking/views/resource_booking_type_views.xml index cf33a750..b86f6aba 100644 --- a/resource_booking/views/resource_booking_type_views.xml +++ b/resource_booking/views/resource_booking_type_views.xml @@ -3,7 +3,7 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). --> - + Resource booking type form resource.booking.type @@ -40,6 +40,7 @@ groups="base.group_multi_company" /> + diff --git a/resource_booking/views/resource_booking_views.xml b/resource_booking/views/resource_booking_views.xml index 4f30bcbf..c181df93 100644 --- a/resource_booking/views/resource_booking_views.xml +++ b/resource_booking/views/resource_booking_views.xml @@ -29,6 +29,7 @@ + @@ -38,7 +39,7 @@ - + Resource booking form resource.booking @@ -136,6 +137,7 @@ +