Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[15.0][IMP] resource_booking: Allow a booking to span more than one calendar day #100

Merged
merged 1 commit into from
Sep 19, 2023
Merged
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
53 changes: 51 additions & 2 deletions resource_booking/models/resource_booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,61 @@


def _availability_is_fitting(available_intervals, start_dt, end_dt):
# Test whether the stretch between start_dt and end_dt is an uninterrupted
# stretch of time as determined by `available_intervals`.
#
# `available_intervals` is typically created by `_get_intervals()`, which in
# turn uses `calendar._work_intervals()`. It appears to be default upstream
# behaviour of `_work_intervals()` to create a (start_dt, end_dt, record)
# tuple for every day, where end_dt is at 23:59, and the next tuple's
# start_dt is at 00:00.
#
# Changing this upstream behaviour of `_work_intervals()` to return a
# _single_ tuple for any multi-day uninterrupted stretch of time would
# probably be preferable, but (1.) the code in `_work_intervals()` is
# unbelievably arcane, and (2.) changing this behaviour is extremely likely
# to cause bugs elsewhere. So instead, we account for the upstream behaviour
# here.
start_date = start_dt.date()
end_date = end_dt.date()
# Booking is uninterrupted on the same calendar day.
return (
if (
len(available_intervals) == 1
and available_intervals._items[0][0] <= start_dt
and available_intervals._items[0][1] >= end_dt
)
):
return True
# Booking spans more than one calendar day, e.g. from 23:00 to 1:00
# the next day.
elif available_intervals and start_date != end_date:
tally_date = start_date
for item in available_intervals:
item0_date = item[0].date()
item1_date = item[1].date()
# FIXME: Really weird workaround for when available_intervals has
# nonsensical items in it where item1_date is before item0_date.
# Just ignore those items and pretend they don't exist; all the
# other items appear to make sense.
if item1_date < item0_date:
continue

Check warning on line 53 in resource_booking/models/resource_booking.py

View check run for this annotation

Codecov / codecov/patch

resource_booking/models/resource_booking.py#L53

Added line #L53 was not covered by tests
# Intervals that aren't on the running tally date break the streak.
# This check is for malformed data in `available_intervals` where a
# day is skipped.
if item0_date != tally_date or item1_date != tally_date:
break
# Intervals that aren't on the end date should end at 23:59 (and any
# number of seconds).
if item1_date != end_date and (item[1].hour != 23 or item[1].minute != 59):
break
# Intervals that aren't on the start date should start at 00:00 (and
# any number of seconds).
if item0_date != start_date and (item[0].hour != 0 or item[0].minute != 0):
break
# The next interval should be on the next day.
tally_date += timedelta(days=1)
else:
return True
return False


class ResourceBooking(models.Model):
Expand Down
47 changes: 41 additions & 6 deletions resource_booking/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ def create_test_data(obj):
)
)
# Create one resource.calendar available on Mondays, another one on
# Tuesdays, and another one on Mondays and Tuesdays; in that order
# Tuesdays, and another one on Mondays and Tuesdays; in that order.
# Also create an all-day calendar for Saturday and Sunday.
attendances = [
(
0,
Expand All @@ -34,12 +35,46 @@ def create_test_data(obj):
"day_period": "morning",
},
),
(
0,
0,
{
"name": "Fridays",
"dayofweek": "4",
"hour_from": 0,
"hour_to": 23.99,
"day_period": "morning",
},
),
(
0,
0,
{
"name": "Saturdays",
"dayofweek": "5",
"hour_from": 0,
"hour_to": 23.99,
"day_period": "morning",
},
),
(
0,
0,
{
"name": "Sunday",
"dayofweek": "6",
"hour_from": 0,
"hour_to": 23.99,
"day_period": "morning",
},
),
]
obj.r_calendars = obj.env["resource.calendar"].create(
[
{"name": "Mon", "attendance_ids": attendances[:1], "tz": "UTC"},
{"name": "Tue", "attendance_ids": attendances[1:], "tz": "UTC"},
{"name": "MonTue", "attendance_ids": attendances, "tz": "UTC"},
{"name": "Mon", "attendance_ids": [attendances[0]], "tz": "UTC"},
{"name": "Tue", "attendance_ids": [attendances[1]], "tz": "UTC"},
{"name": "MonTue", "attendance_ids": attendances[0:2], "tz": "UTC"},
{"name": "FriSun", "attendance_ids": attendances[2:], "tz": "UTC"},
]
)
# Create one material resource for each of those calendars; same order
Expand All @@ -62,7 +97,7 @@ def create_test_data(obj):
"login": "user_%d" % num,
"name": "User %d" % num,
}
for num in range(3)
for num, _ in enumerate(obj.r_calendars)
]
)
obj.r_users = obj.env["resource.resource"].create(
Expand All @@ -85,7 +120,7 @@ def create_test_data(obj):
for (user, material) in zip(obj.r_users, obj.r_materials)
]
)
# Create one RBT that includes all 3 RBCs as available combinations
# Create one RBT that includes all RBCs as available combinations
obj.rbt = obj.env["resource.booking.type"].create(
{
"name": "Test resource booking type",
Expand Down
147 changes: 146 additions & 1 deletion resource_booking/tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
from odoo.exceptions import ValidationError
from odoo.tests.common import Form, TransactionCase, new_test_user, users

from odoo.addons.resource.models.resource import Intervals
from odoo.addons.resource_booking.models.resource_booking import (
_availability_is_fitting,
)

from .common import create_test_data

_2dt = fields.Datetime.to_datetime
Expand Down Expand Up @@ -109,6 +114,116 @@ def test_scheduling_conflict_constraints(self):
}
)

def test_scheduling_constraints_span_two_days(self):
# Booking can span across two calendar days.
cal_frisun = self.r_calendars[3]
rbc_frisun = self.rbcs[3]
self.rbt.resource_calendar_id = cal_frisun
self.env["resource.booking"].create(
{
"partner_id": self.partner.id,
"start": "2021-03-06 23:00:00",
"duration": 2,
"type_id": self.rbt.id,
"combination_id": rbc_frisun.id,
"combination_auto_assign": False,
}
)
# Booking cannot overlap.
with self.assertRaises(ValidationError), self.env.cr.savepoint():
self.env["resource.booking"].create(
{
"partner_id": self.partner.id,
"start": "2021-03-06 22:00:00",
"duration": 4,
"type_id": self.rbt.id,
"combination_id": rbc_frisun.id,
"combination_auto_assign": False,
}
)
# Test a case where there is an overlap, but the conflict happens at
# 00:00 exactly.
self.env["resource.booking"].create(
{
"partner_id": self.partner.id,
"start": "2021-03-14 00:00:00",
"duration": 1,
"type_id": self.rbt.id,
"combination_id": rbc_frisun.id,
"combination_auto_assign": False,
}
)
with self.assertRaises(ValidationError), self.env.cr.savepoint():
self.env["resource.booking"].create(
{
"partner_id": self.partner.id,
"start": "2021-03-13 23:00:00",
"duration": 4,
"type_id": self.rbt.id,
"combination_id": rbc_frisun.id,
"combination_auto_assign": False,
}
)
# If there are too many minutes between the end and start of the two
# dates, the booking cannot be contiguous.
cal_frisun.attendance_ids.write({"hour_to": 23.96}) # 23:58
with self.assertRaises(ValidationError), self.env.cr.savepoint():
self.env["resource.booking"].create(
{
"partner_id": self.partner.id,
"start": "2021-03-20 23:00:00",
"duration": 2,
"type_id": self.rbt.id,
"combination_id": rbc_frisun.id,
"combination_auto_assign": False,
}
)

def test_scheduling_constraints_span_three_days(self):
# Booking can span across two calendar days.
cal_frisun = self.r_calendars[3]
rbc_frisun = self.rbcs[3]
self.rbt.resource_calendar_id = cal_frisun
self.env["resource.booking"].create(
{
"partner_id": self.partner.id,
"start": "2021-03-05 23:00:00",
"duration": 24 * 2,
"type_id": self.rbt.id,
"combination_id": rbc_frisun.id,
"combination_auto_assign": False,
}
)

def test_availability_is_fitting_malformed_date_skip(self):
"""Test a case for malformed data where a date is skipped in the
available_intervals list of tuples.
"""
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, 3, 0, 0), datetime(2021, 3, 3, 18, 0), recset),
]
available_intervals = Intervals(tuples)
self.assertTrue(
_availability_is_fitting(
available_intervals,
datetime(2021, 3, 1, 18, 0),
datetime(2021, 3, 3, 18, 0),
)
)
# Skip a day by removing it.
tuples.pop(1)
available_intervals = Intervals(tuples)
self.assertFalse(
_availability_is_fitting(
available_intervals,
datetime(2021, 3, 1, 18, 0),
datetime(2021, 3, 3, 18, 0),
)
)

def test_rbc_forced_calendar(self):
# Type is available on Mondays
cal_mon = self.r_calendars[0]
Expand Down Expand Up @@ -259,7 +374,7 @@ def test_state(self):

def test_sorted_assignment(self):
"""Set sorted assignment on RBT and test it works correctly."""
rbc_mon, rbc_tue, rbc_montue = self.rbcs
rbc_mon, rbc_tue, rbc_montue, rbc_frisun = self.rbcs
with Form(self.rbt) as rbt_form:
rbt_form.combination_assignment = "sorted"
# Book next monday at 10:00
Expand Down Expand Up @@ -715,3 +830,33 @@ def test_resource_is_available(self):
utc.localize(datetime(2021, 3, 3, 11, 0)),
)
)

def test_resource_is_available_span_days(self):
# Correctly handle bookings that span across midnight.
cal_satsun = self.r_calendars[3]
rbc_satsun = self.rbcs[3]
resource = rbc_satsun.resource_ids[1]
self.rbt.resource_calendar_id = cal_satsun
self.env["resource.booking"].create(
{
"partner_id": self.partner.id,
"start": "2021-03-06 23:00:00",
"duration": 2,
"type_id": self.rbt.id,
"combination_id": rbc_satsun.id,
"combination_auto_assign": False,
}
)
self.assertFalse(
resource.is_available(
utc.localize(datetime(2021, 3, 6, 22, 0)),
utc.localize(datetime(2021, 3, 7, 2, 0)),
)
)
# Resource is available on the next weekend.
self.assertTrue(
resource.is_available(
utc.localize(datetime(2021, 3, 13, 22, 0)),
utc.localize(datetime(2021, 3, 14, 2, 0)),
)
)
Loading