From 1e5644ed8e358dc94cbbe8d3231acc643d3cc335 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 16 May 2026 12:12:42 +0000 Subject: [PATCH] [IMP] resource_booking: per-resource booking buffer (post-booking cooldown) Add a Float field booking_buffer (hours, SQL-constrained nonnegative, default 0) on resource.resource. When non-zero, the resource is treated as busy for that many hours after each scheduled booking ends, so the next slot cannot start within the cooldown window. Wire it into _get_intervals via a new _get_buffered_booking_intervals helper that scans recently-ending overlapping bookings sharing at least one buffered resource, then subtracts the cooldown windows from the candidate availability. Default 0 preserves the existing back-to-back slot behavior. Useful for resources that need cleanup/reset/turnaround time between appointments (rooms, equipment, etc.) without inflating the booking duration itself. Co-Authored-By: Brenden Eshbach --- resource_booking/__manifest__.py | 1 + resource_booking/models/resource_booking.py | 48 ++++++++++++++- resource_booking/models/resource_resource.py | 18 +++++- resource_booking/tests/test_backend.py | 61 +++++++++++++++++++ .../views/resource_resource_views.xml | 28 +++++++++ 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 resource_booking/views/resource_resource_views.xml diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index 976c0abb..b1f78f45 100644 --- a/resource_booking/__manifest__.py +++ b/resource_booking/__manifest__.py @@ -41,6 +41,7 @@ "views/res_partner_views.xml", "views/resource_booking_combination_views.xml", "views/resource_booking_type_views.xml", + "views/resource_resource_views.xml", "views/resource_booking_views.xml", "views/menus.xml", ], diff --git a/resource_booking/models/resource_booking.py b/resource_booking/models/resource_booking.py index c1dadc98..b44faba2 100644 --- a/resource_booking/models/resource_booking.py +++ b/resource_booking/models/resource_booking.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta -from pytz import timezone +from pytz import timezone, utc from odoo import api, fields, models from odoo.exceptions import ValidationError @@ -584,6 +584,48 @@ def _get_available_slots(self, start_dt, end_dt): test_start += slot_duration return result + def _get_buffered_booking_intervals(self, start_dt, end_dt, combinations): + """Return post-booking buffer intervals for resources in ``combinations``. + + Each resource may declare a ``booking_buffer`` (hours of cooldown after + a scheduled booking ends). For every other booking that ends shortly + before ``end_dt`` and shares at least one buffered resource with the + candidate combinations, emit an interval covering the cooldown so it + is subtracted from the available intervals returned by ``_get_intervals``. + """ + resources = combinations.resource_ids.filtered("booking_buffer") + if not resources: + return Intervals([]) + max_buffer = max(resources.mapped("booking_buffer")) + max_buffer_delta = timedelta(hours=max_buffer) + booking_id = self.id or self._origin.id or -1 + search_start = ( + (start_dt - max_buffer_delta).astimezone(utc).replace(tzinfo=None) + ) + search_end = end_dt.astimezone(utc).replace(tzinfo=None) + buffered = ( + self.env["resource.booking"] + .sudo() + .search( + [ + ("id", "!=", booking_id), + ("combination_id.resource_ids", "in", resources.ids), + ("meeting_id", "!=", False), + ("stop", ">", fields.Datetime.to_string(search_start)), + ("stop", "<", fields.Datetime.to_string(search_end)), + ] + ) + ) + intervals = [] + for booking in buffered: + shared = booking.combination_id.resource_ids & resources + buffer_hours = max(shared.mapped("booking_buffer")) + buffer_start = fields.Datetime.context_timestamp(self, booking.stop) + buffer_stop = buffer_start + timedelta(hours=buffer_hours) + if buffer_start < end_dt and buffer_stop > start_dt: + intervals.append((buffer_start, buffer_stop, booking)) + return Intervals(intervals) + def _get_intervals(self, start_dt, end_dt, combination=None): """Get available intervals for this booking, based on the calendar of the booking type @@ -613,6 +655,10 @@ def _get_intervals(self, start_dt, end_dt, combination=None): ).with_context(analyzing_booking=booking_id) tz = timezone(self.type_id.resource_calendar_id.tz) result &= combinations._get_intervals(start_dt, end_dt, tz) + # Subtract per-resource post-booking cooldown buffers + result -= booking._get_buffered_booking_intervals( + start_dt, end_dt, combinations + ) return result def _sync_booking_activities_date(self): diff --git a/resource_booking/models/resource_resource.py b/resource_booking/models/resource_resource.py index 92b98ab8..e7044450 100644 --- a/resource_booking/models/resource_resource.py +++ b/resource_booking/models/resource_resource.py @@ -1,13 +1,29 @@ # Copyright 2021 Tecnativa - Jairo Llopis # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import api, models +from odoo import api, fields, models from .resource_booking import _availability_is_fitting class ResourceResource(models.Model): _inherit = "resource.resource" + _sql_constraints = [ + ( + "booking_buffer_nonnegative", + "CHECK(booking_buffer >= 0)", + "Booking buffer must be zero or positive.", + ), + ] + + booking_buffer = fields.Float( + string="Booking Buffer Time", + default=0.0, + help=( + "Extra time, expressed in hours, to block this resource after a " + "scheduled booking before it can be booked again. Set to 0 for no buffer." + ), + ) @api.constrains("calendar_id", "resource_type", "tz", "user_id") def _check_bookings_scheduling(self): diff --git a/resource_booking/tests/test_backend.py b/resource_booking/tests/test_backend.py index 0b758067..8b5749ca 100644 --- a/resource_booking/tests/test_backend.py +++ b/resource_booking/tests/test_backend.py @@ -592,6 +592,67 @@ def test_free_slots_with_different_type_and_booking_durations(self): }, ) + def test_booking_buffer_blocks_following_slots(self): + """A resource's booking_buffer blocks slots within the cooldown window.""" + rbc = self.rbcs[0] # Monday combination + # Apply a 1-hour cooldown to all resources in this combination + rbc.resource_ids.booking_buffer = 1.0 + # Existing 30-minute booking ends at 09:00 + self.env["resource.booking"].create( + { + "partner_ids": [(4, self.partner.id)], + "type_id": self.rbt.id, + "combination_id": rbc.id, + "combination_auto_assign": False, + "start": "2021-03-01 08:30:00", + } + ) + # 09:30 is within the 1h buffer -> conflict + rb_f = Form(self.env["resource.booking"]) + rb_f.partner_ids.add(self.partner) + rb_f.type_id = self.rbt + rb_f.combination_auto_assign = False + rb_f.combination_id = rbc + rb_f.start = datetime(2021, 3, 1, 9, 30) + with self.assertRaises(ValidationError): + rb_f.save() + # 10:00 (exactly buffer-end) is fine + rb_f.start = datetime(2021, 3, 1, 10) + rb_f.save() + + def test_booking_buffer_zero_keeps_legacy_behavior(self): + """booking_buffer = 0 keeps the existing back-to-back slot behavior.""" + rbc = self.rbcs[0] + self.assertTrue(all(r.booking_buffer == 0 for r in rbc.resource_ids)) + self.env["resource.booking"].create( + { + "partner_ids": [(4, self.partner.id)], + "type_id": self.rbt.id, + "combination_id": rbc.id, + "combination_auto_assign": False, + "start": "2021-03-01 08:30:00", + } + ) + # Back-to-back at 09:00 is allowed when there is no buffer + self.env["resource.booking"].create( + { + "partner_ids": [(4, self.partner.id)], + "type_id": self.rbt.id, + "combination_id": rbc.id, + "combination_auto_assign": False, + "start": "2021-03-01 09:00:00", + } + ) + + def test_booking_buffer_constraint(self): + """Negative booking_buffer is rejected by SQL constraint.""" + from psycopg2 import IntegrityError + + with self.assertRaises(IntegrityError), mute_logger("odoo.sql_db"): + with self.env.cr.savepoint(): + self.rbcs[0].resource_ids[0].write({"booking_buffer": -1}) + self.env.flush_all() + @mute_logger("odoo.models.unlink") def test_location(self): """Location across records works as expected.""" diff --git a/resource_booking/views/resource_resource_views.xml b/resource_booking/views/resource_resource_views.xml new file mode 100644 index 00000000..3b824aee --- /dev/null +++ b/resource_booking/views/resource_resource_views.xml @@ -0,0 +1,28 @@ + + + + + resource.resource.form.booking.buffer + resource.resource + + + + + + + + + resource.resource.list.booking.buffer + resource.resource + + + + + + + +