microsoft_calendar.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import requests
  4. import json
  5. import logging
  6. from werkzeug import urls
  7. from odoo import fields
  8. from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent
  9. from odoo.addons.microsoft_account.models.microsoft_service import TIMEOUT, RESOURCE_NOT_FOUND_STATUSES
  10. _logger = logging.getLogger(__name__)
  11. def requires_auth_token(func):
  12. def wrapped(self, *args, **kwargs):
  13. if not kwargs.get('token'):
  14. raise AttributeError("An authentication token is required")
  15. return func(self, *args, **kwargs)
  16. return wrapped
  17. class InvalidSyncToken(Exception):
  18. pass
  19. # In Outlook, an event can be:
  20. # - a 'singleInstance' event,
  21. # - a 'serie master' which contains all the information about an event reccurrence such as
  22. # - an 'occurrence' which is an event from a reccurrence (serie) that follows this reccurrence
  23. # - an 'exception' which is an event from a reccurrence (serie) but some differences with the reccurrence template (could be
  24. # the name, the day of occurrence, ...)
  25. #
  26. # All these kinds of events are identified by:
  27. # - a event ID (id) which is specific to an Outlook calendar.
  28. # - a global event ID (iCalUId) which is common to all Outlook calendars containing this event.
  29. #
  30. # - 'singleInstance' and 'serie master' events are retrieved through the end-point `/v1.0/me/calendarView/delta` which provides
  31. # the last modified/deleted items since the last sync (or all of these items at the first time).
  32. # - 'occurrence' and 'exception' events are retrieved through the end-point `/v1.0/me/events/{serieMaster.id}/instances`,
  33. # using the corresponding serie master ID.
  34. class MicrosoftCalendarService():
  35. def __init__(self, microsoft_service):
  36. self.microsoft_service = microsoft_service
  37. @requires_auth_token
  38. def _get_events_from_paginated_url(self, url, token=None, params=None, timeout=TIMEOUT):
  39. """
  40. Get a list of events from a paginated URL.
  41. Each page contains a link to the next page, so loop over all the pages to get all the events.
  42. """
  43. headers = {
  44. 'Content-type': 'application/json',
  45. 'Authorization': 'Bearer %s' % token,
  46. 'Prefer': 'outlook.body-content-type="html", odata.maxpagesize=50'
  47. }
  48. if not params:
  49. params = {
  50. 'startDateTime': fields.Datetime.subtract(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"),
  51. 'endDateTime': fields.Datetime.add(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"),
  52. }
  53. # get the first page of events
  54. _, data, _ = self.microsoft_service._do_request(
  55. url, params, headers, method='GET', timeout=timeout
  56. )
  57. # and then, loop on other pages to get all the events
  58. events = data.get('value', [])
  59. next_page_token = data.get('@odata.nextLink')
  60. while next_page_token:
  61. _, data, _ = self.microsoft_service._do_request(
  62. next_page_token, {}, headers, preuri='', method='GET', timeout=timeout
  63. )
  64. next_page_token = data.get('@odata.nextLink')
  65. events += data.get('value', [])
  66. token_url = data.get('@odata.deltaLink')
  67. next_sync_token = urls.url_parse(token_url).decode_query().get('$deltatoken', False) if token_url else None
  68. return events, next_sync_token
  69. @requires_auth_token
  70. def _get_events_delta(self, sync_token=None, token=None, timeout=TIMEOUT):
  71. """
  72. Get a set of events that have been added, deleted or updated in a time range.
  73. See: https://docs.microsoft.com/en-us/graph/api/event-delta?view=graph-rest-1.0&tabs=http
  74. """
  75. url = "/v1.0/me/calendarView/delta"
  76. params = {'$deltatoken': sync_token} if sync_token else None
  77. try:
  78. events, next_sync_token = self._get_events_from_paginated_url(
  79. url, params=params, token=token, timeout=timeout)
  80. except requests.HTTPError as e:
  81. if e.response.status_code == 410 and 'fullSyncRequired' in str(e.response.content) and sync_token:
  82. # retry with a full sync
  83. return self._get_events_delta(token=token, timeout=timeout)
  84. raise e
  85. # event occurrences (from a recurrence) are retrieved separately to get all their info,
  86. # # and mainly the iCalUId attribute which is not provided by the 'get_delta' api end point
  87. events = [e for e in events if e.get('type') != 'occurrence']
  88. return MicrosoftEvent(events), next_sync_token
  89. @requires_auth_token
  90. def _get_occurrence_details(self, serieMasterId, token=None, timeout=TIMEOUT):
  91. """
  92. Get all occurrences details from a serie master.
  93. See: https://docs.microsoft.com/en-us/graph/api/event-list-instances?view=graph-rest-1.0&tabs=http
  94. """
  95. url = f"/v1.0/me/events/{serieMasterId}/instances"
  96. events, _ = self._get_events_from_paginated_url(url, token=token, timeout=timeout)
  97. return MicrosoftEvent(events)
  98. @requires_auth_token
  99. def get_events(self, sync_token=None, token=None, timeout=TIMEOUT):
  100. """
  101. Retrieve all the events that have changed (added/updated/removed) from Microsoft Outlook.
  102. This is done in 2 steps:
  103. 1) get main changed events (so single events and serie masters)
  104. 2) get occurrences linked to a serie masters (to retrieve all needed details such as iCalUId)
  105. """
  106. events, next_sync_token = self._get_events_delta(sync_token=sync_token, token=token, timeout=timeout)
  107. # get occurences details for all serie masters
  108. for master in filter(lambda e: e.type == 'seriesMaster', events):
  109. events |= self._get_occurrence_details(master.id, token=token, timeout=timeout)
  110. return events, next_sync_token
  111. @requires_auth_token
  112. def insert(self, values, token=None, timeout=TIMEOUT):
  113. url = "/v1.0/me/calendar/events"
  114. headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token}
  115. _dummy, data, _dummy = self.microsoft_service._do_request(url, json.dumps(values), headers, method='POST', timeout=timeout)
  116. return data['id'], data['iCalUId']
  117. @requires_auth_token
  118. def patch(self, event_id, values, token=None, timeout=TIMEOUT):
  119. url = "/v1.0/me/calendar/events/%s" % event_id
  120. headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token}
  121. try:
  122. status, _dummy, _dummy = self.microsoft_service._do_request(url, json.dumps(values), headers, method='PATCH', timeout=timeout)
  123. except requests.HTTPError:
  124. _logger.info("Microsoft event %s has not been updated", event_id)
  125. return False
  126. return status not in RESOURCE_NOT_FOUND_STATUSES
  127. @requires_auth_token
  128. def delete(self, event_id, token=None, timeout=TIMEOUT):
  129. url = "/v1.0/me/calendar/events/%s" % event_id
  130. headers = {'Authorization': 'Bearer %s' % token}
  131. params = {}
  132. try:
  133. status, _dummy, _dummy = self.microsoft_service._do_request(url, params, headers=headers, method='DELETE', timeout=timeout)
  134. except requests.HTTPError as e:
  135. # For some unknown reason Microsoft can also return a 403 response when the event is already cancelled.
  136. status = e.response.status_code
  137. if status in (410, 403):
  138. _logger.info("Microsoft event %s was already deleted", event_id)
  139. else:
  140. raise e
  141. return status not in RESOURCE_NOT_FOUND_STATUSES
  142. @requires_auth_token
  143. def answer(self, event_id, answer, values, token=None, timeout=TIMEOUT):
  144. url = "/v1.0/me/calendar/events/%s/%s" % (event_id, answer)
  145. headers = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % token}
  146. status, _dummy, _dummy = self.microsoft_service._do_request(url, json.dumps(values), headers, method='POST', timeout=timeout)
  147. return status not in RESOURCE_NOT_FOUND_STATUSES
  148. #####################################
  149. ## MANAGE CONNEXION TO MICROSOFT ##
  150. #####################################
  151. def is_authorized(self, user):
  152. return bool(user.sudo().microsoft_calendar_rtoken)
  153. def _get_calendar_scope(self):
  154. return 'offline_access openid Calendars.ReadWrite'
  155. def _microsoft_authentication_url(self, from_url='http://www.odoo.com'):
  156. return self.microsoft_service._get_authorize_uri(from_url, service='calendar', scope=self._get_calendar_scope())
  157. def _can_authorize_microsoft(self, user):
  158. return user.has_group('base.group_erp_manager')