summaryrefslogtreecommitdiff
path: root/addons/microsoft_calendar/models/calendar.py
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/microsoft_calendar/models/calendar.py
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/microsoft_calendar/models/calendar.py')
-rw-r--r--addons/microsoft_calendar/models/calendar.py411
1 files changed, 411 insertions, 0 deletions
diff --git a/addons/microsoft_calendar/models/calendar.py b/addons/microsoft_calendar/models/calendar.py
new file mode 100644
index 00000000..625587c0
--- /dev/null
+++ b/addons/microsoft_calendar/models/calendar.py
@@ -0,0 +1,411 @@
+# -*- 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)