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"
/>