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 @@