Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resource_booking/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
48 changes: 47 additions & 1 deletion resource_booking/models/resource_booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
18 changes: 17 additions & 1 deletion resource_booking/models/resource_resource.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
61 changes: 61 additions & 0 deletions resource_booking/tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
28 changes: 28 additions & 0 deletions resource_booking/views/resource_resource_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 Ledoent
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="resource_resource_view_form_booking_buffer" model="ir.ui.view">
<field name="name">resource.resource.form.booking.buffer</field>
<field name="model">resource.resource</field>
<field name="inherit_id" ref="resource.resource_resource_form" />
<field name="arch" type="xml">
<xpath
expr="//group[@name='resource_details']/field[@name='calendar_id']"
position="after"
>
<field name="booking_buffer" widget="float_time" />
</xpath>
</field>
</record>
<record id="resource_resource_view_tree_booking_buffer" model="ir.ui.view">
<field name="name">resource.resource.list.booking.buffer</field>
<field name="model">resource.resource</field>
<field name="inherit_id" ref="resource.resource_resource_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='calendar_id']" position="after">
<field name="booking_buffer" widget="float_time" optional="show" />
</xpath>
</field>
</record>
</odoo>
Loading