1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4import pytz 5from dateutil.parser import parse 6from dateutil.relativedelta import relativedelta 7 8from odoo import api, fields, models, _ 9from odoo.exceptions import UserError, ValidationError 10 11ATTENDEE_CONVERTER_O2M = { 12 'needsAction': 'notresponded', 13 'tentative': 'tentativelyaccepted', 14 'declined': 'declined', 15 'accepted': 'accepted' 16} 17ATTENDEE_CONVERTER_M2O = { 18 'notResponded': 'needsAction', 19 'tentativelyAccepted': 'tentative', 20 'declined': 'declined', 21 'accepted': 'accepted', 22 'organizer': 'accepted', 23} 24MAX_RECURRENT_EVENT = 720 25 26class Meeting(models.Model): 27 _name = 'calendar.event' 28 _inherit = ['calendar.event', 'microsoft.calendar.sync'] 29 30 microsoft_id = fields.Char('Microsoft Calendar Event Id') 31 microsoft_recurrence_master_id = fields.Char('Microsoft Recurrence Master Id') 32 33 @api.model 34 def _get_microsoft_synced_fields(self): 35 return {'name', 'description', 'allday', 'start', 'date_end', 'stop', 36 'user_id', 'privacy', 37 'attendee_ids', 'alarm_ids', 'location', 'show_as', 'active'} 38 39 @api.model_create_multi 40 def create(self, vals_list): 41 return super().create([ 42 dict(vals, need_sync_m=False) if vals.get('recurrency') else vals 43 for vals in vals_list 44 ]) 45 46 def write(self, values): 47 recurrence_update_setting = values.get('recurrence_update') 48 if recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1: 49 values = dict(values, need_sync_m=False) 50 elif recurrence_update_setting == 'self_only' and 'start' in values: 51 previous_event_before_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < self.start.date() and e != self) 52 new_start = parse(values['start']).date() 53 previous_event_after_write = self.recurrence_id.calendar_event_ids.filtered(lambda e: e.start.date() < new_start and e != self) 54 if previous_event_before_write != previous_event_after_write: 55 # Outlook returns a 400 error if you try to synchronize an occurrence of this type. 56 raise UserError(_("Modified occurrence is crossing or overlapping adjacent occurrence.")) 57 return super().write(values) 58 59 def _get_microsoft_sync_domain(self): 60 return [('partner_ids.user_ids', 'in', self.env.user.id)] 61 62 @api.model 63 def _microsoft_to_odoo_values(self, microsoft_event, default_reminders=(), default_values={}): 64 if microsoft_event.is_cancelled(): 65 return {'active': False} 66 67 sensitivity_o2m = { 68 'normal': 'public', 69 'private': 'private', 70 'confidential': 'confidential', 71 } 72 73 commands_attendee, commands_partner = self._odoo_attendee_commands_m(microsoft_event) 74 timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) 75 timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) 76 start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) 77 if microsoft_event.isAllDay: 78 stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) 79 else: 80 stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) 81 values = { 82 **default_values, 83 'name': microsoft_event.subject or _("(No title)"), 84 'description': microsoft_event.bodyPreview, 85 'location': microsoft_event.location and microsoft_event.location.get('displayName') or False, 86 'user_id': microsoft_event.owner(self.env).id, 87 'privacy': sensitivity_o2m.get(microsoft_event.sensitivity, self.default_get(['privacy'])['privacy']), 88 'attendee_ids': commands_attendee, 89 'partner_ids': commands_partner, 90 'allday': microsoft_event.isAllDay, 91 'start': start, 92 'stop': stop, 93 'show_as': 'free' if microsoft_event.showAs == 'free' else 'busy', 94 'recurrency': microsoft_event.is_recurrent() 95 } 96 97 values['microsoft_id'] = microsoft_event.id 98 if microsoft_event.is_recurrent(): 99 values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId 100 101 alarm_commands = self._odoo_reminders_commands_m(microsoft_event) 102 if alarm_commands: 103 values['alarm_ids'] = alarm_commands 104 105 return values 106 107 @api.model 108 def _microsoft_to_odoo_recurrence_values(self, microsoft_event, default_reminders=(), values={}): 109 timeZone_start = pytz.timezone(microsoft_event.start.get('timeZone')) 110 timeZone_stop = pytz.timezone(microsoft_event.end.get('timeZone')) 111 start = parse(microsoft_event.start.get('dateTime')).astimezone(timeZone_start).replace(tzinfo=None) 112 if microsoft_event.isAllDay: 113 stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) - relativedelta(days=1) 114 else: 115 stop = parse(microsoft_event.end.get('dateTime')).astimezone(timeZone_stop).replace(tzinfo=None) 116 values['microsoft_id'] = microsoft_event.id 117 values['microsoft_recurrence_master_id'] = microsoft_event.seriesMasterId 118 values['start'] = start 119 values['stop'] = stop 120 return values 121 122 @api.model 123 def _odoo_attendee_commands_m(self, microsoft_event): 124 commands_attendee = [] 125 commands_partner = [] 126 127 microsoft_attendees = microsoft_event.attendees or [] 128 emails = [a.get('emailAddress').get('address') for a in microsoft_attendees] 129 existing_attendees = self.env['calendar.attendee'] 130 if microsoft_event.exists(self.env): 131 existing_attendees = self.env['calendar.attendee'].search([ 132 ('event_id', '=', microsoft_event.odoo_id(self.env)), 133 ('email', 'in', emails)]) 134 elif self.env.user.partner_id.email not in emails: 135 commands_attendee += [(0, 0, {'state': 'accepted', 'partner_id': self.env.user.partner_id.id})] 136 commands_partner += [(4, self.env.user.partner_id.id)] 137 attendees_by_emails = {a.email: a for a in existing_attendees} 138 for attendee in microsoft_attendees: 139 email = attendee.get('emailAddress').get('address') 140 state = ATTENDEE_CONVERTER_M2O.get(attendee.get('status').get('response')) 141 142 if email in attendees_by_emails: 143 # Update existing attendees 144 commands_attendee += [(1, attendees_by_emails[email].id, {'state': state})] 145 else: 146 # Create new attendees 147 partner = self.env['res.partner'].find_or_create(email) 148 commands_attendee += [(0, 0, {'state': state, 'partner_id': partner.id})] 149 commands_partner += [(4, partner.id)] 150 if attendee.get('emailAddress').get('name') and not partner.name: 151 partner.name = attendee.get('emailAddress').get('name') 152 for odoo_attendee in attendees_by_emails.values(): 153 # Remove old attendees 154 if odoo_attendee.email not in emails: 155 commands_attendee += [(2, odoo_attendee.id)] 156 commands_partner += [(3, odoo_attendee.partner_id.id)] 157 return commands_attendee, commands_partner 158 159 @api.model 160 def _odoo_reminders_commands_m(self, microsoft_event): 161 reminders_commands = [] 162 if microsoft_event.isReminderOn: 163 event_id = self.browse(microsoft_event.odoo_id(self.env)) 164 alarm_type_label = _("Notification") 165 166 minutes = microsoft_event.reminderMinutesBeforeStart or 0 167 alarm = self.env['calendar.alarm'].search([ 168 ('alarm_type', '=', 'notification'), 169 ('duration_minutes', '=', minutes) 170 ], limit=1) 171 if alarm and alarm not in event_id.alarm_ids: 172 reminders_commands = [(4, alarm.id)] 173 elif not alarm: 174 if minutes == 0: 175 interval = 'minutes' 176 duration = minutes 177 name = _("%s - At time of event", alarm_type_label) 178 elif minutes % (60*24) == 0: 179 interval = 'days' 180 duration = minutes / 60 / 24 181 name = _( 182 "%(reminder_type)s - %(duration)s Days", 183 reminder_type=alarm_type_label, 184 duration=duration, 185 ) 186 elif minutes % 60 == 0: 187 interval = 'hours' 188 duration = minutes / 60 189 name = _( 190 "%(reminder_type)s - %(duration)s Hours", 191 reminder_type=alarm_type_label, 192 duration=duration, 193 ) 194 else: 195 interval = 'minutes' 196 duration = minutes 197 name = _( 198 "%(reminder_type)s - %(duration)s Minutes", 199 reminder_type=alarm_type_label, 200 duration=duration, 201 ) 202 reminders_commands = [(0, 0, {'duration': duration, 'interval': interval, 'name': name, 'alarm_type': 'notification'})] 203 204 alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification' and a.id != alarm.id) 205 if alarm_to_rm: 206 reminders_commands += [(3, a.id) for a in alarm_to_rm] 207 208 else: 209 event_id = self.browse(microsoft_event.odoo_id(self.env)) 210 alarm_to_rm = event_id.alarm_ids.filtered(lambda a: a.alarm_type == 'notification') 211 if alarm_to_rm: 212 reminders_commands = [(3, a.id) for a in alarm_to_rm] 213 return reminders_commands 214 215 def _get_attendee_status_o2m(self, attendee): 216 if self.user_id and self.user_id == attendee.partner_id.user_id: 217 return 'organizer' 218 return ATTENDEE_CONVERTER_O2M.get(attendee.state, 'None') 219 220 def _microsoft_values(self, fields_to_sync, initial_values={}): 221 values = dict(initial_values) 222 if not fields_to_sync: 223 return values 224 225 values['id'] = self.microsoft_id 226 microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) 227 values['singleValueExtendedProperties'] = [{ 228 'id': 'String {%s} Name odoo_id' % microsoft_guid, 229 'value': str(self.id), 230 }, { 231 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, 232 'value': str(self.user_id.id), 233 }] 234 235 if self.microsoft_recurrence_master_id and 'type' not in values: 236 values['seriesMasterId'] = self.microsoft_recurrence_master_id 237 values['type'] = 'exception' 238 239 if 'name' in fields_to_sync: 240 values['subject'] = self.name or '' 241 242 if 'description' in fields_to_sync: 243 values['body'] = { 244 'content': self.description or '', 245 'contentType': "text", 246 } 247 248 if any(x in fields_to_sync for x in ['allday', 'start', 'date_end', 'stop']): 249 if self.allday: 250 start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} 251 end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} 252 else: 253 start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} 254 end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} 255 256 values['start'] = start 257 values['end'] = end 258 values['isAllDay'] = self.allday 259 260 if 'location' in fields_to_sync: 261 values['location'] = {'displayName': self.location or ''} 262 263 if 'alarm_ids' in fields_to_sync: 264 alarm_id = self.alarm_ids.filtered(lambda a: a.alarm_type == 'notification')[:1] 265 values['isReminderOn'] = bool(alarm_id) 266 values['reminderMinutesBeforeStart'] = alarm_id.duration_minutes 267 268 if 'user_id' in fields_to_sync: 269 values['organizer'] = {'emailAddress': {'address': self.user_id.email or '', 'name': self.user_id.display_name or ''}} 270 values['isOrganizer'] = self.user_id == self.env.user 271 272 if 'attendee_ids' in fields_to_sync: 273 attendees = self.attendee_ids.filtered(lambda att: att.partner_id not in self.user_id.partner_id) 274 values['attendees'] = [ 275 { 276 'emailAddress': {'address': attendee.email or '', 'name': attendee.display_name or ''}, 277 'status': {'response': self._get_attendee_status_o2m(attendee)} 278 } for attendee in attendees] 279 280 if 'privacy' in fields_to_sync or 'show_as' in fields_to_sync: 281 values['showAs'] = self.show_as 282 sensitivity_o2m = { 283 'public': 'normal', 284 'private': 'private', 285 'confidential': 'confidential', 286 } 287 values['sensitivity'] = sensitivity_o2m.get(self.privacy) 288 289 if 'active' in fields_to_sync and not self.active: 290 values['isCancelled'] = True 291 292 if values.get('type') == 'seriesMaster': 293 recurrence = self.recurrence_id 294 pattern = { 295 'interval': recurrence.interval 296 } 297 if recurrence.rrule_type in ['daily', 'weekly']: 298 pattern['type'] = recurrence.rrule_type 299 else: 300 prefix = 'absolute' if recurrence.month_by == 'date' else 'relative' 301 pattern['type'] = prefix + recurrence.rrule_type.capitalize() 302 303 if recurrence.month_by == 'date': 304 pattern['dayOfMonth'] = recurrence.day 305 306 if recurrence.month_by == 'day' or recurrence.rrule_type == 'weekly': 307 pattern['daysOfWeek'] = [ 308 weekday_name for weekday_name, weekday in { 309 'monday': recurrence.mo, 310 'tuesday': recurrence.tu, 311 'wednesday': recurrence.we, 312 'thursday': recurrence.th, 313 'friday': recurrence.fr, 314 'saturday': recurrence.sa, 315 'sunday': recurrence.su, 316 }.items() if weekday] 317 pattern['firstDayOfWeek'] = 'sunday' 318 319 if recurrence.rrule_type == 'monthly' and recurrence.month_by == 'day': 320 byday_selection = { 321 '1': 'first', 322 '2': 'second', 323 '3': 'third', 324 '4': 'fourth', 325 '-1': 'last', 326 } 327 pattern['index'] = byday_selection[recurrence.byday] 328 329 rule_range = { 330 'startDate': (recurrence.dtstart.date()).isoformat() 331 } 332 333 if recurrence.end_type == 'count': # e.g. stop after X occurence 334 rule_range['numberOfOccurrences'] = min(recurrence.count, MAX_RECURRENT_EVENT) 335 rule_range['type'] = 'numbered' 336 elif recurrence.end_type == 'forever': 337 rule_range['numberOfOccurrences'] = MAX_RECURRENT_EVENT 338 rule_range['type'] = 'numbered' 339 elif recurrence.end_type == 'end_date': # e.g. stop after 12/10/2020 340 rule_range['endDate'] = recurrence.until.isoformat() 341 rule_range['type'] = 'endDate' 342 343 values['recurrence'] = { 344 'pattern': pattern, 345 'range': rule_range 346 } 347 348 return values 349 350 def _ensure_attendees_have_email(self): 351 invalid_event_ids = self.env['calendar.event'].search_read( 352 domain=[('id', 'in', self.ids), ('attendee_ids.partner_id.email', '=', False)], 353 fields=['display_time', 'display_name'], 354 order='start', 355 ) 356 if invalid_event_ids: 357 list_length_limit = 50 358 total_invalid_events = len(invalid_event_ids) 359 invalid_event_ids = invalid_event_ids[:list_length_limit] 360 invalid_events = ['\t- %s: %s' % (event['display_time'], event['display_name']) 361 for event in invalid_event_ids] 362 invalid_events = '\n'.join(invalid_events) 363 details = "(%d/%d)" % (list_length_limit, total_invalid_events) if list_length_limit < total_invalid_events else "(%d)" % total_invalid_events 364 raise ValidationError(_("For a correct synchronization between Odoo and Outlook Calendar, " 365 "all attendees must have an email address. However, some events do " 366 "not respect this condition. As long as the events are incorrect, " 367 "the calendars will not be synchronized." 368 "\nEither update the events/attendees or archive these events %s:" 369 "\n%s", details, invalid_events)) 370 371 def _microsoft_values_occurence(self, initial_values={}): 372 values = dict(initial_values) 373 values['id'] = self.microsoft_id 374 microsoft_guid = self.env['ir.config_parameter'].sudo().get_param('microsoft_calendar.microsoft_guid', False) 375 values['singleValueExtendedProperties'] = [{ 376 'id': 'String {%s} Name odoo_id' % microsoft_guid, 377 'value': str(self.id), 378 }, { 379 'id': 'String {%s} Name owner_odoo_id' % microsoft_guid, 380 'value': str(self.user_id.id), 381 }] 382 383 values['type'] = 'occurrence' 384 385 if self.allday: 386 start = {'dateTime': self.start_date.isoformat(), 'timeZone': 'Europe/London'} 387 end = {'dateTime': (self.stop_date + relativedelta(days=1)).isoformat(), 'timeZone': 'Europe/London'} 388 else: 389 start = {'dateTime': pytz.utc.localize(self.start).isoformat(), 'timeZone': 'Europe/London'} 390 end = {'dateTime': pytz.utc.localize(self.stop).isoformat(), 'timeZone': 'Europe/London'} 391 392 values['start'] = start 393 values['end'] = end 394 values['isAllDay'] = self.allday 395 396 return values 397 398 def _cancel_microsoft(self): 399 # only owner can delete => others refuse the event 400 user = self.env.user 401 my_cancelled_records = self.filtered(lambda e: e.user_id == user) 402 super(Meeting, my_cancelled_records)._cancel_microsoft() 403 attendees = (self - my_cancelled_records).attendee_ids.filtered(lambda a: a.partner_id == user.partner_id) 404 attendees.state = 'declined' 405 406 def _notify_attendees(self): 407 # filter events before notifying attendees through calendar_alarm_manager 408 need_notifs = self.filtered(lambda event: event.alarm_ids and event.stop >= fields.Datetime.now()) 409 partners = need_notifs.partner_ids 410 if partners: 411 self.env['calendar.alarm_manager']._notify_next_alarm(partners.ids) 412