# Part of Odoo. See LICENSE file for full copyright and licensing details. import pytz from datetime import datetime, timedelta from werkzeug.exceptions import BadRequest, Forbidden from odoo.http import Controller, request, route from odoo.tools import consteq, email_normalize, formataddr, hmac class GoogleReserveController(Controller): @route('/appointment///google_reserve/availabilities', auth="public", methods=['GET']) def google_reserve_batch_availability_lookup(self, appointment_id, appointment_token): """ Route accessed by IAP to check the availabilities. The request data contains a list of slots to get availabilities from. Each of those slots are formatted as follows: { 'start_sec': 1754466026 # UTC timestamp, 'duration_sec': 3600 # Slot duration in seconds, 'resources': { 'party_size': 4 # The requested party-size, what we usually call 'capacity' in Odoo } } It has to return a list with each given slot along with a boolean flag "available". See full documentation for details: https://developers.google.com/actions-center/verticals/reservations/e2e/reference/booking-server-api-rest/e2e-methods/batchavailabilitylookup-method?hl=en """ slot_time = request.get_json_data() # not compatible with JSON-RPC, manually fetch params if not slot_time: raise BadRequest("Invalid JSON payload") appointment_type_su = self._fetch_appointment(appointment_id, appointment_token) slot_time_availability = [] min_date = datetime.fromtimestamp(int(slot_time[0]['start_sec']), tz=pytz.UTC) max_date = min_date + timedelta(seconds=int(slot_time[0]['duration_sec'])) for slot in slot_time: slot_start = datetime.fromtimestamp(int(slot['start_sec']), tz=pytz.UTC) slot_stop = slot_start + timedelta(seconds=int(slot['duration_sec'])) if slot_start < min_date: min_date = slot_start if slot_stop > max_date: max_date = slot_stop availabilities = appointment_type_su._google_reserve_get_availabilities(start_time=min_date, end_time=max_date) for slot in slot_time: slot_time_availability.append({ "available": any(( ( not slot.get('resource_ids') or (slot.get('resource_ids') and slot['resource_ids'].get('party_size') and a['resources']['party_size'] >= slot['resource_ids']['party_size']) ) and a['start_sec'] == int(slot['start_sec']) and a['duration_sec'] == int(slot['duration_sec']) and a['spots_open'] >= 1) for a in availabilities), "slot_time": slot, }) return request.make_json_response(slot_time_availability) @route('/appointment///google_reserve/booking/create', auth="public", methods=['POST'], csrf=False) def google_reserve_booking_create(self, appointment_id, appointment_token): """ Route accessed by Google (through IAP) when a booking needs to be created. Format of Google provided information: { 'idempotency_token': 'token1', 'slot': { 'confirmation_mode': 'CONFIRMATION_MODE_SYNCHRONOUS', 'duration_sec': '3600', 'merchant_id': 1, 'resources': { 'party_size': '4', }, 'service_id': 1, 'start_sec': 1644850800, }, 'user_information': { 'email': 'john.doe@test.com', 'family_name': 'Doe', 'given_name': 'John', 'telephone': '+32476112233', 'user_id': '1234567890', } } Global flow: 1. Generate slots Generate slots based on desired slot time and capacity (party_size). This only generates a single slot as the duration we receive from Google matches the appointment duration. (Note: 'party_size' param is only provided for resources-based appointments) 2. Fill slots availability Fill our single slot with availabilities for users or resources. This will re-use the existing appointment logic and account for time-off, pick the best resource to accommodate the desired party size, etc. 3. Create the booking Based on information extracted from the generated slot and the booker information provided by Google, we create the calendar.event record. We re-use '_prepare_calendar_event_values' here as the flow is very similar to what we do when booking from the Odoo front-end. 4. Return the response with Google's format. See: https://developers.google.com/actions-center/verticals/reservations/e2e/reference/booking-server-api-rest/e2e-methods/createbooking-method Technical note: We force the access_token of the created calendar.event manually by using the provided idempotency_token from Google. This allows us to identify the booking without having to store yet another field. """ route_params = request.get_json_data() # not compatible with JSON-RPC, manually fetch params if not all(bool(route_params.get(key)) for key in [ 'idempotency_token', 'user_information', 'slot' ]): raise BadRequest("Invalid JSON payload") appointment_type_su = self._fetch_appointment(appointment_id, appointment_token) slot_info = route_params['slot'] user_info = route_params['user_information'] token = hmac( request.env(su=True), "appointment-google-reserve-create-booking", (appointment_type_su.id, route_params['idempotency_token']) )[:36] calendar_event = request.env['calendar.event'].sudo().search([('access_token', '=', token)]) if calendar_event: # booking already created, shortcut the method return request.make_json_response({ 'booking': { 'booking_id': str(calendar_event.id), 'status': 'CONFIRMED', 'slot': slot_info, 'user_information': user_info, } }) duration_sec = int(slot_info['duration_sec']) start_sec = int(slot_info['start_sec']) stop_sec = start_sec + duration_sec start_slot = datetime.fromtimestamp(start_sec) stop_slot = datetime.fromtimestamp(stop_sec) party_size = 0 if 'resources' in slot_info and 'party_size' in slot_info['resources']: party_size = int(slot_info['resources']['party_size']) generated_slots = appointment_type_su._slots_generate( start_slot.astimezone(pytz.utc), stop_slot.astimezone(pytz.utc), 'UTC', ) selected_slot = next(( slot for slot in generated_slots if slot['UTC'][0] == start_slot and slot['UTC'][1] == stop_slot), None) slots_to_fill = [selected_slot] if selected_slot else [] if appointment_type_su.schedule_based_on == 'users': appointment_type_su._slots_fill_users_availability( slots_to_fill, start_slot.astimezone(pytz.utc), stop_slot.astimezone(pytz.utc), ) else: appointment_type_su._slots_fill_resources_availability( slots_to_fill, start_slot.astimezone(pytz.utc), stop_slot.astimezone(pytz.utc), asked_capacity=party_size, ) slot = slots_to_fill[0] if slots_to_fill else False staff_user = request.env['res.users'] booking_lines = [] booking_response = False if appointment_type_su.schedule_based_on == 'resources' and slot and 'available_resource_ids' in slot: resources = slot['available_resource_ids'] resources_remaining_capacity = appointment_type_su._get_resources_remaining_capacity( resources, start_slot, stop_slot, with_linked_resources=False ) booking_lines = [] capacity_to_assign = party_size for resource in resources: resource_remaining_capacity = resources_remaining_capacity.get(resource) new_capacity_reserved = min(resource_remaining_capacity, capacity_to_assign, resource.capacity) capacity_to_assign -= new_capacity_reserved booking_lines.append({ 'appointment_resource_id': resource.id, 'capacity_reserved': new_capacity_reserved, 'capacity_used': new_capacity_reserved if resource.shareable and appointment_type_su.manage_capacity else resource.capacity if appointment_type_su.manage_capacity else 1, }) elif appointment_type_su.schedule_based_on == 'users' and slot and ('available_staff_users' in slot or 'staff_user_id' in slot): staff_user = slot.get('available_staff_users', [False])[0] or slot['staff_user_id'] if booking_lines or staff_user: customer_email = email_normalize(user_info['email']) customer_name = ' '.join(filter(None, (user_info.get('given_name'), user_info.get('family_name')))) customer = request.env['mail.thread'].sudo()._mail_find_partner_from_emails( [formataddr((customer_name, customer_email)) if customer_email else customer_email], force_create=True ) if customer: customer = customer[0] else: raise BadRequest('Invalid Email') if 'telephone' in user_info: customer_phone = customer._phone_format(number=user_info['telephone']) or user_info['telephone'] if customer.phone_sanitized != customer_phone: customer.phone = user_info['telephone'] appointment_values = appointment_type_su._prepare_calendar_event_values( asked_capacity=party_size, booking_line_values=booking_lines, description='', duration=duration_sec / 3600, allday=False, appointment_invite=request.env['appointment.invite'], guests=None, name=customer.name, customer=customer, staff_user=staff_user, start=start_slot, stop=stop_slot, ) calendar_event = request.env['calendar.event'].sudo().create({ **appointment_values, 'access_token': token, 'is_google_reserve': True, }) booking_response = { 'booking': { 'booking_id': str(calendar_event.id), 'status': 'CONFIRMED', 'slot': slot_info, 'user_information': user_info, } } else: booking_response = { 'booking': { 'booking_id': False, 'status': 'FAILED', 'slot': slot_info, 'user_information': user_info, }, 'booking_failure': { 'cause': 'SLOT_UNAVAILABLE', } } return request.make_json_response(booking_response) @route('/appointment///google_reserve/booking//update', auth="public", methods=['POST'], csrf=False) def google_reserve_booking_update(self, appointment_id, appointment_token, calendar_event_id): """ Route accessed by Google (through IAP) when a booking needs to be updated. Format of Google provided information: { 'booking': { 'booking_id': '42', # matches calendar.event record ID 'slot': { 'resources': { 'duration_sec': '3600', 'start_sec': 1644850800, 'party_size': 11, }, }, } } RESOURCES BOOKINGS -> 3 cases 1. Modify date and party_size We generate the slot to find suitable resources, similarly to what is done in 'google_reserve_booking_create'. If we have available resources, we then modify the values of 'resource_ids' and 'total_capacity_reserved' and let the inverse method fix the booking lines, see 'calendar.event#_inverse_resource_ids_or_capacity'. 2. Modify the party_size only We fetch the booking lines related to our calendar.event. If there is only 1 booking line and the resource can accommodate the capacity, we only alter the 'total_capacity_reserved' and keep the same resources. Otherwise, meaning we ask more capacity than the resource can accommodate or we already have multiple booking lines (typically for shared resources), the code fallbacks to use case 1 to try to find another suitable slot. 3. Modify dates only We try to keep the same resource as the initial booking and verify its availability using '_check_appointment_is_valid_slot'. If it's available -> great, otherwise fallback to use case 1 to try to find another suitable slot. STAFF BOOKINGS -> 1 case It is much simpler for staff bookings as only the date can be modified. We check if it's available using '_check_appointment_is_valid_slot' and if not we simply return to Google with an availability error, we do not try to find another staff user as it would be rather complex: have to cancel the initial meeting or adapt the attendees, what about notifications, etc. We may consider adding support for this in the future but resources bookings (restaurants) are our main target currently. Once the calendar.event has been modified (or if we don't have availabilities), we return a response with Google's format. See: https://developers.google.com/actions-center/verticals/reservations/e2e/reference/booking-server-api-rest/e2e-methods/updatebooking-method """ route_params = request.get_json_data() # not compatible with JSON-RPC, manually fetch params if not route_params.get('booking'): raise BadRequest("Invalid JSON payload") appointment_type_su = self._fetch_appointment(appointment_id, appointment_token) calendar_event = request.env['calendar.event'].sudo().with_context( active_test=False, ).search([ ('id', '=', calendar_event_id), ('appointment_type_id', '=', appointment_type_su.id) ]) if not calendar_event: raise BadRequest("Unknown Booking") booking_info = route_params['booking'] booking_response = {} failure_status = False if booking_info.get('status') == 'CANCELED': if not calendar_event.active: failure_status = 'BOOKING_ALREADY_CANCELLED' else: calendar_event.with_context(google_reserve_service_rpc=True).action_archive() booking_response = { 'booking': booking_info, } elif 'slot' in booking_info: calendar_event_vals = { 'start': calendar_event.start, 'duration': calendar_event.duration, } resources_booking = appointment_type_su.schedule_based_on == 'resources' modifying_dates = 'start_sec' in booking_info['slot'] and 'duration_sec' in booking_info['slot'] modifying_party_size = 'resources' in booking_info['slot'] and 'party_size' in booking_info['slot']['resources'] if modifying_dates: # try to simply move the date while keeping the same resources / staff user calendar_event_vals.update({ 'start': datetime.fromtimestamp(int(booking_info['slot']['start_sec'])), 'duration': int(booking_info['slot']['duration_sec']) / 3600, }) if not calendar_event.with_context( ignore_event_ids=calendar_event.ids ).appointment_type_id._check_appointment_is_valid_slot( staff_user=calendar_event.partner_id.user_ids[0] if not resources_booking and calendar_event.partner_id.user_ids else False, resources=calendar_event.appointment_resource_ids, asked_capacity=calendar_event.total_capacity_reserved, timezone='UTC', start_dt=calendar_event_vals['start'].astimezone(pytz.utc), duration=calendar_event_vals['duration'], allday=calendar_event.allday, ): if resources_booking and not modifying_party_size: # the same resources are not available with the new dates # -> force re-computing party size to try with new resources booking_info['slot'].update({ 'resources': { 'party_size': calendar_event.total_capacity_reserved } }) modifying_party_size = True elif not resources_booking: # staff user is not available -> we currently do not support trying to find # someone else instead as it would require cancelling meetings which would # in turn notify Google, it would also be confusing for meeting organization failure_status = 'SLOT_UNAVAILABLE' if resources_booking and modifying_party_size: old_party_size = calendar_event.total_capacity_reserved new_party_size = int(booking_info['slot']['resources']['party_size']) booking_lines = calendar_event.booking_line_ids if not modifying_dates and len(booking_lines) == 1 and ( new_party_size <= max(old_party_size, booking_lines.appointment_resource_id.capacity) ): # when a single booking line and capacity allows -> just adapt capacity calendar_event_vals['total_capacity_reserved'] = new_party_size else: # otherwise re-generate the slot to find available resources to accommodate it start_dt_utc = calendar_event_vals['start'].astimezone(pytz.utc) stop_dt_utc = start_dt_utc + timedelta(hours=calendar_event_vals['duration']) slots = calendar_event.appointment_type_id._slots_generate(start_dt_utc, stop_dt_utc, 'UTC') slots = [slot for slot in slots if slot['UTC'][0] == start_dt_utc.replace(tzinfo=None) and slot['UTC'][1] == stop_dt_utc.replace(tzinfo=None)] calendar_event.with_context( ignore_event_ids=calendar_event.ids ).appointment_type_id._slots_fill_resources_availability( slots, start_dt_utc, stop_dt_utc, asked_capacity=new_party_size, ) if slots and slots[0].get('available_resource_ids'): calendar_event_vals.update({ 'resource_ids': slots[0]['available_resource_ids'], 'total_capacity_reserved': new_party_size, }) elif modifying_dates: failure_status = 'SLOT_UNAVAILABLE' else: failure_status = 'USER_OVER_BOOKING_LIMIT' if not failure_status: calendar_event.with_context(google_reserve_service_rpc=True).write(calendar_event_vals) booking_response = { 'booking': dict( booking_info, status='CONFIRMED', payment_information={'prepayment_status': 'PREPAYMENT_NOT_PROVIDED'}, ), } booking_response['booking']['slot']['resources'] = { 'party_size': str(calendar_event.total_capacity_reserved) } if failure_status: booking_response = { 'booking': { 'booking_id': str(calendar_event.id), 'status': 'FAILED', }, 'booking_failure': { 'cause': failure_status, } } return request.make_json_response(booking_response) @route('/appointment/google_reserve/upload_availabilities_feed', auth="public", methods=['POST'], csrf=False) def google_reserve_upload_availabilities_feed(self): route_params = request.get_json_data() # not compatible with JSON-RPC, manually fetch params google_reserve_access_token_pairs = route_params.get('google_reserve_access_token_pairs') if not google_reserve_access_token_pairs or not isinstance(google_reserve_access_token_pairs, list): raise Forbidden() for google_reserve_access_token_pair in google_reserve_access_token_pairs: appointment_type = request.env['appointment.type'].sudo().search([ ('id', '=', google_reserve_access_token_pair['appointment_type_id']), ('google_reserve_enable', '=', True) ]) if not appointment_type or not consteq( appointment_type.google_reserve_access_token, google_reserve_access_token_pair['appointment_token'] ): raise Forbidden() request.env.ref('appointment_google_reserve.ir_cron_iap_google_reserve_availabilities')._trigger() # -------------------------------------- # UTILS # -------------------------------------- def _fetch_appointment(self, appointment_id, appointment_token): if not appointment_id or not appointment_token: raise BadRequest("Unknown Appointment") appointment_type = request.env['appointment.type'].sudo().search([ ('id', '=', appointment_id), ('google_reserve_enable', '=', True) ]) if not appointment_type or not consteq( appointment_type.google_reserve_access_token, appointment_token): raise BadRequest("Unknown Appointment") return appointment_type