Skip to content

Commit

Permalink
[IMP] resource_booking: Allow a booking to span more than one calenda…
Browse files Browse the repository at this point in the history
…r day

Signed-off-by: Carmen Bianca BAKKER <carmen@coopiteasy.be>
  • Loading branch information
carmenbianca committed Aug 28, 2023
1 parent 60b2764 commit ff31f09
Show file tree
Hide file tree
Showing 3 changed files with 238 additions and 9 deletions.
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
# 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, SavepointCase, 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 @@ -743,3 +858,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)),
)
)

0 comments on commit ff31f09

Please sign in to comment.