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

650 lines
36 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
import logging
from datetime import datetime, timedelta
from markupsafe import Markup
from odoo import _, api, fields, models, tools, SUPERUSER_ID
from odoo.addons.calendar.models.utils import interval_from_events
from odoo.exceptions import UserError, ValidationError
from odoo.fields import Command, Domain
from odoo.tools.intervals import Intervals, intervals_overlap, invert_intervals
from odoo.tools.date_utils import localized
from odoo.tools.mail import email_normalize, email_split_and_format_normalize, html_sanitize
_logger = logging.getLogger(__name__)
class CalendarEvent(models.Model):
_inherit = "calendar.event"
@api.model
def default_get(self, fields):
res = super().default_get(fields)
# If the event has an apt type, set the event stop datetime to match the apt type duration
if res.get('appointment_type_id') and res.get('duration') and res.get('start') and 'stop' in fields:
res['stop'] = res['start'] + timedelta(hours=res['duration'])
if not self.env.context.get('booking_gantt_create_record', False):
return res
# Round the stop datetime to the nearest minute when coming from the gantt view
if res.get('stop') and isinstance(res['stop'], datetime) and res['stop'].second != 0:
res['stop'] = datetime.min + round((res['stop'] - datetime.min) / timedelta(minutes=1)) * timedelta(minutes=1)
user_id = res.get('user_id')
appointment_resource_ids = self.env['calendar.event']._fields['resource_ids'].convert_to_cache(res.get('resource_ids', []), self.env['calendar.event'])
appointment_resources = self.env['appointment.resource'].browse(appointment_resource_ids)
# get a relevant appointment type for ease of use when coming from a view that groups by resource
if not res.get('appointment_type_id') and 'appointment_type_id' in fields:
appointment_types = False
if appointment_resources:
appointment_types = appointment_resources.appointment_type_ids
elif user_id:
appointment_types = self.env['appointment.type'].search([('staff_user_ids', 'in', user_id)])
if appointment_types:
res['appointment_type_id'] = appointment_types[0].id
if (appointment_type_id := res.get('appointment_type_id')):
appointment_type = self.env['appointment.type'].browse(appointment_type_id)
if 'name' in fields:
res.setdefault('name', appointment_type.name)
# set the maximum capacity if managing capacities
if 'total_capacity_reserved' in fields:
if appointment_type.schedule_based_on == 'resources' and appointment_resources:
res.setdefault('total_capacity_reserved', sum(
resource.capacity for resource in appointment_resources
) if appointment_type.manage_capacity else len(appointment_resources))
elif appointment_type.schedule_based_on == 'users':
res.setdefault(
'total_capacity_reserved',
(appointment_type.manage_capacity and appointment_type.user_capacity) or 1
)
if self.env.context.get('appointment_default_assign_user_attendees'):
default_partner_ids = self.env.context.get('default_partner_ids', [])
# If there is only one attendee -> set him as organizer of the calendar event
# Mostly used when you click on a specific slot in the appointment kanban
if len(default_partner_ids) == 1 and 'user_id' in fields:
attendee_user = self.env['res.partner'].browse(default_partner_ids).user_ids
if attendee_user:
res['user_id'] = attendee_user[0].id
# Special gantt case: we want to assign the current user to the attendees if he's set as organizer
elif res.get('user_id') and res.get('partner_ids', Command.set([])) == [Command.set([])] and \
res['user_id'] == self.env.uid and 'partner_ids' in fields:
res['partner_ids'] = [Command.set(self.env.user.partner_id.ids)]
return res
def _default_access_token(self):
return str(uuid.uuid4())
name = fields.Char(compute='_compute_name', store=True, readonly=False)
access_token = fields.Char('Access Token', default=_default_access_token, readonly=True)
alarm_ids = fields.Many2many(compute='_compute_alarm_ids', store=True, readonly=False)
appointment_answer_input_ids = fields.One2many('appointment.answer.input', 'calendar_event_id', string="Appointment Answers")
appointment_status = fields.Selection([
('request', 'Request'),
('booked', 'Booked'),
('attended', 'Checked-In'),
('no_show', 'No Show'),
('cancelled', 'Cancelled'),
], string="Appointment Status", compute='_compute_appointment_status', store=True, readonly=False, tracking=True)
appointment_type_id = fields.Many2one('appointment.type', 'Appointment', index='btree_not_null', tracking=True)
appointment_type_schedule_based_on = fields.Selection(related="appointment_type_id.schedule_based_on")
appointment_type_manage_capacity = fields.Boolean(related="appointment_type_id.manage_capacity")
appointment_invite_id = fields.Many2one('appointment.invite', 'Appointment Invitation', readonly=True, index='btree_not_null', ondelete='set null')
appointment_resource_ids = fields.Many2many('appointment.resource', 'appointment_booking_line', 'calendar_event_id', 'appointment_resource_id',
string="Appointment Resources", group_expand="_read_group_appointment_resource_ids",
depends=['booking_line_ids'], readonly=True, copy=False)
# This field is used in the form view to create/manage the booking lines based on the total_capacity_reserved
# selected. This allows to have the appointment_resource_ids field linked to the appointment_booking_line model and
# thus avoid the duplication of information.
resource_ids = fields.Many2many('appointment.resource', string="Resources",
compute="_compute_resource_ids", inverse="_inverse_resource_ids_or_capacity",
search="_search_resource_ids",
group_expand="_read_group_appointment_resource_ids", copy=False)
booking_line_ids = fields.One2many('appointment.booking.line', 'calendar_event_id', string="Booking Lines", copy=True)
partner_ids = fields.Many2many('res.partner', group_expand="_read_group_partner_ids")
total_capacity_reserved = fields.Integer('Total Capacity Reserved', compute="_compute_total_capacity", inverse="_inverse_resource_ids_or_capacity")
total_capacity_used = fields.Integer('Total Capacity Used', compute="_compute_total_capacity")
user_id = fields.Many2one('res.users', group_expand="_read_group_user_id")
videocall_redirection = fields.Char('Meeting redirection URL', compute='_compute_videocall_redirection')
appointment_booker_id = fields.Many2one('res.partner', string="Person who is booking the appointment", index='btree_not_null')
unavailable_resource_ids = fields.Many2many('appointment.resource', string='Resources intersecting with leave time', compute="_compute_unavailable_resource_ids")
@api.constrains('appointment_resource_ids', 'appointment_type_id')
def _check_resource_and_appointment_type(self):
for event in self:
if event.appointment_resource_ids and not event.appointment_type_id:
raise ValidationError(_("The event %s cannot book resources without an appointment type.", event.name))
@api.constrains('appointment_type_id', 'appointment_status')
def _check_status_and_appointment_type(self):
for event in self:
if event.appointment_status and not event.appointment_type_id:
raise ValidationError(_("The event %s cannot have an appointment status without being linked to an appointment type.", event.name))
def _check_organizer_validation_conditions(self, vals_list):
res = super()._check_organizer_validation_conditions(vals_list)
appointment_type_ids = list({vals["appointment_type_id"] for vals in vals_list if vals.get("appointment_type_id")})
if appointment_type_ids:
appointment_type_ids = self.env['appointment.type'].browse(appointment_type_ids)
resource_appointment_type_ids = set(appointment_type_ids.filtered(lambda apt: apt.schedule_based_on == 'resources').ids)
return [vals.get("appointment_type_id") not in resource_appointment_type_ids for vals in vals_list]
return res
@api.depends('appointment_type_id')
def _compute_alarm_ids(self):
for event in self.filtered('appointment_type_id'):
if not event.alarm_ids:
event.alarm_ids = event.appointment_type_id.reminder_ids
@api.depends('appointment_type_id')
def _compute_appointment_status(self):
for event in self:
if not event.appointment_type_id:
event.appointment_status = False
elif not event.appointment_status:
event.appointment_status = 'booked'
@api.depends('partner_ids')
def _compute_name(self):
for event in self.filtered(lambda e: e.appointment_type_id and not e.name):
non_staff_attendees = event.partner_ids.filtered(
lambda p: p._origin.id not in event.appointment_type_id.staff_user_ids.partner_id.ids
)
if len(non_staff_attendees) == 1:
event.name = non_staff_attendees.name + " - " + event.appointment_type_id.name
@api.depends('booking_line_ids', 'booking_line_ids.appointment_resource_id')
def _compute_resource_ids(self):
for event in self:
event.resource_ids = event.booking_line_ids.appointment_resource_id
@api.depends('start', 'stop', 'resource_ids')
def _compute_unavailable_resource_ids(self):
self.unavailable_resource_ids = False
resource_events = self.filtered(lambda event: event.resource_ids)
if not resource_events:
return
for start, stop, events in interval_from_events(resource_events):
group_resources = events.resource_ids
availabilities_values = self.env['appointment.type']._slot_availability_prepare_resources_values(
group_resources, start, stop)
resource_unavailabilities = availabilities_values['resource_unavailabilities']
resource_to_bookings = availabilities_values['resource_to_bookings']
events_to_check = self.env['calendar.event']
for resource, bookings in resource_to_bookings.items():
booking_events = bookings.calendar_event_id
events_manage_capacity = booking_events.mapped('appointment_type_manage_capacity')
isAllCapacityTrue = all(events_manage_capacity)
isAllCapacityFalse = not any(events_manage_capacity)
# Add events of the bookings to check if:
# - There are event appointments with manage capacity True and False
# - Manage capacity is all True and resource is not shareable or capacity used > resource capacity
# - Manage capacity is all False and more than one appointment type or number of bookings > max_bookings
if (
(not isAllCapacityTrue and not isAllCapacityFalse) or
(isAllCapacityTrue and (sum(bookings.mapped('capacity_used')) >= resource.capacity or not resource.shareable)) or
(isAllCapacityFalse and (len(bookings.appointment_type_id) > 1 or len(bookings) > bookings.appointment_type_id.max_bookings))
):
events_to_check |= booking_events
for event in events:
event_resources = event.resource_ids
event_interval = (localized(event.start), localized(event.stop))
event.unavailable_resource_ids = event_resources.filtered(lambda resource: any(
intervals_overlap(tuple(map(localized, interval)), event_interval)
for interval in resource_unavailabilities.get(resource, [])
))
for conflicting_event in events_to_check - event._origin:
if (
(resources := event_resources._origin & conflicting_event.resource_ids)
and intervals_overlap(event_interval, (localized(conflicting_event.start), localized(conflicting_event.stop)))
):
event.unavailable_resource_ids += resources
@api.depends('booking_line_ids')
def _compute_total_capacity(self):
booking_data = self.env['appointment.booking.line']._read_group(
[('calendar_event_id', 'in', self.ids)],
['calendar_event_id'],
['capacity_reserved:sum', 'capacity_used:sum'],
)
mapped_data = {
meeting.id: {
'total_capacity_reserved': total_capacity_reserved,
'total_capacity_used': total_capacity_used,
} for meeting, total_capacity_reserved, total_capacity_used in booking_data}
for event in self:
data = mapped_data.get(event.id)
event.total_capacity_reserved = data.get('total_capacity_reserved', 0) if data else 0
event.total_capacity_used = data.get('total_capacity_used', 0) if data else 0
@api.depends('videocall_location', 'access_token')
def _compute_videocall_redirection(self):
for event in self:
if not event.videocall_location:
event.videocall_redirection = False
continue
if not event.access_token:
event.access_token = uuid.uuid4().hex
event.videocall_redirection = f"{event.get_base_url()}/calendar/videocall/{event.access_token}"
@api.depends('appointment_type_id.event_videocall_source')
def _compute_videocall_source(self):
events_no_appointment = self.env['calendar.event']
for event in self:
if not event.appointment_type_id or event.videocall_location and not self.DISCUSS_ROUTE in event.videocall_location:
events_no_appointment |= event
continue
event.videocall_source = event.sudo().appointment_type_id.event_videocall_source
super(CalendarEvent, events_no_appointment)._compute_videocall_source()
def _compute_is_highlighted(self):
super(CalendarEvent, self)._compute_is_highlighted()
if self.env.context.get('active_model') == 'appointment.type':
appointment_type_id = self.env.context.get('active_id')
for event in self:
if event.appointment_type_id.id == appointment_type_id:
event.is_highlighted = True
def get_base_url(self):
if self.appointment_type_id:
return self.appointment_type_id.sudo().get_base_url()
return super().get_base_url()
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('appointment_type_id'):
if 'active' not in vals and vals.get('appointment_status') == 'cancelled':
vals['active'] = False
elif 'appointment_status' not in vals and vals.get('active') is False:
vals['appointment_status'] = 'cancelled'
return super().create(vals_list)
def write(self, vals):
if any(event.appointment_type_id for event in self) or vals.get('appointment_type_id'):
if 'active' in vals and 'appointment_status' not in vals:
vals['appointment_status'] = 'booked' if vals['active'] else 'cancelled'
if 'active' not in vals and 'appointment_status' in vals:
vals['active'] = vals['appointment_status'] != 'cancelled'
return super().write(vals)
def _is_partner_unavailable(self, partner, partner_events):
self.ensure_one()
appointment = self.appointment_type_id
if (partner in appointment.staff_user_ids.partner_id and
not partner_events.filtered(lambda event: event.appointment_type_id != appointment)):
max_capacity = appointment.user_capacity if appointment.manage_capacity else appointment.max_bookings
return sum(partner_events.mapped('total_capacity_used')) > max_capacity
return super()._is_partner_unavailable(partner, partner_events)
def _init_column(self, column_name):
""" Initialize the value of the given column for existing rows.
Overridden here because we skip generating unique access tokens
for potentially tons of existing event, should they be needed,
they will be generated on the fly.
"""
if column_name != 'access_token':
super(CalendarEvent, self)._init_column(column_name)
def _inverse_resource_ids_or_capacity(self):
"""Update booking lines as inverse of both resource capacity and resource_ids.
As both values are related to the booking line and resource capacity is dependant
on resources existing in the first place. They need to both use the same inverse
field to ensure there is no ordering conflict.
"""
booking_lines = []
booking_lines_to_delete = self.env['appointment.booking.line']
for event in self:
if event.appointment_type_schedule_based_on == 'resources':
resources = event.resource_ids
if event.appointment_type_manage_capacity and event.total_capacity_reserved:
capacity_to_reserve = event.total_capacity_reserved
elif event.appointment_type_manage_capacity:
capacity_to_reserve = sum(event.booking_line_ids.mapped('capacity_reserved')) or sum(resources.mapped('capacity'))
else:
capacity_to_reserve = len(event.resource_ids)
booking_lines_to_delete |= event.booking_line_ids
for resource in resources.sorted("shareable"):
if event.appointment_type_manage_capacity and capacity_to_reserve <= 0:
break
resource_capacity_used = min(resource.capacity, capacity_to_reserve) if event.appointment_type_manage_capacity else 1
booking_lines.append({
'appointment_resource_id': resource.id,
'calendar_event_id': event.id,
'capacity_reserved': resource_capacity_used,
})
capacity_to_reserve -= resource_capacity_used
capacity_to_reserve = max(0, capacity_to_reserve)
if event.appointment_type_manage_capacity and capacity_to_reserve:
raise UserError(_(
"%(capacity)d seats are missing to be able to book the %(appointment_name)s: %(event_name)s (%(event_id)s)",
capacity=capacity_to_reserve, appointment_name=event.appointment_type_id.name,
event_name=event.name, event_id=repr(event.id),
))
elif event.appointment_type_schedule_based_on == 'users':
max_user_capacity = (event.appointment_type_manage_capacity and event.appointment_type_id.user_capacity) or 1
if event.appointment_type_manage_capacity and event.total_capacity_reserved:
capacity_to_reserve = event.total_capacity_reserved
else:
capacity_to_reserve = max_user_capacity
if event.appointment_type_manage_capacity and max_user_capacity < capacity_to_reserve:
raise UserError(_(
"%(capacity)d seats are missing to be able to book the %(appointment_name)s: %(event_name)s (%(event_id)s)",
capacity=capacity_to_reserve - event.appointment_type_id.user_capacity,
appointment_name=event.appointment_type_id.name, event_name=event.display_name, event_id=repr(event.id)
))
booking_lines_to_delete += event.booking_line_ids
booking_lines.append({
'calendar_event_id': event.id,
'capacity_reserved': capacity_to_reserve,
})
booking_lines_to_delete.unlink()
self.env['appointment.booking.line'].sudo().create(booking_lines)
def _search_resource_ids(self, operator, value):
return [('appointment_resource_ids', operator, value)]
def _read_group_groupby(self, alias, groupby_spec, query):
""" Simulate group_by on resource_ids by using appointment_resource_ids.
appointment_resource_ids is only used to store the data through the appointment_booking_line
table. All computation on the resources and the capacity reserved is done with capacity_reserved.
Simulating the group_by on resource_ids also avoids to do weird override in JS on appointment_resource_ids.
This is needed because when simply writing on the field, it tries to create the corresponding booking line
with the field capacity_reserved required leading to ValidationError.
"""
if groupby_spec == 'resource_ids':
return super()._read_group_groupby(alias, 'appointment_resource_ids', query)
return super()._read_group_groupby(alias, groupby_spec, query)
def _read_group_appointment_resource_ids(self, resources, domain):
if not self.env.context.get('appointment_booking_gantt_show_all_resources'):
return resources
resources_domain = [
('appointment_type_ids', '!=', False), '|', ('company_id', '=', False), ('company_id', 'in', self.env.context.get('allowed_company_ids', [])),
]
# If we have a default appointment type, we only want to show those resources
default_appointment_type = self.env.context.get('default_appointment_type_id')
if default_appointment_type:
return self.env['appointment.type'].browse(default_appointment_type).resource_ids.filtered_domain(resources_domain)
return self.env['appointment.resource'].search(resources_domain)
def _read_group_partner_ids(self, partners, domain):
"""Show the partners associated with relevant staff users in appointment gantt context."""
if not self.env.context.get('appointment_booking_gantt_show_all_resources'):
return partners
appointment_type_id = self.env.context.get('default_appointment_type_id', False)
appointment_types = self.env['appointment.type'].browse(appointment_type_id)
if appointment_types:
return appointment_types.staff_user_ids.partner_id
return self.env['appointment.type'].search([('schedule_based_on', '=', 'users')]).staff_user_ids.partner_id
def _read_group_user_id(self, users, domain):
if not self.env.context.get('appointment_booking_gantt_show_all_resources'):
return users
appointment_types = self.env['appointment.type'].browse(self.env.context.get('default_appointment_type_id', []))
if appointment_types:
return appointment_types.staff_user_ids
return self.env['appointment.type'].search([('schedule_based_on', '=', 'users')]).staff_user_ids
def _track_filter_for_display(self, tracking_values):
if self.appointment_type_id:
return tracking_values.filtered(lambda t: t.field_id.name != 'active')
return super()._track_filter_for_display(tracking_values)
def _track_get_default_log_message(self, tracked_fields):
if self.appointment_type_id and 'active' in tracked_fields:
if self.active:
return _('Appointment re-booked')
else:
return _("Appointment cancelled")
return super()._track_get_default_log_message(tracked_fields)
def _generate_access_token(self):
for event in self:
event.access_token = self._default_access_token()
def action_cancel_meeting(self, partner_ids):
""" In case there are more than two attendees (responsible + another attendee),
we do not want to archive the calendar.event.
We'll just remove the attendee(s) that made the cancellation request
"""
self.ensure_one()
message_body = _("Appointment cancelled")
if partner_ids:
attendees = self.env['calendar.attendee'].search([('event_id', '=', self.id), ('partner_id', 'in', partner_ids)])
if attendees:
cancelling_attendees = ", ".join([attendee.display_name for attendee in attendees])
message_body = _("Appointment cancelled by: %(partners)s", partners=cancelling_attendees)
self._track_set_log_message(message_body)
# Use the organizer if set or fallback on SUPERUSER to notify attendees that the event is archived
self.with_user(self.user_id or SUPERUSER_ID).sudo().action_archive()
def action_set_appointment_attended(self):
self.ensure_one()
self.appointment_status = 'attended'
def action_set_appointment_booked(self):
self.ensure_one()
self.appointment_status = 'booked'
def action_set_appointment_cancelled(self):
self.ensure_one()
self.appointment_status = 'cancelled'
def action_set_appointment_no_show(self):
self.ensure_one()
self.appointment_status = 'no_show'
def _find_or_create_partners(self, guest_emails_str):
"""Used to find the partners from the emails strings and creates partners if not found.
:param str guest_emails: optional line-separated guest emails. It will
fetch or create partners to add them as event attendees;
:returns: partners (recordset)"""
# Split and normalize guest emails
formatted_emails = email_split_and_format_normalize(guest_emails_str)
valid_normalized = list(tools.misc.unique(email_normalize(email_input, strict=False) for email_input in formatted_emails))
if not valid_normalized:
return self.env['res.partner']
# limit public usage of guests
if self.env.su and len(valid_normalized) > 10:
raise ValueError(
_('Guest usage is limited to 10 customers for performance reason.')
)
# Find or create existing partners
return self.env['mail.thread']._partner_find_from_emails_single(formatted_emails)
def _get_mail_tz(self):
self.ensure_one()
if not self.event_tz and self.appointment_type_id.appointment_tz:
return self.appointment_type_id.appointment_tz
return super()._get_mail_tz()
def _get_public_fields(self):
return super()._get_public_fields() | {
'appointment_resource_ids',
'appointment_type_id',
'resource_ids',
'total_capacity_reserved',
'total_capacity_used',
}
def _track_template(self, changes):
res = super(CalendarEvent, self)._track_template(changes)
if not self.appointment_type_id:
return res
appointment_type_sudo = self.appointment_type_id.sudo()
# set 'author_id' and 'email_from' based on the organizer
vals = {'author_id': self.user_id.partner_id.id, 'email_from': self.user_id.email_formatted} if self.user_id else {}
if {'appointment_status', 'appointment_type_id'} & set(changes) and self.appointment_status in ['booked', 'request']:
try:
booked_template = self.env.ref('appointment.appointment_booked_mail_template')
except ValueError as e:
_logger.warning("Mail could not be sent, as mail template is not found : %s", e)
else:
res['appointment_type_id'] = (booked_template.sudo(), {
**vals,
'auto_delete_keep_log': False,
'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('appointment.mt_calendar_event_booked'),
'email_layout_xmlid': 'mail.mail_notification_light',
} | (
{'partner_ids': []} # notify followers of the subtype only, not default recipients
if booked_template.use_default_to # stable-compatibility, we don't want to suddenly start sending these to attendees again
else {}
))
if (
'active' in changes and not self.active and self.start > fields.Datetime.now()
and appointment_type_sudo.canceled_mail_template_id
):
res['active'] = (appointment_type_sudo.canceled_mail_template_id, {
**vals,
'auto_delete_keep_log': False,
'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('appointment.mt_calendar_event_canceled'),
'email_layout_xmlid': 'mail.mail_notification_light',
'notify_author': True,
})
return res
def _get_customer_description(self):
# Description should make sense for the person who booked the meeting
if not self.appointment_type_id:
return super()._get_customer_description()
confirmation_html = html_sanitize(self.appointment_type_id.sudo().message_confirmation or '')
base_url = self.appointment_type_id.sudo().get_base_url()
url = f"{base_url}/calendar/view/{self.access_token}"
link_html = Markup("<span>%s <a href=%s>%s</a></span>") % (_("Need to reschedule?"), url, _("Click here"))
return Markup("").join([
self.description,
Markup('<br>'),
confirmation_html,
link_html,
])
@api.model
def _get_activity_excluded_models(self):
return super()._get_activity_excluded_models() + ['appointment.type']
def _get_customer_summary(self):
# Summary should make sense for the person who booked the meeting
if self.appointment_type_id and self.appointment_type_id.schedule_based_on == 'users' and self.partner_id:
return _('%(appointment_name)s with %(partner_name)s',
appointment_name=self.appointment_type_id.name,
partner_name=self.partner_id.name or _('somebody'))
return super()._get_customer_summary()
def _get_default_privacy_domain(self):
"""
Resource related events need to be visible and accessible from the gantt view no matter their privacy.
The privacy of an event is related to the user settings but resource events aren't typically linked to any user
meaning their visiblity shouldn't depend on the privacy field.
Returns:
The formatted_read_group privacy domain adapted to include every events related to a resource appointment type.
"""
domain = super()._get_default_privacy_domain()
return Domain.OR([domain, [
'&',
('appointment_type_id', '!=', False),
('appointment_type_id.schedule_based_on', '=', 'resources')
]])
@api.model
def _get_new_invited_attendees(self, current_attendees, previous_attendees, update_vals):
if update_vals.get('appointment_status') in ['booked', 'request']:
return current_attendees
return super()._get_new_invited_attendees(current_attendees, previous_attendees, update_vals)
@api.model
def _gantt_unavailability(self, field, res_ids, start, stop, scale):
# skip if not dealing with appointments
if field not in ('resource_ids', 'partner_ids'):
return super()._gantt_unavailability(field, res_ids, start, stop, scale)
# if viewing a specific appointment type generate unavailable intervals outside of the defined slots
slots_unavailable_intervals = []
appointment_type = self.env['appointment.type']
if appointment_type_id := self.env.context.get('default_appointment_type_id'):
appointment_type = appointment_type.browse(appointment_type_id)
if appointment_type:
start_utc = localized(start)
stop_utc = localized(stop)
slot_available_intervals = [
(slot['utc'][0], slot['utc'][1])
for slot in appointment_type._slots_generate(start_utc, stop_utc, 'utc', reference_date=start)
]
slots_unavailable_intervals = invert_intervals(slot_available_intervals, start_utc, stop_utc)
# in staff view, add conflicting events to unavailabilities and return
if field == 'partner_ids':
result = {}
partner_unavailabilities = self._gantt_unavailabilities_events(start, stop, self.env['res.partner'].browse(res_ids))
for res_id in res_ids:
unavailabilities = partner_unavailabilities.get(res_id, Intervals([]))
unavailabilities |= Intervals([(start, stop, self.env['res.partner']) for start, stop in slots_unavailable_intervals])
result[res_id] = [{'start': start, 'stop': stop} for start, stop, _ in unavailabilities]
return result
appointment_resource_ids = self.env['appointment.resource'].browse(res_ids)
# in multi-company, if people can't access some of the resources we don't really care
if self.env.context.get('allowed_company_ids'):
appointment_resource_ids = appointment_resource_ids.filtered_domain([
'|', ('company_id', '=', False), ('company_id', 'in', self.env.context['allowed_company_ids'])]
)
availabilities_values = self.env['appointment.type']._slot_availability_prepare_resources_values(
appointment_resource_ids, start, stop)
resource_unavailabilities = availabilities_values['resource_unavailabilities']
resource_to_bookings = availabilities_values['resource_to_bookings']
# Exclude resources that are shareable but not fully occupied
resource_unavailability_by_bookings = {resource: interval_from_events(bookings.calendar_event_id)
for resource, bookings in resource_to_bookings.items() if not resource.shareable or not (sum(bookings.mapped('capacity_reserved')) < resource.capacity)}
result = {}
for appointment_resource_id in appointment_resource_ids:
unavailabilities = Intervals([(start, stop, set()) for start, stop in slots_unavailable_intervals])
unavailabilities |= Intervals([
(start, stop, set())
for start, stop in resource_unavailabilities.get(appointment_resource_id, [])])
if event_intervals := resource_unavailability_by_bookings.get(appointment_resource_id):
unavailabilities |= Intervals([(localized(start), localized(stop), set()) for start, stop, _ in event_intervals])
result[appointment_resource_id.id] = [{'start': start, 'stop': stop} for start, stop, _ in unavailabilities]
return result
def _gantt_unavailabilities_events(self, start, stop, partners):
"""Get a mapping from partner id to unavailabilities based on existing events.
:returns: {5: Intervals([(monday_morning, monday_noon, <res.partner>(5))])}
:rtype: dict[int, Intervals[<res.partner>]]
"""
return {
attendee.id: Intervals([
(localized(event.start), localized(event.stop), attendee)
for event in partners._get_busy_calendar_events(start, stop).get(attendee.id, [])
]) for attendee in partners
}
@api.model
def get_gantt_data(self, domain, groupby, read_specification, limit=None, offset=0, unavailability_fields=None, progress_bar_fields=None, start_date=None, stop_date=None, scale=None):
"""Filter out rows where the partner isn't linked to an staff user."""
gantt_data = super().get_gantt_data(domain, groupby, read_specification, limit=limit, offset=offset, unavailability_fields=unavailability_fields, progress_bar_fields=progress_bar_fields, start_date=start_date, stop_date=stop_date, scale=scale)
if self.env.context.get('appointment_booking_gantt_show_all_resources') and groupby and groupby[0] == 'partner_ids':
staff_partner_ids = self.env['appointment.type'].search([('schedule_based_on', '=', 'users')]).staff_user_ids.partner_id.ids
gantt_data['groups'] = [group for group in gantt_data['groups'] if group.get('partner_ids') and group['partner_ids'][0] in staff_partner_ids]
return gantt_data