From 3b1b153068d6c3b92666117869824c40cbdf0320 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Tue, 27 Apr 2021 10:34:02 +0100 Subject: [PATCH 01/61] [ADD] resource_booking: new app to manage bookings This module adds a new app to allow you to book resource combinations in given schedules. Example use cases: * Management of consultations in a clinic. * Salesman appointments. * Classroom and projector reservations. * Hotel room booking. Among the things you can do: * Specify the type of booking, which includes a calendar of availability. * Specify which resources can be booked together. All of them must be free to be booked. * Place pending bookings, effectively giving permissions to someone to see the availability calendar and choose one slot. * Partners can do that from their portals. * If a partner has no user, he can still do the same via a tokenized URL. * Backend users can also do that from the backend. * Booking lifecycle with computed states. * Automatic meeting creation and deletion. * Automatic conflict detection. * Deadline to block modifications. @Tecnativa TT28201 --- resource_booking/README.rst | 204 + resource_booking/__init__.py | 2 + resource_booking/__manifest__.py | 43 + resource_booking/controllers/__init__.py | 1 + resource_booking/controllers/portal.py | 139 + resource_booking/demo/res_users_demo.xml | 11 + resource_booking/i18n/es.po | 1181 + resource_booking/i18n/resource_booking.pot | 1086 + resource_booking/models/__init__.py | 7 + resource_booking/models/calendar_event.py | 53 + resource_booking/models/resource_booking.py | 514 + .../models/resource_booking_combination.py | 101 + .../models/resource_booking_type.py | 186 + .../resource_booking_type_combination_rel.py | 28 + resource_booking/models/resource_calendar.py | 97 + resource_booking/models/resource_resource.py | 16 + resource_booking/readme/CONFIGURE.rst | 32 + resource_booking/readme/CONTRIBUTORS.rst | 1 + resource_booking/readme/DESCRIPTION.rst | 22 + resource_booking/readme/INSTALL.rst | 13 + resource_booking/readme/ROADMAP.rst | 4 + resource_booking/readme/USAGE.rst | 37 + resource_booking/security/ir.model.access.csv | 11 + .../security/resource_booking_security.xml | 55 + resource_booking/static/description/icon.png | Bin 0 -> 4337 bytes resource_booking/static/description/icon.svg | 22828 ++++++++++++++++ .../static/description/index.html | 548 + resource_booking/static/src/css/portal.scss | 8 + resource_booking/templates/assets.xml | 12 + resource_booking/templates/portal.xml | 410 + resource_booking/tests/__init__.py | 2 + resource_booking/tests/common.py | 105 + resource_booking/tests/test_backend.py | 306 + resource_booking/tests/test_portal.py | 244 + .../views/calendar_event_views.xml | 18 + resource_booking/views/menus.xml | 30 + .../resource_booking_combination_views.xml | 75 + .../views/resource_booking_type_views.xml | 100 + .../views/resource_booking_views.xml | 129 + 39 files changed, 28659 insertions(+) create mode 100644 resource_booking/README.rst create mode 100644 resource_booking/__init__.py create mode 100644 resource_booking/__manifest__.py create mode 100644 resource_booking/controllers/__init__.py create mode 100644 resource_booking/controllers/portal.py create mode 100644 resource_booking/demo/res_users_demo.xml create mode 100644 resource_booking/i18n/es.po create mode 100644 resource_booking/i18n/resource_booking.pot create mode 100644 resource_booking/models/__init__.py create mode 100644 resource_booking/models/calendar_event.py create mode 100644 resource_booking/models/resource_booking.py create mode 100644 resource_booking/models/resource_booking_combination.py create mode 100644 resource_booking/models/resource_booking_type.py create mode 100644 resource_booking/models/resource_booking_type_combination_rel.py create mode 100644 resource_booking/models/resource_calendar.py create mode 100644 resource_booking/models/resource_resource.py create mode 100644 resource_booking/readme/CONFIGURE.rst create mode 100644 resource_booking/readme/CONTRIBUTORS.rst create mode 100644 resource_booking/readme/DESCRIPTION.rst create mode 100644 resource_booking/readme/INSTALL.rst create mode 100644 resource_booking/readme/ROADMAP.rst create mode 100644 resource_booking/readme/USAGE.rst create mode 100644 resource_booking/security/ir.model.access.csv create mode 100644 resource_booking/security/resource_booking_security.xml create mode 100644 resource_booking/static/description/icon.png create mode 100644 resource_booking/static/description/icon.svg create mode 100644 resource_booking/static/description/index.html create mode 100644 resource_booking/static/src/css/portal.scss create mode 100644 resource_booking/templates/assets.xml create mode 100644 resource_booking/templates/portal.xml create mode 100644 resource_booking/tests/__init__.py create mode 100644 resource_booking/tests/common.py create mode 100644 resource_booking/tests/test_backend.py create mode 100644 resource_booking/tests/test_portal.py create mode 100644 resource_booking/views/calendar_event_views.xml create mode 100644 resource_booking/views/menus.xml create mode 100644 resource_booking/views/resource_booking_combination_views.xml create mode 100644 resource_booking/views/resource_booking_type_views.xml create mode 100644 resource_booking/views/resource_booking_views.xml diff --git a/resource_booking/README.rst b/resource_booking/README.rst new file mode 100644 index 00000000..3b4cb174 --- /dev/null +++ b/resource_booking/README.rst @@ -0,0 +1,204 @@ +================ +Resource booking +================ + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcalendar-lightgray.png?logo=github + :target: https://github.com/OCA/calendar/tree/12.0/resource_booking + :alt: OCA/calendar +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/calendar-12-0/calendar-12-0-resource_booking + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/279/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a new app to allow you to book resource combinations in given +schedules. + +Example use cases: + +* Management of consultations in a clinic. +* Salesman appointments. +* Classroom and projector reservations. +* Hotel room booking. + +Among the things you can do: + +* Specify the type of booking, which includes a calendar of availability. +* Specify which resources can be booked together. All of them must be free to be booked. +* Place pending bookings, effectively giving permissions to someone to see the availability calendar and choose one slot. +* Partners can do that from their portals. +* If a partner has no user, he can still do the same via a tokenized URL. +* Backend users can also do that from the backend. +* Booking lifecycle with computed states. +* Automatic meeting creation and deletion. +* Automatic conflict detection. +* Deadline to block modifications. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +To install this module, you need to install these dependencies: + +#. `freezegun `__ +#. `web_calendar_slot_duration `__ + +When someone is a manager, he will have access to *Resource Bookings > +Configuration*, where he will be able to configure resources, leaves and +schedules. This menu is just provided as a commodity. However, if you want to +manage that stuff more comfortably: + +* To manage human resources, install `hr `__. +* To manage their leaves, install `hr_holidays `__. +* To manage work centers, install `mrp `__. + +Configuration +============= + +To let some backend user to book resources: + +#. Go to *Settings > Users & Companies > Users*. +#. Pick or create one. +#. Assign *Resource Booking > User*. + +To let some backend user to configure types and combinations, and to be able to +modify overdue bookings: + +#. Go to *Settings > Users & Companies > Users*. +#. Pick or create one. +#. Assign *Resource Booking > Manager*. + +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 *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. +#. Under *Meeting defaults*, you will be able to fill some values that will + be used by default on calendar meetings. These will appear in the global + calendar when some booking is reserved. +#. Choose some *Available resource combinations*. All combinations in the same + line must be free to be booked together; otherwise the booking will not be + able to be scheduled. You can sort them. +#. Pick up one *Combination Assignment*. If you choose *Sorted*, then the order + of the combinations you chose will indicate the one that is selected first. + Of course, it must be free to be selected. +#. Save. + +Usage +===== + +This module installs a new app, "Resource bookings". + +Bookings may involve you: + +* Maybe because you requested to book something. +* Maybe because you are one of the booked resources, if a booking represents + some kind of appointment. + +To see which bookings involve you: + +#. Go to *Resource Bookings > Bookings*. +#. You can switch to the list view if you need to see also the pending ones. +#. You can remove the "Involving me" filter if you want to see others' bookings. + +To book some resources: + +#. Go to *Resource Bookings > Types*. +#. Pick the type of booking you want. +#. 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. + +To invite someone to book a resource combination from the portal: + +#. Go to *Resource Bookings > Types*. +#. Pick the type of booking you want. +#. Click on *Booking Count*. +#. 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. +#. Click on *Share > Send*. +#. The requester will receive an email to select a calendar slot from his portal. + +Known issues / Roadmap +====================== + +* Allow combination auto-assignment based on least used combination. +* Allow customer to choose combination. +* Some error messages would be a bit more helpful if they specify the schedule + impossibility reason, but that should be done without affecting performance. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* Jairo Llopis (https://www.tecnativa.com/) + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-Yajo| image:: https://github.com/Yajo.png?size=40px + :target: https://github.com/Yajo + :alt: Yajo + +Current `maintainer `__: + +|maintainer-Yajo| + +This module is part of the `OCA/calendar `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/resource_booking/__init__.py b/resource_booking/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/resource_booking/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py new file mode 100644 index 00000000..41d8e5e8 --- /dev/null +++ b/resource_booking/__manifest__.py @@ -0,0 +1,43 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Resource booking", + "summary": "Manage appointments and resource booking", + "version": "12.0.1.0.0", + "development_status": "Beta", + "category": "Appointments", + "website": "https://github.com/OCA/calendar", + "author": "Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["Yajo"], + "license": "AGPL-3", + "application": True, + "installable": True, + "external_dependencies": { + "python": [ + "cssselect", # Used implicitly + "freezegun", # Only for tests + ], + }, + "depends": [ + "calendar", + "mail", + "portal", + "resource", + "web_calendar_slot_duration", + ], + "data": [ + "security/resource_booking_security.xml", + "security/ir.model.access.csv", + "templates/assets.xml", + "templates/portal.xml", + "views/calendar_event_views.xml", + "views/resource_booking_combination_views.xml", + "views/resource_booking_type_views.xml", + "views/resource_booking_views.xml", + "views/menus.xml", + ], + "demo": [ + "demo/res_users_demo.xml", + ], +} diff --git a/resource_booking/controllers/__init__.py b/resource_booking/controllers/__init__.py new file mode 100644 index 00000000..8c3feb6f --- /dev/null +++ b/resource_booking/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/resource_booking/controllers/portal.py b/resource_booking/controllers/portal.py new file mode 100644 index 00000000..11da8785 --- /dev/null +++ b/resource_booking/controllers/portal.py @@ -0,0 +1,139 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime +from urllib.parse import quote_plus + +from dateutil.parser import isoparse +from odoo.addons.portal.controllers import portal +from odoo.exceptions import AccessError, MissingError, ValidationError +from odoo.http import request, route +from odoo.tests.common import Form + + +class CustomerPortal(portal.CustomerPortal): + def _get_booking_sudo(self, booking_id, access_token): + """Get sudoed booking record from its ID.""" + booking_sudo = self._document_check_access( + "resource.booking", booking_id, access_token + ) + return booking_sudo.with_context( + using_portal=True, tz=booking_sudo.type_id.resource_calendar_id.tz + ) + + def _prepare_portal_layout_values(self): + """Compute values for multi-booking portal views.""" + values = super(CustomerPortal, self)._prepare_portal_layout_values() + booking_count = request.env["resource.booking"].search_count([]) + values.update({"booking_count": booking_count}) + return values + + def _booking_get_page_view_values(self, booking_sudo, access_token, **kwargs): + """Compute values for single-booking portal views.""" + return self._get_page_view_values( + booking_sudo, + access_token, + {"page_name": "booking", "booking_sudo": booking_sudo}, + "my_bookings_history", + False, + **kwargs + ) + + @route( + ["/my/bookings", "/my/bookings/page/"], + auth="user", + type="http", + website=True, + ) + def portal_my_bookings(self, page=1, **kwargs): + """List bookings that I can access.""" + Booking = request.env["resource.booking"] + values = self._prepare_portal_layout_values() + pager = portal.pager( + url="/my/bookings", + total=values["booking_count"], + page=page, + step=self._items_per_page, + ) + bookings = Booking.search( + [], limit=self._items_per_page, offset=pager["offset"] + ) + request.session["my_bookings_history"] = bookings.ids + values.update({"bookings": bookings, "pager": pager, "page_name": "bookings"}) + return request.render("resource_booking.portal_my_bookings", values) + + @route(["/my/bookings/"], type="http", auth="public", website=True) + def portal_booking_page(self, booking_id, access_token=None, **kwargs): + """Portal booking form.""" + try: + booking_sudo = self._get_booking_sudo(booking_id, access_token) + except (AccessError, MissingError): + return request.redirect("/my") + # ensure attachment are accessible with access token inside template + for attachment in booking_sudo.mapped("message_ids.attachment_ids"): + attachment.generate_access_token() + values = self._booking_get_page_view_values( + booking_sudo, access_token, **kwargs + ) + return request.render("resource_booking.resource_booking_portal_form", values) + + @route( + [ + "/my/bookings//schedule", + "/my/bookings//schedule//", + ], + auth="public", + type="http", + website=True, + ) + def portal_booking_schedule( + self, booking_id, access_token=None, year=None, month=None, error=None, **kwargs + ): + """Portal booking scheduling.""" + try: + booking_sudo = self._get_booking_sudo(booking_id, access_token) + except (AccessError, MissingError): + return request.redirect("/my") + values = self._booking_get_page_view_values( + booking_sudo, access_token, **kwargs + ) + values.update(booking_sudo._get_calendar_context(year, month)) + values.update({"error": error, "page_name": "booking_schedule"}) + return request.render( + "resource_booking.resource_booking_portal_schedule", values + ) + + @route( + ["/my/bookings//cancel"], + auth="public", + type="http", + website=True, + ) + def portal_booking_cancel(self, booking_id, access_token=None, **kwargs): + """Cancel the booking.""" + booking_sudo = self._get_booking_sudo(booking_id, access_token) + booking_sudo.action_cancel() + return request.redirect("/my") + + @route( + ["/my/bookings//confirm"], + auth="public", + type="http", + website=True, + ) + def portal_booking_confirm(self, booking_id, access_token, when, **kwargs): + """Confirm a booking in a given datetime.""" + booking_sudo = self._get_booking_sudo(booking_id, access_token) + when_tz_aware = isoparse(when) + when_naive = datetime.utcfromtimestamp(when_tz_aware.timestamp()) + try: + with Form(booking_sudo) as booking_form: + booking_form.start = when_naive + except ValidationError as error: + url = booking_sudo.get_portal_url( + suffix="/schedule/{0:%Y/%m}".format(when_tz_aware), + query_string="&error={}".format(quote_plus(error.name)), + ) + return request.redirect(url) + booking_sudo.action_confirm() + return request.redirect(booking_sudo.get_portal_url()) diff --git a/resource_booking/demo/res_users_demo.xml b/resource_booking/demo/res_users_demo.xml new file mode 100644 index 00000000..5bfa5eed --- /dev/null +++ b/resource_booking/demo/res_users_demo.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/resource_booking/i18n/es.po b/resource_booking/i18n/es.po new file mode 100644 index 00000000..d581310f --- /dev/null +++ b/resource_booking/i18n/es.po @@ -0,0 +1,1181 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * resource_booking +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-04-26 06:31+0000\n" +"PO-Revision-Date: 2021-04-26 07:35+0100\n" +"Last-Translator: Jairo Llopis \n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.4.2\n" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:359 +#, python-format +msgid "%(partner)s - %(type)s" +msgstr "%(partner)s - %(type)s" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:358 +#, python-format +msgid "%(partner)s - %(type)s - %(time)s" +msgstr "%(partner)s - %(type)s - %(time)s" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:57 +#, python-format +msgid "%(resources)s" +msgstr "%(resources)s" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:55 +#, python-format +msgid "%(resources)s (using calendar %(calendar)s)" +msgstr "%(resources)s (usando el calendario %(calendar)s)" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.alert_availability_lost +msgid "" +"
\n" +" Error details:" +msgstr "" +"
\n" +" Detalles del error:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "" +"\n" +" Reschedule" +msgstr "" +"\n" +" Reagendar" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "" +"\n" +" Schedule" +msgstr "" +"\n" +" Agendar" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "" +"\n" +" Feedback" +msgstr "" +"\n" +" Comentarios" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "" +"\n" +" Cancel this booking" +msgstr "" +"\n" +" Cancelar esta reserva/cita" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "" +"\n" +" Cancel" +msgstr "" +"\n" +" Cancelar" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_header +msgid "State:" +msgstr "Estado:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Preview" +msgstr "Previsualizar" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Advice:" +msgstr "Aviso:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Booked resources:" +msgstr "Recursos reservados:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Dates:" +msgstr "Fechas:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Location:" +msgstr "Ubicación:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Requested by:" +msgstr "Solicitado por:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.alert_availability_lost +msgid "The chosen schedule is no longer available." +msgstr "El horario escogido ya no está disponible." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Type:" +msgstr "Tipo:" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__access_warning +msgid "Access warning" +msgstr "Alerta de acceso" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_needaction +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_needaction +msgid "Action Needed" +msgstr "Acción necesaria" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__active +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__active +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__active +msgid "Active" +msgstr "Activo" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_ids +msgid "Activities" +msgstr "Actividades" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_state +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_state +msgid "Activity State" +msgstr "Estado de la actividad" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "All times are displayed using this timezone:" +msgstr "Todos los horarios se muestran usando esta zona horaria:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Are you sure?" +msgstr "¿Está seguro/a?" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_attachment_count +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_attachment_count +msgid "Attachment Count" +msgstr "Nº adjuntos" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__resource_calendar_id +msgid "Availability Calendar" +msgstr "Calendario de disponibilidad" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__combination_rel_ids +msgid "Available resource combinations" +msgstr "Combinaciones de recursos disponibles" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking_combination +msgid "Bookable resource combinations" +msgstr "Combinaciones de recursos reservables" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_header +msgid "Booking #" +msgstr "Reserva/cita nº" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__booking_count +msgid "Booking Count" +msgstr "Cuenta de reservas/citas" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__booking_count +msgid "Booking count" +msgstr "Cuenta de reservas/citas" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "Booking ref." +msgstr "Ref. reserva/cita" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:97 +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__type_count +#, python-format +msgid "Booking types" +msgstr "Tipos de reserva/cita" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:87 +#: code:addons/resource_booking/models/resource_booking_type.py:182 +#: model:ir.actions.act_window,name:resource_booking.resource_booking_action +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__booking_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__booking_ids +#: model:ir.ui.menu,name:resource_booking.resource_booking_menu +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_breadcrumbs +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_home +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_calendar +#, python-format +msgid "Bookings" +msgstr "Reservas/citas" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__booking_ids +msgid "Bookings available for this type" +msgstr "Reservas/citas disponibles para este tipo" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Cancel" +msgstr "Cancelar" + +#. module: resource_booking +#: selection:resource.booking,state:0 +msgid "Canceled" +msgstr "Cancelado" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:263 +#, python-format +msgid "" +"Cannot schedule these bookings because no resources are selected for them:\n" +"\n" +"- %s" +msgstr "" +"No se pueden agendar estas reservas/citas porque no se les han seleccionado " +"recursos:\n" +"\n" +"- %s" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:285 +#, python-format +msgid "" +"Cannot schedule these bookings because they do not fit in their type or " +"resources calendars, or because all resources are busy:\n" +"\n" +"- %s" +msgstr "" +"No se pueden agendar estas reservas/citas porque no encajan en los " +"calendarios de sus tipos o recursos, o porque todos sus recursos están " +"ocupados:\n" +"\n" +"- %s" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__combination_assignment +msgid "" +"Choose how to auto-assign resource combinations. It has no effect if assiged " +"manually." +msgstr "" +"Escoja cómo autoasignar combinaciones de recursos. No tendrá efecto si se " +"asigna manualmente." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.alert_availability_lost +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Close" +msgstr "Cerrar" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__combination_id +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Combination" +msgstr "Combinación" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__combination_assignment +msgid "Combination Assignment" +msgstr "Asignación de combinaciones" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.resource_booking_combination_menu +msgid "Combinations" +msgstr "Combinaciones" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__company_id +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_view_search +msgid "Company" +msgstr "Compañía" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__company_id +msgid "Company where this booking type is available." +msgstr "Compañía en la que este tipo de reservas/citas está disponible." + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.resource_booking_type_configuration_menu +msgid "Configuration" +msgstr "Configuración" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Confirm" +msgstr "Confirmar" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Confirm booking" +msgstr "Confirmar reserva/cita" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "" +"Confirm that the requesting partner and yourself will attend the scheduled " +"meeting." +msgstr "" +"Confirmar que el solicitante y usted mismo asistirán a la reunión agendada." + +#. module: resource_booking +#: selection:resource.booking,state:0 +msgid "Confirmed" +msgstr "Confirmado" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__create_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__create_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__create_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__create_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__create_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__create_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__create_date +msgid "Created on" +msgstr "Creado en" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_header +msgid "Current state of this booking" +msgstr "Estado actual de esta reserva/cita" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__access_url +msgid "Customer Portal URL" +msgstr "URL del portal de cliente" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "Date" +msgstr "Fecha" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__alarm_ids +msgid "Default reminders" +msgstr "Recordatorios por defecto" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__categ_ids +msgid "Default tags" +msgstr "Etiquetas por defecto" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_combination_action +msgid "Define bookable resource combinations." +msgstr "Defina las combinaciones de recursos reservables." + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_type_action +msgid "Define resource booking types." +msgstr "Defina los tipos de reservas/citas de recursos." + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_action +msgid "Define resource bookings." +msgstr "Defina las reservas/citas de recursos." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__display_name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__display_name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__display_name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__duration +msgid "Duration" +msgstr "Duración" + +#. module: resource_booking +#: sql_constraint:resource.booking.type:0 +msgid "Duration must be positive." +msgstr "La duración debe ser positiva." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Duration:" +msgstr "Duración:" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__duration +msgid "Establish each interval's duration." +msgstr "Establezca la duración de cada intervalo." + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_calendar_event +msgid "Event" +msgstr "Evento" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_follower_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_follower_ids +msgid "Followers" +msgstr "Seguidores" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_channel_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_channel_ids +msgid "Followers (Channels)" +msgstr "Seguidores (canales)" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_partner_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_partner_ids +msgid "Followers (Partners)" +msgstr "Seguidores (contactos)" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_combination__forced_calendar_id +msgid "Force a specific calendar, instead of combining the resources'." +msgstr "" +"Forzar un calendario específico, en lugar de combinar los de los recursos." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__forced_calendar_id +msgid "Forced calendar" +msgstr "Calendario forzado" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Go back" +msgstr "Volver" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_view_search +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Group By" +msgstr "Agrupar por" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__id +msgid "ID" +msgstr "ID" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_unread +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_unread +msgid "If checked new messages require your attention." +msgstr "Si está marcado, hay nuevos mensajes que requieren de su atención." + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_needaction +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_needaction +msgid "If checked, new messages require your attention." +msgstr "Si está marcado, hay nuevos mensajes que requieren de su atención." + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_has_error +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "Si está marcado, algunos mensajes no se pudieron entregar." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "If you cancel this booking:" +msgstr "Si cancela esta reserva/cita:" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Invite requesting partner to portal." +msgstr "Invitar al solicitante al portal." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__involves_me +msgid "Involves Me" +msgstr "Me corresponde" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Involving me" +msgstr "Me corresponden" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_is_follower +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_is_follower +msgid "Is Follower" +msgstr "Es un seguidor" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__is_modifiable +msgid "Is Modifiable" +msgstr "Es modificable" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__is_overdue +msgid "Is Overdue" +msgstr "Está vencida" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "It will be unscheduled." +msgstr "Se desagendará." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "It will disappear from your bookings list." +msgstr "Desaparecerá de su lista de reservas/citas." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking____last_update +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination____last_update +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type____last_update +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel____last_update +msgid "Last Modified on" +msgstr "Última modificación en" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__write_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__write_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__write_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__write_uid +msgid "Last Updated by" +msgstr "Última actualización de" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__write_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__write_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__write_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__write_date +msgid "Last Updated on" +msgstr "Última actualización en" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__location +msgid "Location" +msgstr "Ubicación" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_main_attachment_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_main_attachment_id +msgid "Main Attachment" +msgstr "Adjunto principal" + +#. module: resource_booking +#: model:res.groups,name:resource_booking.group_manager +msgid "Manager" +msgstr "Responsable" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__meeting_id +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Meeting" +msgstr "Reunión" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__meeting_id +msgid "Meeting confirmed for this booking." +msgstr "Reunión confirmada para esta reserva/cita." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_form +msgid "Meeting defaults" +msgstr "Valores por defecto para la reunión" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__alarm_ids +msgid "Meetings will be created with these reminders by default." +msgstr "Las reuniones se crearán con estos recordatorios por defecto." + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__categ_ids +msgid "Meetings will be created with these tags by default." +msgstr "Las reuniones se crearán con estas etiquetas por defecto." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_has_error +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_has_error +msgid "Message Delivery error" +msgstr "Error de entrega de mensaje" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Message and communication history" +msgstr "Historial de mensajes y comunicaciones" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_ids +msgid "Messages" +msgstr "Mensajes" + +#. module: resource_booking +#: sql_constraint:resource.booking:0 +msgid "Missing resource booking combination." +msgstr "Falta la combinación de reserva de recursos." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__modifications_deadline +msgid "Modifications Deadline" +msgstr "Fecha límite para modificaciones" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__type_name +msgid "Name" +msgstr "Nombre" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_date_deadline +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "Fecha límite de siguiente actividad" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_summary +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_summary +msgid "Next Activity Summary" +msgstr "Resumen de siguiente actividad" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_type_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_type_id +msgid "Next Activity Type" +msgstr "Tipo de siguiente actividad" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Next month" +msgstr "Siguiente mes" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "No free slots found this month." +msgstr "No quedan huecos libres este mes." + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:390 +#, python-format +msgid "No resource combinations available on %s" +msgstr "No hay combinaciones de recursos disponibles en %s" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_needaction_counter +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_needaction_counter +msgid "Number of Actions" +msgstr "Número de acciones" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_has_error_counter +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_has_error_counter +msgid "Number of error" +msgstr "Número de errores" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_needaction_counter +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "Número de mensajes que requieren una acción" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_has_error_counter +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Número de mensajes con error de entrega" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_unread_counter +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_unread_counter +msgid "Number of unread messages" +msgstr "Número de mensajes no leídos" + +#. module: resource_booking +#: sql_constraint:resource.booking:0 +msgid "Only one event per resource booking can exist." +msgstr "Solo puede existir un evento por reserva de recursos." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Open a calendar to schedule a meeting for this booking request." +msgstr "" +"Abrir un calendario para agendar una reunión para esta solicitud de reserva/" +"cita." + +#. module: resource_booking +#: selection:resource.booking,activity_state:0 +#: selection:resource.booking.type,activity_state:0 +msgid "Overdue" +msgstr "Atrasado" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +#: selection:resource.booking,state:0 +msgid "Pending" +msgstr "Pendiente" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__state +msgid "" +"Pending: No meeting scheduled.\n" +"Scheduled: The requester has not confirmed attendance yet.\n" +"Confirmed: Meeting scheduled, and requester attendance confirmed.\n" +"Canceled: Meeting removed, booking archived." +msgstr "" +"Pendiente: No se ha agendado ninguna reunión.\n" +"Agendada: El solicitante todavía no ha confirmado su asistencia.\n" +"Confirmada: La reunión ha sido agendada, y el solicitante ha confirmado su " +"asistencia.\n" +"Cancelada: La reunión se ha borrado y la reserva/cita se ha archivado." + +#. module: resource_booking +#: selection:resource.booking,activity_state:0 +#: selection:resource.booking.type,activity_state:0 +msgid "Planned" +msgstr "Planificado" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Please confirm this is really what you want." +msgstr "Por favor, confirme que esto es lo que de verdad quiere hacer." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__access_url +msgid "Portal Access URL" +msgstr "URL de acceso al portal" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Previous month" +msgstr "Mes anterior" + +#. module: resource_booking +#: selection:resource.booking.type,combination_assignment:0 +msgid "Randomly: order is not important" +msgstr "Aleatoria: el orden no es importante" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__partner_id +msgid "Requester" +msgstr "Solicitante" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__requester_advice +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__requester_advice +msgid "Requester Advice" +msgstr "Aviso al solicitante" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:445 +#, python-format +msgid "Requesting partner" +msgstr "Solicitante" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking +#: model:ir.module.category,name:resource_booking.category_resource_booking +msgid "Resource Booking" +msgstr "Reserva de recursos" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking_type +msgid "Resource Booking Type" +msgstr "Tipo de reserva de recursos" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.resource_booking_main_menu +msgid "Resource Bookings" +msgstr "Reservas de recursos" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.menu_view_resource_calendar_leaves_search +msgid "Resource Leaves" +msgstr "Ausencias del recurso" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_calendar +msgid "Resource Working Time" +msgstr "Horario de trabajo del recurso" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_calendar_event__resource_booking_ids +msgid "Resource booking" +msgstr "Reserva de recursos" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking_type_combination_rel +msgid "Resource booking type relation with combinations" +msgstr "Relación del tipo de reservas de recursos con sus combinaciones" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__type_rel_ids +msgid "Resource booking types" +msgstr "Tipos de reserva de recursos" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_combination__type_rel_ids +msgid "Resource booking types where this combination is available." +msgstr "Tipos de reserva de recursos donde esta combinación está disponible." + +#. module: resource_booking +#: model:ir.actions.act_window,name:resource_booking.resource_booking_combination_action +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_form +msgid "Resource combinations" +msgstr "Combinaciones de recursos" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__combination_rel_ids +msgid "Resource combinations available for this type of bookings." +msgstr "" +"Combinaciones de recursos disponibles para este tipo de reservas/citas." + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_resource +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__resource_ids +#: model:ir.ui.menu,name:resource_booking.menu_resource_resource +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "Resources" +msgstr "Recursos" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__combination_id +msgid "Resources combination" +msgstr "Combinación de recursos" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_combination__resource_ids +msgid "Resources that must be free to be booked together." +msgstr "Recursos que deben estar libres para reservarse juntos." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_user_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_user_id +msgid "Responsible User" +msgstr "Usuario responsable" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__resource_calendar_id +msgid "Restrict bookings to this schedule." +msgstr "Restringir reservas/citas a este horario." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_breadcrumbs +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Schedule" +msgstr "Horario" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:465 +#, python-format +msgid "Schedule booking" +msgstr "Agendar reserva/cita" + +#. module: resource_booking +#: selection:resource.booking,state:0 +msgid "Scheduled" +msgstr "Planificado" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Scheduled or confirmed" +msgstr "Agendada o confirmada" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__access_token +msgid "Security Token" +msgstr "Token de seguridad" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__sequence +msgid "Sequence" +msgstr "Secuencia" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Set pending" +msgstr "Hacer pendiente" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Set to pending" +msgstr "Establecer como pendiente" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_form +msgid "Settings" +msgstr "Ajustes" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Share" +msgstr "Compartir" + +#. module: resource_booking +#: selection:resource.booking.type,combination_assignment:0 +msgid "Sorted: pick the first one that is free" +msgstr "Ordenada: escoger el primero que esté libre" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__start +msgid "Start" +msgstr "Iniciar" + +#. module: resource_booking +#: sql_constraint:resource.booking:0 +msgid "Start and stop must be filled or emptied together." +msgstr "El inicio y el fin deben rellenarse o vaciarse simultáneamente." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Start:" +msgstr "Inicio:" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__state +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "State" +msgstr "Estado" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__activity_state +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"Estado basado en actividades\n" +"Vencida: la fecha tope ya ha pasado\n" +"Hoy: La fecha tope es hoy\n" +"Planificada: futuras actividades." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__stop +msgid "Stop" +msgstr "Parar" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__categ_ids +msgid "Tags" +msgstr "Etiquetas" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__requester_advice +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__requester_advice +msgid "" +"Text that will appear by default in portal invitation emails and in calendar " +"views for scheduling." +msgstr "" +"Texto que aparecerá por defecto en los correos electrónicos de invitación al " +"portal y en las vistas de calendario para agendar." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "There are currently no bookings for your account." +msgstr "Actualmente no hay reservas/citas en su cuenta." + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_type_action +msgid "" +"These records categorize resource bookings and apply restrictions to them, " +"such as available resource combinations, availability schedules and interval " +"duration." +msgstr "" +"Estos registros categorizan las reservas/citas de recursos y les aplican " +"restricciones, como combinaciones de recursos disponibles, horarios de " +"disponibilidad y duración de los intervalos." + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_combination_action +msgid "" +"These records define resource combinations that can be booked together in " +"specified schedules and intervals." +msgstr "" +"Estos registros definen combinaciones de recursos que se pueden reservar " +"juntas en horarios e intervalos especificados." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "This booking exceeded its modifications deadline." +msgstr "Esta reserva/cita ha sobrepasado su fecha límite para modificaciones." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "" +"This will remove the associated meeting to unschedule the booking. Are you " +"sure?" +msgstr "" +"Esto eliminará la reunión asociada para desagendar esta reserva/cita. ¿Está " +"seguro?" + +#. module: resource_booking +#: selection:resource.booking,activity_state:0 +#: selection:resource.booking.type,activity_state:0 +msgid "Today" +msgstr "Hoy" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "" +"Try next month\n" +" " +msgstr "" +"Intentar el mes siguiente\n" +" " + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__type_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__type_id +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Type" +msgstr "Tipo" + +#. module: resource_booking +#: model:ir.actions.act_window,name:resource_booking.resource_booking_type_action +#: model:ir.ui.menu,name:resource_booking.resource_booking_type_menu +msgid "Types" +msgstr "Tipos" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_unread +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_unread +msgid "Unread Messages" +msgstr "Mensajes por leer" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_unread_counter +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_unread_counter +msgid "Unread Messages Counter" +msgstr "Contador de mensajes sin leer" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Unschedule" +msgstr "Desagendar" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Unschedule this booking and archive it." +msgstr "Desagendar esta reserva/cita y archivarla." + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Unschedule this booking." +msgstr "Desagendar esta reserva/cita." + +#. module: resource_booking +#: model:res.groups,name:resource_booking.group_user +msgid "User" +msgstr "Usuario" + +#. module: resource_booking +#: model:res.groups,comment:resource_booking.group_user +msgid "Users allowed to book resources" +msgstr "Usuarios que pueden reservar recursos" + +#. module: resource_booking +#: model:res.groups,comment:resource_booking.group_manager +msgid "Users allowed to manage resource booking configurations." +msgstr "" +"Usuarios que pueden gestionar la configuración de las reservas de recursos." + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__website_message_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__website_message_ids +msgid "Website Messages" +msgstr "Mensajes del sitio web" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__website_message_ids +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__website_message_ids +msgid "Website communication history" +msgstr "Historial de comunicaciones del sitio web" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_action +msgid "" +"When scheduled, resources will be blocked. When pending, it means the " +"requester didn't place the booking yet." +msgstr "" +"Cuando esté agendada, los recursos estarán bloqueados. Cuando esté " +"pendiente, significa que el solicitante todavía no ha agendado su reserva/" +"cita." + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__modifications_deadline +msgid "" +"When this deadline has been exceeded, if a booking was not yet confirmed, it " +"will be canceled automatically. Also, only booking managers will be able to " +"unschedule or reschedule them. The value is expressed in hours." +msgstr "" +"Cuando esta fecha límite se haya sobrepasado, si una reserva/cita todavía no " +"se había confirmado, será cancelada automáticamente. También, solo los " +"responsables de reservas/citas podrán desagendarla o reagendarla. El valor " +"se expresa en horas." + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__partner_id +msgid "Who requested this booking?" +msgstr "¿Quién solicitó esta reserva/cita?" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.menu_resource_calendar +msgid "Working Times" +msgstr "Tiempos de Trabajo" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "You are about to confirm this booking:" +msgstr "Está a punto de confirmar esta reserva/cita:" + +#. module: resource_booking +#: code:addons/resource_booking/models/calendar_event.py:32 +#, python-format +msgid "" +"You are not allowed to alter these bookings because they exceeded their " +"modification deadlines:\n" +"\n" +"- %s" +msgstr "" +"No puede alterar estas reservas/citas porque han sobrepasado su fecha límite " +"de modificaciones:\n" +"\n" +"- %s" diff --git a/resource_booking/i18n/resource_booking.pot b/resource_booking/i18n/resource_booking.pot new file mode 100644 index 00000000..bef30b9b --- /dev/null +++ b/resource_booking/i18n/resource_booking.pot @@ -0,0 +1,1086 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * resource_booking +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:359 +#, python-format +msgid "%(partner)s - %(type)s" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:358 +#, python-format +msgid "%(partner)s - %(type)s - %(time)s" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:57 +#, python-format +msgid "%(resources)s" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:55 +#, python-format +msgid "%(resources)s (using calendar %(calendar)s)" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.alert_availability_lost +msgid "
\n" +" Error details:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "\n" +" Reschedule" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "\n" +" Schedule" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "\n" +" Feedback" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "\n" +" Cancel this booking" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "\n" +" Cancel" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_header +msgid "State:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Preview" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Advice:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Booked resources:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Dates:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Location:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Requested by:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.alert_availability_lost +msgid "The chosen schedule is no longer available." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Type:" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__access_warning +msgid "Access warning" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_needaction +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__active +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__active +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__active +msgid "Active" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_ids +msgid "Activities" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_state +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_state +msgid "Activity State" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "All times are displayed using this timezone:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Are you sure?" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_attachment_count +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__resource_calendar_id +msgid "Availability Calendar" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__combination_rel_ids +msgid "Available resource combinations" +msgstr "" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking_combination +msgid "Bookable resource combinations" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_header +msgid "Booking #" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__booking_count +msgid "Booking Count" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__booking_count +msgid "Booking count" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "Booking ref." +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:97 +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__type_count +#, python-format +msgid "Booking types" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking_combination.py:87 +#: code:addons/resource_booking/models/resource_booking_type.py:182 +#: model:ir.actions.act_window,name:resource_booking.resource_booking_action +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__booking_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__booking_ids +#: model:ir.ui.menu,name:resource_booking.resource_booking_menu +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_breadcrumbs +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_home +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_calendar +#, python-format +msgid "Bookings" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__booking_ids +msgid "Bookings available for this type" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Cancel" +msgstr "" + +#. module: resource_booking +#: selection:resource.booking,state:0 +msgid "Canceled" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:263 +#, python-format +msgid "Cannot schedule these bookings because no resources are selected for them:\n" +"\n" +"- %s" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:285 +#, python-format +msgid "Cannot schedule these bookings because they do not fit in their type or resources calendars, or because all resources are busy:\n" +"\n" +"- %s" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__combination_assignment +msgid "Choose how to auto-assign resource combinations. It has no effect if assiged manually." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.alert_availability_lost +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Close" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__combination_id +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Combination" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__combination_assignment +msgid "Combination Assignment" +msgstr "" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.resource_booking_combination_menu +msgid "Combinations" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__company_id +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_view_search +msgid "Company" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__company_id +msgid "Company where this booking type is available." +msgstr "" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.resource_booking_type_configuration_menu +msgid "Configuration" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Confirm" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Confirm booking" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Confirm that the requesting partner and yourself will attend the scheduled meeting." +msgstr "" + +#. module: resource_booking +#: selection:resource.booking,state:0 +msgid "Confirmed" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__create_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__create_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__create_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__create_uid +msgid "Created by" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__create_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__create_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__create_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__create_date +msgid "Created on" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_header +msgid "Current state of this booking" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__access_url +msgid "Customer Portal URL" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "Date" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__alarm_ids +msgid "Default reminders" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__categ_ids +msgid "Default tags" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_combination_action +msgid "Define bookable resource combinations." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_type_action +msgid "Define resource booking types." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_action +msgid "Define resource bookings." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__display_name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__display_name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__display_name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__display_name +msgid "Display Name" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__duration +msgid "Duration" +msgstr "" + +#. module: resource_booking +#: sql_constraint:resource.booking.type:0 +msgid "Duration must be positive." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Duration:" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__duration +msgid "Establish each interval's duration." +msgstr "" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_calendar_event +msgid "Event" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_follower_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_channel_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_channel_ids +msgid "Followers (Channels)" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_partner_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_combination__forced_calendar_id +msgid "Force a specific calendar, instead of combining the resources'." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__forced_calendar_id +msgid "Forced calendar" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Go back" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_view_search +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Group By" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__id +msgid "ID" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_unread +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_unread +msgid "If checked new messages require your attention." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_needaction +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_has_error +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "If you cancel this booking:" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Invite requesting partner to portal." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__involves_me +msgid "Involves Me" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Involving me" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_is_follower +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__is_modifiable +msgid "Is Modifiable" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__is_overdue +msgid "Is Overdue" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "It will be unscheduled." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "It will disappear from your bookings list." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking____last_update +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination____last_update +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type____last_update +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel____last_update +msgid "Last Modified on" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__write_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__write_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__write_uid +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__write_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__write_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__write_date +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__write_date +msgid "Last Updated on" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__location +msgid "Location" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_main_attachment_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_main_attachment_id +msgid "Main Attachment" +msgstr "" + +#. module: resource_booking +#: model:res.groups,name:resource_booking.group_manager +msgid "Manager" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__meeting_id +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Meeting" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__meeting_id +msgid "Meeting confirmed for this booking." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_form +msgid "Meeting defaults" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__alarm_ids +msgid "Meetings will be created with these reminders by default." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__categ_ids +msgid "Meetings will be created with these tags by default." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_has_error +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Message and communication history" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_ids +msgid "Messages" +msgstr "" + +#. module: resource_booking +#: sql_constraint:resource.booking:0 +msgid "Missing resource booking combination." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__modifications_deadline +msgid "Modifications Deadline" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__name +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__type_name +msgid "Name" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_date_deadline +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_summary +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_type_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Next month" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "No free slots found this month." +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:390 +#, python-format +msgid "No resource combinations available on %s" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_needaction_counter +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_has_error_counter +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_has_error_counter +msgid "Number of error" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_needaction_counter +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_has_error_counter +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__message_unread_counter +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__message_unread_counter +msgid "Number of unread messages" +msgstr "" + +#. module: resource_booking +#: sql_constraint:resource.booking:0 +msgid "Only one event per resource booking can exist." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Open a calendar to schedule a meeting for this booking request." +msgstr "" + +#. module: resource_booking +#: selection:resource.booking,activity_state:0 +#: selection:resource.booking.type,activity_state:0 +msgid "Overdue" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +#: selection:resource.booking,state:0 +msgid "Pending" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__state +msgid "Pending: No meeting scheduled.\n" +"Scheduled: The requester has not confirmed attendance yet.\n" +"Confirmed: Meeting scheduled, and requester attendance confirmed.\n" +"Canceled: Meeting removed, booking archived." +msgstr "" + +#. module: resource_booking +#: selection:resource.booking,activity_state:0 +#: selection:resource.booking.type,activity_state:0 +msgid "Planned" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "Please confirm this is really what you want." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__access_url +msgid "Portal Access URL" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Previous month" +msgstr "" + +#. module: resource_booking +#: selection:resource.booking.type,combination_assignment:0 +msgid "Randomly: order is not important" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__partner_id +msgid "Requester" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__requester_advice +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__requester_advice +msgid "Requester Advice" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:445 +#, python-format +msgid "Requesting partner" +msgstr "" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking +#: model:ir.module.category,name:resource_booking.category_resource_booking +msgid "Resource Booking" +msgstr "" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking_type +msgid "Resource Booking Type" +msgstr "" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.resource_booking_main_menu +msgid "Resource Bookings" +msgstr "" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.menu_view_resource_calendar_leaves_search +msgid "Resource Leaves" +msgstr "" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_calendar +msgid "Resource Working Time" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_calendar_event__resource_booking_ids +msgid "Resource booking" +msgstr "" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_booking_type_combination_rel +msgid "Resource booking type relation with combinations" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__type_rel_ids +msgid "Resource booking types" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_combination__type_rel_ids +msgid "Resource booking types where this combination is available." +msgstr "" + +#. module: resource_booking +#: model:ir.actions.act_window,name:resource_booking.resource_booking_combination_action +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_form +msgid "Resource combinations" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__combination_rel_ids +msgid "Resource combinations available for this type of bookings." +msgstr "" + +#. module: resource_booking +#: model:ir.model,name:resource_booking.model_resource_resource +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_combination__resource_ids +#: model:ir.ui.menu,name:resource_booking.menu_resource_resource +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "Resources" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__combination_id +msgid "Resources combination" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_combination__resource_ids +msgid "Resources that must be free to be booked together." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__activity_user_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__resource_calendar_id +msgid "Restrict bookings to this schedule." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_breadcrumbs +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Schedule" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/resource_booking.py:465 +#, python-format +msgid "Schedule booking" +msgstr "" + +#. module: resource_booking +#: selection:resource.booking,state:0 +msgid "Scheduled" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Scheduled or confirmed" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__access_token +msgid "Security Token" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__sequence +msgid "Sequence" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Set pending" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Set to pending" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_type_form +msgid "Settings" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Share" +msgstr "" + +#. module: resource_booking +#: selection:resource.booking.type,combination_assignment:0 +msgid "Sorted: pick the first one that is free" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__start +msgid "Start" +msgstr "" + +#. module: resource_booking +#: sql_constraint:resource.booking:0 +msgid "Start and stop must be filled or emptied together." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Start:" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__state +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "State" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__activity_state +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__activity_state +msgid "Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__stop +msgid "Stop" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__categ_ids +msgid "Tags" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__requester_advice +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__requester_advice +msgid "Text that will appear by default in portal invitation emails and in calendar views for scheduling." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +msgid "There are currently no bookings for your account." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_type_action +msgid "These records categorize resource bookings and apply restrictions to them, such as available resource combinations, availability schedules and interval duration." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_combination_action +msgid "These records define resource combinations that can be booked together in specified schedules and intervals." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_portal_form +msgid "This booking exceeded its modifications deadline." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "This will remove the associated meeting to unschedule the booking. Are you sure?" +msgstr "" + +#. module: resource_booking +#: selection:resource.booking,activity_state:0 +#: selection:resource.booking.type,activity_state:0 +msgid "Today" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "Try next month\n" +" " +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__type_id +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type_combination_rel__type_id +#: model_terms:ir.ui.view,arch_db:resource_booking.portal_my_bookings +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Type" +msgstr "" + +#. module: resource_booking +#: model:ir.actions.act_window,name:resource_booking.resource_booking_type_action +#: model:ir.ui.menu,name:resource_booking.resource_booking_type_menu +msgid "Types" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_unread +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_unread +msgid "Unread Messages" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__message_unread_counter +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__message_unread_counter +msgid "Unread Messages Counter" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Unschedule" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Unschedule this booking and archive it." +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_form +msgid "Unschedule this booking." +msgstr "" + +#. module: resource_booking +#: model:res.groups,name:resource_booking.group_user +msgid "User" +msgstr "" + +#. module: resource_booking +#: model:res.groups,comment:resource_booking.group_user +msgid "Users allowed to book resources" +msgstr "" + +#. module: resource_booking +#: model:res.groups,comment:resource_booking.group_manager +msgid "Users allowed to manage resource booking configurations." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking__website_message_ids +#: model:ir.model.fields,field_description:resource_booking.field_resource_booking_type__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__website_message_ids +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.actions.act_window,help:resource_booking.resource_booking_action +msgid "When scheduled, resources will be blocked. When pending, it means the requester didn't place the booking yet." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking_type__modifications_deadline +msgid "When this deadline has been exceeded, if a booking was not yet confirmed, it will be canceled automatically. Also, only booking managers will be able to unschedule or reschedule them. The value is expressed in hours." +msgstr "" + +#. module: resource_booking +#: model:ir.model.fields,help:resource_booking.field_resource_booking__partner_id +msgid "Who requested this booking?" +msgstr "" + +#. module: resource_booking +#: model:ir.ui.menu,name:resource_booking.menu_resource_calendar +msgid "Working Times" +msgstr "" + +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar +msgid "You are about to confirm this booking:" +msgstr "" + +#. module: resource_booking +#: code:addons/resource_booking/models/calendar_event.py:32 +#, python-format +msgid "You are not allowed to alter these bookings because they exceeded their modification deadlines:\n" +"\n" +"- %s" +msgstr "" + diff --git a/resource_booking/models/__init__.py b/resource_booking/models/__init__.py new file mode 100644 index 00000000..38ad3480 --- /dev/null +++ b/resource_booking/models/__init__.py @@ -0,0 +1,7 @@ +from . import calendar_event +from . import resource_booking +from . import resource_booking_combination +from . import resource_booking_type +from . import resource_booking_type_combination_rel +from . import resource_calendar +from . import resource_resource diff --git a/resource_booking/models/calendar_event.py b/resource_booking/models/calendar_event.py new file mode 100644 index 00000000..a14e6721 --- /dev/null +++ b/resource_booking/models/calendar_event.py @@ -0,0 +1,53 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CalendarEvent(models.Model): + _name = "calendar.event" + _inherit = "calendar.event" + + # One2one field, actually + resource_booking_ids = fields.One2many( + comodel_name="resource.booking", + inverse_name="meeting_id", + string="Resource booking", + ) + + @api.constrains("resource_booking_ids", "start", "stop") + def _check_bookings_scheduling(self): + """Scheduled bookings must have no conflicts.""" + bookings = self.mapped("resource_booking_ids") + return bookings._check_scheduling() + + def _validate_booking_modifications(self): + """Make sure you can cancel a booking meeting.""" + bookings = self.mapped("resource_booking_ids") + modifiable = bookings.filtered("is_modifiable") + frozen = bookings - modifiable + if frozen: + raise ValidationError( + _( + "You are not allowed to alter these bookings because " + "they exceeded their modification deadlines:\n\n- %s" + ) + % "\n- ".join(frozen.mapped("display_name")) + ) + + def unlink(self): + """Check you're allowed to unschedule it.""" + self._validate_booking_modifications() + return super().unlink() + + def write(self, vals): + """Check you're allowed to reschedule it.""" + before = [(one.start, one.stop) for one in self] + result = super().write(vals) + rescheduled = self + for (old_start, old_stop), new in zip(before, self): + if old_start == new.start and old_stop == new.stop: + rescheduled -= new + rescheduled._validate_booking_modifications() + return result diff --git a/resource_booking/models/resource_booking.py b/resource_booking/models/resource_booking.py new file mode 100644 index 00000000..11bac403 --- /dev/null +++ b/resource_booking/models/resource_booking.py @@ -0,0 +1,514 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import calendar + +from datetime import datetime, timedelta +from contextlib import suppress + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.addons.resource.models.resource import Intervals +from odoo.exceptions import ValidationError +from odoo.osv.expression import NEGATIVE_TERM_OPERATORS + + +class ResourceBooking(models.Model): + _name = "resource.booking" + _inherit = ["mail.thread", "mail.activity.mixin", "portal.mixin"] + _description = "Resource Booking" + _order = "start DESC" + _sql_constraints = [ + ( + "combination_required_if_event", + "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)", + "Only one event per resource booking can exist.", + ), + ] + + active = fields.Boolean(index=True, default=True) + 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, + ondelete="set null", + help="Meeting confirmed for this booking.", + ) + categ_ids = fields.Many2many( + string="Tags", + comodel_name="calendar.event.type", + ) + combination_id = fields.Many2one( + comodel_name="resource.booking.combination", + string="Resources combination", + copy=False, + domain="[('type_rel_ids.type_id', 'in', [type_id])]", + index=True, + states={"scheduled": [("required", True)], "confirmed": [("required", True)]}, + track_visibility="onchange", + ) + name = fields.Char(compute="_compute_name") + partner_id = fields.Many2one( + "res.partner", + string="Requester", + index=True, + ondelete="cascade", + required=True, + track_visibility="onchange", + help="Who requested this booking?", + ) + 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_overdue") + is_overdue = fields.Boolean(compute="_compute_overdue") + state = fields.Selection( + [ + ("pending", "Pending"), + ("scheduled", "Scheduled"), + ("confirmed", "Confirmed"), + ("canceled", "Canceled"), + ], + compute="_compute_state", + store=True, + default="pending", + index=True, + track_visibility="onchange", + help=( + "Pending: No meeting scheduled.\n" + "Scheduled: The requester has not confirmed attendance yet.\n" + "Confirmed: Meeting scheduled, and requester attendance confirmed.\n" + "Canceled: Meeting removed, booking archived." + ), + ) + start = fields.Datetime( + compute="_compute_dates", + copy=False, + index=True, + inverse="_inverse_dates", + store=True, + track_sequence=200, + track_visibility="onchange", + ) + stop = fields.Datetime( + compute="_compute_dates", + copy=False, + index=True, + inverse="_inverse_dates", + store=True, + track_sequence=210, + track_visibility="onchange", + ) + type_id = fields.Many2one( + comodel_name="resource.booking.type", + string="Type", + index=True, + ondelete="cascade", + required=True, + track_visibility="onchange", + ) + + def _compute_access_url(self): + result = super()._compute_access_url() + for one in self: + one.access_url = "/my/bookings/%d" % one.id + return result + + @api.depends("combination_id", "partner_id") + def _compute_involves_me(self): + """Indicate if the booking involves you.""" + mine = self.search([("involves_me", "=", True)]) + alien = self - mine + alien.update({"involves_me": False}) + mine.update({"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_overdue(self): + """Indicate if booking is overdue and modifiable.""" + is_manager = self.env.user.has_group( + "resource_booking.group_manager" + ) and not self.env.context.get("using_portal") + now = fields.Datetime.now() + for one in self: + # You can always modify it if there's no meeting yet + if not one.start: + one.is_overdue = False + one.is_modifiable = True + continue + anticipation = timedelta(hours=one.type_id.modifications_deadline) + deadline = one.start - anticipation + one.is_overdue = now > deadline + # Managers can always modify bookings + one.is_modifiable = is_manager or not one.is_overdue + + @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("active", "meeting_id.attendee_ids.state") + def _compute_state(self): + """Obtain request state.""" + for one in self: + if not one.active: + one.state = "canceled" + continue + confirmed = False + for attendee in one.meeting_id.attendee_ids: + if attendee.partner_id == one.partner_id: + confirmed = attendee.state == "accepted" + break + if confirmed: + one.state = "confirmed" + continue + one.state = "scheduled" if one.meeting_id else "pending" + + @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 one.env.in_onchange and 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): + """Lazy-create or destroy calendar.event.""" + # Notify changed dates to attendees + _self = self.with_context(from_ui=self.env.context.get("from_ui", True)) + to_create, to_delete = [], _self.env["calendar.event"] + for one in _self: + if one.start and one.stop: + 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(), + categ_ids=[(6, 0, one.categ_ids.ids)], + name=one._get_name_formatted(one.partner_id, one.type_id), + partner_ids=[ + (4, partner.id, 0) + for partner in one.partner_id | resource_partners + ], + resource_booking_ids=[(6, 0, one.ids)], + start=one.start, + stop=one.stop, + # These 2 avoid creating event as activity + res_model_id=False, + res_id=False, + ) + if one.meeting_id: + one.meeting_id.write(meeting_vals) + else: + to_create.append(meeting_vals) + else: + to_delete |= one.meeting_id + to_delete.unlink() + _self.env["calendar.event"].create(to_create) + + @api.constrains("combination_id", "meeting_id", "type_id") + def _check_scheduling(self): + """Scheduled bookings must have no conflicts.""" + # Nothing to do if no bookings are scheduled + has_meeting = self.filtered("meeting_id") + if not has_meeting: + return + # Ensure all scheduled bookings have booked some resources + has_rbc = self.filtered("combination_id.resource_ids") + missing_rbc = has_meeting - has_rbc + if missing_rbc: + raise ValidationError( + _( + "Cannot schedule these bookings because no resources " + "are selected for them:\n\n- %s" + ) + % ("\n- ".join(missing_rbc.mapped("display_name"))) + ) + # Ensure all bookings fit in their type and resources calendars + unfitting_bookings = has_meeting + for booking in has_meeting: + meeting_dates = tuple( + fields.Datetime.context_timestamp(self, booking[field]) + for field in ("start", "stop") + ) + available_intervals = booking._get_intervals(*meeting_dates) + if ( + len(available_intervals) == 1 + and available_intervals._items[0][:2] == meeting_dates + ): + unfitting_bookings -= booking + # Explain which bookings failed validation + if unfitting_bookings: + raise ValidationError( + _( + "Cannot schedule these bookings because they do not fit " + "in their type or resources calendars, or because " + "all resources are busy:\n\n- %s" + ) + % "\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) + + @api.onchange("type_id") + def _onchange_type_fill_tags(self): + """Copy default tags from RBT when changing it.""" + if self.type_id: + self.categ_ids = self.type_id.categ_ids + + @api.onchange("start", "stop", "type_id") + def _onchange_dates_pick_combination(self): + """Select best combination candidate when changing booking dates.""" + # Useless without the interval + if not (self.start and self.stop): + return + self.combination_id = self._get_best_combination() + + def _get_calendar_context(self, year=None, month=None, now=None): + """Get the required context for the calendar view in the portal. + + See the `resource_booking.scheduling_calendar` view. + + :param int year: Year of the calendar to be displayed. + :param int month: Month of the calendar to be displayed. + :param datetime now: Represents the current datetime. + """ + month1 = relativedelta(months=1) + now = 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 = 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) + return { + "booking": self, + "calendar": calendar.Calendar(lang.week_start - 1), + "now": now, + "res_lang": lang, + "slots": slots, + "start": start, + "weekday_names": weekday_names, + } + + @api.model + def _get_name_formatted(self, partner, type_, meeting=None): + """Produce a beautifully formatted name.""" + values = {"partner": partner.display_name, "type": type_.display_name} + if meeting: + values["time"] = meeting.display_time + return _("%(partner)s - %(type)s - %(time)s") % values + return _("%(partner)s - %(type)s") % values + + 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): + return self.combination_id + # If there's a combination already, put it 1st (highest priority) + sorted_combinations = self.combination_id + ( + self.type_id._get_combinations_priorized() - self.combination_id + ) + desired_interval = tuple( + fields.Datetime.context_timestamp(self, dt) + for dt in (self.start, self.stop) + ) + # Get 1st combination available in the desired interval + for combination in sorted_combinations: + availability = self._get_intervals(*desired_interval, combination) + if ( + len(availability) == 1 + and availability._items[0][:2] == desired_interval + ): + return combination + # TODO In v13 experiment failing always. In v12, datetime widget + # triggers onchange on every click, and renders fail=True unusable, but + # it would be nice to warn users when they're selecting dates where + # nobody is available + if self.env.context.get("using_portal"): + # Tell user there's no combination available + hours = (self.stop - self.start).total_seconds() / 3600 + raise ValidationError( + _("No resource combinations available on %s") + % self.env["calendar.event"]._get_display_time( + self.start, self.stop, hours, False + ) + ) + + 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) + current = 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 + duration, self)]) + for start, end, _meta in available_intervals & current_interval: + if end - start == duration: + result.setdefault(current.date(), []) + result[current.date()].append(current) + # I actually only care about the 1st interval, if any + break + current += duration + return result + + def _get_intervals(self, start_dt, end_dt, combination=None): + """Get available intervals for this booking.""" + # Get all intervals except those from current booking + try: + booking_id = self.id or self._origin.id or -1 + except AttributeError: + booking_id = -1 + booking = self.with_context(analyzing_booking=booking_id) + # 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 + # available ones + combinations = ( + combination + or booking.combination_id + or booking.mapped("type_id.combination_rel_ids.combination_id") + ).with_context(analyzing_booking=booking_id) + result &= combinations._get_intervals(start_dt, end_dt) + return result + + def message_get_suggested_recipients(self): + recipients = super().message_get_suggested_recipients() + for one in self: + if one.partner_id: + one._message_add_suggested_recipient( + recipients, partner=one.partner_id, reason=_("Requesting partner") + ) + return recipients + + def action_schedule(self): + """Redirect user to a simpler way to schedule this booking.""" + FloatTimeParser = self.env["ir.qweb.field.float_time"] + return { + "context": dict( + self.env.context, + # These 2 avoid creating event as activity + default_res_model_id=False, + default_res_id=False, + # Context used by web_calendar_slot_duration module + calendar_slot_duration=FloatTimeParser.value_to_html( + self.type_id.duration, False + ), + default_resource_booking_ids=[(6, 0, self.ids)], + default_name=self.name, + ), + "name": _("Schedule booking"), + "res_model": "calendar.event", + "target": "self", + "type": "ir.actions.act_window", + "view_mode": "calendar,tree,form", + } + + def action_confirm(self): + """Confirm own and requesting partner's attendance.""" + attendees_to_confirm = self.env["calendar.attendee"] + confirm_always = self.env["res.partner"] + if self.env.context.get("confirm_own_attendance"): + confirm_always |= self.env.user.partner_id + # Avoid wasted state recomputes + with self.env.norecompute(): + for booking in self: + if not booking.meeting_id: + continue + # Make sure requester and user resources are meeting attendees + booking.meeting_id.partner_ids |= booking.partner_id | booking.mapped( + "combination_id.resource_ids.user_id.partner_id" + ) + # Find meeting attendees that should be confirmed + partners_to_confirm = confirm_always | booking.partner_id + for attendee in booking.meeting_id.attendee_ids: + if attendee.partner_id & partners_to_confirm: + # attendee.state='accepted' + attendees_to_confirm |= attendee + attendees_to_confirm.write({"state": "accepted"}) + self.recompute() + + def action_unschedule(self): + """Remove associated meetings.""" + self.mapped("meeting_id").unlink() + # Force recomputing, in case meeting_id is not visible in the form + self.write({"meeting_id": False}) + + def action_cancel(self): + """Cancel this booking.""" + # Remove related meeting + self.action_unschedule() + # Archive and reset access token + self.write({"active": False, "access_token": False}) + + def action_open_portal(self): + return { + "target": "self", + "type": "ir.actions.act_url", + "url": self.get_portal_url(), + } diff --git a/resource_booking/models/resource_booking_combination.py b/resource_booking/models/resource_booking_combination.py new file mode 100644 index 00000000..752c1172 --- /dev/null +++ b/resource_booking/models/resource_booking_combination.py @@ -0,0 +1,101 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.addons.resource.models.resource import Intervals + + +class ResourceBookingCombination(models.Model): + _name = "resource.booking.combination" + _description = "Bookable resource combinations" + + active = fields.Boolean(index=True, default=True) + booking_count = fields.Integer( + compute="_compute_booking_count", string="Booking count" + ) + booking_ids = fields.One2many( + comodel_name="resource.booking", + inverse_name="combination_id", + string="Bookings", + ) + forced_calendar_id = fields.Many2one( + comodel_name="resource.calendar", + string="Forced calendar", + index=True, + help="Force a specific calendar, instead of combining the resources'.", + ) + name = fields.Char(compute="_compute_name", store=True) + type_count = fields.Integer(compute="_compute_type_count", string="Booking types") + type_rel_ids = fields.One2many( + comodel_name="resource.booking.type.combination.rel", + inverse_name="combination_id", + string="Resource booking types", + help="Resource booking types where this combination is available.", + ) + resource_ids = fields.Many2many( + string="Resources", + comodel_name="resource.resource", + required=True, + help="Resources that must be free to be booked together.", + ) + + @api.depends("booking_ids") + def _compute_booking_count(self): + for one in self: + one.booking_count = len(one.booking_ids) + + @api.depends("resource_ids.name", "forced_calendar_id.name") + def _compute_name(self): + for one in self: + data = { + "resources": " + ".join(sorted(one.resource_ids.mapped("name"))), + "calendar": one.forced_calendar_id.name, + } + if one.forced_calendar_id: + one.name = _("%(resources)s (using calendar %(calendar)s)") % data + else: + one.name = _("%(resources)s") % data + + @api.depends("type_rel_ids") + def _compute_type_count(self): + for one in self: + one.type_count = len(one.type_rel_ids) + + @api.constrains("booking_ids", "forced_calendar_id", "resource_ids") + def _check_bookings_scheduling(self): + """Scheduled bookings must have no conflicts.""" + bookings = self.mapped("booking_ids") + return bookings._check_scheduling() + + 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: + combination_intervals = base + for res in combination.resource_ids: + if not combination_intervals: + break # Can't restrict more + calendar = combination.forced_calendar_id or res.calendar_id + combination_intervals &= calendar._work_intervals(start_dt, end_dt, res) + result |= combination_intervals + return result + + def action_open_bookings(self): + return { + "domain": [("combination_id", "in", self.ids)], + "name": _("Bookings"), + "res_model": "resource.booking", + "type": "ir.actions.act_window", + "view_mode": "calendar,tree,form", + } + + def action_open_resource_booking_types(self): + return { + "context": self.env.context, + "domain": [("combination_rel_ids.combination_id", "in", self.ids)], + "name": _("Booking types"), + "res_model": "resource.booking.type", + "type": "ir.actions.act_window", + "view_mode": "tree,form", + } diff --git a/resource_booking/models/resource_booking_type.py b/resource_booking/models/resource_booking_type.py new file mode 100644 index 00000000..6a99bc45 --- /dev/null +++ b/resource_booking/models/resource_booking_type.py @@ -0,0 +1,186 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta +from odoo import _, api, fields, models +from math import ceil +from random import random + + +class ResourceBookingType(models.Model): + _name = "resource.booking.type" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Resource Booking Type" + _sql_constraints = [ + ("duration_positive", "CHECK(duration > 0)", "Duration must be positive."), + ] + + active = fields.Boolean(index=True, default=True) + alarm_ids = fields.Many2many( + string="Default reminders", + comodel_name="calendar.alarm", + help="Meetings will be created with these reminders by default.", + ) + booking_count = fields.Integer(compute="_compute_booking_count") + categ_ids = fields.Many2many( + string="Default tags", + comodel_name="calendar.event.type", + help="Meetings will be created with these tags by default.", + ) + combination_assignment = fields.Selection( + [ + ("sorted", "Sorted: pick the first one that is free"), + ("random", "Randomly: order is not important"), + ], + required=True, + default="random", + help=( + "Choose how to auto-assign resource combinations. " + "It has no effect if assiged manually." + ), + ) + combination_rel_ids = fields.One2many( + comodel_name="resource.booking.type.combination.rel", + inverse_name="type_id", + string="Available resource combinations", + help="Resource combinations available for this type of bookings.", + ) + company_id = fields.Many2one( + comodel_name="res.company", + default=lambda self: self._default_company(), + index=True, + readonly=False, + store=True, + string="Company", + help="Company where this booking type is available.", + ) + duration = fields.Float( + required=True, + default=0.5, # 30 minutes + help="Establish each interval's duration.", + ) + location = fields.Char() + modifications_deadline = fields.Float( + required=True, + default=24, + help=( + "When this deadline has been exceeded, if a booking was not yet " + "confirmed, it will be canceled automatically. Also, only booking " + "managers will be able to unschedule or reschedule them. " + "The value is expressed in hours." + ), + ) + name = fields.Char(index=True, translate=True, required=True) + booking_ids = fields.One2many( + "resource.booking", + "type_id", + string="Bookings", + help="Bookings available for this type", + ) + resource_calendar_id = fields.Many2one( + comodel_name="resource.calendar", + default=lambda self: self._default_resource_calendar(), + index=True, + required=True, + ondelete="restrict", + string="Availability Calendar", + help="Restrict bookings to this schedule.", + ) + requester_advice = fields.Text( + translate=True, + help=( + "Text that will appear by default in portal invitation emails " + "and in calendar views for scheduling." + ), + ) + + @api.model + def _default_company(self): + return self.env["res.company"]._company_default_get() + + @api.model + def _default_resource_calendar(self): + return self._default_company().resource_calendar_id + + @api.depends("booking_ids") + def _compute_booking_count(self): + for one in self: + one.booking_count = len(one.booking_ids) + + @api.constrains("booking_ids", "resource_calendar_id", "combination_rel_ids") + def _check_bookings_scheduling(self): + """Scheduled bookings must have no conflicts.""" + 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: + return self.combination_rel_ids.mapped("combination_id") + keys = {"sorted": "sequence", "random": lambda *a: random()} + rels = self.combination_rel_ids.sorted(keys[self.combination_assignment]) + 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) + attendance_intervals = self.resource_calendar_id._attendance_intervals( + workday_min, end_dt + ) + try: + workday_start, valid_end, _meta = attendance_intervals._items[-1] + if valid_end != end_dt: + # Inteval 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 ( + self.resource_calendar_id.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"] + return { + "context": dict( + self.env.context, + # 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"), + "res_model": "resource.booking", + "type": "ir.actions.act_window", + "view_mode": "calendar,tree,form", + } diff --git a/resource_booking/models/resource_booking_type_combination_rel.py b/resource_booking/models/resource_booking_type_combination_rel.py new file mode 100644 index 00000000..51330088 --- /dev/null +++ b/resource_booking/models/resource_booking_type_combination_rel.py @@ -0,0 +1,28 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResourceBookingCombinationRel(models.Model): + _name = "resource.booking.type.combination.rel" + _description = "Resource booking type relation with combinations" + _order = "sequence" + _rec_name = "combination_id" + + sequence = fields.Integer(index=True, required=True, default=100) + combination_id = fields.Many2one( + "resource.booking.combination", + string="Combination", + index=True, + required=True, + ondelete="cascade", + ) + type_id = fields.Many2one( + "resource.booking.type", + string="Type", + index=True, + required=True, + ondelete="cascade", + ) + type_name = fields.Char(related="type_id.name") diff --git a/resource_booking/models/resource_calendar.py b/resource_booking/models/resource_calendar.py new file mode 100644 index 00000000..3b0b6032 --- /dev/null +++ b/resource_booking/models/resource_calendar.py @@ -0,0 +1,97 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from pytz import UTC +from odoo import api, fields, models +from odoo.addons.resource.models.resource import Intervals +from odoo.addons.calendar.models.calendar import calendar_id2real_id + + +class Busy(Exception): + pass # This is not a real exception, just a helper + + +class ResourceCalendar(models.Model): + _inherit = "resource.calendar" + + @api.constrains("attendance_ids", "global_leave_ids", "leave_ids", "tz") + def _check_bookings_scheduling(self): + """Scheduled bookings must have no conflicts.""" + bookings = self.env["resource.booking"].search( + [ + "|", + ("combination_id.forced_calendar_id", "in", self.ids), + ("combination_id.resource_ids.calendar_id", "in", self.ids), + ] + ) + return bookings._check_scheduling() + + @api.model + def _calendar_event_busy_intervals( + self, start_dt, end_dt, resource, analyzed_booking_id + ): + """Get busy meeting intervals.""" + assert start_dt.tzinfo + assert end_dt.tzinfo + start_dt, end_dt = ( + fields.Datetime.to_string(dt.astimezone(UTC)) for dt in (start_dt, end_dt) + ) + intervals = [] + resource_user = ( + resource.resource_type == "user" + and resource.user_id.active + and resource.user_id + ) + # Simple domain to get all possibly conflicting events in a single + # query; this reduces DB calls and helps the underlying recurring + # system (in calendar.event) to work smoothly + all_events = ( + self.env["calendar.event"] + .with_context(active_test=True) + .search([("start", "<=", end_dt), ("stop", ">=", start_dt)]) + ) + for event in all_events: + real_event = self.env["calendar.event"].browse( + calendar_id2real_id(event.id), all_events._prefetch + ) + # Is the event the same one we're currently checking? + if real_event.resource_booking_ids.id == analyzed_booking_id: + continue + try: + # Is the event not booking our resource? + if resource & real_event.mapped( + "resource_booking_ids.combination_id.resource_ids" + ): + raise Busy + # Special cases when the booked resource is a person + if resource_user: + # Is it a busy event belonging to the resource? + if event.user_id == resource_user and event.show_as == "busy": + raise Busy + # ... or is he invited to this event? + for attendee in event.attendee_ids: + if ( + attendee.partner_id == resource_user.partner_id + and attendee.state != "declined" + ): + raise Busy + except Busy: + # Add the matched event as a busy interval + intervals.append( + ( + fields.Datetime.context_timestamp(event, event.start), + fields.Datetime.context_timestamp(event, event.stop), + event, + ) + ) + return Intervals(intervals) + + # TODO Override _leave_intervals_batch in v13 + def _leave_intervals(self, start_dt, end_dt, resource=None, domain=None): + """Count busy meetings as leaves if required by context.""" + result = super()._leave_intervals(start_dt, end_dt, resource, domain) + if resource and self.env.context.get("analyzing_booking"): + result |= self._calendar_event_busy_intervals( + start_dt, end_dt, resource, self.env.context["analyzing_booking"] + ) + return result diff --git a/resource_booking/models/resource_resource.py b/resource_booking/models/resource_resource.py new file mode 100644 index 00000000..b2e11615 --- /dev/null +++ b/resource_booking/models/resource_resource.py @@ -0,0 +1,16 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ResourceResource(models.Model): + _inherit = "resource.resource" + + @api.constrains("calendar_id", "resource_type", "tz", "user_id") + def _check_bookings_scheduling(self): + """Scheduled bookings must have no conflicts.""" + bookings = self.env["resource.booking"].search( + [("combination_id.resource_ids", "in", self.ids)] + ) + return bookings._check_scheduling() diff --git a/resource_booking/readme/CONFIGURE.rst b/resource_booking/readme/CONFIGURE.rst new file mode 100644 index 00000000..8f1675bb --- /dev/null +++ b/resource_booking/readme/CONFIGURE.rst @@ -0,0 +1,32 @@ +To let some backend user to book resources: + +#. Go to *Settings > Users & Companies > Users*. +#. Pick or create one. +#. Assign *Resource Booking > User*. + +To let some backend user to configure types and combinations, and to be able to +modify overdue bookings: + +#. Go to *Settings > Users & Companies > Users*. +#. Pick or create one. +#. Assign *Resource Booking > Manager*. + +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 *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. +#. Under *Meeting defaults*, you will be able to fill some values that will + be used by default on calendar meetings. These will appear in the global + calendar when some booking is reserved. +#. Choose some *Available resource combinations*. All combinations in the same + line must be free to be booked together; otherwise the booking will not be + able to be scheduled. You can sort them. +#. Pick up one *Combination Assignment*. If you choose *Sorted*, then the order + of the combinations you chose will indicate the one that is selected first. + Of course, it must be free to be selected. +#. Save. diff --git a/resource_booking/readme/CONTRIBUTORS.rst b/resource_booking/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..7ee45dc9 --- /dev/null +++ b/resource_booking/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Jairo Llopis (https://www.tecnativa.com/) diff --git a/resource_booking/readme/DESCRIPTION.rst b/resource_booking/readme/DESCRIPTION.rst new file mode 100644 index 00000000..1ac3b12a --- /dev/null +++ b/resource_booking/readme/DESCRIPTION.rst @@ -0,0 +1,22 @@ +This module adds a new app to allow you to book resource combinations in given +schedules. + +Example use cases: + +* Management of consultations in a clinic. +* Salesman appointments. +* Classroom and projector reservations. +* Hotel room booking. + +Among the things you can do: + +* Specify the type of booking, which includes a calendar of availability. +* Specify which resources can be booked together. All of them must be free to be booked. +* Place pending bookings, effectively giving permissions to someone to see the availability calendar and choose one slot. +* Partners can do that from their portals. +* If a partner has no user, he can still do the same via a tokenized URL. +* Backend users can also do that from the backend. +* Booking lifecycle with computed states. +* Automatic meeting creation and deletion. +* Automatic conflict detection. +* Deadline to block modifications. diff --git a/resource_booking/readme/INSTALL.rst b/resource_booking/readme/INSTALL.rst new file mode 100644 index 00000000..45b8c178 --- /dev/null +++ b/resource_booking/readme/INSTALL.rst @@ -0,0 +1,13 @@ +To install this module, you need to install these dependencies: + +#. `freezegun `__ +#. `web_calendar_slot_duration `__ + +When someone is a manager, he will have access to *Resource Bookings > +Configuration*, where he will be able to configure resources, leaves and +schedules. This menu is just provided as a commodity. However, if you want to +manage that stuff more comfortably: + +* To manage human resources, install `hr `__. +* To manage their leaves, install `hr_holidays `__. +* To manage work centers, install `mrp `__. diff --git a/resource_booking/readme/ROADMAP.rst b/resource_booking/readme/ROADMAP.rst new file mode 100644 index 00000000..d3ea9f1b --- /dev/null +++ b/resource_booking/readme/ROADMAP.rst @@ -0,0 +1,4 @@ +* Allow combination auto-assignment based on least used combination. +* Allow customer to choose combination. +* Some error messages would be a bit more helpful if they specify the schedule + impossibility reason, but that should be done without affecting performance. diff --git a/resource_booking/readme/USAGE.rst b/resource_booking/readme/USAGE.rst new file mode 100644 index 00000000..da908d65 --- /dev/null +++ b/resource_booking/readme/USAGE.rst @@ -0,0 +1,37 @@ +This module installs a new app, "Resource bookings". + +Bookings may involve you: + +* Maybe because you requested to book something. +* Maybe because you are one of the booked resources, if a booking represents + some kind of appointment. + +To see which bookings involve you: + +#. Go to *Resource Bookings > Bookings*. +#. You can switch to the list view if you need to see also the pending ones. +#. You can remove the "Involving me" filter if you want to see others' bookings. + +To book some resources: + +#. Go to *Resource Bookings > Types*. +#. Pick the type of booking you want. +#. 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. + +To invite someone to book a resource combination from the portal: + +#. Go to *Resource Bookings > Types*. +#. Pick the type of booking you want. +#. Click on *Booking Count*. +#. 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. +#. Click on *Share > Send*. +#. The requester will receive an email to select a calendar slot from his portal. diff --git a/resource_booking/security/ir.model.access.csv b/resource_booking/security/ir.model.access.csv new file mode 100644 index 00000000..3f55dc84 --- /dev/null +++ b/resource_booking/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +resource_booking_combination_user,Permission to read resource booking combinations,model_resource_booking_combination,group_user,1,0,0,0 +resource_booking_combination_manager,Permission to write resource booking combinations,model_resource_booking_combination,group_manager,1,1,1,1 +resource_booking_type_user,Permission to read resource booking types,model_resource_booking_type,group_user,1,0,0,0 +resource_booking_type_manager,Permission to write resource booking types,model_resource_booking_type,group_manager,1,1,1,1 +resource_booking_portal,Resource bookings for portal,model_resource_booking,base.group_portal,1,0,0,0 +resource_booking_user,Resource bookings for users,model_resource_booking,group_user,1,1,1,0 +resource_booking_manager,Resource bookings for managers,model_resource_booking,group_manager,1,1,1,1 +resource_resource_manager,Permission to write resources,resource.model_resource_resource,group_manager,1,1,1,1 +resource_booking_type_combination_rel_user,Permission to read resource booking type combination relations for users,model_resource_booking_type_combination_rel,group_user,1,0,0,0 +resource_booking_type_combination_rel_manager,Permission to read resource booking type combination relations for managers,model_resource_booking_type_combination_rel,group_manager,1,1,1,1 diff --git a/resource_booking/security/resource_booking_security.xml b/resource_booking/security/resource_booking_security.xml new file mode 100644 index 00000000..1f3af9a0 --- /dev/null +++ b/resource_booking/security/resource_booking_security.xml @@ -0,0 +1,55 @@ + + + + + + + Resource Booking + + + + User + + Users allowed to book resources + + + + Manager + + + Users allowed to manage resource booking configurations. + + + + + + Resource booking type multi company rule + + + ['|', ('company_id', '=', False), ('company_id', 'child_of', user.company_id.ids)] + + + + Resource booking portal rule + + + ['|', ('partner_id', 'child_of', user.partner_id.ids), ('message_partner_ids', 'child_of', user.partner_id.ids)] + + + + Resource booking user rule + + + ['|', '|', ('partner_id', 'child_of', user.partner_id.ids), ('message_partner_ids', 'child_of', user.partner_id.ids), ('combination_id.resource_ids.user_id', 'in', user.ids)] + + + + Resource booking manager rule + + + [(1, '=', 1)] + + + + diff --git a/resource_booking/static/description/icon.png b/resource_booking/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f27a772eb46cb0c35783d004b5bf8e9b86791717 GIT binary patch literal 4337 zcmV14g%i{uvxV;jkq;+K$QAaRYuThoMw&d?-~0Cot5H0TBB zw6u^;ThBBh6JqiLrk;`xHV$-JLY)brgqcidT00FTP?!?h27~;HC1kzc?MZjKz1{5} zrgrdm<|u70ECT?f z*g?-R5ml*6JpBV549es!Bw3D&iaa_pIsMYPap93y@4tJj&r(!aJ z#Tt!%Kd$_NHV5TE%(Wbc=DIqxH?9IlC;(u1Z0womZ@+cVVwG2a=Lh>QU)%oId?W%{ zmT_`&67Nh*LX;$3G5J#_jWSFo{bdT^DMe2tf}ThiJf(Q!^yvqG@zuM&O?nO-i1yWl z&UJ6vRG*e*{Kwhzn4V9$5tBujOkT+Z0Ii`Ac6N1w5~N>v=eL*AtVHgn^`ZJKfM1?D zhv`&m#YKva8|x`!FBYRJb;{^*Td_GP2fw(PbQ&klo&!f{C{|N<50xqTEUgRS?XfY; zq|>=l^1^@a#AE>`lb13HnGD_>Zg*I;*oxaAZ0&GXp~C z1_~fDK9_>5sCQa+N0yn)S7(`E$fN+6$;`J>03@z9X{GMSWHMKsWr87-kjYRAC?)2J zm`tXR+lv2X%19C@0LY45q6m}8^l@A9w@k94MC?cBBw70*%jjERl-0&7ApBo37(}&gv^eQz>xc<7Mt*1dCBA z05|plh?uc4CaZXtnKXKN{>e-8-I$8Ce2N#C;}_iZ_|5L`GVE7jH8Yot<^K zPh>DEMFBrH3L=UQ#$*-uGn0;Sr+1l&WadE}8=V!au}m3R22Bb+mq^9k%v2^LEx(Jq zQv-BX05@r5w%01<{#7cd`CtUd20U-F>~*t7iZJuHCp1 z?F|hEuCP}o8$>czIhTloOHAW;wrmC=?r%L&sSp4=4G&iAlgTEL^i|F!X22(MYi6C1 zh<#Nv003RhMK{+ggFQ0YD3W&NydZ#2Bo>dftXgGn)eN+0y!@bzb(w4ysdyz^JOM5~ zlV34w)@;YxXKGC*2Sm!RgclOvg}6pQ4F6zWCWl1IRl+CY;MC`cm`4G@oJ@|1#3coA z%lbrMO<902F{PvHnGS<+i8%N~;d4YRB?Sv(%O-U3oK0{-vWP^CV4^=W@p%4_=Es1VgFkmZ=F4dU%&gV?e`5LS9Et{`}#{f zj6gdx6@MPdfJ~g=j6?wN+I#Qe(VxBO$i0KcpZ>v(9*YEM$wbXQIRF5BA_3l!_stmo zMQg3igiN_iv@lP^=7{`*E3+(GYi$N(;)H@sq_8<6U*UmF`bKTbj&p(lKAu=O%mnf+ zF3YrXi9|!D!c0V*T&iPO$3>Znn^3YjORPM>@HZ~Wq-)g13yHjk(ilF)!U)_dWk+qC zAb>K7f`dmgX^z_Xq>y{Yn)RuJg$FW~9JS@1C(3oK+1no0XsD}mzfzZFD!SIj%@*{D z6vde>`zxIGmKHp|?@nxAe+mBeH^0F{&;A31l#BaKntT)63@b)$T*2EOfc=$L1JULt zJT-97;`-Qi+qU7z;DGyY&$yJC@b@w-@Vw>(UEt&ud@?R4ct?a zGA)X<^tK1X3aeXN@$}FDE{R5qo_Aa(n_uhK3k8&1$2!S|_*Pld)`p|^4wjTirLVIS zPk!yIXsQo66Nm>gQ7$Qf7iP+rWN5NB+=in=1L)HED*E>I>+y{*ebKpqT$YLQLISKe zZH;x|Fpdrl8s5*~`t93MS7UOoGd;K{69w2)AXey%L~wLy5bJbrbhX55?+s(#;IBIw z#wD330GR%jrz)o_62Z}-0jxFgduu00M)AjokJuknF zVtYOJB~o!uriyIZcK7w-yLWs!Z|mic_w?Y2yZ4!zx7pC%j>7}}rX*53*6vHB;!ZPH zaU$*R>&4^y?nEuWQkb2aH{;-4#$?*q(T=Bw223?)?)8bv;k-;0C6bmzD!aC9!DDx7 zmr=3OocTv06=!9t7?F1OZNb62_URI2cV90K?A-&7mj3g5)~>qD^pQj= z&dF2}B5{=9kv)41{T#*b?%0m+?7h9HOqW|M4B26$KqYbS})GXQ~@F>AcSgW zmr{J@^2_n9+iuOfcFVf8I6T;oaAQy<(lha6zf1uaDK{p~N)o>D{4ppd)_QLI*hld< zU%UmB5cGDg!{LE`v>EzMj~xDxNX31b0z8kDKe6%C*Iq+oD1^WG+)akxdb#P!E6@`#WwHO}FBq2TQ#*E;isaPTMUUr+v~W+R z05)yfAI@{XdIkHR`?0BJ&M2qGF5=4v{Wv;Xjyug<07WXFY21)07!s*CFH?X-GA4zS zG6hQ_6=!7%h)CvM4Ru1MU`nLooJ;`_$=dtHcFGiNiBz1CDF7l_f5XKNnJPmf75ilh zfJpXWMW)JgRjo)sPNs*C{UlGOcSlFD=ds7DOd=KYG6i(e);^v&egad;B=&ytlQ=m# zf=6F?5%IY>ufy^Xa|^vyFA@N}^v0Wb>5VtN4#P(nm#M0oHij0)nmHyCLj(OXF_9Q$ z&?gfUiJ^n?GBJ@DI?vRt;zO;=ulXmb-XS%ag=a>{7wZXS3*k;YCbpJdEdtOzw$PVr2SXr%vJ4 z@BaX!)6=enQYnll61eT3p2O=GM#`3TS|%=f<;V8}W`~UWuOhT`+3`dI$6oz40HAyI zYSh)#n2M%a5K<}p==gEm|MOqq^s;@K^n4pmO%0GO_uj9VpL-y%+jWjoT)trgx}p(; z8ygL8m{qALih`+R66Yr-@#g8%NQYyPL!lLIidQoE?wM^#mht-VFkT-X_Bs^bl8JUe z2^If6T#I|OnZ`eBZ5Hxk<+GIa#b`T}P~Flg87umq7GHNXg?~gM_BWH20|7uFMx(jr z48V%d%43=QBN9UsG1d+_RJh}=e`I1J1pqM;g0m`J{XER4>I&sh6CGwBk^e5aa?>hwV%#v(;J z)`eKiOb*JVOC<9;uyLIp#Ark#6BXi5nOM{2B?Kjg_~r1QOiU!tAP~b*jUj$H{M7+x zB6)-ujX0o=ByO{$V^Nm7f}cx#$u37H)kd9Z2q`SnaMtp*dwt; zSXkCp6o3HPXcTgt-4ZR;W$Cy@nJ8e>Al-%EkPRLr_t_GHOe6}q)~@EP6yC|inl=~6 zM59pDc_PREy?G-O)nA-p1~D32l1RnxG8sCXV?iX=zj17=BN{I+E>NK|i4zl0Bq=$sG(1V)dGwJRYQs?lgdjCKg7o;fUC~xN zneg)FtjxDxNir!4GNU8Nj9frnSJwj8Ysi_%?=B{#;BGUr00wtS;JU|#817{xmlfNr@ zhb{g6Cl%1kva_>9nxBW9nYTUcAd_usfl^zhPhBw%S-(~Qa{Mh4t?E700000NkvXXu0mjf25~x^ literal 0 HcmV?d00001 diff --git a/resource_booking/static/description/icon.svg b/resource_booking/static/description/icon.svg new file mode 100644 index 00000000..f0361814 --- /dev/null +++ b/resource_booking/static/description/icon.svg @@ -0,0 +1,22828 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resource_booking/static/description/index.html b/resource_booking/static/description/index.html new file mode 100644 index 00000000..c3917ac8 --- /dev/null +++ b/resource_booking/static/description/index.html @@ -0,0 +1,548 @@ + + + + + + +Resource booking + + + +
+

Resource booking

+ + +

Beta License: AGPL-3 OCA/calendar Translate me on Weblate Try me on Runbot

+

This module adds a new app to allow you to book resource combinations in given +schedules.

+

Example use cases:

+
    +
  • Management of consultations in a clinic.
  • +
  • Salesman appointments.
  • +
  • Classroom and projector reservations.
  • +
  • Hotel room booking.
  • +
+

Among the things you can do:

+
    +
  • Specify the type of booking, which includes a calendar of availability.
  • +
  • Specify which resources can be booked together. All of them must be free to be booked.
  • +
  • Place pending bookings, effectively giving permissions to someone to see the availability calendar and choose one slot.
  • +
  • Partners can do that from their portals.
  • +
  • If a partner has no user, he can still do the same via a tokenized URL.
  • +
  • Backend users can also do that from the backend.
  • +
  • Booking lifecycle with computed states.
  • +
  • Automatic meeting creation and deletion.
  • +
  • Automatic conflict detection.
  • +
  • Deadline to block modifications.
  • +
+

Table of contents

+ +
+

Installation

+

To install this module, you need to install these dependencies:

+
    +
  1. freezegun
  2. +
  3. web_calendar_slot_duration
  4. +
+

When someone is a manager, he will have access to Resource Bookings > +Configuration, where he will be able to configure resources, leaves and +schedules. This menu is just provided as a commodity. However, if you want to +manage that stuff more comfortably:

+
    +
  • To manage human resources, install hr.
  • +
  • To manage their leaves, install hr_holidays.
  • +
  • To manage work centers, install mrp.
  • +
+
+
+

Configuration

+

To let some backend user to book resources:

+
    +
  1. Go to Settings > Users & Companies > Users.
  2. +
  3. Pick or create one.
  4. +
  5. Assign Resource Booking > User.
  6. +
+

To let some backend user to configure types and combinations, and to be able to +modify overdue bookings:

+
    +
  1. Go to Settings > Users & Companies > Users.
  2. +
  3. Pick or create one.
  4. +
  5. Assign Resource Booking > Manager.
  6. +
+

To configure one booking type:

+
    +
  1. Go to Resource Bookings > Types.
  2. +
  3. Create one.
  4. +
  5. Give it a name.
  6. +
  7. Set the Duration, to know the time assigned to each calendar slot.
  8. +
  9. Set the Modifications Deadline, to forbid non-managers to alter dates of +a booking when it’s too late.
  10. +
  11. Choose one Availability Calendar. No bookings will exist outside of it.
  12. +
  13. Under Meeting defaults, you will be able to fill some values that will +be used by default on calendar meetings. These will appear in the global +calendar when some booking is reserved.
  14. +
  15. Choose some Available resource combinations. All combinations in the same +line must be free to be booked together; otherwise the booking will not be +able to be scheduled. You can sort them.
  16. +
  17. Pick up one Combination Assignment. If you choose Sorted, then the order +of the combinations you chose will indicate the one that is selected first. +Of course, it must be free to be selected.
  18. +
  19. Save.
  20. +
+
+
+

Usage

+

This module installs a new app, “Resource bookings”.

+

Bookings may involve you:

+
    +
  • Maybe because you requested to book something.
  • +
  • Maybe because you are one of the booked resources, if a booking represents +some kind of appointment.
  • +
+

To see which bookings involve you:

+
    +
  1. Go to Resource Bookings > Bookings.
  2. +
  3. You can switch to the list view if you need to see also the pending ones.
  4. +
  5. You can remove the “Involving me” filter if you want to see others’ bookings.
  6. +
+

To book some resources:

+
    +
  1. Go to Resource Bookings > Types.
  2. +
  3. Pick the type of booking you want.
  4. +
  5. Click on Booking Count.
  6. +
  7. Click on a free slot.
  8. +
  9. Fill the Requester, which may or not be yourself.
  10. +
  11. Pick one Resources combination, in case the one assigned automatically +isn’t the one you want.
  12. +
+

To invite someone to book a resource combination from the portal:

+
    +
  1. Go to Resource Bookings > Types.
  2. +
  3. Pick the type of booking you want.
  4. +
  5. Click on Booking Count.
  6. +
  7. Click on the list view icon.
  8. +
  9. Click on Create.
  10. +
  11. Fill the Requester.
  12. +
  13. 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.
  14. +
  15. Click on Share > Send.
  16. +
  17. The requester will receive an email to select a calendar slot from his portal.
  18. +
+
+
+

Known issues / Roadmap

+
    +
  • Allow combination auto-assignment based on least used combination.
  • +
  • Allow customer to choose combination.
  • +
  • Some error messages would be a bit more helpful if they specify the schedule +impossibility reason, but that should be done without affecting performance.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Tecnativa
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

Yajo

+

This module is part of the OCA/calendar project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/resource_booking/static/src/css/portal.scss b/resource_booking/static/src/css/portal.scss new file mode 100644 index 00000000..cbca7f46 --- /dev/null +++ b/resource_booking/static/src/css/portal.scss @@ -0,0 +1,8 @@ +/* Copyright 2021 Tecnativa - Jairo Llopis + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +// Too many available slots? Allow scrolling +.slots-dropdown { + max-height: 40vh; + overflow: auto; +} diff --git a/resource_booking/templates/assets.xml b/resource_booking/templates/assets.xml new file mode 100644 index 00000000..bf05e7bb --- /dev/null +++ b/resource_booking/templates/assets.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/resource_booking/templates/portal.xml b/resource_booking/templates/portal.xml new file mode 100644 index 00000000..0b182135 --- /dev/null +++ b/resource_booking/templates/portal.xml @@ -0,0 +1,410 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resource_booking/tests/__init__.py b/resource_booking/tests/__init__.py new file mode 100644 index 00000000..60b403f7 --- /dev/null +++ b/resource_booking/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_backend +from . import test_portal diff --git a/resource_booking/tests/common.py b/resource_booking/tests/common.py new file mode 100644 index 00000000..943d95df --- /dev/null +++ b/resource_booking/tests/common.py @@ -0,0 +1,105 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +def create_test_data(obj): + """Create test data for a case.""" + obj.env = obj.env(context={"tz": "UTC"}) + # Create one resource.calendar available on Mondays, another one on + # Tuesdays, and another one on Mondays and Tuesdays; in that order + attendances = [ + ( + 0, + 0, + { + "name": "Mondays", + "dayofweek": "0", + "hour_from": 8, + "hour_to": 17, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Tuesdays", + "dayofweek": "1", + "hour_from": 8, + "hour_to": 17, + "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", + }, + ] + ) + # Create one material resource for each of those calendars; same order + obj.r_materials = obj.env["resource.resource"].create( + [ + { + "name": "Material resource for %s" % cal.name, + "calendar_id": cal.id, + "resource_type": "material", + "tz": "UTC", + } + for cal in obj.r_calendars + ] + ) + # Create one human resource for each of those calendars; same order + obj.users = obj.env["res.users"].create( + [ + { + "email": "user_%d@example.com" % num, + "login": "user_%d" % num, + "name": "User %d" % num, + } + for num in range(3) + ] + ) + obj.r_users = obj.env["resource.resource"].create( + [ + { + "calendar_id": cal.id, + "name": "User %s" % user.name, + "resource_type": "user", + "tz": "UTC", + "user_id": user.id, + } + for (user, cal) in zip(obj.users, obj.r_calendars) + ] + ) + # Create one RBC for each of those calendars, which includes the + # corresponding material and human resources simultaneously; same order + obj.rbcs = obj.env["resource.booking.combination"].create( + [ + {"resource_ids": [(6, 0, [user.id, material.id])]} + for (user, material) in zip(obj.r_users, obj.r_materials) + ] + ) + # Create one RBT that includes all 3 RBCs as available combinations + obj.rbt = obj.env["resource.booking.type"].create( + { + "name": "Test resource booking type", + "combination_rel_ids": [ + (0, 0, {"sequence": num, "combination_id": rbc.id}) + for num, rbc in enumerate(obj.rbcs) + ], + "resource_calendar_id": obj.r_calendars[2].id, + "location": "Main office", + } + ) + # Create some partner + obj.partner = obj.env["res.partner"].create({"name": "some customer"}) diff --git a/resource_booking/tests/test_backend.py b/resource_booking/tests/test_backend.py new file mode 100644 index 00000000..4e9541b8 --- /dev/null +++ b/resource_booking/tests/test_backend.py @@ -0,0 +1,306 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from freezegun import freeze_time +from odoo.tests.common import SavepointCase, Form +from odoo.exceptions import ValidationError +from odoo import fields +from datetime import datetime +from .common import create_test_data + +_2dt = fields.Datetime.to_datetime + + +@freeze_time("2021-02-26 09:00:00", tick=True) +class BackendCase(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + create_test_data(cls) + + def test_scheduling_conflict_constraints(self): + # Combination is available on Mondays and Tuesdays + rbc_montue = self.rbcs[2] + # Type is available on Mondays + cal_mon = self.r_calendars[0] + self.rbt.resource_calendar_id = cal_mon + # Booking cannot be placed next Tuesday + with self.assertRaises(ValidationError), self.env.cr.savepoint(): + self.env["resource.booking"].create( + { + "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, + } + ) + # Booking cannot be placed next Monday before 8:00 + with self.assertRaises(ValidationError), self.env.cr.savepoint(): + self.env["resource.booking"].create( + { + "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, + } + ) + # Booking cannot be placed next Monday after 17:00 + with self.assertRaises(ValidationError), self.env.cr.savepoint(): + self.env["resource.booking"].create( + { + "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, + } + ) + # Booking can be placed next Monday + 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_id": rbc_montue.id, + } + ) + # Another event cannot collide with the same RBC + with self.assertRaises(ValidationError), self.env.cr.savepoint(): + self.env["resource.booking"].create( + { + "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, + } + ) + # Another event can collide with another RBC + rbc_mon = self.rbcs[0] + 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_id": rbc_mon.id, + } + ) + + def test_rbc_forced_calendar(self): + # Type is available on Mondays + cal_mon = self.r_calendars[0] + self.rbt.resource_calendar_id = cal_mon + # Cannot book an combination with resources that only work on Tuesdays + rbc_tue = self.rbcs[1] + with self.assertRaises(ValidationError), self.env.cr.savepoint(): + 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_id": rbc_tue.id, + } + ) + # However, if the combination is forced to Mondays, you can book it + rbc_tue.forced_calendar_id = cal_mon + 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_id": rbc_tue.id, + } + ) + + def test_booking_from_calendar_view(self): + # The type is configured by default with bookings of 30 minutes + self.assertEqual(self.rbt.duration, 0.5) + # Change it to 45 minutes + self.rbt.duration = 0.75 + # Bookings smart button configures calendar with slots of 45 minutes + button_context = self.rbt.action_open_bookings()["context"] + 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 + 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" + ) + ) + # 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(_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 + 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)) + # I can book it (which means type & combination were autofilled) + booking = booking_form.save() + self.assertTrue(booking.meeting_id) + self.assertEqual(booking.state, "scheduled") + + def test_dates_inverse(self): + """Start & stop fields are computed with inverse. Test their workflow.""" + # Set type to be available only on mondays + self.rbt.resource_calendar_id = self.r_calendars[0] + # Create a booking from scratch + booking_form = Form(self.env["resource.booking"]) + booking_form.type_id = self.rbt + booking_form.partner_id = self.partner + self.assertFalse(booking_form.start) + self.assertFalse(booking_form.stop) + self.assertFalse(booking_form.combination_id) + # I can save it without booking + booking = booking_form.save() + self.assertEqual(booking.state, "pending") + self.assertFalse(booking.meeting_id) + self.assertFalse(booking.start) + self.assertFalse(booking.stop) + self.assertFalse(booking.combination_id) + # I edit it again + with Form(booking) as booking_form: + # Start next Tuesday: updates stop; no combination available + booking_form.start = datetime(2021, 3, 2, 8) + self.assertEqual(_2dt(booking_form.stop), datetime(2021, 3, 2, 8, 30)) + self.assertFalse(booking_form.combination_id) + # Move to Monday: updates stop; found one combination available + booking_form.start = datetime(2021, 3, 1, 8) + self.assertEqual(_2dt(booking_form.stop), datetime(2021, 3, 1, 8, 30)) + self.assertTrue(booking_form.combination_id) + self.assertEqual(booking.state, "scheduled") + self.assertTrue(booking.meeting_id) + self.assertTrue(booking.start) + self.assertTrue(booking.stop) + self.assertTrue(booking.combination_id) + + def test_state(self): + # I create a pending booking + booking = self.env["resource.booking"].create( + {"type_id": self.rbt.id, "partner_id": self.partner.id} + ) + # Without dates, it's pending + self.assertEqual(booking.state, "pending") + self.assertTrue(booking.active) + self.assertFalse(booking.meeting_id) + self.assertFalse(booking.start) + self.assertFalse(booking.stop) + self.assertFalse(booking.combination_id) + # With a linked meeting, it's scheduled + with Form(booking) as booking_form: + booking_form.start = datetime(2021, 3, 1, 8) + meeting = booking.meeting_id + self.assertEqual(booking.state, "scheduled") + self.assertTrue(booking.active) + self.assertTrue(meeting.exists()) + self.assertTrue(booking.start) + self.assertTrue(booking.stop) + self.assertTrue(booking.combination_id) + # When partner confirms attendance, it's confirmed + booker_attendance = meeting.attendee_ids.filtered( + lambda one: one.partner_id == booking.partner_id + ) + self.assertTrue(booker_attendance) + booker_attendance.do_accept() + self.assertEqual(booking.state, "confirmed") + self.assertTrue(booking.active) + self.assertTrue(meeting.exists()) + self.assertTrue(booking.start) + self.assertTrue(booking.stop) + self.assertTrue(booking.combination_id) + # Without dates, it's pending again + booking.action_unschedule() + self.assertEqual(booking.state, "pending") + self.assertTrue(booking.active) + self.assertFalse(meeting.exists()) + self.assertFalse(booking.start) + self.assertFalse(booking.stop) + self.assertTrue(booking.combination_id) + # Archived and without dates, it's canceled + booking.action_cancel() + self.assertEqual(booking.state, "canceled") + self.assertFalse(booking.active) + self.assertFalse(meeting.exists()) + self.assertFalse(booking.start) + self.assertFalse(booking.stop) + self.assertTrue(booking.combination_id) + + def test_sorted_assignment(self): + """Set sorted assignment on RBT and test it works correctly.""" + rbc_mon, rbc_tue, rbc_montue = self.rbcs + with Form(self.rbt) as rbt_form: + rbt_form.combination_assignment = "sorted" + # Book next monday at 10:00 + rb1_form = Form(self.env["resource.booking"]) + rb1_form.type_id = self.rbt + rb1_form.partner_id = self.partner + rb1_form.start = datetime(2021, 3, 1, 10) + self.assertEqual(rb1_form.combination_id, rbc_mon) + rb1 = rb1_form.save() + self.assertEqual(rb1.combination_id, rbc_mon) + # Another booking, same time + rb2_form = Form(self.env["resource.booking"]) + rb2_form.type_id = self.rbt + rb2_form.partner_id = self.partner + rb2_form.start = datetime(2021, 3, 1, 10) + self.assertEqual(rb2_form.combination_id, rbc_montue) + rb2 = rb2_form.save() + self.assertEqual(rb2.combination_id, rbc_montue) + # I'm able to alter rb1 timing + with Form(rb1) as rb1_form: + rb1_form.start = datetime(2021, 3, 2, 10) + self.assertEqual(rb1_form.combination_id, rbc_tue) + self.assertEqual(rb1.combination_id, rbc_tue) + + def test_same_slot_twice_not_utc(self): + """Scheduling the same slot twice fails, when not in UTC.""" + for loop in range(2): + rb_f = Form(self.env["resource.booking"].with_context(tz="Europe/Madrid")) + rb_f.partner_id = self.partner + rb_f.type_id = self.rbt + rb_f.start = datetime(2021, 3, 1, 10) + rb_f.combination_id = self.rbcs[0] + # 1st one works + if loop == 0: + rb = rb_f.save() + self.assertEqual(rb.state, "scheduled") + else: + with self.assertRaises(ValidationError): + rb_f.save() + + def test_recurring_event(self): + """Recurrent events are considered.""" + # Everyone busy past and next Mondays with a recurring meeting + ce_f = Form(self.env["calendar.event"]) + ce_f.name = "recurring event past monday" + for user in self.users: + ce_f.partner_ids.add(user.partner_id) + ce_f.start_datetime = datetime(2021, 2, 22, 8) + ce_f.duration = 1 + ce_f.recurrency = True + ce_f.interval = 1 + ce_f.rrule_type = "weekly" + ce_f.end_type = "count" + ce_f.count = 2 + ce_f.save() + # Cannot book next Monday at 8 + rb_f = Form(self.env["resource.booking"]) + rb_f.partner_id = self.partner + rb_f.type_id = self.rbt + # No RBC when starting + self.assertFalse(rb_f.combination_id) + # No RBC available next Monday at 8 + rb_f.start = datetime(2021, 3, 1, 8) + self.assertFalse(rb_f.combination_id) + # Everyone's free at 9 + rb_f.start = datetime(2021, 3, 1, 9) + self.assertTrue(rb_f.combination_id) diff --git a/resource_booking/tests/test_portal.py b/resource_booking/tests/test_portal.py new file mode 100644 index 00000000..f535f8c9 --- /dev/null +++ b/resource_booking/tests/test_portal.py @@ -0,0 +1,244 @@ +# Copyright 2021 Tecnativa - Jairo Llopis +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import HttpCase +from .common import create_test_data +from lxml.html import fromstring +from datetime import datetime + +from freezegun import freeze_time + + +@freeze_time("2021-02-26 09:00:00", tick=True) +class PortalCase(HttpCase): + def setUp(self): + super().setUp() + create_test_data(self) + self.user_portal, self.user_manager = self.env["res.users"].create( + [ + { + "name": "portal", + "login": "ptl", + "password": "ptl", + "groups_id": [(4, self.env.ref("base.group_portal").id, 0)], + }, + { + "name": "manager", + "login": "mgr", + "password": "mgr", + "groups_id": [ + (4, self.env.ref("resource_booking.group_manager").id, 0) + ], + }, + ] + ) + + def _url_xml(self, url, data=None, timeout=10): + """Open an URL and return the lxml etree object resulting from its content.""" + response = self.url_open(url, data, timeout) + return fromstring(response.content) + + def test_portal_no_bookings(self): + self.authenticate("ptl", "ptl") + page = fromstring(self.url_open("/my").content) + self.assertTrue(page.cssselect(".o_portal_docs")) + self.assertFalse(page.cssselect('.o_portal_docs a:contains("Bookings")')) + + def test_portal_list_with_bookings(self): + # Create one pending booking + booking = self.env["resource.booking"].create( + {"partner_id": self.user_portal.partner_id.id, "type_id": self.rbt.id} + ) + self.authenticate("ptl", "ptl") + # Main portal page contains bookings count + page = self._url_xml("/my") + link = page.cssselect('.o_portal_docs a:contains("Bookings")')[0] + self.assertEqual(link.cssselect(".badge")[0].text.strip(), "1") + # Bookings page lists 1 booking + page = self._url_xml(link.get("href")) + self.assertEqual(len(page.cssselect(".o_portal_my_doc_table tr")), 2) + link = page.cssselect('.o_portal_my_doc_table a:contains("%d")' % booking.id)[0] + # Booking page has schedule button + page = self._url_xml(link.get("href")) + self.assertTrue(page.cssselect('.badge:contains("Pending")')) + + def test_portal_scheduling_conflict(self): + """Produce a scheduling conflict and see how UI behaves. + + This test would be better as a tour, but since there are a few back and + forth actions among backend and frontend, and among distinct portal + users, it seemed easier to do it completely on python. + """ + # Set RBT to have only 1 combination available: the one for Mondays + self.rbt.combination_rel_ids[1:].unlink() + # One booking for portal user, another for a partner without user + bookings = self.env["resource.booking"].create( + [ + {"partner_id": self.user_portal.partner_id.id, "type_id": self.rbt.id}, + {"partner_id": self.partner.id, "type_id": self.rbt.id}, + ] + ) + booking_portal, booking_public = bookings + # We assume they were invited by email and clicked on their links + portal_url, public_url = (one.get_portal_url() for one in bookings) + # Portal guy goes to scheduling page + portal_page = self._url_xml(portal_url) + self.assertTrue(portal_page.cssselect('.badge:contains("Pending")')) + link = portal_page.cssselect('a:contains("Schedule")')[0] + portal_url = link.get("href") + portal_page = self._url_xml(portal_url) + # Nothing free on February, he goes to March + self.assertTrue( + portal_page.cssselect(".o_booking_calendar:contains('February 2021')") + ) + self.assertTrue( + portal_page.cssselect( + ".o_booking_calendar td" + ":contains('All times are displayed using this timezone:')" + ":contains('UTC')" + ) + ) + self.assertFalse(portal_page.cssselect(".o_booking_calendar .dropdown")) + self.assertFalse(portal_page.cssselect(".o_booking_calendar form")) + link = portal_page.cssselect('a[title="Next month"]')[0] + portal_url = link.get("href") + portal_page = self._url_xml(portal_url) + self.assertTrue( + portal_page.cssselect(".o_booking_calendar:contains('March 2021')") + ) + self.assertTrue(portal_page.cssselect(".o_booking_calendar .dropdown")) + self.assertTrue(portal_page.cssselect(".o_booking_calendar form")) + # Public guy does the same + public_page = self._url_xml(public_url) + self.assertTrue(public_page.cssselect('.badge:contains("Pending")')) + link = public_page.cssselect('a:contains("Schedule")')[0] + public_url = link.get("href") + public_page = self._url_xml(public_url) + self.assertTrue( + public_page.cssselect(".o_booking_calendar:contains('February 2021')") + ) + self.assertTrue( + public_page.cssselect( + ".o_booking_calendar td" + ":contains('All times are displayed using this timezone:')" + ":contains('UTC')" + ) + ) + self.assertFalse(public_page.cssselect(".o_booking_calendar .dropdown")) + self.assertFalse(public_page.cssselect(".o_booking_calendar form")) + link = public_page.cssselect('a[title="Next month"]')[0] + public_url = link.get("href") + public_page = self._url_xml(public_url) + self.assertTrue( + public_page.cssselect(".o_booking_calendar:contains('March 2021')") + ) + self.assertTrue(public_page.cssselect(".o_booking_calendar .dropdown")) + self.assertTrue(public_page.cssselect(".o_booking_calendar form")) + # Public guy makes reservation next Monday at 10:00 + slot = datetime(2021, 3, 1, 10).timestamp() + selector_10am = ( + "#dropdown-trigger-2021-03-01 " + "+ .slots-dropdown .dropdown-item:contains('10:00:00')" + ) + selector_1030am = ( + "#dropdown-trigger-2021-03-01 " + "+ .slots-dropdown .dropdown-item:contains('10:30:00')" + ) + self.assertTrue(public_page.cssselect(selector_10am)) + self.assertTrue(public_page.cssselect(selector_1030am)) + form = public_page.cssselect("form#modal-confirm-%d" % slot)[0] + public_url = form.get("action") + data = { + element.get("name"): element.get("value") + for element in form.cssselect("input") + } + public_page = self._url_xml(public_url, data) + # Public guy's reservation succeeded + self.assertTrue(public_page.cssselect('.badge:contains("Confirmed")')) + self.assertTrue( + public_page.cssselect( + 'div:contains("Booked resources:")' + ':contains("Material resource for Mon")' + ':contains("User User 0")' + ) + ) + self.assertTrue( + public_page.cssselect('div:contains("Location:"):contains("Main office")') + ) + self.assertTrue( + public_page.cssselect( + 'div:contains("Dates:")' + ':contains("03/01/2021 at (10:00:00 To 10:30:00) (UTC)")' + ) + ) + # Public guy's booking and related meeting are OK in backend + booking_public.invalidate_cache(ids=booking_public.ids) + self.assertEqual(booking_public.state, "confirmed") + self.assertEqual(len(booking_public.meeting_id.attendee_ids), 2) + for attendee in booking_public.meeting_id.attendee_ids: + self.assertTrue(attendee.partner_id) + self.assertIn( + attendee.partner_id, + self.partner | self.users[0].partner_id, + ) + self.assertEqual( + attendee.state, + "accepted" if attendee.partner_id == self.partner else "needsAction", + ) + # At the same time, portal guy tries to reserve the same slot, which + # appears as free to him due to the race condition we just created + self.assertTrue(portal_page.cssselect(selector_10am)) + self.assertTrue(portal_page.cssselect(selector_1030am)) + form = portal_page.cssselect("form#modal-confirm-%d" % slot)[0] + portal_url = form.get("action") + data = { + element.get("name"): element.get("value") + for element in form.cssselect("input") + } + portal_page = self._url_xml(portal_url, data) + # He's back on the March calendar view, with an error message + self.assertTrue( + portal_page.cssselect( + ".alert-danger:contains('The chosen schedule is no longer available.')" + ) + ) + self.assertTrue( + portal_page.cssselect(".o_booking_calendar:contains('March 2021')") + ) + self.assertTrue(portal_page.cssselect(".o_booking_calendar .dropdown")) + self.assertTrue(portal_page.cssselect(".o_booking_calendar form")) + # He can't select that slot anymore, so he books it 30 minutes later + self.assertFalse(portal_page.cssselect(selector_10am)) + self.assertTrue(portal_page.cssselect(selector_1030am)) + slot = datetime(2021, 3, 1, 10, 30).timestamp() + self.assertTrue(portal_page.cssselect("#dropdown-trigger-2021-03-08")) + form = portal_page.cssselect("form#modal-confirm-%d" % slot)[0] + portal_url = form.get("action") + data = { + element.get("name"): element.get("value") + for element in form.cssselect("input") + } + portal_page = self._url_xml(portal_url, data) + # Portal guy's reservation succeeded + self.assertTrue(portal_page.cssselect('.badge:contains("Confirmed")')) + self.assertTrue( + portal_page.cssselect( + 'div:contains("Booked resources:")' + ':contains("Material resource for Mon")' + ':contains("User User 0")' + ) + ) + self.assertTrue( + portal_page.cssselect('div:contains("Location:"):contains("Main office")') + ) + self.assertTrue( + portal_page.cssselect( + 'div:contains("Dates:")' + ':contains("03/01/2021 at (10:30:00 To 11:00:00) (UTC)")' + ) + ) + # Portal guy cancels + link = portal_page.cssselect('a:contains("Cancel this booking")')[0] + portal_url = link.get("href") + portal_page = self._url_xml(portal_url) + self.assertTrue(portal_page.cssselect(".oe_login_form")) diff --git a/resource_booking/views/calendar_event_views.xml b/resource_booking/views/calendar_event_views.xml new file mode 100644 index 00000000..908ebff6 --- /dev/null +++ b/resource_booking/views/calendar_event_views.xml @@ -0,0 +1,18 @@ + + + + + + + calendar.event.view.form.inherit + calendar.event + + +
+ + +
+
+ +
diff --git a/resource_booking/views/menus.xml b/resource_booking/views/menus.xml new file mode 100644 index 00000000..9dcb549e --- /dev/null +++ b/resource_booking/views/menus.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/resource_booking/views/resource_booking_combination_views.xml b/resource_booking/views/resource_booking_combination_views.xml new file mode 100644 index 00000000..a4ddf705 --- /dev/null +++ b/resource_booking/views/resource_booking_combination_views.xml @@ -0,0 +1,75 @@ + + + + + + + + Resource booking combination form + resource.booking.combination + +
+
+ +
+ + + +
+
+

+ +

+
+ + + + +
+
+
+
+ + + Resource booking combination tree + resource.booking.combination + + + + + + + + + + + resource.booking.combination.view.search + resource.booking.combination + + + + + + + + + + Resource combinations + resource.booking.combination + tree,form + [] + {} + +

Define bookable resource combinations.

+

These records define resource combinations that can be booked together in specified schedules and intervals.

+
+
+ +
diff --git a/resource_booking/views/resource_booking_type_views.xml b/resource_booking/views/resource_booking_type_views.xml new file mode 100644 index 00000000..e5c290f7 --- /dev/null +++ b/resource_booking/views/resource_booking_type_views.xml @@ -0,0 +1,100 @@ + + + + + + + + Resource booking type form + resource.booking.type + +
+
+ +
+ + +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + Resource booking type tree + resource.booking.type + + + + + + + + + + + resource.booking.type.view.search + resource.booking.type + + + + + + + + + + + + + + Types + resource.booking.type + tree,form + [] + {} + +

Define resource booking types.

+

These records categorize resource bookings and apply restrictions to them, such as available resource combinations, availability schedules and interval duration.

+
+
+ +
diff --git a/resource_booking/views/resource_booking_views.xml b/resource_booking/views/resource_booking_views.xml new file mode 100644 index 00000000..ca6e56b0 --- /dev/null +++ b/resource_booking/views/resource_booking_views.xml @@ -0,0 +1,129 @@ + + + + + + + + Resource booking calendar + resource.booking + + + + + + + + + + + Resource booking tree + resource.booking + + + + + + + + + + + + + + Resource booking form + resource.booking + +
+
+ +
+ +
+ + + + +
+ + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + resource.booking.view.search + resource.booking + + + + + + + + + + + + + + + + + + + Bookings + resource.booking + calendar,tree,form + [] + {'search_default_is_mine': 1} + +

+ Define resource bookings. +

+

+ When scheduled, resources will be blocked. When pending, it means the requester didn't place the booking yet. +

+
+
+ +
From f9a07ccacda945fe565612f14d86d622188bac9f Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Fri, 30 Apr 2021 10:15:32 +0000 Subject: [PATCH 02/61] Translated using Weblate (Spanish) Currently translated at 100.0% (190 of 190 strings) Translation: calendar-12.0/calendar-12.0-resource_booking Translate-URL: https://translation.odoo-community.org/projects/calendar-12-0/calendar-12-0-resource_booking/es/ --- resource_booking/i18n/es.po | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/resource_booking/i18n/es.po b/resource_booking/i18n/es.po index d581310f..1adbed58 100644 --- a/resource_booking/i18n/es.po +++ b/resource_booking/i18n/es.po @@ -7,15 +7,15 @@ msgstr "" "Project-Id-Version: Odoo Server 12.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-04-26 06:31+0000\n" -"PO-Revision-Date: 2021-04-26 07:35+0100\n" +"PO-Revision-Date: 2021-04-30 12:47+0000\n" "Last-Translator: Jairo Llopis \n" "Language-Team: \n" -"Language: es_ES\n" +"Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 2.4.2\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.3.2\n" #. module: resource_booking #: code:addons/resource_booking/models/resource_booking.py:359 @@ -952,7 +952,7 @@ msgstr "Ordenada: escoger el primero que esté libre" #. module: resource_booking #: model:ir.model.fields,field_description:resource_booking.field_resource_booking__start msgid "Start" -msgstr "Iniciar" +msgstr "Inicio" #. module: resource_booking #: sql_constraint:resource.booking:0 @@ -988,7 +988,7 @@ msgstr "" #. module: resource_booking #: model:ir.model.fields,field_description:resource_booking.field_resource_booking__stop msgid "Stop" -msgstr "Parar" +msgstr "Fin" #. module: resource_booking #: model:ir.model.fields,field_description:resource_booking.field_resource_booking__categ_ids From 9d3ff11a3993d6fc5a5fc5a47cce1cf8aacdbd85 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Fri, 30 Apr 2021 11:27:07 +0100 Subject: [PATCH 03/61] [IMP] resource_booking: filter/group by date TT29508 --- resource_booking/__manifest__.py | 2 +- resource_booking/i18n/es.po | 10 ++++++++-- resource_booking/i18n/resource_booking.pot | 6 ++++++ resource_booking/views/resource_booking_views.xml | 2 ++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index 41d8e5e8..efb8020a 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": "12.0.1.0.0", + "version": "12.0.1.1.0", "development_status": "Beta", "category": "Appointments", "website": "https://github.com/OCA/calendar", diff --git a/resource_booking/i18n/es.po b/resource_booking/i18n/es.po index 1adbed58..7dcea3cf 100644 --- a/resource_booking/i18n/es.po +++ b/resource_booking/i18n/es.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 12.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-26 06:31+0000\n" -"PO-Revision-Date: 2021-04-30 12:47+0000\n" +"POT-Creation-Date: 2021-04-30 10:28+0000\n" +"PO-Revision-Date: 2021-04-30 11:29+0100\n" "Last-Translator: Jairo Llopis \n" "Language-Team: \n" "Language: es\n" @@ -951,6 +951,7 @@ msgstr "Ordenada: escoger el primero que esté libre" #. module: resource_booking #: model:ir.model.fields,field_description:resource_booking.field_resource_booking__start +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search msgid "Start" msgstr "Inicio" @@ -959,6 +960,11 @@ msgstr "Inicio" msgid "Start and stop must be filled or emptied together." msgstr "El inicio y el fin deben rellenarse o vaciarse simultáneamente." +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Start date" +msgstr "Fecha de inicio" + #. module: resource_booking #: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar msgid "Start:" diff --git a/resource_booking/i18n/resource_booking.pot b/resource_booking/i18n/resource_booking.pot index bef30b9b..7f1272f8 100644 --- a/resource_booking/i18n/resource_booking.pot +++ b/resource_booking/i18n/resource_booking.pot @@ -900,6 +900,7 @@ msgstr "" #. module: resource_booking #: model:ir.model.fields,field_description:resource_booking.field_resource_booking__start +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search msgid "Start" msgstr "" @@ -908,6 +909,11 @@ msgstr "" msgid "Start and stop must be filled or emptied together." msgstr "" +#. module: resource_booking +#: model_terms:ir.ui.view,arch_db:resource_booking.resource_booking_view_search +msgid "Start date" +msgstr "" + #. module: resource_booking #: model_terms:ir.ui.view,arch_db:resource_booking.scheduling_calendar msgid "Start:" diff --git a/resource_booking/views/resource_booking_views.xml b/resource_booking/views/resource_booking_views.xml index ca6e56b0..abf2ed09 100644 --- a/resource_booking/views/resource_booking_views.xml +++ b/resource_booking/views/resource_booking_views.xml @@ -100,10 +100,12 @@ + + From 6735a842360409f795c4ace10f809f30da99c5cb Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Thu, 6 May 2021 12:48:10 +0100 Subject: [PATCH 04/61] [FIX] resource_calendar: ignore past or unconfirmed bookings when updating calendars Without this patch, users couldn't change a calendar schedule if there were past or unconfirmed bookings that wouldn't fit in it. Excluding those bookings from the check fixes the situation. We also check that, to confirm a booking, it must fit in the calendar (because now it can happen that, in the time that has passed since the booking was scheduled until it is confirmed, the calendar changes). @Tecnativa TT29509 --- resource_booking/__manifest__.py | 2 +- resource_booking/i18n/es.po | 14 +++--- resource_booking/i18n/resource_booking.pot | 14 +++--- resource_booking/models/resource_booking.py | 3 ++ resource_booking/models/resource_calendar.py | 2 + resource_booking/tests/test_backend.py | 46 ++++++++++++++++++++ 6 files changed, 66 insertions(+), 15 deletions(-) diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index efb8020a..a588b3b1 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": "12.0.1.1.0", + "version": "12.0.1.1.1", "development_status": "Beta", "category": "Appointments", "website": "https://github.com/OCA/calendar", diff --git a/resource_booking/i18n/es.po b/resource_booking/i18n/es.po index 7dcea3cf..0d270a94 100644 --- a/resource_booking/i18n/es.po +++ b/resource_booking/i18n/es.po @@ -18,13 +18,13 @@ msgstr "" "X-Generator: Weblate 4.3.2\n" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:359 +#: code:addons/resource_booking/models/resource_booking.py:362 #, python-format msgid "%(partner)s - %(type)s" msgstr "%(partner)s - %(type)s" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:358 +#: code:addons/resource_booking/models/resource_booking.py:361 #, python-format msgid "%(partner)s - %(type)s - %(time)s" msgstr "%(partner)s - %(type)s - %(time)s" @@ -260,7 +260,7 @@ msgid "Canceled" msgstr "Cancelado" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:263 +#: code:addons/resource_booking/models/resource_booking.py:266 #, python-format msgid "" "Cannot schedule these bookings because no resources are selected for them:\n" @@ -273,7 +273,7 @@ msgstr "" "- %s" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:285 +#: code:addons/resource_booking/models/resource_booking.py:288 #, python-format msgid "" "Cannot schedule these bookings because they do not fit in their type or " @@ -689,7 +689,7 @@ msgid "No free slots found this month." msgstr "No quedan huecos libres este mes." #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:390 +#: code:addons/resource_booking/models/resource_booking.py:393 #, python-format msgid "No resource combinations available on %s" msgstr "No hay combinaciones de recursos disponibles en %s" @@ -800,7 +800,7 @@ msgid "Requester Advice" msgstr "Aviso al solicitante" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:445 +#: code:addons/resource_booking/models/resource_booking.py:448 #, python-format msgid "Requesting partner" msgstr "Solicitante" @@ -899,7 +899,7 @@ msgid "Schedule" msgstr "Horario" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:465 +#: code:addons/resource_booking/models/resource_booking.py:468 #, python-format msgid "Schedule booking" msgstr "Agendar reserva/cita" diff --git a/resource_booking/i18n/resource_booking.pot b/resource_booking/i18n/resource_booking.pot index 7f1272f8..68855020 100644 --- a/resource_booking/i18n/resource_booking.pot +++ b/resource_booking/i18n/resource_booking.pot @@ -14,13 +14,13 @@ msgstr "" "Plural-Forms: \n" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:359 +#: code:addons/resource_booking/models/resource_booking.py:362 #, python-format msgid "%(partner)s - %(type)s" msgstr "" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:358 +#: code:addons/resource_booking/models/resource_booking.py:361 #, python-format msgid "%(partner)s - %(type)s - %(time)s" msgstr "" @@ -238,7 +238,7 @@ msgid "Canceled" msgstr "" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:263 +#: code:addons/resource_booking/models/resource_booking.py:266 #, python-format msgid "Cannot schedule these bookings because no resources are selected for them:\n" "\n" @@ -246,7 +246,7 @@ msgid "Cannot schedule these bookings because no resources are selected for them msgstr "" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:285 +#: code:addons/resource_booking/models/resource_booking.py:288 #, python-format msgid "Cannot schedule these bookings because they do not fit in their type or resources calendars, or because all resources are busy:\n" "\n" @@ -647,7 +647,7 @@ msgid "No free slots found this month." msgstr "" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:390 +#: code:addons/resource_booking/models/resource_booking.py:393 #, python-format msgid "No resource combinations available on %s" msgstr "" @@ -750,7 +750,7 @@ msgid "Requester Advice" msgstr "" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:445 +#: code:addons/resource_booking/models/resource_booking.py:448 #, python-format msgid "Requesting partner" msgstr "" @@ -848,7 +848,7 @@ msgid "Schedule" msgstr "" #. module: resource_booking -#: code:addons/resource_booking/models/resource_booking.py:465 +#: code:addons/resource_booking/models/resource_booking.py:468 #, python-format msgid "Schedule booking" msgstr "" diff --git a/resource_booking/models/resource_booking.py b/resource_booking/models/resource_booking.py index 11bac403..2d281606 100644 --- a/resource_booking/models/resource_booking.py +++ b/resource_booking/models/resource_booking.py @@ -188,6 +188,7 @@ def _compute_name(self): @api.depends("active", "meeting_id.attendee_ids.state") def _compute_state(self): """Obtain request state.""" + to_check = self.browse(prefetch=self._prefetch) for one in self: if not one.active: one.state = "canceled" @@ -199,8 +200,10 @@ def _compute_state(self): break if confirmed: one.state = "confirmed" + to_check |= one continue 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): diff --git a/resource_booking/models/resource_calendar.py b/resource_booking/models/resource_calendar.py index 3b0b6032..9c4bcc8f 100644 --- a/resource_booking/models/resource_calendar.py +++ b/resource_booking/models/resource_calendar.py @@ -19,6 +19,8 @@ def _check_bookings_scheduling(self): """Scheduled bookings must have no conflicts.""" bookings = self.env["resource.booking"].search( [ + ("state", "=", "confirmed"), + ("stop", ">=", fields.Datetime.now()), "|", ("combination_id.forced_calendar_id", "in", self.ids), ("combination_id.resource_ids.calendar_id", "in", self.ids), diff --git a/resource_booking/tests/test_backend.py b/resource_booking/tests/test_backend.py index 4e9541b8..716ec13a 100644 --- a/resource_booking/tests/test_backend.py +++ b/resource_booking/tests/test_backend.py @@ -304,3 +304,49 @@ def test_recurring_event(self): # Everyone's free at 9 rb_f.start = datetime(2021, 3, 1, 9) self.assertTrue(rb_f.combination_id) + + def test_change_calendar_after_bookings_exist(self): + """Calendar changes can be done only if they introduce no conflicts.""" + rbc_mon = self.rbcs[0] + cal_mon = self.r_calendars[0] + # There's a booking for last monday + past_booking = self.env["resource.booking"].create( + { + "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.state, "confirmed") + # There's another one for next monday, confirmed too + future_booking = self.env["resource.booking"].create( + { + "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, + } + ) + future_booking.action_confirm() + self.assertEqual(future_booking.state, "confirmed") + # Now, it's impossible for me to change the resource calendar + with self.assertRaises(ValidationError), self.env.cr.savepoint(): + with Form(cal_mon) as cal_mon_f: + with cal_mon_f.attendance_ids.edit(0) as att_mon_f: + att_mon_f.hour_from = 9 + # But let's unconfirm future boooking + future_booking.action_unschedule() + with Form(future_booking) as future_booking_f: + future_booking_f.start = "2021-03-01 08:00:00" + self.assertEqual(future_booking.state, "scheduled") + # Now I should be able to change the resource calendar + with Form(cal_mon) as cal_mon_f: + with cal_mon_f.attendance_ids.edit(0) as att_mon_f: + att_mon_f.hour_from = 9 + # However, now I shouldn't be able to confirm future booking + with self.assertRaises(ValidationError), self.env.cr.savepoint(): + future_booking.action_confirm() From c474bd608ff45aa044ffb8c8f1723dbbf4270125 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Tue, 8 Jun 2021 10:28:12 +0100 Subject: [PATCH 05/61] [FIX] resource_booking: always notify in resource TZ The notifications emitted to the resource booking requester must always be in the same TZ as the resource booking itself. For example, if you book one hotel room in the other side of the world, a notification in your own TZ is confusing. Besides, res.partner created from website_sale are created with `tz=False`, making it even more confusing. @Tecnativa TT30331 --- resource_booking/__manifest__.py | 2 +- resource_booking/models/calendar_event.py | 10 ++++++++ resource_booking/tests/test_backend.py | 28 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index a588b3b1..ea25059d 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": "12.0.1.1.1", + "version": "12.0.1.1.2", "development_status": "Beta", "category": "Appointments", "website": "https://github.com/OCA/calendar", diff --git a/resource_booking/models/calendar_event.py b/resource_booking/models/calendar_event.py index a14e6721..9500e8c1 100644 --- a/resource_booking/models/calendar_event.py +++ b/resource_booking/models/calendar_event.py @@ -51,3 +51,13 @@ def write(self, vals): rescheduled -= new rescheduled._validate_booking_modifications() return result + + def get_interval(self, interval, tz=None): + """Autofix tz from related resource booking. + + This function is called to render calendar.event notification mails. + Any notification related to a resource.booking must be emitted in the + same TZ as the resource.booking. Otherwise it's confusing to the user. + """ + tz = self.resource_booking_ids.type_id.resource_calendar_id.tz or tz + return super().get_interval(interval=interval, tz=tz) diff --git a/resource_booking/tests/test_backend.py b/resource_booking/tests/test_backend.py index 716ec13a..6576886b 100644 --- a/resource_booking/tests/test_backend.py +++ b/resource_booking/tests/test_backend.py @@ -350,3 +350,31 @@ def test_change_calendar_after_bookings_exist(self): # However, now I shouldn't be able to confirm future booking with self.assertRaises(ValidationError), self.env.cr.savepoint(): future_booking.action_confirm() + + def test_notification_tz(self): + """Mail notification TZ is the same as resource.booking.type always.""" + # Configure RBT with Madrid calendar, but partner has other TZ + self.r_calendars.write({"tz": "Europe/Madrid"}) + self.partner.tz = "Australia/Sydney" + rb = self.env["resource.booking"].create( + { + "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, + } + ) + rb.action_confirm() + invitation_mail = self.env["mail.mail"].search( + [ + ("state", "=", "outgoing"), + ( + "subject", + "=", + "Invitation to some customer - Test resource booking type", + ), + ] + ) + # Invitation must display Madrid TZ (CET) + self.assertIn("09:00:00 CET", invitation_mail.body) From a0babfaa3667acc17e4657779e39f9b017efca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Marques?= Date: Fri, 18 Jun 2021 12:53:45 +0100 Subject: [PATCH 06/61] [FIX] resource_booking: Limit constraint only to future bookings The constraint that checks the schedule of a resource booking is currently being applied to all the bookings, including past ones. As the resource combination or associated calendars might change regularly and trigger a recomputation of this, such change might take a very long time. Plus, the calendar restrictions might change, trigger a recompute of the constraint and detect bookings that can't be assigned, which makes no sense when they already happened. This applies it only to future bookings, ignoring past ones. TT30478 --- resource_booking/__manifest__.py | 2 +- resource_booking/models/resource_booking.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index ea25059d..89b28732 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": "12.0.1.1.2", + "version": "12.0.1.1.3", "development_status": "Beta", "category": "Appointments", "website": "https://github.com/OCA/calendar", diff --git a/resource_booking/models/resource_booking.py b/resource_booking/models/resource_booking.py index 2d281606..90669407 100644 --- a/resource_booking/models/resource_booking.py +++ b/resource_booking/models/resource_booking.py @@ -271,7 +271,13 @@ def _check_scheduling(self): ) # Ensure all bookings fit in their type and resources calendars unfitting_bookings = has_meeting + now = fields.Datetime.now() for booking in has_meeting: + # Ignore if the event already happened + already_happened = booking.stop and booking.stop < now + if already_happened: + unfitting_bookings -= booking + continue meeting_dates = tuple( fields.Datetime.context_timestamp(self, booking[field]) for field in ("start", "stop") From 3782ef28adb432bac547e7ad259ab736a83c1052 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Tue, 13 Jul 2021 11:10:06 +0100 Subject: [PATCH 07/61] [IMP] resource_booking: black, isort, prettier --- resource_booking/__manifest__.py | 10 +- resource_booking/controllers/portal.py | 6 +- resource_booking/demo/res_users_demo.xml | 5 +- resource_booking/models/resource_booking.py | 15 +- .../models/resource_booking_combination.py | 1 + .../models/resource_booking_type.py | 3 +- resource_booking/models/resource_calendar.py | 4 +- .../security/resource_booking_security.xml | 32 +- resource_booking/templates/assets.xml | 10 +- resource_booking/templates/portal.xml | 295 +++++++++++++----- resource_booking/tests/common.py | 12 +- resource_booking/tests/test_backend.py | 9 +- resource_booking/tests/test_portal.py | 11 +- .../views/calendar_event_views.xml | 5 +- resource_booking/views/menus.xml | 65 ++-- .../resource_booking_combination_views.xml | 44 ++- .../views/resource_booking_type_views.xml | 68 ++-- .../views/resource_booking_views.xml | 198 +++++++++--- 18 files changed, 566 insertions(+), 227 deletions(-) diff --git a/resource_booking/__manifest__.py b/resource_booking/__manifest__.py index 89b28732..b2c54620 100644 --- a/resource_booking/__manifest__.py +++ b/resource_booking/__manifest__.py @@ -15,8 +15,10 @@ "installable": True, "external_dependencies": { "python": [ - "cssselect", # Used implicitly - "freezegun", # Only for tests + # Used implicitly + "cssselect", + # Only for tests + "freezegun", ], }, "depends": [ @@ -37,7 +39,5 @@ "views/resource_booking_views.xml", "views/menus.xml", ], - "demo": [ - "demo/res_users_demo.xml", - ], + "demo": ["demo/res_users_demo.xml",], } diff --git a/resource_booking/controllers/portal.py b/resource_booking/controllers/portal.py index 11da8785..25556207 100644 --- a/resource_booking/controllers/portal.py +++ b/resource_booking/controllers/portal.py @@ -5,11 +5,13 @@ from urllib.parse import quote_plus from dateutil.parser import isoparse -from odoo.addons.portal.controllers import portal + from odoo.exceptions import AccessError, MissingError, ValidationError from odoo.http import request, route from odoo.tests.common import Form +from odoo.addons.portal.controllers import portal + class CustomerPortal(portal.CustomerPortal): def _get_booking_sudo(self, booking_id, access_token): @@ -131,7 +133,7 @@ def portal_booking_confirm(self, booking_id, access_token, when, **kwargs): booking_form.start = when_naive except ValidationError as error: url = booking_sudo.get_portal_url( - suffix="/schedule/{0:%Y/%m}".format(when_tz_aware), + suffix="/schedule/{:%Y/%m}".format(when_tz_aware), query_string="&error={}".format(quote_plus(error.name)), ) return request.redirect(url) diff --git a/resource_booking/demo/res_users_demo.xml b/resource_booking/demo/res_users_demo.xml index 5bfa5eed..338a929f 100644 --- a/resource_booking/demo/res_users_demo.xml +++ b/resource_booking/demo/res_users_demo.xml @@ -1,11 +1,8 @@ - + - - - diff --git a/resource_booking/models/resource_booking.py b/resource_booking/models/resource_booking.py index 90669407..9ea7bad7 100644 --- a/resource_booking/models/resource_booking.py +++ b/resource_booking/models/resource_booking.py @@ -2,17 +2,17 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import calendar - -from datetime import datetime, timedelta from contextlib import suppress +from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models -from odoo.addons.resource.models.resource import Intervals from odoo.exceptions import ValidationError from odoo.osv.expression import NEGATIVE_TERM_OPERATORS +from odoo.addons.resource.models.resource import Intervals + class ResourceBooking(models.Model): _name = "resource.booking" @@ -51,10 +51,7 @@ class ResourceBooking(models.Model): ondelete="set null", help="Meeting confirmed for this booking.", ) - categ_ids = fields.Many2many( - string="Tags", - comodel_name="calendar.event.type", - ) + categ_ids = fields.Many2many(string="Tags", comodel_name="calendar.event.type",) combination_id = fields.Many2one( comodel_name="resource.booking.combination", string="Resources combination", @@ -74,9 +71,7 @@ class ResourceBooking(models.Model): track_visibility="onchange", help="Who requested this booking?", ) - requester_advice = fields.Text( - related="type_id.requester_advice", readonly=True - ) + requester_advice = fields.Text(related="type_id.requester_advice", readonly=True) involves_me = fields.Boolean( compute="_compute_involves_me", search="_search_involves_me" ) diff --git a/resource_booking/models/resource_booking_combination.py b/resource_booking/models/resource_booking_combination.py index 752c1172..76919c12 100644 --- a/resource_booking/models/resource_booking_combination.py +++ b/resource_booking/models/resource_booking_combination.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import _, api, fields, models + from odoo.addons.resource.models.resource import Intervals diff --git a/resource_booking/models/resource_booking_type.py b/resource_booking/models/resource_booking_type.py index 6a99bc45..69bd4da2 100644 --- a/resource_booking/models/resource_booking_type.py +++ b/resource_booking/models/resource_booking_type.py @@ -2,10 +2,11 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from datetime import timedelta -from odoo import _, api, fields, models from math import ceil from random import random +from odoo import _, api, fields, models + class ResourceBookingType(models.Model): _name = "resource.booking.type" diff --git a/resource_booking/models/resource_calendar.py b/resource_booking/models/resource_calendar.py index 9c4bcc8f..510a2788 100644 --- a/resource_booking/models/resource_calendar.py +++ b/resource_booking/models/resource_calendar.py @@ -2,9 +2,11 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from pytz import UTC + from odoo import api, fields, models -from odoo.addons.resource.models.resource import Intervals + from odoo.addons.calendar.models.calendar import calendar_id2real_id +from odoo.addons.resource.models.resource import Intervals class Busy(Exception): diff --git a/resource_booking/security/resource_booking_security.xml b/resource_booking/security/resource_booking_security.xml index 1f3af9a0..ed8b20ff 100644 --- a/resource_booking/security/resource_booking_security.xml +++ b/resource_booking/security/resource_booking_security.xml @@ -1,49 +1,52 @@ - + - - Resource Booking - User Users allowed to book resources - Manager - Users allowed to manage resource booking configurations. - + Users allowed to manage resource booking configurations. + - Resource booking type multi company rule - ['|', ('company_id', '=', False), ('company_id', 'child_of', user.company_id.ids)] + ['|', ('company_id', '=', False), ('company_id', 'child_of', user.company_id.ids)] - Resource booking portal rule - ['|', ('partner_id', 'child_of', user.partner_id.ids), ('message_partner_ids', 'child_of', user.partner_id.ids)] + ['|', ('partner_id', 'child_of', user.partner_id.ids), ('message_partner_ids', 'child_of', user.partner_id.ids)] - Resource booking user rule - ['|', '|', ('partner_id', 'child_of', user.partner_id.ids), ('message_partner_ids', 'child_of', user.partner_id.ids), ('combination_id.resource_ids.user_id', 'in', user.ids)] + ['|', '|', ('partner_id', 'child_of', user.partner_id.ids), ('message_partner_ids', 'child_of', user.partner_id.ids), ('combination_id.resource_ids.user_id', 'in', user.ids)] - Resource booking manager rule @@ -51,5 +54,4 @@ [(1, '=', 1)] - diff --git a/resource_booking/templates/assets.xml b/resource_booking/templates/assets.xml index bf05e7bb..b640702b 100644 --- a/resource_booking/templates/assets.xml +++ b/resource_booking/templates/assets.xml @@ -1,12 +1,14 @@ - + - - diff --git a/resource_booking/templates/portal.xml b/resource_booking/templates/portal.xml index 0b182135..0da9f40b 100644 --- a/resource_booking/templates/portal.xml +++ b/resource_booking/templates/portal.xml @@ -1,9 +1,7 @@ - + - - - - - - - - -