# Part of Odoo. See LICENSE file for full copyright and licensing details. import json import pytz import re from pytz.exceptions import UnknownTimeZoneError from babel.dates import format_datetime, format_date, format_time from datetime import datetime, date from dateutil.relativedelta import relativedelta from markupsafe import Markup from urllib.parse import unquote_plus from werkzeug.exceptions import Forbidden, NotFound from werkzeug.urls import url_encode from odoo import Command, exceptions, http, fields, _ from odoo.http import request, route from odoo.osv import expression from odoo.tools import email_normalize, plaintext2html, DEFAULT_SERVER_DATETIME_FORMAT as dtf from odoo.tools.mail import is_html_empty from odoo.tools.misc import babel_locale_parse, get_lang from odoo.addons.base.models.ir_qweb import keep_query from odoo.addons.base.models.res_partner import _tz_get from odoo.addons.http_routing.models.ir_http import unslug def _formated_weekdays(locale): """ Return the weekdays' name for the current locale from Mon to Sun. :param locale: locale """ formated_days = [ format_date(date(2021, 3, day), 'EEE', locale=locale) for day in range(1, 8) ] # Get the first weekday based on the lang used on the website first_weekday_index = babel_locale_parse(locale).first_week_day # Reorder the list of days to match with the first weekday formated_days = list(formated_days[first_weekday_index:] + formated_days)[:7] return formated_days class AppointmentController(http.Controller): # ------------------------------------------------------------ # APPOINTMENT INVITATION # ------------------------------------------------------------ @route(['/book/'], type='http', auth="public", website=True) def appointment_invite(self, short_code): """ Invitation link that simplify the URL sent or shared to partners. This will redirect to a correct URL with the params selected with the invitation. """ invitation = request.env['appointment.invite'].sudo().search([('short_code', '=', short_code)]) if not invitation: raise NotFound() return request.redirect(invitation.redirect_url) # ------------------------------------------------------------ # APPOINTMENT INDEX PAGE # ------------------------------------------------------------ @route(['/calendar', '/calendar/page/'], type='http', auth="public", website=True, sitemap=True) def appointment_type_index_old(self, page=1, **kwargs): """ For backward compatibility """ return request.redirect( '/appointment%s?%s' % ('/page/%s' % page if page != 1 else '', url_encode(kwargs)), code=301, ) @route(['/appointment', '/appointment/page/'], type='http', auth="public", website=True, sitemap=True) def appointment_type_index(self, page=1, **kwargs): """ Display the appointments to choose (the display depends of a custom option called 'Card Design') :param page: the page number displayed when the appointments are organized by cards A param filter_appointment_type_ids can be passed to display a define selection of appointments types. This param is propagated through templates to allow people to go back with the initial appointment types filter selection """ kwargs['domain'] = self._appointment_website_domain() return request.render('appointment.appointments_list_layout', self._prepare_appointments_list_data(**kwargs)) # Tools / Data preparation # ------------------------------------------------------------ def _prepare_appointments_list_data(self, appointment_types=None, **kwargs): """Compute specific data used to render the list layout :param recordset appointment_types: Record set of appointments to show. If not provided, fetch them using _fetch_and_check_private_appointment_types """ appointment_types = appointment_types or self._fetch_and_check_private_appointment_types( kwargs.get('filter_appointment_type_ids'), kwargs.get('filter_staff_user_ids'), kwargs.get('filter_resource_ids'), kwargs.get('invite_token'), domain=self._appointments_base_domain( filter_appointment_type_ids=kwargs.get('filter_appointment_type_ids'), search=kwargs.get('search'), invite_token=kwargs.get('invite_token'), additional_domain=kwargs.get('domain') ) ) appointment_types = appointment_types.sorted('is_published', reverse=True) return { 'appointment_types': appointment_types, 'invite_token': kwargs.get('invite_token'), 'filter_appointment_type_ids': kwargs.get('filter_appointment_type_ids'), 'filter_staff_user_ids': kwargs.get('filter_staff_user_ids'), 'filter_resource_ids': kwargs.get('filter_resource_ids'), } @classmethod def _appointments_base_domain(cls, filter_appointment_type_ids, search=False, invite_token=False, additional_domain=None): """ Generate a domain for appointment filtering. This method constructs a domain to filter appointment records based on various criteria. Args: filter_appointment_type_ids (str): A comma-separated string of appointment type IDs to filter by. Example: "1,2,3" search (str, optional): A search string to filter appointments by name (case-insensitive). invite_token (bool, optional): A boolean flag indicating whether to include invite token filtering. If False, it considers the user's country and published status of appointments. additional_domain (list, optional): Additional domain expressions to include in the filter. Returns: list: A list of domain expressions suitable for use in Odoo record filtering. """ domain = list(additional_domain) if additional_domain else [] if filter_appointment_type_ids: filter_appointment_type_ids = unquote_plus(filter_appointment_type_ids) domain = expression.AND([domain, [('id', 'in', json.loads(filter_appointment_type_ids))]]) if not invite_token: country = cls._get_customer_country() if country: country_domain = ['|', ('country_ids', '=', False), ('country_ids', 'in', [country.id])] domain = expression.AND([domain, country_domain]) # Add domain related to the search bar if search: domain = expression.AND([domain, [('name', 'ilike', search)]]) # Because of sudo search, we need to search only published ones if there is no invite_token if request.env.user.share and not invite_token: domain = expression.AND([domain, [('is_published', '=', True)]]) return domain def _appointment_website_domain(self): return [ ('category', 'in', ['punctual', 'recurring']), '|', ('end_datetime', '=', False), ('end_datetime', '>=', datetime.utcnow()) ] # ------------------------------------------------------------ # APPOINTMENT TYPE PAGE VIEW # ------------------------------------------------------------ @route(['/calendar/'], type='http', auth="public", website=True, sitemap=True) def appointment_type_page_old(self, appointment_type, **kwargs): """ For backward compatibility: appointment_type is transformed from a recordset to a string because we removed the rights for public user. """ return request.redirect('/appointment/%s?%s' % (unslug(appointment_type)[1], keep_query('*')), code=301) @route(['/appointment/'], type='http', auth="public", website=True, sitemap=True) def appointment_type_page(self, appointment_type_id, state=False, staff_user_id=False, resource_selected_id=False, **kwargs): """ This route renders the appointment page: It first computes a dict of values useful for all potential views and to choose between them in _get_appointment_type_page_view, that renders the chosen one. :param appointment_type_id: the appointment_type_id of the appointment type that we want to access :param state: the type of message that will be displayed in case of an error/info. Possible values: - cancel: Info message to confirm that an appointment has been canceled - failed-staff-user: Error message displayed when the slot has been taken while doing the registration :param staff_user_id: id of the selected user, from upstream or coming back from an error. :param resource_selected_id: id of the selected resource, from upstream or coming back from an error. """ kwargs['domain'] = self._appointments_base_domain( filter_appointment_type_ids=kwargs.get('filter_appointment_type_ids'), search=kwargs.get('search'), invite_token=kwargs.get('invite_token'), additional_domain=kwargs.get('domain') ) available_appointments = self._fetch_and_check_private_appointment_types( kwargs.get('filter_appointment_type_ids'), kwargs.get('filter_staff_user_ids'), kwargs.get('filter_resource_ids'), kwargs.get('invite_token'), domain=kwargs['domain'] ) appointment_type = available_appointments.filtered(lambda appt: appt.id == int(appointment_type_id)) kwargs['available_appointments'] = available_appointments if not appointment_type: raise NotFound() page_values = self._prepare_appointment_type_page_values(appointment_type, staff_user_id, resource_selected_id, **kwargs) return self._get_appointment_type_page_view(appointment_type, page_values, state, **kwargs) def _get_slots_from_filter(self, appointment_type, filter_records, asked_capacity=1): """ Compute the slots and the first month that has available slots from the given filter. :param appointment_type: the appointment type that we want to access. :param filter_records: users/resources that are used to compute the slots :param asked_capacity: the capacity asked by the user :return: a dict containing: - slots: the available slots - month_first_available: the first month that has available slots or False if there is none """ slots = appointment_type._get_appointment_slots( request.session['timezone'], filter_users=filter_records if appointment_type.schedule_based_on == "users" else None, filter_resources=filter_records if appointment_type.schedule_based_on == "resources" else None, asked_capacity=asked_capacity, ) return { 'slots': slots, 'month_first_available': next((month['id'] for month in slots if month['has_availabilities']), False), } def _get_slots_values(self, appointment_type, selected_filter_record, default_filter_record, possible_filter_records, asked_capacity=1): """ Compute the slots and the first month that has available slots from the given filters. :param appointment_type: the appointment type that we want to access. :param selected_filter_record: the selected users/resources :param default_filter_record: the default users/resources :param possible_filter_records: the possible users/resources :param asked_capacity: the capacity asked by the user :return: a dict containing: - slots: the available slots - month_first_available: the first month that has available slots or False if there is none """ if selected_filter_record: return self._get_slots_from_filter(appointment_type, selected_filter_record, asked_capacity) if not default_filter_record: return self._get_slots_from_filter(appointment_type, possible_filter_records, asked_capacity) ordered_filters = default_filter_record | possible_filter_records for current_filter in ordered_filters: values = self._get_slots_from_filter(appointment_type, current_filter, asked_capacity) if values['month_first_available'] is not False: if appointment_type.schedule_based_on == "users": values['user_selected'] = current_filter else: values['resource_selected'] = current_filter return values return values def _get_appointment_type_page_view(self, appointment_type, page_values, state=False, **kwargs): """ Renders the appointment information alongside the calendar for the slot selection, after computation of the slots and preparation of other values, depending on the arguments values. This is the method to override in order to select another view for the appointment page. :param appointment_type: the appointment type that we want to access. :param page_values: dict containing common appointment page values. See _prepare_appointment_type_page_values for details. :param state: the type of message that will be displayed in case of an error/info. See appointment_type_page. """ request.session.timezone = self._get_default_timezone(appointment_type) asked_capacity = int(kwargs.get('asked_capacity', 1)) filter_prefix = 'user' if appointment_type.schedule_based_on == "users" else 'resource' slots_values = self._get_slots_values(appointment_type, selected_filter_record=page_values[f'{filter_prefix}_selected'], default_filter_record=page_values[f'{filter_prefix}_default'], possible_filter_records=page_values[f'{filter_prefix}s_possible'], asked_capacity=asked_capacity) formated_days = _formated_weekdays(get_lang(request.env).code) render_params = { 'appointment_type': appointment_type, 'is_html_empty': is_html_empty, 'formated_days': formated_days, 'main_object': appointment_type, 'month_kept_from_update': False, 'state': state, 'timezone': request.session['timezone'], # bw compatibility **page_values, **slots_values, } # Do not let the browser store the page, this ensures correct timezone and params management in case # the user goes back and forth to this endpoint using browser controls (or mouse back button) # this is especially necessary as we alter the request.session parameters. return request.render("appointment.appointment_info", render_params, headers={'Cache-Control': 'no-store'}) def _prepare_appointment_type_page_values(self, appointment_type, staff_user_id=False, resource_selected_id=False, **kwargs): """ Computes all values needed to choose between / common to all appointment_type page templates. :return: a dict containing: - available_appointments: all available appointments according to current filters and invite tokens. - filter_appointment_type_ids, filter_staff_user_ids and invite_token parameters. - user_default: the first of possible staff users. It will be selected by default (in the user select dropdown) if no user_selected. Otherwise, the latter will be preselected instead. It is only set if there is at least one possible user and the choice is activated in appointment_type, or used for having the user name in title if there is a single possible user, for random selection. - user_selected: the user corresponding to staff_user_id in the url and to the selected one. It can be selected upstream, from the operator_select screen (see WebsiteAppointment controller), or coming back from an error. It is only set if among the possible users. - users_possible: all possible staff users considering filter_staff_user_ids and staff members of appointment_type. - resource_selected: the resource corresponding to resource_selected_id in the url and to the selected one. It can be selected upstream, from the operator_select screen (see WebsiteAppointment controller), or coming back from an error. - resources_possible: all possible resources considering filter_resource_ids and resources of appointment type. - max_capacity: the maximum capacity that can be selected by the user to make an appointment on a resource. - hide_select_dropdown: True if the user select dropdown should be hidden. (e.g. an operator has been selected before) Even if hidden, it can still be in the view and used to update availabilities according to the selected user in the js. """ filter_staff_user_ids = json.loads(kwargs.get('filter_staff_user_ids') or '[]') filter_resource_ids = json.loads(kwargs.get('filter_resource_ids') or '[]') users_possible = self._get_possible_staff_users(appointment_type, filter_staff_user_ids) resources_possible = self._get_possible_resources(appointment_type, filter_resource_ids) user_default = user_selected = request.env['res.users'] resource_default = resource_selected = request.env['appointment.resource'] staff_user_id = int(staff_user_id) if staff_user_id else False resource_selected_id = int(resource_selected_id) if resource_selected_id else False if appointment_type.schedule_based_on == 'users': if appointment_type.assign_method != 'time_auto_assign' and users_possible: if staff_user_id and staff_user_id in users_possible.ids: user_selected = request.env['res.users'].sudo().browse(staff_user_id) user_default = users_possible[0] elif appointment_type.assign_method == 'time_auto_assign' and len(users_possible) == 1: user_default = users_possible[0] elif resources_possible: if resource_selected_id and resource_selected_id in resources_possible.ids and appointment_type.assign_method != 'time_resource': resource_selected = request.env['appointment.resource'].sudo().browse(resource_selected_id) elif appointment_type.assign_method == 'resource_time': resource_default = resources_possible[0] possible_combinations = (resource_selected or resource_default or resources_possible)._get_filtered_possible_capacity_combinations(1, {}) max_capacity_possible = possible_combinations[-1][1] if possible_combinations else 1 return { 'asked_capacity': int(kwargs['asked_capacity']) if kwargs.get('asked_capacity') else False, 'available_appointments': kwargs['available_appointments'], 'filter_appointment_type_ids': kwargs.get('filter_appointment_type_ids'), 'filter_staff_user_ids': kwargs.get('filter_staff_user_ids'), 'filter_resource_ids': kwargs.get('filter_resource_ids'), 'hide_select_dropdown': len(users_possible if appointment_type.schedule_based_on == 'users' else resources_possible) <= 1, 'invite_token': kwargs.get('invite_token'), 'max_capacity': min(12, max_capacity_possible), 'resource_default': resource_default, 'resource_selected': resource_selected, 'resources_possible': resources_possible, 'user_default': user_default, 'user_selected': user_selected, 'users_possible': users_possible, } # Staff User tools # ------------------------------------------------------------ @http.route('/appointment//avatar', type='http', auth="public", cors="*") def appointment_staff_user_avatar(self, appointment_type_id, user_id=False, avatar_size=512): """ Route used to bypass complicated access rights like 'website_published'. We consider we can display the avatar of the user of id user_id if it belongs to the appointment_type_id and if the option avatars_display is set to 'show' for that appointment type. In that case we consider that the avatars can be made public. Default field is avatar_512. Another avatar_size corresponding to an existing avatar field on res.users can be given as route parameter. """ user = request.env['res.users'].sudo().browse(int(user_id)) appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id) user = user if appointment_type.avatars_display == 'show' and user in appointment_type.staff_user_ids else request.env['res.users'] return request.env['ir.binary']._get_image_stream_from( user, field_name='avatar_%s' % (avatar_size if int(avatar_size) in [128, 256, 512, 1024, 1920] else 512), placeholder='mail/static/src/img/smiley/avatar.jpg', ).get_response() def _get_possible_resources(self, appointment_type, filter_resource_ids): """ This method filters the resources of given appointment_type using filter_resource_ids that are possible to pick. If no filter exist and assign method is different than 'time_auto_assign', we allow all resources existing on the appointment type. :param appointment_type_id: the appointment_type_id of the appointment type that we want to access :param filter_resource_ids: list of resource ids used to filter the ones of the appointment_types. :return: an appointment.resource recordset containing all possible resources to choose from. """ if not filter_resource_ids: return appointment_type.resource_ids return appointment_type.resource_ids.filtered(lambda resource: resource.id in filter_resource_ids) def _get_possible_staff_users(self, appointment_type, filter_staff_user_ids): """ This method filters the staff members of given appointment_type using filter_staff_user_ids that are possible to pick. If no filter exist and assign method is different than 'time_auto_assign', we allow all users existing on the appointment type. :param appointment_type_id: the appointment_type_id of the appointment type that we want to access :param filter_staff_user_ids: list of user ids used to filter the ones of the appointment_type. :return: a res.users recordset containing all possible staff users to choose from. """ if not filter_staff_user_ids: return appointment_type.staff_user_ids return appointment_type.staff_user_ids.filtered(lambda staff_user: staff_user.id in filter_staff_user_ids) # Resource tools # ------------------------------------------------------------ @http.route('/appointment//resource_avatar', type='http', auth="public") def appointment_resource_avatar(self, appointment_type_id, resource_id=False, avatar_size=512): """ Route used to bypass access rights on the appointment resource for public user. Equivalent of ``appointment_staff_user_avatar()`` for appointment resource. """ resource = request.env['appointment.resource'].sudo().browse(int(resource_id)) appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id) resource = resource if appointment_type.avatars_display == 'show' and resource in appointment_type.resource_ids else request.env['appointment.resource'] return request.env['ir.binary']._get_image_stream_from( resource, field_name='avatar_%s' % (avatar_size if int(avatar_size) in [128, 256, 512, 1024, 1920] else 512), ).get_response() # Tools / Data preparation # ------------------------------------------------------------ @staticmethod def _fetch_and_check_private_appointment_types(appointment_type_ids, staff_user_ids, resource_ids, invite_token, domain=False): """ When an invite_token is in the params, we need to check if the params used and the ones in the invitation are the same. For the old link, we use the technical field "is_published" to determine if a user had previous access. Check finally if we have the rights on the appointment_types. If the token is correct then we continue, if not we raise an Forbidden error. We return the current appointment type displayed/used if one or the appointment types linked to the filter in the url param :param str appointment_type_ids: list of appointment type ids for the filter linked to the appointment types in a string format :param str staff_user_ids: list of user ids for the filter linked to the staff users in a string format :param str resource_ids: list of resource ids for the filter linked to the resources in a string format :param str invite_token: token of the appointment invite :param domain: a search domain used when displaying the available appointment types """ appointment_type_ids = json.loads(unquote_plus(appointment_type_ids or "[]")) if not appointment_type_ids and domain is not False: appointment_type_ids = request.env['appointment.type'].sudo().search(domain).ids elif not appointment_type_ids: raise ValueError() appointment_types = request.env['appointment.type'].browse(appointment_type_ids).exists() staff_users = request.env['res.users'].sudo().browse(json.loads(unquote_plus(staff_user_ids or "[]"))) resources = request.env['appointment.resource'].sudo().browse(json.loads(unquote_plus(resource_ids or "[]"))) if invite_token: appt_invite = request.env['appointment.invite'].sudo().search([('access_token', '=', invite_token)]) if not appt_invite or not appt_invite._check_appointments_params(appointment_types, staff_users, resources): raise Forbidden() # To bypass the access checks in case we are public user appointment_types = appointment_types.sudo() elif request.env.user.share: # Backward compatibility for old version that had their appointment types "published" by default (aka accessible with read access rights) appointment_types = appointment_types.sudo().filtered('is_published') or appointment_types try: appointment_types.check_access_rights('read') except exceptions.AccessError: raise Forbidden() appointment_types = appointment_types._filter_access_rules('read') if domain: appointment_types = appointment_types.filtered_domain(domain) return appointment_types # ------------------------------------------------------------ # APPOINTMENT TYPE BOOKING # ------------------------------------------------------------ @http.route(['/appointment//info'], type='http', auth="public", website=True, sitemap=False) def appointment_type_id_form(self, appointment_type_id, date_time, duration, staff_user_id=None, resource_selected_id=None, available_resource_ids=None, asked_capacity=1, **kwargs): """ Render the form to get information about the user for the appointment :param appointment_type_id: the appointment type id related :param date_time: the slot datetime selected for the appointment :param duration: the duration of the slot :param staff_user_id: the user selected for the appointment :param resource_selected_id: the resource selected for the appointment :param available_resource_ids: the resources info we want to propagate that are linked to the slot time :param asked_capacity: the asked capacity for the appointment :param filter_appointment_type_ids: see ``Appointment.appointments()`` route """ domain = self._appointments_base_domain( filter_appointment_type_ids=kwargs.get('filter_appointment_type_ids'), search=kwargs.get('search'), invite_token=kwargs.get('invite_token') ) available_appointments = self._fetch_and_check_private_appointment_types( kwargs.get('filter_appointment_type_ids'), kwargs.get('filter_staff_user_ids'), kwargs.get('filter_resource_ids'), kwargs.get('invite_token'), domain=domain, ) appointment_type = available_appointments.filtered(lambda appt: appt.id == int(appointment_type_id)) if not appointment_type: raise NotFound() if not self._check_appointment_is_valid_slot(appointment_type, staff_user_id, resource_selected_id, available_resource_ids, date_time, duration, asked_capacity, **kwargs): raise NotFound() partner = self._get_customer_partner() partner_data = partner.read(fields=['name', 'phone', 'email'])[0] if partner else {} date_time = unquote_plus(date_time) date_time_object = datetime.strptime(date_time, dtf) day_name = format_datetime(date_time_object, 'EEE', locale=get_lang(request.env).code) date_formated = format_date(date_time_object.date(), locale=get_lang(request.env).code) time_locale = format_time(date_time_object.time(), locale=get_lang(request.env).code, format='short') resource = request.env['appointment.resource'].sudo().browse(int(resource_selected_id)) if resource_selected_id else request.env['appointment.resource'] staff_user = request.env['res.users'].browse(int(staff_user_id)) if staff_user_id else request.env['res.users'] users_possible = self._get_possible_staff_users( appointment_type, json.loads(unquote_plus(kwargs.get('filter_staff_user_ids') or '[]')), ) resources_possible = self._get_possible_resources( appointment_type, json.loads(unquote_plus(kwargs.get('filter_resource_ids') or '[]')), ) return request.render("appointment.appointment_form", { 'partner_data': partner_data, 'appointment_type': appointment_type, 'available_appointments': available_appointments, 'main_object': appointment_type, 'datetime': date_time, 'date_locale': f'{day_name} {date_formated}', 'time_locale': time_locale, 'datetime_str': date_time, 'duration_str': duration, 'duration': float(duration), 'staff_user': staff_user, 'resource': resource, 'asked_capacity': int(asked_capacity), 'timezone': request.session.get('timezone') or appointment_type.appointment_tz, # bw compatibility 'users_possible': users_possible, 'resources_possible': resources_possible, 'available_resource_ids': available_resource_ids, }) def _check_appointment_is_valid_slot(self, appointment_type, staff_user_id, resource_selected_id, available_resource_ids, start_dt, duration, asked_capacity, **kwargs): """ Given slot parameters check it is still valid, based on staff_user/resource availability, slot boundaries, ... :param record appointment_type: an appointment.type record under which the appointment is about to be taken; :param str(int) staff_user_id: staff_user linked to the appointment slot; :param str(int) resource_selected_id: resource chosen by the customer; :param str(list) available_resource_ids: list of resources ids available for the slots :param datetime start_dt: appointment slot starting datetime that will be localized in customer timezone; :param str(float) duration: the duration of the appointment; :param str(int) asked_capacity: the capacity asked by the customer; """ if not appointment_type or not start_dt or not duration: return False if appointment_type.schedule_based_on == 'users' and not staff_user_id: return False if appointment_type.schedule_based_on == 'resources' and not resource_selected_id and not available_resource_ids: return False staff_user = None resources = None try: duration = float(duration) asked_capacity = int(asked_capacity) staff_user_id = int(staff_user_id) if staff_user_id else False resource_selected_id = int(resource_selected_id) if resource_selected_id else False available_resource_ids = json.loads(unquote_plus(available_resource_ids)) if available_resource_ids else False start_dt = unquote_plus(start_dt) except ValueError: # Value Error: some parameters don't have the correct format # (duration:float, asked_capacity:int, staff_user_id:int, resource_selected_id:int, available_resource_ids:list, start_dt:str) return False try: session_tz = request.session.get('timezone', appointment_type.appointment_tz) tz_info = pytz.timezone(session_tz) start_dt_utc = tz_info.localize(fields.Datetime.from_string(start_dt)).astimezone(pytz.utc) except (ValueError, UnknownTimeZoneError): # ValueError: the datetime may be ill-formatted return False # we shouldn't be able to book an appointment in the past if start_dt_utc < datetime.today().astimezone(pytz.utc): return False if appointment_type.schedule_based_on == 'users': staff_user = request.env['res.users'].sudo().search([('id', '=', staff_user_id)]) else: resources = request.env['appointment.resource'].sudo().search([('id', 'in', available_resource_ids)]) if resource_selected_id: resource = request.env['appointment.resource'].sudo().search([('id', '=', resource_selected_id)]) # Check that chosen resource exists and is part of resources available if not resource or resource not in resources: return False return appointment_type._check_appointment_is_valid_slot(staff_user, resources, asked_capacity, session_tz, start_dt_utc, duration) @http.route(['/appointment//submit'], type='http', auth="public", website=True, methods=["POST"]) def appointment_form_submit(self, appointment_type_id, datetime_str, duration_str, name, phone, email, staff_user_id=None, available_resource_ids=None, asked_capacity=1, guest_emails_str=None, **kwargs): """ Create the event for the appointment and redirect on the validation page with a summary of the appointment. :param appointment_type_id: the appointment type id related :param datetime_str: the string representing the datetime :param duration_str: the string representing the duration :param name: the name of the user sets in the form :param phone: the phone of the user sets in the form :param email: the email of the user sets in the form :param staff_user_id: the user selected for the appointment :param available_resource_ids: the resources ids available for the appointment :param asked_capacity: asked capacity for the appointment :param str guest_emails: optional line-separated guest emails. It will fetch or create partners to add them as event attendees; """ domain = self._appointments_base_domain( filter_appointment_type_ids=kwargs.get('filter_appointment_type_ids'), search=kwargs.get('search'), invite_token=kwargs.get('invite_token') ) available_appointments = self._fetch_and_check_private_appointment_types( kwargs.get('filter_appointment_type_ids'), kwargs.get('filter_staff_user_ids'), kwargs.get('filter_resource_ids'), kwargs.get('invite_token'), domain=domain, ) appointment_type = available_appointments.filtered(lambda appt: appt.id == int(appointment_type_id)) if not appointment_type: raise NotFound() timezone = request.session.get('timezone') or appointment_type.appointment_tz tz_session = pytz.timezone(timezone) datetime_str = unquote_plus(datetime_str) date_start = tz_session.localize(fields.Datetime.from_string(datetime_str)).astimezone(pytz.utc).replace(tzinfo=None) duration = float(duration_str) date_end = date_start + relativedelta(hours=duration) invite_token = kwargs.get('invite_token') staff_user = request.env['res.users'] resources = request.env['appointment.resource'] resource_ids = None asked_capacity = int(asked_capacity) resources_remaining_capacity = None if appointment_type.schedule_based_on == 'resources': resource_ids = json.loads(unquote_plus(available_resource_ids)) # Check if there is still enough capacity (in case someone else booked with a resource in the meantime) resources = request.env['appointment.resource'].sudo().browse(resource_ids).exists() if any(resource not in appointment_type.resource_ids for resource in resources): raise NotFound() resources_remaining_capacity = appointment_type._get_resources_remaining_capacity(resources, date_start, date_end, with_linked_resources=False) if resources_remaining_capacity['total_remaining_capacity'] < asked_capacity: return request.redirect('/appointment/%s?%s' % (appointment_type.id, keep_query('*', state='failed-resource'))) else: # check availability of the selected user again (in case someone else booked while the client was entering the form) staff_user = request.env['res.users'].sudo().search([('id', '=', int(staff_user_id))]) if staff_user not in appointment_type.staff_user_ids: raise NotFound() if staff_user and not staff_user.partner_id.calendar_verify_availability(date_start, date_end): return request.redirect('/appointment/%s?%s' % (appointment_type.id, keep_query('*', state='failed-staff-user'))) guests = None if appointment_type.allow_guests: if guest_emails_str: guests = request.env['calendar.event'].sudo()._find_or_create_partners(guest_emails_str) customer = self._get_customer_partner() if customer: if not customer.phone: customer.write({'phone': phone}) if not customer.email: customer.write({'email': email}) if not customer and not appointment_type._has_payment_flow(): if email_normalized := email_normalize(email): customer = request.env['res.partner'].sudo().search([('email_normalized', '=', email_normalized)], limit=1) if not customer: customer = customer.sudo().create({ 'name': name, 'phone': customer._phone_format(number=phone, country=self._get_customer_country()) or phone, 'email': email, 'lang': request.lang.code, }) # partner_inputs dictionary structures all answer inputs received on the appointment submission: key is question id, value # is answer id (as string) for choice questions, text input for text questions, array of ids for multiple choice questions. partner_inputs = {} appointment_question_ids = appointment_type.question_ids.ids for k_key, k_value in [item for item in kwargs.items() if item[1]]: question_id_str = re.match(r"\bquestion_([0-9]+)\b", k_key) if question_id_str and int(question_id_str.group(1)) in appointment_question_ids: partner_inputs[int(question_id_str.group(1))] = k_value continue checkbox_ids_str = re.match(r"\bquestion_([0-9]+)_answer_([0-9]+)\b", k_key) if checkbox_ids_str: question_id, answer_id = [int(checkbox_ids_str.group(1)), int(checkbox_ids_str.group(2))] if question_id in appointment_question_ids: partner_inputs[question_id] = partner_inputs.get(question_id, []) + [answer_id] # The answer inputs will be created in _prepare_calendar_event_values from the values in answer_input_values answer_input_values = [] base_answer_input_vals = { 'appointment_type_id': appointment_type.id, 'partner_id': customer.id, } description_bits = [] description = '' if phone: description_bits.append(_('Phone: %s', phone)) if email: description_bits.append(_('Email: %s', email)) for question in appointment_type.question_ids.filtered(lambda question: question.id in partner_inputs.keys()): if question.question_type == 'checkbox': answers = question.answer_ids.filtered(lambda answer: answer.id in partner_inputs[question.id]) answer_input_values.extend([ dict(base_answer_input_vals, question_id=question.id, value_answer_id=answer.id) for answer in answers ]) description_bits.append(f'{question.name}: {", ".join(answers.mapped("name"))}') elif question.question_type in ['select', 'radio']: answer_input_values.append( dict(base_answer_input_vals, question_id=question.id, value_answer_id=int(partner_inputs[question.id])) ) selected_answer = question.answer_ids.filtered(lambda answer: answer.id == int(partner_inputs[question.id])) description_bits.append(f'{question.name}: {selected_answer.name}') elif question.question_type == 'char': answer_escaped = plaintext2html(partner_inputs[question.id].strip()) answer_input_values.append( dict(base_answer_input_vals, question_id=question.id, value_text_box=partner_inputs[question.id].strip()) ) description_bits.append(f'{question.name}: {answer_escaped}') elif question.question_type == 'text': answer_escaped = plaintext2html(partner_inputs[question.id].strip()) answer_input_values.append( dict(base_answer_input_vals, question_id=question.id, value_text_box=partner_inputs[question.id].strip()) ) description_bits.append(Markup('{}:
{}').format(question.name, answer_escaped)) if description_bits: description = f"
    {''.join(f'
  • {bit}
  • ' for bit in description_bits)}
" booking_line_values = [] if appointment_type.schedule_based_on == 'resources': capacity_to_assign = asked_capacity 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_line_values.append({ 'appointment_resource_id': resource.id, 'capacity_reserved': new_capacity_reserved, 'capacity_used': new_capacity_reserved if resource.shareable and appointment_type.resource_manage_capacity else resource.capacity, }) if invite_token: appointment_invite = request.env['appointment.invite'].sudo().search([('access_token', '=', invite_token)]) else: appointment_invite = request.env['appointment.invite'] return self._handle_appointment_form_submission( appointment_type, date_start, date_end, duration, description, answer_input_values, name, customer, appointment_invite, guests, staff_user, asked_capacity, booking_line_values ) def _handle_appointment_form_submission( self, appointment_type, date_start, date_end, duration, # appointment boundaries description, answer_input_values, name, customer, appointment_invite, guests=None, # customer info staff_user=None, asked_capacity=1, booking_line_values=None # appointment staff / resources ): """ This method takes the output of the processing of appointment's form submission and creates the event corresponding to those values. Meant for overrides to set values needed to set a specific redirection. :returns: a dict of useful values used in the redirection to next step """ event = request.env['calendar.event'].with_context( mail_notify_author=True, mail_create_nolog=True, mail_create_nosubscribe=True, allowed_company_ids=self._get_allowed_companies(staff_user or appointment_type.create_uid).ids, ).sudo().create({ 'appointment_answer_input_ids': [Command.create(vals) for vals in answer_input_values], **appointment_type._prepare_calendar_event_values( asked_capacity, booking_line_values, description, duration, appointment_invite, guests, name, customer, staff_user, date_start, date_end ) }) return request.redirect(f"/calendar/view/{event.access_token}?partner_id={customer.id}&{keep_query('*', state='new')}") # Tools / Data preparation # ------------------------------------------------------------ def _get_allowed_companies(self, organizer): """ Get the allowed companies of the organizer of the event :param: organizer: the organizer of the event :return: recordset of res.company """ return organizer.company_ids def _get_customer_partner(self): partner = request.env['res.partner'] if not request.env.user._is_public(): partner = request.env.user.partner_id return partner @staticmethod def _get_customer_country(): """ Find the country from the geoip lib or fallback on the user or the visitor """ country = request.env['res.country'] if request.geoip.country_code: country = country.search([('code', '=', request.geoip.country_code)]) if not country: country = request.env.user.country_id if not request.env.user._is_public() else country return country def _get_default_timezone(self, appointment_type): """ Find the default timezone from the geoip lib or fallback on the user or the visitor """ if appointment_type.location_id: return appointment_type.appointment_tz cookie = request.httprequest.cookies.get('tz') if cookie and cookie in dict(_tz_get(self)): return cookie return appointment_type.appointment_tz # ------------------------------------------------------------ # APPOINTMENT TYPE JSON DATA # ------------------------------------------------------------ @http.route(['/appointment//get_message_intro'], type="json", auth="public", methods=['POST'], website=True) def get_appointment_message_intro(self, appointment_type_id, **kwargs): domain = self._appointments_base_domain( filter_appointment_type_ids=kwargs.get('filter_appointment_type_ids'), search=kwargs.get('search'), invite_token=kwargs.get('invite_token') ) available_appointments = self._fetch_and_check_private_appointment_types( kwargs.get('filter_appointment_type_ids'), kwargs.get('filter_staff_user_ids'), kwargs.get('filter_resource_ids'), kwargs.get('invite_token'), domain=domain, ) appointment_type = available_appointments.filtered(lambda appt: appt.id == int(appointment_type_id)) if not appointment_type: raise NotFound() return appointment_type.message_intro or '' @http.route(['/appointment//update_available_slots'], type="json", auth="public", website=True) def appointment_update_available_slots(self, appointment_type_id, staff_user_id=None, resource_selected_id=None, asked_capacity=1, timezone=None, **kwargs): """ Route called when the selected user or resource or asked_capacity or the timezone is modified to adapt the possible slots accordingly """ domain = self._appointments_base_domain( filter_appointment_type_ids=kwargs.get('filter_appointment_type_ids'), search=kwargs.get('search'), invite_token=kwargs.get('invite_token') ) available_appointments = self._fetch_and_check_private_appointment_types( kwargs.get('filter_appointment_type_ids'), kwargs.get('filter_staff_user_ids'), kwargs.get('filter_resource_ids'), kwargs.get('invite_token'), domain=domain, ) appointment_type = available_appointments.filtered(lambda appt: appt.id == int(appointment_type_id)) if not appointment_type: raise ValueError() request.session['timezone'] = timezone or appointment_type.appointment_tz filter_staff_user_ids = json.loads(kwargs.get('filter_staff_user_ids') or '[]') filter_resource_ids = json.loads(kwargs.get('filter_resource_ids') or '[]') filter_users = filter_resources = False # If no staff_user_id is set, use only the filtered staff users to compute slots. if staff_user_id: filter_users = request.env['res.users'].sudo().browse(int(staff_user_id)) elif resource_selected_id: filter_resources = request.env['appointment.resource'].sudo().browse(int(resource_selected_id)) else: filter_users = self._get_possible_staff_users(appointment_type, filter_staff_user_ids) filter_resources = self._get_possible_resources(appointment_type, filter_resource_ids) asked_capacity = int(asked_capacity) slots = appointment_type._get_appointment_slots(request.session['timezone'], filter_users, filter_resources, asked_capacity=asked_capacity) month_first_available = next((month['id'] for month in slots if month['has_availabilities']), False) month_before_update = kwargs.get('month_before_update') month_kept_from_update = next((month['id'] for month in slots if month['month'] == month_before_update), False) if month_before_update else False formated_days = _formated_weekdays(get_lang(request.env).code) return request.env['ir.qweb']._render('appointment.appointment_calendar', { 'appointment_type': appointment_type, 'available_appointments': available_appointments, 'asked_capacity': asked_capacity, 'timezone': request.session['timezone'], 'formated_days': formated_days, 'slots': slots, 'month_kept_from_update': month_kept_from_update, 'month_first_available': month_first_available, })