diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index f82f84af..e41a3d13 100644 --- a/resource_booking/__manifest__.py +++ b/resource_booking/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Resource booking", "summary": "Manage appointments and resource booking", - "version": "13.0.1.0.0", + "version": "13.0.2.0.0", "development_status": "Beta", "category": "Appointments", "website": "https://github.com/OCA/calendar", diff --git a/resource_booking/controllers/portal.py b/resource_booking/controllers/portal.py index 25556207..bb0cb7e8 100644 --- a/resource_booking/controllers/portal.py +++ b/resource_booking/controllers/portal.py @@ -49,7 +49,7 @@ def _booking_get_page_view_values(self, booking_sudo, access_token, **kwargs): ) def portal_my_bookings(self, page=1, **kwargs): """List bookings that I can access.""" - Booking = request.env["resource.booking"] + Booking = request.env["resource.booking"].with_context(using_portal=True) values = self._prepare_portal_layout_values() pager = portal.pager( url="/my/bookings", diff --git a/resource_booking/migrations/13.0.2.0.0/pre-migrate.py b/resource_booking/migrations/13.0.2.0.0/pre-migrate.py new file mode 100644 index 00000000..8e38ade4 --- /dev/null +++ b/resource_booking/migrations/13.0.2.0.0/pre-migrate.py @@ -0,0 +1,55 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from openupgradelib import openupgrade + + +def remove_start_stop_together_constraint(cr): + """Remove old unnecessary constraint. + + This one is no longer needed because `stop` is now computed and readonly, + so it will always have or not have value automatically. + """ + openupgrade.logged_query( + cr, + """ + ALTER TABLE resource_booking + DROP CONSTRAINT IF EXISTS resource_booking_start_stop_together + """, + ) + + +def fill_resource_booking_duration(env): + """Pre-create and pre-fill resource.booking duration.""" + openupgrade.add_fields( + env, + [ + ( + "duration", + "resource.booking", + "resource_booking", + "float", + None, + "resource_booking", + None, + ) + ], + ) + openupgrade.logged_query( + env.cr, + """ + UPDATE resource_booking rb + SET duration = COALESCE( + -- See https://stackoverflow.com/a/952600/1468388 + EXTRACT(EPOCH FROM rb.stop - rb.start) / 3600, + rbt.duration + ) + FROM resource_booking_type rbt + WHERE rb.type_id = rbt.id + """, + ) + + +@openupgrade.migrate() +def migrate(env, version): + remove_start_stop_together_constraint(env.cr) + fill_resource_booking_duration(env) diff --git a/resource_booking/models/resource_booking.py b/resource_booking/models/resource_booking.py index 75b55903..50feb7ab 100644 --- a/resource_booking/models/resource_booking.py +++ b/resource_booking/models/resource_booking.py @@ -2,14 +2,12 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import calendar -from contextlib import suppress from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models from odoo.exceptions import ValidationError -from odoo.osv.expression import NEGATIVE_TERM_OPERATORS from odoo.addons.resource.models.resource import Intervals @@ -25,14 +23,6 @@ class ResourceBooking(models.Model): "CHECK(meeting_id IS NULL OR combination_id IS NOT NULL)", "Missing resource booking combination.", ), - ( - "start_stop_together", - """CHECK( - (start IS NULL AND stop IS NULL) OR - (start IS NOT NULL AND stop IS NOT NULL) - )""", - "Start and stop must be filled or emptied together.", - ), ( "unique_meeting_id", "UNIQUE(meeting_id)", @@ -44,7 +34,6 @@ class ResourceBooking(models.Model): meeting_id = fields.Many2one( comodel_name="calendar.event", string="Meeting", - auto_join=True, context={"default_res_id": False, "default_res_model": False}, copy=False, index=True, @@ -70,7 +59,15 @@ class ResourceBooking(models.Model): store=True, tracking=True, ) - name = fields.Char(compute="_compute_name") + combination_auto_assign = fields.Boolean( + string="Auto assigned", + default=True, + help=( + "When checked, resource combinations will be (un)assigned automatically " + "based on their availability during the booking dates." + ), + ) + name = fields.Char(index=True, help="Leave empty to autogenerate a booking name.") partner_id = fields.Many2one( "res.partner", string="Requester", @@ -80,10 +77,8 @@ class ResourceBooking(models.Model): tracking=True, help="Who requested this booking?", ) + location = fields.Char(compute="_compute_location", readonly=False, store=True,) requester_advice = fields.Text(related="type_id.requester_advice", readonly=True) - involves_me = fields.Boolean( - compute="_compute_involves_me", search="_search_involves_me" - ) is_modifiable = fields.Boolean(compute="_compute_is_modifiable") is_overdue = fields.Boolean(compute="_compute_is_overdue") state = fields.Selection( @@ -106,19 +101,26 @@ class ResourceBooking(models.Model): ), ) start = fields.Datetime( - compute="_compute_dates", + compute="_compute_start", copy=False, index=True, - inverse="_inverse_dates", + readonly=False, store=True, track_sequence=200, tracking=True, ) + duration = fields.Float( + compute="_compute_duration", + readonly=False, + store=True, + track_sequence=220, + tracking=True, + help="Amount of time that the resources will be booked and unavailable for others.", + ) stop = fields.Datetime( - compute="_compute_dates", + compute="_compute_stop", copy=False, index=True, - inverse="_inverse_dates", store=True, track_sequence=210, tracking=True, @@ -145,38 +147,14 @@ def _compute_categ_ids(self): if one.type_id: one.categ_ids = one.type_id.categ_ids - @api.depends("start", "stop", "type_id") + @api.depends("start", "type_id", "combination_auto_assign") def _compute_combination_id(self): """Select best combination candidate when changing booking dates.""" for one in self: # Useless without the interval - if one.start and one.stop: + if one.start and one.combination_auto_assign: one.combination_id = one._get_best_combination() - @api.depends("combination_id", "partner_id") - def _compute_involves_me(self): - """Indicate if the booking involves you.""" - self.involves_me = False - domain = self._search_involves_me("=", True) - mine = self.filtered_domain(domain) - mine.involves_me = True - - def _search_involves_me(self, operator, value): - """Fast search of own bookings.""" - me = self.env.user.partner_id - if operator in NEGATIVE_TERM_OPERATORS: - value = not value - domain = [ - "|", - "|", - ("partner_id", "=", me.id), - ("meeting_id.attendee_ids.partner_id", "in", me.ids), - ("combination_id.resource_ids.user_id.partner_id", "in", me.ids), - ] - if value: - return domain - return ["!"] + domain - @api.depends("start") def _compute_is_overdue(self): """Indicate if booking is overdue.""" @@ -203,13 +181,23 @@ def _compute_is_modifiable(self): overdue = self.filtered("is_overdue") overdue.is_modifiable = False - @api.depends("partner_id", "type_id", "meeting_id") - def _compute_name(self): - """Show a helpful name.""" - for one in self: - one.name = self._get_name_formatted( - one.partner_id, one.type_id, one.meeting_id - ) + @api.depends("name", "partner_id", "type_id", "meeting_id") + @api.depends_context("uid", "using_portal") + def _compute_display_name(self): + """Overridden just for dependencies; see `name_get()` for implementation.""" + return super()._compute_display_name() + + @api.depends("meeting_id.location", "type_id") + def _compute_location(self): + """Get location from meeting or type.""" + for record in self: + # Get location from type when changing it or creating from ORM + # HACK https://github.com/odoo/odoo/issues/74152 + if not record.location or record._origin.type_id != record.type_id: + record.location = record.type_id.location + # Get it from meeting only when available + elif record.meeting_id: + record.location = record.meeting_id.location @api.depends("active", "meeting_id.attendee_ids.state") def _compute_state(self): @@ -231,32 +219,61 @@ def _compute_state(self): one.state = "scheduled" if one.meeting_id else "pending" to_check._check_scheduling() - @api.depends("meeting_id.start", "meeting_id.stop") - def _compute_dates(self): - for one in self: - # You're creating a new record; calendar view sends proper context - # defaults that at this point are lost; restoring them - if not one.id: - one.update(one.default_get(["start", "stop"])) - continue - # Get values from related meeting, if any; just like a related field - one.start = one.meeting_id.start - one.stop = one.meeting_id.stop - - def _inverse_dates(self): + @api.depends("meeting_id.start") + def _compute_start(self): + """Get start date from related meeting, if available.""" + for record in self: + if record.id: + record.start = record.meeting_id.start + + @api.depends("meeting_id.duration", "type_id") + def _compute_duration(self): + """Compute duration for each booking.""" + for record in self: + # Special case when creating record from UI + if not record.id: + record.duration = self.default_get(["duration"]).get( + "duration", record.type_id.duration + ) + # Get duration from type only when changing type or when creating from ORM + elif not record.duration or record._origin.type_id != record.type_id: + record.duration = record.type_id.duration + # Get it from meeting only when available + elif record.meeting_id: + record.duration = record.meeting_id.duration + + @api.depends("start", "duration") + def _compute_stop(self): + """Get stop date from start date and duration.""" + for record in self: + try: + record.stop = record.start + timedelta(hours=record.duration) + except TypeError: + # Either value is False: no stop date + record.stop = False + + def _sync_meeting(self): """Lazy-create or destroy calendar.event.""" # Notify changed dates to attendees - _self = self.with_context(from_ui=self.env.context.get("from_ui", True)) + _self = self.with_context( + syncing_booking_ids=self.ids, from_ui=self.env.context.get("from_ui", True) + ) + # Avoid sync recursion + _self -= self.browse(self.env.context.get("syncing_booking_ids")) to_create, to_delete = [], _self.env["calendar.event"] for one in _self: - if one.start and one.stop: + if one.start: resource_partners = one.combination_id.resource_ids.filtered( lambda res: res.resource_type == "user" ).mapped("user_id.partner_id") meeting_vals = dict( - one.type_id._event_defaults(), + alarm_ids=[(6, 0, one.type_id.alarm_ids.ids)], categ_ids=[(6, 0, one.categ_ids.ids)], - name=one._get_name_formatted(one.partner_id, one.type_id), + description=one.type_id.requester_advice, + duration=one.duration, + location=one.location, + name=one.name + or one._get_name_formatted(one.partner_id, one.type_id), partner_ids=[ (4, partner.id, 0) for partner in one.partner_id | resource_partners @@ -325,20 +342,6 @@ def _check_scheduling(self): % "\n- ".join(unfitting_bookings.mapped("display_name")) ) - @api.onchange("start") - def _onchange_start_fill_stop(self): - """Apply default stop when changing start.""" - # When creating a new record by clicking on the calendar view, don't - # alter stop the 1st time - if not self.id: - defaults = self.default_get(["start", "stop"]) - with suppress(KeyError): - if self.start == fields.Datetime.to_datetime(defaults["start"]): - self.stop = defaults["stop"] - return - # In the general use case, stop is start + duration - self.stop = self.start and self.start + timedelta(hours=self.type_id.duration) - def _get_calendar_context(self, year=None, month=None, now=None): """Get the required context for the calendar view in the portal. @@ -382,7 +385,7 @@ def _get_name_formatted(self, partner, type_, meeting=None): def _get_best_combination(self): """Pick best combination based on current booking state.""" # No dates? Then return whatever is already selected (can be empty) - if not (self.start and self.stop): + if not self.start: return self.combination_id # If there's a combination already, put it 1st (highest priority) sorted_combinations = self.combination_id + ( @@ -414,7 +417,8 @@ 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()) - duration = timedelta(hours=self.type_id.duration) + slot_duration = timedelta(hours=self.type_id.duration) + booking_duration = timedelta(hours=self.duration) current = max( start_dt, now + timedelta(hours=self.type_id.modifications_deadline) ) @@ -424,14 +428,14 @@ def _get_available_slots(self, start_dt, end_dt): if current != slot_start: current = slot_start continue - current_interval = Intervals([(current, current + duration, self)]) + current_interval = Intervals([(current, current + booking_duration, self)]) for start, end, _meta in available_intervals & current_interval: - if end - start == duration: + 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 += duration + current += slot_duration return result def _get_intervals(self, start_dt, end_dt, combination=None): @@ -441,7 +445,10 @@ def _get_intervals(self, start_dt, end_dt, combination=None): booking_id = self.id or self._origin.id or -1 except AttributeError: booking_id = -1 - booking = self.with_context(analyzing_booking=booking_id) + # Detached compatibility with hr_holidays_public + booking = self.with_context( + analyzing_booking=booking_id, exclude_public_holidays=True + ) # RBT calendar uses no resources to restrict bookings result = booking.type_id.resource_calendar_id._work_intervals(start_dt, end_dt) # Restrict with the chosen combination, or to at least one of the @@ -454,6 +461,42 @@ def _get_intervals(self, start_dt, end_dt, combination=None): result &= combinations._get_intervals(start_dt, end_dt) return result + @api.model_create_multi + def create(self, vals_list): + """Sync booking with meeting if needed.""" + result = super().create(vals_list) + result._sync_meeting() + return result + + def write(self, vals): + """Sync booking with meeting if needed.""" + result = super().write(vals) + self._sync_meeting() + return result + + def unlink(self): + """Unlink meeting if needed.""" + self.meeting_id.unlink() + return super().unlink() + + def name_get(self): + """Autogenerate booking name if none is provided.""" + old = super().name_get() + new = [] + for id_, name in old: + record = self.browse(id_) + if self.env.context.get("using_portal"): + # ID optionally suffixed with custom name for portal users + template = _("# %(id)d - %(name)s") if record.name else _("# %(id)d") + name = template % {"id": id_, "name": name} + elif not record.name: + # Automatic name for backend users + name = self._get_name_formatted( + record.partner_id, record.type_id, record.meeting_id + ) + new.append((id_, name)) + return new + def message_get_suggested_recipients(self): recipients = super().message_get_suggested_recipients() for one in self: @@ -474,7 +517,7 @@ def action_schedule(self): default_res_id=False, # Context used by web_calendar_slot_duration module calendar_slot_duration=FloatTimeParser.value_to_html( - self.type_id.duration, False + self.duration, False ), default_resource_booking_ids=[(6, 0, self.ids)], default_name=self.name, diff --git a/resource_booking/models/resource_booking_combination.py b/resource_booking/models/resource_booking_combination.py index f7c3ab45..798d21ac 100644 --- a/resource_booking/models/resource_booking_combination.py +++ b/resource_booking/models/resource_booking_combination.py @@ -72,7 +72,8 @@ def _get_intervals(self, start_dt, end_dt): """Get available intervals for this booking combination.""" base = Intervals([(start_dt, end_dt, self)]) result = Intervals([]) - for combination in self: + # Detached compatibility with hr_holidays_public + for combination in self.with_context(exclude_public_holidays=True): combination_intervals = base for res in combination.resource_ids: if not combination_intervals: diff --git a/resource_booking/models/resource_booking_type.py b/resource_booking/models/resource_booking_type.py index e57166ab..7cb6874c 100644 --- a/resource_booking/models/resource_booking_type.py +++ b/resource_booking/models/resource_booking_type.py @@ -44,6 +44,7 @@ class ResourceBookingType(models.Model): comodel_name="resource.booking.type.combination.rel", inverse_name="type_id", string="Available resource combinations", + copy=True, help="Resource combinations available for this type of bookings.", ) company_id = fields.Many2one( @@ -58,7 +59,10 @@ class ResourceBookingType(models.Model): duration = fields.Float( required=True, default=0.5, # 30 minutes - help="Establish each interval's duration.", + help=( + "Interval offered to start each resource booking. " + "Also used as booking default duration." + ), ) location = fields.Char() modifications_deadline = fields.Float( @@ -114,15 +118,6 @@ def _check_bookings_scheduling(self): bookings = self.mapped("booking_ids") return bookings._check_scheduling() - def _event_defaults(self, prefix=""): - """Get field names that should fill default values in meetings.""" - return { - prefix + "alarm_ids": [(6, 0, self.alarm_ids.ids)], - prefix + "description": self.requester_advice, - prefix + "duration": self.duration, - prefix + "location": self.location, - } - def _get_combinations_priorized(self): """Gets all combinations sorted by the chosen assignment method.""" if not self.combination_assignment: @@ -145,9 +140,11 @@ def _get_next_slot_start(self, start_dt): 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) - attendance_intervals = self.resource_calendar_id._attendance_intervals( - workday_min, end_dt + # Detached compatibility with hr_holidays_public + res_calendar = self.resource_calendar_id.with_context( + exclude_public_holidays=True ) + attendance_intervals = res_calendar._attendance_intervals(workday_min, end_dt) try: workday_start, valid_end, _meta = attendance_intervals._items[-1] if valid_end != end_dt: @@ -157,9 +154,7 @@ def _get_next_slot_start(self, start_dt): try: # Returns `False` if no slot is found in the next 2 weeks return ( - self.resource_calendar_id.plan_hours( - self.duration, end_dt, compute_leaves=True - ) + res_calendar.plan_hours(self.duration, end_dt, compute_leaves=True) - duration_delta ) except TypeError: @@ -172,12 +167,14 @@ def action_open_bookings(self): return { "context": dict( self.env.context, + default_alarm_ids=[(6, 0, self.alarm_ids.ids)], + default_description=self.requester_advice, + 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 ), - default_type_id=self.id, - **self._event_defaults(prefix="default_"), ), "domain": [("type_id", "=", self.id)], "name": _("Bookings"), diff --git a/resource_booking/readme/CONFIGURE.rst b/resource_booking/readme/CONFIGURE.rst index 8f1675bb..d558f06c 100644 --- a/resource_booking/readme/CONFIGURE.rst +++ b/resource_booking/readme/CONFIGURE.rst @@ -16,7 +16,9 @@ To configure one booking type: #. Go to *Resource Bookings > Types*. #. Create one. #. Give it a *name*. -#. Set the *Duration*, to know the time assigned to each calendar slot. +#. Set the *Duration*, to know the time assigned to each calendar slot. It will + also be the default duration for each booking, although that can be changed + later if necessary. #. Set the *Modifications Deadline*, to forbid non-managers to alter dates of a booking when it's too late. #. Choose one *Availability Calendar*. No bookings will exist outside of it. diff --git a/resource_booking/readme/USAGE.rst b/resource_booking/readme/USAGE.rst index da908d65..aa7c6625 100644 --- a/resource_booking/readme/USAGE.rst +++ b/resource_booking/readme/USAGE.rst @@ -19,8 +19,8 @@ To book some resources: #. Click on *Booking Count*. #. Click on a free slot. #. Fill the *Requester*, which may or not be yourself. -#. Pick one *Resources combination*, in case the one assigned automatically - isn't the one you want. +#. Uncheck *Auto assign* and pick one *Resources combination*, in case the one + assigned automatically isn't the one you want. To invite someone to book a resource combination from the portal: @@ -30,8 +30,11 @@ To invite someone to book a resource combination from the portal: #. Click on the list view icon. #. Click on *Create*. #. Fill the *Requester*. -#. Pick one *Resources combination*, if you want that the requester is assigned - to that combination. Otherwise, leave it empty, and some free combination - will be assigned automatically when the requester picks a free slot. +#. Uncheck *Auto assign* and pick one *Resources combination*, if you want that + the requester is assigned to that combination. Otherwise, leave it empty, + and some free combination will be assigned automatically when the requester + picks a free slot. +#. Choose the *duration*, in case it is different from the one specified in the + resource booking type. #. Click on *Share > Send*. #. The requester will receive an email to select a calendar slot from his portal. diff --git a/resource_booking/templates/portal.xml b/resource_booking/templates/portal.xml index 70908a9c..e3d8ec00 100644 --- a/resource_booking/templates/portal.xml +++ b/resource_booking/templates/portal.xml @@ -187,7 +187,7 @@
  • Duration:
  • @@ -278,8 +278,7 @@ - # - + @@ -306,8 +305,8 @@
    - Booking # - + Booking +
    diff --git a/resource_booking/tests/test_backend.py b/resource_booking/tests/test_backend.py index 294fb924..90754566 100644 --- a/resource_booking/tests/test_backend.py +++ b/resource_booking/tests/test_backend.py @@ -1,8 +1,9 @@ # Copyright 2021 Tecnativa - Jairo Llopis # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from datetime import datetime +from datetime import date, datetime from freezegun import freeze_time +from pytz import utc from odoo import fields from odoo.exceptions import ValidationError @@ -32,9 +33,9 @@ def test_scheduling_conflict_constraints(self): { "partner_id": self.partner.id, "start": "2021-03-02 08:00:00", - "stop": "2021-03-02 08:30:00", "type_id": self.rbt.id, "combination_id": rbc_montue.id, + "combination_auto_assign": False, } ) # Booking cannot be placed next Monday before 8:00 @@ -43,9 +44,9 @@ def test_scheduling_conflict_constraints(self): { "partner_id": self.partner.id, "start": "2021-03-02 07:45:00", - "stop": "2021-03-02 08:15:00", "type_id": self.rbt.id, "combination_id": rbc_montue.id, + "combination_auto_assign": False, } ) # Booking cannot be placed next Monday after 17:00 @@ -54,9 +55,9 @@ def test_scheduling_conflict_constraints(self): { "partner_id": self.partner.id, "start": "2021-03-02 16:45:00", - "stop": "2021-03-02 17:15:00", "type_id": self.rbt.id, "combination_id": rbc_montue.id, + "combination_auto_assign": False, } ) # Booking can be placed next Monday @@ -64,9 +65,9 @@ def test_scheduling_conflict_constraints(self): { "partner_id": self.partner.id, "start": "2021-03-01 08:00:00", - "stop": "2021-03-01 08:30:00", "type_id": self.rbt.id, "combination_id": rbc_montue.id, + "combination_auto_assign": False, } ) # Another event cannot collide with the same RBC @@ -75,9 +76,9 @@ def test_scheduling_conflict_constraints(self): { "partner_id": self.partner.id, "start": "2021-03-01 08:29:59", - "stop": "2021-03-01 08:59:59", "type_id": self.rbt.id, "combination_id": rbc_montue.id, + "combination_auto_assign": False, } ) # Another event can collide with another RBC @@ -86,9 +87,9 @@ def test_scheduling_conflict_constraints(self): { "partner_id": self.partner.id, "start": "2021-03-01 08:00:00", - "stop": "2021-03-01 08:30:00", "type_id": self.rbt.id, "combination_id": rbc_mon.id, + "combination_auto_assign": False, } ) @@ -103,22 +104,23 @@ def test_rbc_forced_calendar(self): { "partner_id": self.partner.id, "start": "2021-03-01 08:00:00", - "stop": "2021-03-01 08:30:00", "type_id": self.rbt.id, "combination_id": rbc_tue.id, + "combination_auto_assign": False, } ) # However, if the combination is forced to Mondays, you can book it rbc_tue.forced_calendar_id = cal_mon - self.env["resource.booking"].create( + rb = self.env["resource.booking"].create( { "partner_id": self.partner.id, "start": "2021-03-01 08:00:00", - "stop": "2021-03-01 08:30:00", "type_id": self.rbt.id, + "combination_auto_assign": False, "combination_id": rbc_tue.id, } ) + self.assertEqual(rb.combination_id, rbc_tue) def test_booking_from_calendar_view(self): # The type is configured by default with bookings of 30 minutes @@ -130,23 +132,24 @@ def test_booking_from_calendar_view(self): self.assertEqual(button_context["calendar_slot_duration"], "00:45") self.assertEqual(button_context["default_duration"], 0.75) # When you click & drag on calendar to create an event, it adds the - # start and end as default; we imitate that here to book a meeting with - # 2 slots next monday + # start and duration as default; we imitate that here to book a meeting + # with 2 slots next monday + button_context["default_duration"] = 1.5 booking_form = Form( self.env["resource.booking"].with_context( - **button_context, - default_start="2021-03-01 08:00:00", - default_stop="2021-03-01 09:30:00" + **button_context, default_start="2021-03-01 08:00:00", ) ) # This might seem redundant, but makes sure onchanges don't mess stuff self.assertEqual(_2dt(booking_form.start), datetime(2021, 3, 1, 8)) + self.assertEqual(booking_form.duration, 1.5) self.assertEqual(_2dt(booking_form.stop), datetime(2021, 3, 1, 9, 30)) - # If I change to next week's monday, then the onchange assumes the stop - # date will be 1 slot, and not 2 + # If I change to next week's monday, then the stop date advances 1:30h booking_form.start = datetime(2021, 3, 8, 8) booking_form.partner_id = self.partner - self.assertEqual(_2dt(booking_form.stop), datetime(2021, 3, 8, 8, 45)) + self.assertEqual(_2dt(booking_form.start), datetime(2021, 3, 8, 8)) + self.assertEqual(booking_form.duration, 1.5) + self.assertEqual(_2dt(booking_form.stop), datetime(2021, 3, 8, 9, 30)) # I can book it (which means type & combination were autofilled) booking = booking_form.save() self.assertTrue(booking.meeting_id) @@ -271,6 +274,7 @@ def test_same_slot_twice_not_utc(self): rb_f.partner_id = self.partner rb_f.type_id = self.rbt rb_f.start = datetime(2021, 3, 1, 10) + rb_f.combination_auto_assign = False rb_f.combination_id = self.rbcs[0] # 1st one works if loop == 0: @@ -319,11 +323,11 @@ def test_change_calendar_after_bookings_exist(self): "combination_id": rbc_mon.id, "partner_id": self.partner.id, "start": "2021-02-22 08:00:00", - "stop": "2021-02-22 08:30:00", "type_id": self.rbt.id, } ) past_booking.action_confirm() + self.assertEqual(past_booking.duration, 0.5) self.assertEqual(past_booking.state, "confirmed") # There's another one for next monday, confirmed too future_booking = self.env["resource.booking"].create( @@ -331,7 +335,6 @@ def test_change_calendar_after_bookings_exist(self): "combination_id": rbc_mon.id, "partner_id": self.partner.id, "start": "2021-03-01 08:00:00", - "stop": "2021-03-01 08:30:00", "type_id": self.rbt.id, } ) @@ -365,7 +368,6 @@ def test_notification_tz(self): "combination_id": self.rbcs[0].id, "partner_id": self.partner.id, "start": "2021-03-01 08:00:00", # 09:00 in Madrid - "stop": "2021-03-01 08:30:00", "type_id": self.rbt.id, } ) @@ -382,3 +384,119 @@ def test_notification_tz(self): ) # Invitation must display Madrid TZ (CET) self.assertIn("09:00:00 CET", invitation_mail.body) + + def test_free_slots_with_different_type_and_booking_durations(self): + """Slot and booking duration are different, and all works.""" + # Type and calendar allow one slot each 30 minutes on Mondays and + # Tuesdays from 08:00 to 17:00 UTC. The booking will span for 3 slots. + rb = self.env["resource.booking"].create( + { + "partner_id": self.partner.id, + "type_id": self.rbt.id, + "duration": self.rbt.duration * 3, + } + ) + self.assertEqual(rb.duration, 1.5) + slots = rb._get_available_slots( + utc.localize(datetime(2021, 3, 2, 14, 15)), + utc.localize(datetime(2021, 3, 8, 10)), + ) + self.assertEqual( + slots, + { + # Thursday + date(2021, 3, 2): [ + # We start searching at 14:15, so first free slot will + # start at 14:30 + utc.localize(datetime(2021, 3, 2, 14, 30)), + utc.localize(datetime(2021, 3, 2, 15)), + # Booking duration is 1:30, and calendar ends at 17:00, so + # last slot starts at 15:30 + utc.localize(datetime(2021, 3, 2, 15, 30)), + ], + # Next Monday, because calendar only allows Mondays and Tuesdays + date(2021, 3, 8): [ + # Calendar starts at 8:00 + utc.localize(datetime(2021, 3, 8, 8)), + # We are searching until 10:00, so last free slot is at 8:30 + utc.localize(datetime(2021, 3, 8, 8, 30)), + ], + }, + ) + + def test_location(self): + """Location across records works as expected.""" + rbt2 = self.rbt.copy({"location": "Office 2"}) + rb_f = Form(self.env["resource.booking"]) + rb_f.partner_id = self.partner + rb_f.type_id = self.rbt + rb = rb_f.save() + # Pending booking inherits location from type + self.assertEqual(rb.state, "pending") + self.assertEqual(rb.location, "Main office") + # Booking can change location independently now + with Form(rb) as rb_f: + rb_f.location = "Office 3" + self.assertEqual(self.rbt.location, "Main office") + self.assertEqual(rb.location, "Office 3") + # Changing booking type changes location + with Form(rb) as rb_f: + rb_f.type_id = rbt2 + self.assertEqual(rb.location, "Office 2") + # Still can change it independently + with Form(rb) as rb_f: + rb_f.location = "Office 1" + self.assertEqual(rb.location, "Office 1") + self.assertEqual(rbt2.location, "Office 2") + # Schedule the booking, meeting inherits location from it + with Form(rb) as rb_f: + rb_f.start = "2021-03-01 08:00:00" + self.assertEqual(rb.state, "scheduled") + self.assertEqual(rb.location, "Office 1") + self.assertEqual(rb.meeting_id.location, "Office 1") + # Changing meeting location changes location of booking + with Form(rb.meeting_id) as meeting_f: + meeting_f.location = "Office 2" + self.assertEqual(rb.location, "Office 2") + self.assertEqual(rb.meeting_id.location, "Office 2") + # Changing booking location changes meeting location + with Form(rb) as rb_f: + rb_f.location = "Office 3" + self.assertEqual(rb.meeting_id.location, "Office 3") + self.assertEqual(rb.location, "Office 3") + # When unscheduled, it keeps location untouched + rb.action_unschedule() + self.assertFalse(rb.meeting_id) + self.assertEqual(rb.location, "Office 3") + + def test_resource_booking_display_name(self): + # Pending booking with no name + rb = self.env["resource.booking"].create( + {"partner_id": self.partner.id, "type_id": self.rbt.id} + ) + self.assertEqual(rb.display_name, "some customer - Test resource booking type") + self.assertEqual( + rb.with_context(using_portal=True).display_name, "# %d" % rb.id + ) + # Pending booking with name + rb.name = "changed" + self.assertEqual(rb.display_name, "changed") + self.assertEqual( + rb.with_context(using_portal=True).display_name, "# %d - changed" % rb.id + ) + # Scheduled booking with name + rb.start = "2021-03-01 08:00:00" + self.assertEqual(rb.display_name, "changed") + self.assertEqual( + rb.with_context(using_portal=True).display_name, "# %d - changed" % rb.id + ) + # Scheduled booking with no name + rb.name = False + self.assertEqual( + rb.display_name, + "some customer - Test resource booking type " + "- 03/01/2021 at (08:00:00 To 08:30:00) (UTC)", + ) + self.assertEqual( + rb.with_context(using_portal=True).display_name, "# %d" % rb.id + ) diff --git a/resource_booking/views/resource_booking_views.xml b/resource_booking/views/resource_booking_views.xml index 293d6555..e143a335 100644 --- a/resource_booking/views/resource_booking_views.xml +++ b/resource_booking/views/resource_booking_views.xml @@ -10,7 +10,7 @@ @@ -125,18 +125,33 @@ Preview
    +
    +

    + +

    +
    - + - +
    This booking exceeded its modifications deadline.
    + - + +
    @@ -180,7 +195,7 @@