518 lines
23 KiB
Python
518 lines
23 KiB
Python
# 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/<int:appointment_id>/<string:appointment_token>/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/<int:appointment_id>/<string:appointment_token>/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/<int:appointment_id>/<string:appointment_token>/google_reserve/booking/<int:calendar_event_id>/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
|