Files
enterprise-addons/appointment/models/appointment_invite.py
T

418 lines
21 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import secrets
import uuid
from markupsafe import Markup
from werkzeug.urls import url_encode
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.fields import Domain
from odoo.tools.urls import urljoin as url_join
SHORT_CODE_PATTERN = re.compile(r"^[\w-]+$")
class AppointmentInvite(models.Model):
_name = 'appointment.invite'
_description = 'Appointment Invite'
_order = 'create_date DESC, id DESC'
_rec_name = 'short_code'
@api.model
def default_get(self, fields):
""" The purpose of this override is to suggest a default short_code.
There are 2 distinct use cases.
1. Identical Configuration
If we find an identical configuration, we try to re-use it by assigning the same
short_code along with a 'identical_config_id' field.
The UI is designed to just copy the existing configuration URL if the end-user doesn't
modify it instead of creating a new appointment.invite record.
When manually modified, it is reset through an onchange trigger.
Note that this could be annoying for code-based creation of appointment.invite records, but
that is less likely and in this case the code can manually assign a short code by calling
'_get_unique_short_code'.
The trade-off seems worth it as it will prevent the UI from creating a lot of identical
appointment.invite records from the numerous "Share" buttons available.
Note that we only attempt to re-use for 'simple' configurations, aka single-appointment
and not specific staff users or resources assigned.
2. Help with Onboarding
When the user first interacts with the "Share" feature, meaning that there are no existing
appointment.invite records for a specific appointment, we attempt to generate a more
user-friendly short-code by using the name of the appointment.
That nicer-looking URL will then be re-applied on subsequent shares by the first use case
here above. """
res = super().default_get(fields)
if 'short_code' in fields and 'short_code' not in res:
appointment_type = False
appointments = res.get('appointment_type_ids')
appointment_type_ids = appointments[0][2] if appointments else []
if len(appointment_type_ids) == 1:
appointment_type = self.env['appointment.type'].browse(appointment_type_ids)
resources_choice = res.get('resources_choice')
if not resources_choice and appointment_type:
resources_choice = 'current_user' if appointment_type.schedule_based_on == 'users' \
and self.env.user in appointment_type.staff_user_ids \
else 'all_assigned_resources'
identical_config = False
if not res.get('staff_user_ids') and not res.get('resource_ids') \
and resources_choice in ['current_user', 'all_assigned_resources']:
identical_config = self._find_identical_config(
appointment_type_ids,
resources_choice,
)
short_code = False
if identical_config:
short_code = identical_config.short_code
res['identical_config_id'] = identical_config.id
elif appointment_type:
short_code = self._get_unique_short_code(appointment_type=appointment_type)
if not short_code:
short_code = self._get_unique_short_code()
res['short_code'] = short_code
return res
access_token = fields.Char('Token', default=lambda s: uuid.uuid4().hex, required=True, copy=False, readonly=True)
short_code = fields.Char('Short Code', required=True)
short_code_format_warning = fields.Boolean('Short Code Format Warning', compute="_compute_short_code_warning")
short_code_unique_warning = fields.Boolean('Short Code Unique Warning', compute="_compute_short_code_warning")
disable_save_button = fields.Boolean('Computes if alert is present', compute='_compute_disable_save_button')
identical_config_id = fields.Many2one('appointment.invite', string="Identical Config",
help="Interface field to try to prevent creating identical links")
base_book_url = fields.Char('Base Link URL', compute="_compute_base_book_url")
book_url = fields.Char('Link URL', compute='_compute_book_url')
book_url_params = fields.Char('Link URL params', compute='_compute_book_url_params')
redirect_url = fields.Char('Redirect URL', compute='_compute_redirect_url')
# Put active_test to False because we always want to be able to check all appointment types from an invitation.
# In case the appointment type is archived, we still want old links to work and display with a message telling
# that it's no longer available.
appointment_type_ids = fields.Many2many('appointment.type', string='Appointment Types', context={"active_test": False})
appointment_type_info_msg = fields.Html('No User Assigned Message', compute='_compute_appointment_type_info_msg')
appointment_type_count = fields.Integer('Selected Appointments Count', compute='_compute_appointment_type_count', store=True)
schedule_based_on = fields.Char('Schedule Based On', compute="_compute_schedule_based_on")
suggested_resource_ids = fields.Many2many('appointment.resource', related="appointment_type_ids.resource_ids", string="Possible resources")
suggested_resource_count = fields.Integer('# Resources', compute='_compute_suggested_resource_count')
suggested_staff_user_ids = fields.Many2many(
'res.users', related='appointment_type_ids.staff_user_ids', string='Possible users',
help="Get the users linked to the appointment type selected to apply a domain on the users that can be selected")
suggested_staff_user_count = fields.Integer('# Staff Users', compute='_compute_suggested_staff_user_count')
resources_choice = fields.Selection(
selection=[
('current_user', 'Me'),
('all_assigned_resources', 'Any User'),
('specific_resources', 'Specific Users')],
string='Assign to', compute='_compute_resources_choice', store=True, readonly=False)
resources_resource_choice = fields.Selection(
selection=[
('all_assigned_resources', 'Any Resource'),
('specific_resources', 'Specific Resources')],
compute='_compute_resources_resource_choice', inverse='_inverse_resources_resource_choice')
resource_ids = fields.Many2many('appointment.resource', string='Resources', domain="[('id', 'in', suggested_resource_ids)]",
compute='_compute_resource_ids', store=True, readonly=False)
staff_user_ids = fields.Many2many('res.users', string='Users', domain="[('id', 'in', suggested_staff_user_ids)]",
compute='_compute_staff_user_ids', store=True, readonly=False)
calendar_event_ids = fields.One2many('calendar.event', 'appointment_invite_id', string="Booked Appointments", readonly=True)
calendar_event_count = fields.Integer('# Bookings', compute="_compute_calendar_event_count")
_short_code_uniq = models.Constraint(
'UNIQUE (short_code)',
"The URL is already taken, please pick another code.",
)
@api.depends('short_code_format_warning',
'short_code_unique_warning',
'appointment_type_count',
'suggested_resource_count',
'suggested_staff_user_ids',
'resources_choice')
def _compute_disable_save_button(self):
for invite in self:
conditions = [
invite.short_code_format_warning,
invite.short_code_unique_warning,
invite.appointment_type_count == 1 and invite.resources_choice == 'current_user' and self.env.user.id not in invite.suggested_staff_user_ids.ids,
not invite.suggested_staff_user_ids and invite.appointment_type_count == 1 and invite.suggested_resource_count < 1,
]
invite.disable_save_button = any(conditions)
@api.constrains('short_code')
def _check_short_code_format(self):
invalid_invite = next((invite for invite in self if invite.short_code_format_warning), False)
if invalid_invite:
raise ValidationError(_(
"Only letters, numbers, underscores and dashes are allowed in your links. You need to adapt %s.", invalid_invite.short_code
))
@api.depends('appointment_type_ids')
def _compute_schedule_based_on(self):
""" Get the schedule_based_on value when selecting one appointment type.
This allows to personalize the warning or info message based on this value. """
for invite in self:
invite.schedule_based_on = invite.appointment_type_ids.schedule_based_on if len(invite.appointment_type_ids) == 1 else False
@api.depends('appointment_type_ids', 'appointment_type_count')
def _compute_appointment_type_info_msg(self):
'''
When there is more than one appointment type selected to be shared and at least one doesn't have any staff user or resource assigned,
display an alert info to tell the current user that, without staff users or resources, an appointment type won't be published.
'''
for invite in self:
appt_without_staff_user = invite.appointment_type_ids.filtered_domain([('schedule_based_on', '=', 'users'), ('staff_user_ids', '=', False)])
appt_without_resource = invite.appointment_type_ids.filtered_domain([('schedule_based_on', '=', 'resources'), ('resource_ids', '=', False)])
appointment_type_info_msg = Markup()
if appt_without_staff_user and invite.appointment_type_count > 1:
appointment_type_info_msg += _(
'The following appointment type(s) have no staff assigned: %s.',
', '.join(appt_without_staff_user.mapped('name'))
) + Markup('<br/>')
if appt_without_resource and invite.appointment_type_count > 1:
appointment_type_info_msg += _(
'The following appointment type(s) have no resource assigned: %s.',
', '.join(appt_without_resource.mapped('name'))
)
invite.appointment_type_info_msg = appointment_type_info_msg if appointment_type_info_msg else False
@api.depends('appointment_type_ids')
def _compute_appointment_type_count(self):
appointment_data = self.env['appointment.type']._read_group(
[('appointment_invite_ids', 'in', self.ids)],
['appointment_invite_ids'],
['__count'],
)
mapped_data = {appointment_invite.id: count for appointment_invite, count in appointment_data}
for invite in self:
if not invite.id: # new record
invite.appointment_type_count = len(invite.appointment_type_ids)
else:
invite.appointment_type_count = mapped_data.get(invite.id, 0)
@api.depends('short_code')
def _compute_base_book_url(self):
for invite in self:
invite.base_book_url = url_join(invite.get_base_url(), '/book/')
@api.depends('calendar_event_ids')
def _compute_calendar_event_count(self):
appointment_invite_data = self.env['calendar.event']._read_group(
[('appointment_invite_id', 'in', self.ids)],
['appointment_invite_id'],
['__count'],
)
mapped_data = {invite.id: count for invite, count in appointment_invite_data}
for invite in self:
invite.calendar_event_count = mapped_data.get(invite.id, 0)
@api.depends('short_code', 'identical_config_id')
def _compute_short_code_warning(self):
for invite in self:
invite.short_code_format_warning = not bool(re.match(SHORT_CODE_PATTERN, invite.short_code)) if invite.short_code else False
invite.short_code_unique_warning = not invite.identical_config_id and bool(self.env['appointment.invite'].search_count([
('id', '!=', invite._origin.id), ('short_code', '=', invite.short_code)]))
@api.depends('appointment_type_ids')
def _compute_resources_choice(self):
for invite in self:
if len(invite.appointment_type_ids) != 1:
invite.resources_choice = False
elif invite.appointment_type_ids.schedule_based_on == 'users' and self.env.user in invite.appointment_type_ids._origin.staff_user_ids:
invite.resources_choice = 'current_user'
else:
invite.resources_choice = 'all_assigned_resources'
@api.depends('appointment_type_ids')
def _compute_resource_ids(self):
for invite in self:
if len(invite.appointment_type_ids) > 1 or invite.appointment_type_ids.schedule_based_on != 'resources':
invite.resource_ids = False
@api.depends('appointment_type_ids', 'resources_choice')
def _compute_staff_user_ids(self):
for invite in self:
if invite.resources_choice == "current_user" and \
self.env.user.id in invite.appointment_type_ids.staff_user_ids.ids:
invite.staff_user_ids = self.env.user
else:
invite.staff_user_ids = False
@api.depends('suggested_resource_ids')
def _compute_suggested_resource_count(self):
for invite in self:
invite.suggested_resource_count = len(invite.suggested_resource_ids)
@api.depends('suggested_staff_user_ids')
def _compute_suggested_staff_user_count(self):
for invite in self:
invite.suggested_staff_user_count = len(invite.suggested_staff_user_ids)
def _get_url_params(self):
return {}
def _compute_book_url_params(self):
params = self._get_url_params()
for invite in self:
invite.book_url_params = f'?{url_encode(params)}' if params else ''
@api.depends('base_book_url', 'short_code', 'book_url_params')
def _compute_book_url(self):
"""
Compute a short link linked to an appointment invitation.
"""
for invite in self:
# short_code is validated separately, so book_url will never be False on save
try:
invite.book_url = url_join(invite.base_book_url, invite.short_code) if invite.short_code else False
except ValueError:
invite.book_url = False
if invite.book_url_params and invite.book_url:
invite.book_url += invite.book_url_params
@api.depends('appointment_type_ids', 'staff_user_ids', 'resource_ids')
def _compute_redirect_url(self):
"""
Compute a link that will be share for the user depending on the appointment types and users
selected. We allow to preselect a group of them if there is only one appointment type selected.
Indeed, it would be too complex to manage ones with multiple appointment types.
Three possible params can be generated with the link:
- filter_resource_ids: which allows the user to select a resource between the ones selected
- filter_staff_user_ids: which allows the user to select an user between the ones selected
- filter_appointment_type_ids: which display a selection of appointment types to user from which
they can choose
"""
for invite in self:
if len(invite.appointment_type_ids) == 1:
base_redirect_url = url_join("/appointment/", str(invite.appointment_type_ids.id))
else:
base_redirect_url = '/appointment'
invite.redirect_url = '%s?%s' % (
base_redirect_url,
url_encode(invite._get_redirect_url_parameters()),
)
@api.depends('resources_choice')
def _compute_resources_resource_choice(self):
for invite in self:
if invite.resources_choice != 'current_user':
invite.resources_resource_choice = invite.resources_choice
def _inverse_resources_resource_choice(self):
for invite in self:
invite.resources_choice = invite.resources_resource_choice if invite.schedule_based_on == "resources" else invite.resources_choice
@api.onchange('appointment_type_ids', 'resources_choice', 'staff_user_ids', 'resource_ids', 'short_code')
def _onchange_configuration(self):
""" Reset the short code when the configuration is manually modified.
Note: Don't reset the short_code which has been modified manually via input.
So, we can allow the modified short_code to be saved.
See 'default_get' for details. """
for invite in self.filtered('identical_config_id'):
reset_identical_config = False
if invite.resources_choice not in ['current_user', 'all_assigned_resources'] \
or (invite.staff_user_ids and invite.resources_choice != 'current_user') \
or invite.resource_ids:
reset_identical_config = True
else:
if invite.short_code != invite.identical_config_id.short_code:
invite.identical_config_id = False
else:
new_identical_config = invite._find_identical_config(
invite.appointment_type_ids.ids,
invite.resources_choice,
short_code=invite.short_code,
)
reset_identical_config = new_identical_config != invite.identical_config_id
if reset_identical_config:
invite.identical_config_id = False
invite.short_code = invite._get_unique_short_code(short_code=invite.access_token[:8])
@api.model
def _get_invitation_url_parameters(self):
""" Returns invitation-related url parameters we want to keep between the different steps of booking """
return {'filter_appointment_type_ids', 'filter_resource_ids', 'filter_staff_user_ids', 'invite_token'}
def _get_redirect_url_parameters(self):
self.ensure_one()
url_param = {
'invite_token': self.access_token,
}
if self.appointment_type_ids:
url_param.update({
'filter_appointment_type_ids': str(self.appointment_type_ids.ids),
})
if self.staff_user_ids:
url_param.update({
'filter_staff_user_ids': str(self.staff_user_ids.ids)
})
elif self.resource_ids:
url_param.update({
'filter_resource_ids': str(self.resource_ids.ids)
})
return url_param
def _check_appointments_params(self, appointment_types, users, resources):
"""
Check if the param receive through the URL match with the appointment invite info
:param recordset appointment_types: the appointment types representing the filter_appointment_type_ids
:param recordset users: the staff users representing the filter_staff_user_ids
:param recordset resources: the resources representing the filter_resource_ids
"""
self.ensure_one()
if (self.appointment_type_ids and self.appointment_type_ids != appointment_types) or self.staff_user_ids != users or self.resource_ids != resources:
return False
return True
def _get_unique_short_code(self, short_code=None, appointment_type=None):
name_based_code = False
if appointment_type and appointment_type.appointment_invite_count == 0:
code = re.sub(r'[\s\-*]+', '-', appointment_type.name.strip().lower())
if bool(re.match(SHORT_CODE_PATTERN, code)):
name_based_code = code
short_code = name_based_code or short_code or self.short_code or (self.access_token[:8] if self.access_token else secrets.token_hex(4))
nb_short_code = self.env['appointment.invite'].search_count([('id', '!=', self._origin.id), ('short_code', 'ilike', short_code)])
if nb_short_code:
short_code = "%s-%s" % (short_code, nb_short_code)
return short_code
def _find_identical_config(self, appointment_type_ids, resources_choice, short_code=False):
domain = Domain([
('appointment_type_ids', '=', appointment_type_ids),
('resources_choice', '=', resources_choice),
])
if short_code:
domain &= Domain('short_code', '=', short_code)
return self.env['appointment.invite'].search(domain, limit=1)
@api.autovacuum
def _gc_appointment_invite(self):
limit_dt = fields.Datetime.subtract(fields.Datetime.now(), months=6)
invites = self.env['appointment.invite'].search([('create_date', '<=', limit_dt)])
to_remove = invites.filtered(lambda invite: not invite.calendar_event_ids or max([event.stop for event in invite.calendar_event_ids]) < limit_dt)
to_remove.unlink()