1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/appointment/models/calendar_event.py
2024-12-10 09:04:09 +07:00

520 lines
30 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
import logging
from datetime import datetime, timedelta
from odoo import _, api, Command, fields, models, SUPERUSER_ID
from odoo.exceptions import ValidationError
from odoo.tools import html2plaintext, email_normalize, email_split_tuples
from odoo.addons.appointment.utils import invert_intervals
from odoo.addons.resource.models.utils import Intervals, timezone_datetime
from ..utils import interval_from_events, intervals_overlap
_logger = logging.getLogger(__name__)
class CalendarEvent(models.Model):
_inherit = "calendar.event"
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
# 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_list:
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')
resource_ids = self.env.context.get('default_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_list:
appointment_types = False
if resource_ids:
appointment_types = self.env['appointment.resource'].browse(resource_ids).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 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_list:
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_list:
res['partner_ids'] = [Command.set(self.env.user.partner_id.ids)]
return res
def _default_access_token(self):
return str(uuid.uuid4())
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_attended = fields.Boolean('Attendees Arrived')
appointment_type_id = fields.Many2one('appointment.type', 'Appointment', 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.resource_manage_capacity")
appointment_invite_id = fields.Many2one('appointment.invite', 'Appointment Invitation', readonly=True, ondelete='set null')
# currently unused but kept because of stable constraint, properly removed by: https://github.com/odoo/enterprise/commit/dea2f65fcb106848c9f1985e3d49f177c597fe71
appointment_resource_id = fields.Many2one('appointment.resource', string="Appointment Resource",
compute="_compute_appointment_resource_id", inverse="_inverse_appointment_resource_id_or_capacity",
store=True, group_expand="_read_group_appointment_resource_id", copy=False)
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_id",
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 resource_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",
group_expand="_read_group_appointment_resource_id", 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")
resource_total_capacity_reserved = fields.Integer('Total Capacity Reserved', compute="_compute_resource_total_capacity",
inverse="_inverse_appointment_resource_id_or_capacity", copy=True)
resource_total_capacity_used = fields.Integer('Total Capacity Used', compute="_compute_resource_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')
resources_on_leave = fields.Many2many('appointment.resource', string='Resources intersecting with leave time', compute="_compute_resources_on_leave")
_sql_constraints = [
('check_resource_and_appointment_type',
"CHECK(appointment_resource_id IS NULL OR (appointment_resource_id IS NOT NULL AND appointment_type_id IS NOT NULL))",
"An event cannot book resources without an appointment type.")
]
@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))
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('booking_line_ids.appointment_resource_id')
def _compute_appointment_resource_id(self):
for event in self:
if len(event.booking_line_ids) == 1:
event.appointment_resource_id = event.booking_line_ids.appointment_resource_id
else:
event.appointment_resource_id = False
@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', 'appointment_resource_ids', 'appointment_resource_id')
def _compute_resources_on_leave(self):
self.resources_on_leave = False
resource_events = self.filtered(lambda event: event.appointment_resource_ids or event.appointment_resource_id)
if not resource_events:
return
for start, stop, events in interval_from_events(resource_events):
group_resources = events.appointment_resource_ids | events.appointment_resource_id
unavailabilities = group_resources.sudo().resource_id._get_unavailable_intervals(start, stop)
for event in events:
event_resources = event.appointment_resource_ids | event.appointment_resource_id
event.resources_on_leave = event_resources.filtered(lambda resource: any(
intervals_overlap(interval, (event.start, event.stop)) for interval
in unavailabilities.get(resource.resource_id.id, [])
))
@api.depends('booking_line_ids')
def _compute_resource_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.resource_total_capacity_reserved = data.get('total_capacity_reserved', 0) if data else 0
event.resource_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"{self.get_base_url()}/calendar/videocall/{self.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.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 _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_appointment_resource_id_or_capacity(self):
"""Update booking lines as inverse of both resource capacity and resource id.
As both values are related to the booking line and resource capacity is dependant
on resource id existing in the first place, They need to both use the same inverse
field to ensure there is no ordering conflict.
"""
for event in self:
if not event.booking_line_ids and event.appointment_resource_id:
self.env['appointment.booking.line'].sudo().create({
'appointment_resource_id': event.appointment_resource_id.id,
'calendar_event_id': event.id,
'capacity_reserved': event.resource_total_capacity_reserved,
})
elif len(event.booking_line_ids) == 1 and event.appointment_resource_id:
event.booking_line_ids.appointment_resource_id = event.appointment_resource_id
event.booking_line_ids.capacity_reserved = min(
event.resource_total_capacity_reserved or event.booking_line_ids.capacity_reserved,
event.appointment_resource_id.capacity
)
elif len(event.booking_line_ids) == 1:
event.booking_line_ids.sudo().unlink()
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 = []
for event in self:
resources = event.resource_ids
if resources:
if event.appointment_type_manage_capacity and self.resource_total_capacity_reserved:
capacity_to_reserve = self.resource_total_capacity_reserved
else:
capacity_to_reserve = sum(event.booking_line_ids.mapped('capacity_reserved')) or sum(resources.mapped('capacity'))
event.booking_line_ids.sudo().unlink()
for resource in resources.sorted("shareable"):
if event.appointment_type_manage_capacity and capacity_to_reserve <= 0:
break
booking_lines.append({
'appointment_resource_id': resource.id,
'calendar_event_id': event.id,
'capacity_reserved': min(resource.capacity, capacity_to_reserve),
})
capacity_to_reserve -= min(resource.capacity, capacity_to_reserve)
capacity_to_reserve = max(0, capacity_to_reserve)
else:
event.booking_line_ids.sudo().unlink()
self.env['appointment.booking.line'].sudo().create(booking_lines)
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
""" 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.
"""
groupby = [group_element if group_element != "resource_ids" else "appointment_resource_ids" for group_element in groupby]
read_group_data = super().read_group(domain, fields, groupby, offset, limit, orderby, lazy)
for data in read_group_data:
if 'appointment_resource_ids' in data:
data['resource_ids'] = data['appointment_resource_ids']
if 'appointment_resource_ids_count' in data:
data['resource_ids_count'] = data['appointment_resource_ids_count']
return read_group_data
def _read_group_appointment_resource_id(self, resources, domain, order):
if not self.env.context.get('appointment_booking_gantt_show_all_resources'):
return resources
resources_domain = [
'|', ('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, order):
"""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, order):
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 canceled")
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_calendar_more_options(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("calendar.action_calendar_event")
action["views"] = [(False, 'form')]
action["res_id"] = self.id
return action
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()
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 canceled by: %(partners)s", partners=cancelling_attendees)
if self.appointment_booker_id.id == partner_ids[0] or len(self.attendee_ids - attendees) < 2:
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()
else:
# Use the organizer as the author if set or fallback on the first attendee cancelling
author_id = self.user_id.partner_id.id or partner_ids[0]
self.message_post(body=message_body, message_type='notification', author_id=author_id)
self.partner_ids -= attendees.partner_id
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;
:return tuple: partners (recordset)"""
# Split and normalize guest emails
name_emails = email_split_tuples(guest_emails_str)
emails_normalized = [email_normalize(email, strict=False) for _, email in name_emails]
valid_normalized = set(filter(None, emails_normalized)) # uniquify, valid only
partners = self.env['res.partner']
if not valid_normalized:
return partners
# Find existing partners
partners = self.env['mail.thread']._mail_find_partner_from_emails(list(valid_normalized))
partners = self.env['res.partner'].concat(*partners)
remaining_emails = valid_normalized - set(partners.mapped('email_normalized'))
# limit public usage of guests
if self.env.su and len(remaining_emails) > 10:
raise ValueError(
_('Guest usage is limited to 10 customers for performance reason.')
)
if remaining_emails:
partner_values = [
{'email': email, 'name': name if name else email}
for name, email in name_emails
if email_normalize(email) in remaining_emails
]
partners += self.env['res.partner'].create(partner_values)
return partners
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_id',
'appointment_resource_ids',
'appointment_type_id',
'resource_ids',
'resource_total_capacity_reserved',
'resource_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_type_id' in changes:
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'
})
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'
})
return res
def _get_customer_description(self):
# Description should make sense for the person who booked the meeting
if self.appointment_type_id:
message_confirmation = self.appointment_type_id.message_confirmation or ''
contact_details = ''
if self.partner_id and (self.partner_id.name or self.partner_id.email or self.partner_id.phone):
email_detail_line = _('Email: %(email)s', email=self.partner_id.email) if self.partner_id.email else ''
phone_detail_line = _('Phone: %(phone)s', phone=self.partner_id.phone) if self.partner_id.phone else ''
contact_details = '\n'.join(line for line in (_('Contact Details:'), self.partner_id.name, email_detail_line, phone_detail_line) if line)
return f"{html2plaintext(message_confirmation)}\n\n{contact_details}".strip()
return super()._get_customer_description()
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()
@api.model
def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None):
# skip if not dealing with appointments
resource_ids = [row['resId'] for row in rows if row.get('resId')] # remove empty rows
if not group_bys or group_bys[0] not in ('resource_ids', 'partner_ids') or not resource_ids:
return super().gantt_unavailability(start_date, end_date, scale, group_bys=group_bys, rows=rows)
start_datetime = fields.Datetime.from_string(start_date)
end_datetime = fields.Datetime.from_string(end_date)
start_datetime_utc = timezone_datetime(start_datetime)
end_datetime_utc = timezone_datetime(end_datetime)
# 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:
slot_available_intervals = [
(slot['utc'][0], slot['utc'][1])
for slot in appointment_type._slots_generate(start_datetime_utc, end_datetime_utc, 'utc', reference_date=start_datetime_utc)
]
slots_unavailable_intervals = invert_intervals(slot_available_intervals, start_datetime_utc, end_datetime_utc)
# in staff view, add conflicting events to unavailabilities and return
if group_bys[0] == 'partner_ids':
unavailabilities = self._gantt_unavailabilities_events(start_datetime, end_datetime, self.env['res.partner'].browse(resource_ids))
for row in rows:
row_unavailabilities = unavailabilities.get(row['resId'], Intervals([]))
row_unavailabilities |= Intervals([(start, stop, self.env['res.partner']) for start, stop in slots_unavailable_intervals])
row['unavailabilities'] = [{'start': start, 'stop': stop} for start, stop, _ in row_unavailabilities]
return rows
appointment_resource_ids = self.env['appointment.resource'].browse(resource_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'])]
)
resource_unavailabilities = appointment_resource_ids.resource_id._get_unavailable_intervals(start_datetime, end_datetime)
for row in rows:
appointment_resource_id = appointment_resource_ids.browse(row.get('resId'))
unavailabilities = Intervals([
(start, stop, set())
for start, stop in resource_unavailabilities.get(appointment_resource_id.resource_id.id, [])])
unavailabilities |= Intervals([(start, stop, set()) for start, stop in slots_unavailable_intervals])
row['unavailabilities'] = [{'start': start, 'stop': stop} for start, stop, _ in unavailabilities]
return rows
def _gantt_unavailabilities_events(self, start_datetime, end_datetime, partners):
"""Get a mapping from partner id to unavailabilities based on existing events.
:return dict[int, Intervals[<res.partner>]]: {5: Intervals([(monday_morning, monday_noon, <res.partner>(5))])}
"""
return {
attendee.id: Intervals([
(timezone_datetime(event.start), timezone_datetime(event.stop), attendee)
for event in partners._get_calendar_events(start_datetime, end_datetime).get(attendee.id, [])
]) for attendee in partners
}
@api.model
def get_gantt_data(self, domain, groupby, read_specification, limit=None, offset=0):
"""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)
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