418 lines
21 KiB
Python
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()
|