# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import pytz from dateutil.parser import parse from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError ATTENDEE_CONVERTER_O2M = { 'needsAction': 'notresponded', 'tentative': 'tentativelyaccepted', 'declined': 'declined', 'accepted': 'accepted' } ATTENDEE_CONVERTER_M2O = { 'notResponded': 'needsAction', 'tentativelyAccepted': 'tentative', 'declined': 'declined', 'accepted': 'accepted', 'organizer': 'accepted', } MAX_RECURRENT_EVENT = 720 class Meeting(models.Model): _name = 'calendar.event' _inherit = ['calendar.event', 'microsoft.calendar.sync'] microsoft_id = fields.Char('Microsoft Calendar Event Id') microsoft_recurrence_master_id = fields.Char('Microsoft Recurrence Master Id') @api.model def _get_microsoft_synced_fields(self): return {'name', 'description', 'allday', 'start', 'date_end', 'stop', 'user_id', 'privacy', 'attendee_ids', 'alarm_ids', 'location', 'show_as', 'active'} @api.model_create_multi def create(self, vals_list): return super().create([ dict(vals, need_sync_m=False) if vals.get('recurrency') else vals for vals in vals_list ]) def write(self, values): recurrence_update_setting = values.get('recurrence_update') if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1: values = dict(values, need_sync_m=False) elif recurrence_update_setting == 'self_only' and 'start' in values: previous_event_before_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < self.start.date() and e != self) new_start = parse(values['start']).date() previous_event_after_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < new_start and e != self) if previous_event_before_write != previous_event_after_write: # Outlook returns a 400 error if you try to synchronize an occurrence of this type. raise UserError(_("Modified occurrence is crossing or overlapping adjacent occurrence.")) return super().write(values) def _get_microsoft_sync_domain(self): return [('partner_ids.user_ids', 'in', self.env.user.id)] @api.model def _microsoft_to_odoo_values(self, microsoft_event, default_reminders=(), default_values={}): if microsoft_event.is_cancelled(): return {'active': False} sensitivity_o2m = { 'normal': 'public', 'private': 'private', 'confidential': 'confidential', } commands_attendee, commands_partner = self._odoo_attendee_commands_m(microsoft_event) timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) if microsoft_event.isAllDay: stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) else: stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) values = { **default_values, 'name': microsoft_event.subject or _("(No title)"), 'description': microsoft_event.bodyPreview, 'location': microsoft_event.location and microsoft_event.location.get('displayName') or False, 'user_id': microsoft_event.owner(self.env).id, 'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, self.default_get(['privacy'])['privacy']), 'attendee_ids': commands_attendee, 'partner_ids': commands_partner, 'allday': microsoft_event.isAllDay, 'start': start, 'stop': stop, 'show_as': 'free' if microsoft_event.showAs == 'free' else 'busy', 'recurrency': microsoft_event.is_recurrent() } values['microsoft_id'] = microsoft_event.id if microsoft_event.is_recurrent(): values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId alarm_commands = self._odoo_reminders_commands_m(microsoft_event) if alarm_commands: values['alarm_ids'] = alarm_commands return values @api.model def _microsoft_to_odoo_recurrence_values(self, microsoft_event, default_reminders=(), values={}): timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) if microsoft_event.isAllDay: stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) else: stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) values['microsoft_id'] = microsoft_event.id values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId values['start'] = start values['stop'] = stop return values @api.model def _odoo_attendee_commands_m(self, microsoft_event): commands_attendee = [] commands_partner = [] microsoft_attendees = microsoft_event.attendees or [] emails = [a.get('emailAddress').get('address') for a in microsoft_attendees] existing_attendees = self.env['calendar.attendee'] if microsoft_event.exists(self.env): existing_attendees = self.env['calendar.attendee'].search([ ('event_id', '=', microsoft_event.odoo_id(self.env)), ('email', 'in', emails)]) elif self.env.user.partner_id.email not in emails: commands_attendee += [(0, 0, {'state': 'accepted', 'partner_id': self.env.user.partner_id.id})] commands_partner += [(4, self.env.user.partner_id.id)] attendees_by_emails = {a.email: a for a in existing_attendees} for attendee in microsoft_attendees: email = attendee.get('emailAddress').get('address') state = ATTENDEE_CONVERTER_M2O.get(attendee.get('status').get('response')) if email in attendees_by_emails: # Update existing attendees commands_attendee += [(1, attendees_by_emails[email].id, {'state': state})] else: # Create new attendees partner = self.env['res.partner'].find_or_create(email) commands_attendee += [(0, 0, {'state': state, 'partner_id': partner.id})] commands_partner += [(4, partner.id)] if attendee.get('emailAddress').get('name') and not partner.name: partner.name = attendee.get('emailAddress').get('name') for odoo_attendee in attendees_by_emails.values(): # Remove old attendees if odoo_attendee.email not in emails: commands_attendee += [(2, odoo_attendee.id)] commands_partner += [(3, odoo_attendee.partner_id.id)] return commands_attendee, commands_partner @api.model def _odoo_reminders_commands_m(self, microsoft_event): reminders_commands = [] if microsoft_event.isReminderOn: event_id = self.browse(microsoft_event.odoo_id(self.env)) alarm_type_label = _("Notification") minutes = microsoft_event.reminderMinutesBeforeStart or 0 alarm = self.env['calendar.alarm'].search([ ('alarm_type', '=', 'notification'), ('duration_minutes', '=', minutes) ], limit=1) if alarm and alarm not in event_id.alarm_ids: reminders_commands = [(4, alarm.id)] elif not alarm: if minutes == 0: interval = 'minutes' duration = minutes name = _("%s - At time of event", alarm_type_label) elif minutes % (60*24) == 0: interval = 'days' duration = minutes / 60 / 24 name = _( "%(reminder_type)s - %(duration)s Days", reminder_type=alarm_type_label, duration=duration, ) elif minutes % 60 == 0: interval = 'hours' duration = minutes / 60 name = _( "%(reminder_type)s - %(duration)s Hours", reminder_type=alarm_type_label, duration=duration, ) else: interval = 'minutes' duration = minutes name = _( "%(reminder_type)s - %(duration)s Minutes", reminder_type=alarm_type_label, duration=duration, ) reminders_commands = [(0, 0, {'duration': duration, 'interval': interval, 'name': name, 'alarm_type': 'notification'})] alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification' and a.id != alarm.id) if alarm_to_rm: reminders_commands += [(3, a.id) for a in alarm_to_rm] else: event_id = self.browse(microsoft_event.odoo_id(self.env)) alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification') if alarm_to_rm: reminders_commands = [(3, a.id) for a in alarm_to_rm] return reminders_commands def _get_attendee_status_o2m(self, attendee): if self.user_id and self.user_id == attendee.partner_id.user_id: return 'organizer' return ATTENDEE_CONVERTER_O2M.get(attendee.state, 'None') def _microsoft_values(self, fields_to_sync, initial_values={}): values = dict(initial_values) if not fields_to_sync: return values values['id'] = self.microsoft_id microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) values['singleValueExtendedProperties'] = [{ 'id': 'String {%s} Name odoo_id' % microsoft_guid, 'value': str(self.id), }, { 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, 'value': str(self.user_id.id), }] if self.microsoft_recurrence_master_id and 'type' not in values: values['seriesMasterId'] = self.microsoft_recurrence_master_id values['type'] = 'exception' if 'name' in fields_to_sync: values['subject'] = self.name or '' if 'description' in fields_to_sync: values['body'] = { 'content': self.description or '', 'contentType': "text", } if any(x in fields_to_sync for x in ['allday', 'start', 'date_end', 'stop']): if self.allday: start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} else: start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} values['start'] = start values['end'] = end values['isAllDay'] = self.allday if 'location' in fields_to_sync: values['location'] = {'displayName': self.location or ''} if 'alarm_ids' in fields_to_sync: alarm_id = self.alarm_ids.filtered(lambda a: a.alarm_type == 'notification')[:1] values['isReminderOn'] = bool(alarm_id) values['reminderMinutesBeforeStart'] = alarm_id.duration_minutes if 'user_id' in fields_to_sync: values['organizer'] = {'emailAddress': {'address': self.user_id.email or '', 'name': self.user_id.display_name or ''}} values['isOrganizer'] = self.user_id == self.env.user if 'attendee_ids' in fields_to_sync: attendees = self.attendee_ids.filtered(lambda att: att.partner_id not in self.user_id.partner_id) values['attendees'] = [ { 'emailAddress': {'address': attendee.email or '', 'name': attendee.display_name or ''}, 'status': {'response': self._get_attendee_status_o2m(attendee)} } for attendee in attendees] if 'privacy' in fields_to_sync or 'show_as' in fields_to_sync: values['showAs'] = self.show_as sensitivity_o2m = { 'public': 'normal', 'private': 'private', 'confidential': 'confidential', } values['sensitivity'] = sensitivity_o2m.get(self.privacy) if 'active' in fields_to_sync and not self.active: values['isCancelled'] = True if values.get('type') == 'seriesMaster': recurrence = self.recurrence_id pattern = { 'interval': recurrence.interval } if recurrence.rrule_type in ['daily', 'weekly']: pattern['type'] = recurrence.rrule_type else: prefix = 'absolute' if recurrence.month_by == 'date' else 'relative' pattern['type'] = prefix + recurrence.rrule_type.capitalize() if recurrence.month_by == 'date': pattern['dayOfMonth'] = recurrence.day if recurrence.month_by == 'day' or recurrence.rrule_type == 'weekly': pattern['daysOfWeek'] = [ weekday_name for weekday_name, weekday in { 'monday': recurrence.mo, 'tuesday': recurrence.tu, 'wednesday': recurrence.we, 'thursday': recurrence.th, 'friday': recurrence.fr, 'saturday': recurrence.sa, 'sunday': recurrence.su, }.items() if weekday] pattern['firstDayOfWeek'] = 'sunday' if recurrence.rrule_type == 'monthly' and recurrence.month_by == 'day': byday_selection = { '1': 'first', '2': 'second', '3': 'third', '4': 'fourth', '-1': 'last', } pattern['index'] = byday_selection[recurrence.byday] rule_range = { 'startDate': (recurrence.dtstart.date()).isoformat() } if recurrence.end_type == 'count': # e.g. stop after X occurence rule_range['numberOfOccurrences'] = min(recurrence.count, MAX_RECURRENT_EVENT) rule_range['type'] = 'numbered' elif recurrence.end_type == 'forever': rule_range['numberOfOccurrences'] = MAX_RECURRENT_EVENT rule_range['type'] = 'numbered' elif recurrence.end_type == 'end_date': # e.g. stop after 12/10/2020 rule_range['endDate'] = recurrence.until.isoformat() rule_range['type'] = 'endDate' values['recurrence'] = { 'pattern': pattern, 'range': rule_range } return values def _ensure_attendees_have_email(self): invalid_event_ids = self.env['calendar.event'].search_read( domain=[('id', 'in', self.ids), ('attendee_ids.partner_id.email', '=', False)], fields=['display_time', 'display_name'], order='start', ) if invalid_event_ids: list_length_limit = 50 total_invalid_events = len(invalid_event_ids) invalid_event_ids = invalid_event_ids[:list_length_limit] invalid_events = ['\t- %s: %s' % (event['display_time'], event['display_name']) for event in invalid_event_ids] invalid_events = '\n'.join(invalid_events) details = "(%d/%d)" % (list_length_limit, total_invalid_events) if list_length_limit < total_invalid_events else "(%d)" % total_invalid_events raise ValidationError(_("For a correct synchronization between Odoo and Outlook Calendar, " "all attendees must have an email address. However, some events do " "not respect this condition. As long as the events are incorrect, " "the calendars will not be synchronized." "\nEither update the events/attendees or archive these events %s:" "\n%s", details, invalid_events)) def _microsoft_values_occurence(self, initial_values={}): values = dict(initial_values) values['id'] = self.microsoft_id microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) values['singleValueExtendedProperties'] = [{ 'id': 'String {%s} Name odoo_id' % microsoft_guid, 'value': str(self.id), }, { 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, 'value': str(self.user_id.id), }] values['type'] = 'occurrence' if self.allday: start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} else: start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} values['start'] = start values['end'] = end values['isAllDay'] = self.allday return values def _cancel_microsoft(self): # only owner can delete => others refuse the event user = self.env.user my_cancelled_records = self.filtered(lambda e: e.user_id == user) super(Meeting, my_cancelled_records)._cancel_microsoft() attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id) attendees.state = 'declined' def _notify_attendees(self): # filter events before notifying attendees through calendar_alarm_manager need_notifs = self.filtered(lambda event: event.alarm_ids and event.stop >= fields.Datetime.now()) partners = need_notifs.partner_ids if partners: self.env['calendar.alarm_manager']._notify_next_alarm(partners.ids)