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