wbcrm 2.2.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wbcrm might be problematic. Click here for more details.

Files changed (155) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +4 -0
  3. wbcrm/admin/accounts.py +59 -0
  4. wbcrm/admin/activities.py +101 -0
  5. wbcrm/admin/groups.py +7 -0
  6. wbcrm/admin/products.py +8 -0
  7. wbcrm/apps.py +5 -0
  8. wbcrm/configurations/__init__.py +1 -0
  9. wbcrm/configurations/base.py +16 -0
  10. wbcrm/dynamic_preferences_registry.py +38 -0
  11. wbcrm/factories/__init__.py +14 -0
  12. wbcrm/factories/accounts.py +56 -0
  13. wbcrm/factories/activities.py +125 -0
  14. wbcrm/factories/groups.py +23 -0
  15. wbcrm/factories/products.py +10 -0
  16. wbcrm/filters/__init__.py +10 -0
  17. wbcrm/filters/accounts.py +67 -0
  18. wbcrm/filters/activities.py +181 -0
  19. wbcrm/filters/groups.py +20 -0
  20. wbcrm/filters/products.py +37 -0
  21. wbcrm/filters/signals.py +94 -0
  22. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  23. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  24. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  25. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  26. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  27. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  28. wbcrm/migrations/0007_alter_account_status.py +23 -0
  29. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  30. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  31. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  32. wbcrm/migrations/0011_activity_summary.py +22 -0
  33. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  34. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  35. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  36. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  37. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  38. wbcrm/migrations/__init__.py +0 -0
  39. wbcrm/models/__init__.py +4 -0
  40. wbcrm/models/accounts.py +637 -0
  41. wbcrm/models/activities.py +1335 -0
  42. wbcrm/models/groups.py +118 -0
  43. wbcrm/models/products.py +83 -0
  44. wbcrm/models/recurrence.py +279 -0
  45. wbcrm/preferences.py +14 -0
  46. wbcrm/serializers/__init__.py +23 -0
  47. wbcrm/serializers/accounts.py +126 -0
  48. wbcrm/serializers/activities.py +526 -0
  49. wbcrm/serializers/groups.py +30 -0
  50. wbcrm/serializers/products.py +57 -0
  51. wbcrm/serializers/recurrence.py +90 -0
  52. wbcrm/serializers/signals.py +70 -0
  53. wbcrm/synchronization/__init__.py +0 -0
  54. wbcrm/synchronization/activity/__init__.py +0 -0
  55. wbcrm/synchronization/activity/admin.py +72 -0
  56. wbcrm/synchronization/activity/backend.py +207 -0
  57. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  58. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  59. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
  60. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  61. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  62. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  63. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  64. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  65. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  66. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  67. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  68. wbcrm/synchronization/activity/backends/google/utils.py +216 -0
  69. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  70. wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
  71. wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
  72. wbcrm/synchronization/activity/backends/outlook/parser.py +423 -0
  73. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  74. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  75. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  76. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
  77. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
  78. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
  79. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
  80. wbcrm/synchronization/activity/controller.py +545 -0
  81. wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
  82. wbcrm/synchronization/activity/preferences.py +21 -0
  83. wbcrm/synchronization/activity/shortcuts.py +9 -0
  84. wbcrm/synchronization/activity/signals.py +28 -0
  85. wbcrm/synchronization/activity/tasks.py +21 -0
  86. wbcrm/synchronization/activity/urls.py +6 -0
  87. wbcrm/synchronization/activity/utils.py +46 -0
  88. wbcrm/synchronization/activity/views.py +37 -0
  89. wbcrm/synchronization/admin.py +1 -0
  90. wbcrm/synchronization/apps.py +15 -0
  91. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  92. wbcrm/synchronization/management.py +36 -0
  93. wbcrm/synchronization/tasks.py +1 -0
  94. wbcrm/synchronization/urls.py +5 -0
  95. wbcrm/tasks.py +312 -0
  96. wbcrm/tests/__init__.py +0 -0
  97. wbcrm/tests/accounts/__init__.py +0 -0
  98. wbcrm/tests/accounts/test_models.py +380 -0
  99. wbcrm/tests/accounts/test_viewsets.py +87 -0
  100. wbcrm/tests/conftest.py +76 -0
  101. wbcrm/tests/disable_signals.py +52 -0
  102. wbcrm/tests/e2e/__init__.py +1 -0
  103. wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
  104. wbcrm/tests/e2e/test_e2e.py +369 -0
  105. wbcrm/tests/test_assignee_methods.py +39 -0
  106. wbcrm/tests/test_chartviewsets.py +111 -0
  107. wbcrm/tests/test_dto.py +63 -0
  108. wbcrm/tests/test_filters.py +51 -0
  109. wbcrm/tests/test_models.py +216 -0
  110. wbcrm/tests/test_recurrence.py +291 -0
  111. wbcrm/tests/test_report.py +20 -0
  112. wbcrm/tests/test_serializers.py +170 -0
  113. wbcrm/tests/test_tasks.py +94 -0
  114. wbcrm/tests/test_viewsets.py +967 -0
  115. wbcrm/tests/tests.py +120 -0
  116. wbcrm/typings.py +107 -0
  117. wbcrm/urls.py +67 -0
  118. wbcrm/viewsets/__init__.py +22 -0
  119. wbcrm/viewsets/accounts.py +121 -0
  120. wbcrm/viewsets/activities.py +315 -0
  121. wbcrm/viewsets/buttons/__init__.py +7 -0
  122. wbcrm/viewsets/buttons/accounts.py +27 -0
  123. wbcrm/viewsets/buttons/activities.py +68 -0
  124. wbcrm/viewsets/buttons/signals.py +17 -0
  125. wbcrm/viewsets/display/__init__.py +12 -0
  126. wbcrm/viewsets/display/accounts.py +110 -0
  127. wbcrm/viewsets/display/activities.py +443 -0
  128. wbcrm/viewsets/display/groups.py +22 -0
  129. wbcrm/viewsets/display/products.py +105 -0
  130. wbcrm/viewsets/endpoints/__init__.py +8 -0
  131. wbcrm/viewsets/endpoints/accounts.py +32 -0
  132. wbcrm/viewsets/endpoints/activities.py +30 -0
  133. wbcrm/viewsets/endpoints/groups.py +7 -0
  134. wbcrm/viewsets/endpoints/products.py +9 -0
  135. wbcrm/viewsets/groups.py +37 -0
  136. wbcrm/viewsets/menu/__init__.py +8 -0
  137. wbcrm/viewsets/menu/accounts.py +18 -0
  138. wbcrm/viewsets/menu/activities.py +61 -0
  139. wbcrm/viewsets/menu/groups.py +16 -0
  140. wbcrm/viewsets/menu/products.py +20 -0
  141. wbcrm/viewsets/mixins.py +34 -0
  142. wbcrm/viewsets/previews/__init__.py +1 -0
  143. wbcrm/viewsets/previews/activities.py +10 -0
  144. wbcrm/viewsets/products.py +56 -0
  145. wbcrm/viewsets/recurrence.py +26 -0
  146. wbcrm/viewsets/titles/__init__.py +13 -0
  147. wbcrm/viewsets/titles/accounts.py +22 -0
  148. wbcrm/viewsets/titles/activities.py +61 -0
  149. wbcrm/viewsets/titles/products.py +13 -0
  150. wbcrm/viewsets/titles/utils.py +46 -0
  151. wbcrm/workflows/__init__.py +1 -0
  152. wbcrm/workflows/assignee_methods.py +25 -0
  153. wbcrm-2.2.1.dist-info/METADATA +11 -0
  154. wbcrm-2.2.1.dist-info/RECORD +155 -0
  155. wbcrm-2.2.1.dist-info/WHEEL +5 -0
@@ -0,0 +1,576 @@
1
+ import json
2
+ import re
3
+ import time
4
+ from datetime import date, timedelta
5
+ from typing import Any
6
+
7
+ from dateutil import parser
8
+ from dateutil.relativedelta import relativedelta
9
+ from django.contrib.auth import get_user_model
10
+ from django.http import HttpRequest, HttpResponse
11
+ from django.utils import timezone
12
+ from django.utils.translation import gettext, gettext_lazy
13
+ from dynamic_preferences.registries import global_preferences_registry
14
+ from psycopg.types.range import TimestamptzRange
15
+ from wbcrm.synchronization.activity.backend import SyncBackend
16
+ from wbcrm.synchronization.activity.utils import flattened_dict_into_nested_dict
17
+ from wbcrm.typings import Activity as ActivityDTO
18
+ from wbcrm.typings import ParticipantStatus as ParticipantStatusDTO
19
+ from wbcrm.typings import User as UserDTO
20
+
21
+ from .msgraph import MicrosoftGraphAPI
22
+ from .parser import OutlookParser, parse
23
+
24
+ User = get_user_model()
25
+
26
+
27
+ class OutlookSyncBackend(SyncBackend):
28
+ METADATA_KEY = "outlook"
29
+
30
+ def open(self):
31
+ self.msgraph = MicrosoftGraphAPI()
32
+
33
+ def close(self):
34
+ pass
35
+
36
+ def _validation_response(self, request: HttpRequest) -> HttpResponse:
37
+ # handle validation
38
+ if token := request.GET.get("validationToken"):
39
+ return HttpResponse(token, content_type="text/plain")
40
+ # handle callback consent permision
41
+ if _consent := request.GET.get("admin_consent"):
42
+ return HttpResponse(_consent, content_type="text/plain")
43
+ return super()._validation_response(request)
44
+
45
+ def _is_inbound_request_valid(self, request: HttpRequest) -> bool:
46
+ is_valid = False
47
+ try:
48
+ if request.body and (json_body := json.loads(request.body)) and (notifications := json_body.get("value")):
49
+ # When many changes occur, MSGraph may send multiple notifications that correspond to different subscriptions in the same POST request.
50
+ for notification in parse(notifications):
51
+ if (
52
+ notification.get("client_state")
53
+ == global_preferences_registry.manager()["wbactivity_sync__outlook_sync_client_state"]
54
+ and (resource_data := notification.get("resource_data"))
55
+ and (event_type := resource_data.get("@odata.type", "").lower())
56
+ and (event_type == "#microsoft.graph.event")
57
+ ):
58
+ is_valid = True
59
+ else:
60
+ return False
61
+ except json.decoder.JSONDecodeError:
62
+ return False
63
+ return is_valid
64
+
65
+ def _get_events_from_request(self, request: HttpRequest) -> list[dict[str, Any]]:
66
+ events = []
67
+ for notification in parse(json.loads(request.body)["value"]):
68
+ event = {
69
+ "change_type": notification["change_type"],
70
+ "resource": notification["resource"],
71
+ "subscription_id": notification["subscription_id"],
72
+ }
73
+ if data := self.msgraph.get_event_by_resource(notification["resource"]):
74
+ # Try to get the organizer's event in case if it's an invitation event
75
+ if data["is_organizer"] is True:
76
+ event["organizer_resource"] = notification["resource"]
77
+ elif (
78
+ (tenant_id := self.msgraph.get_tenant_id(data["organizer.email_address.address"]))
79
+ and data["is_organizer"] is False
80
+ and (organizer_data := self.msgraph.get_event_by_uid(tenant_id, data["uid"]))
81
+ ):
82
+ event["organizer_resource"] = f"Users/{tenant_id}/Events/{organizer_data['id']}"
83
+ data = organizer_data
84
+ event.update(data)
85
+ events.append(event)
86
+ return events
87
+
88
+ def _deserialize(self, event: dict[str, Any], include_metadata: bool = True) -> tuple[ActivityDTO, bool, UserDTO]:
89
+ is_deleted = False
90
+ if event.get("change_type") == "deleted" or not event.get("id") or event.get("is_cancelled"):
91
+ is_deleted = True
92
+ user_dto = None
93
+ if subscription_id := event.get("subscription_id"):
94
+ user_dto = UserDTO(metadata={self.METADATA_KEY: {"subscription": {"id": subscription_id}}})
95
+
96
+ if include_metadata:
97
+ _metadata = {"event_uid": event["uid"], "event_id": event["id"]} if event.get("id") else {}
98
+ if resource := event.get("resource"):
99
+ _metadata["resources"] = [resource]
100
+ if organizer_resource := event.get("organizer_resource"):
101
+ _metadata["organizer_resource"] = organizer_resource
102
+ elif is_deleted and resource:
103
+ _metadata["organizer_resource"] = resource
104
+ metadata = {self.METADATA_KEY: _metadata}
105
+ else:
106
+ metadata = {}
107
+
108
+ if event.get("id"):
109
+ start = OutlookParser.convert_string_to_datetime(event["start.date_time"], event["start.time_zone"])
110
+ end = OutlookParser.convert_string_to_datetime(event["end.date_time"], event["end.time_zone"])
111
+ if start == end:
112
+ end += timedelta(seconds=1)
113
+ period = TimestamptzRange(start, end)
114
+ if event["is_all_day"] is True:
115
+ period = OutlookParser.convert_to_all_day_period(period)
116
+ participants, conference_room = OutlookParser.deserialize_participants(event)
117
+ recurrence_dict = OutlookParser.deserialize_recurring_activities(event)
118
+ if event.get("is_reminder_on"):
119
+ reminder_choice = OutlookParser.convert_reminder_minutes_to_choice(
120
+ event.get("reminder_minutes_before_start")
121
+ )
122
+ else:
123
+ reminder_choice = ActivityDTO.ReminderChoice.NEVER.name
124
+ activity_dto = ActivityDTO(
125
+ metadata=metadata,
126
+ title=event["subject"] if event.get("subject") else "(No Subject)",
127
+ period=period,
128
+ description=event.get("body.content", ""),
129
+ participants=participants,
130
+ creator=OutlookParser.deserialize_person(
131
+ event["organizer.email_address.address"], event["organizer.email_address.name"]
132
+ ),
133
+ visibility=OutlookParser.convert_sensitivity_to_visibility(event.get("sensitivity")),
134
+ reminder_choice=reminder_choice,
135
+ is_cancelled=event.get("is_cancelled", False),
136
+ all_day=event.get("is_all_day", False),
137
+ online_meeting=event.get("is_online_meeting", False),
138
+ location=event.get("location.display_name"),
139
+ conference_room=conference_room[0] if conference_room else None,
140
+ recurrence_end=recurrence_dict["recurrence_end"],
141
+ recurrence_count=recurrence_dict["recurrence_count"],
142
+ repeat_choice=recurrence_dict["repeat_choice"],
143
+ )
144
+ if (
145
+ event["type"] == "seriesMaster"
146
+ and (tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email))
147
+ and (
148
+ occurrences := self.msgraph.get_instances_event(
149
+ tenant_id, event["id"], start.date(), end.date() + relativedelta(years=10)
150
+ )
151
+ )
152
+ ):
153
+ recurring_activities = []
154
+ for occurrence in occurrences:
155
+ instance_dto, *_ = self._deserialize(occurrence, include_metadata=False)
156
+ instance_dto.recurrence_end = activity_dto.recurrence_end
157
+ instance_dto.recurrence_count = activity_dto.recurrence_count
158
+ instance_dto.repeat_choice = activity_dto.repeat_choice
159
+ instance_dto.metadata = {
160
+ self.METADATA_KEY: {
161
+ **metadata[self.METADATA_KEY],
162
+ "occurrence_id": occurrence["id"],
163
+ "occurrence_resource": f"Users/{tenant_id}/Events/{occurrence['id']}",
164
+ }
165
+ }
166
+ if (extension := self.msgraph.get_extension_event(tenant_id, occurrence["id"])) and (
167
+ act_id := extension.get("activity_id")
168
+ ):
169
+ instance_dto.id = act_id
170
+ recurring_activities.append(instance_dto)
171
+ activity_dto.recurring_activities = recurring_activities
172
+ else:
173
+ activity_dto = ActivityDTO(metadata=metadata, title=event.get("subject", "(No Subject)"))
174
+ return activity_dto, is_deleted, user_dto
175
+
176
+ def _serialize(self, activity_dto: ActivityDTO, created: bool = False) -> dict[str, Any]:
177
+ attendees = OutlookParser.serialize_participants(activity_dto.participants)
178
+ if activity_dto.conference_room:
179
+ attendees.append(OutlookParser.serialize_conference_room(activity_dto.conference_room))
180
+
181
+ activity_dict = {
182
+ "subject": activity_dto.title,
183
+ "start": {
184
+ "dateTime": activity_dto.period.lower.isoformat(),
185
+ "timeZone": activity_dto.period.lower.tzname(),
186
+ },
187
+ "end": {
188
+ "dateTime": activity_dto.period.upper.isoformat(),
189
+ "timeZone": activity_dto.period.upper.tzname(),
190
+ },
191
+ "body": {
192
+ "contentType": "HTML",
193
+ "content": activity_dto.description,
194
+ },
195
+ "attendees": attendees,
196
+ "sensitivity": OutlookParser.convert_visibility_to_sensitivity(activity_dto.visibility),
197
+ "reminderMinutesBeforeStart": OutlookParser.convert_reminder_choice_to_minutes(
198
+ activity_dto.reminder_choice
199
+ ),
200
+ "isReminderOn": False if activity_dto.reminder_choice == ActivityDTO.ReminderChoice.NEVER.name else True,
201
+ "isAllDay": activity_dto.all_day,
202
+ "responseRequested": True,
203
+ }
204
+
205
+ if activity_dto.online_meeting:
206
+ activity_dict.update(
207
+ {"allowNewTimeProposals": True, "isOnlineMeeting": True, "onlineMeetingProvider": "teamsForBusiness"}
208
+ )
209
+ if activity_dto.location:
210
+ activity_dict["location"] = {
211
+ "displayName": activity_dto.location
212
+ if isinstance(activity_dto.location, str)
213
+ else str(activity_dto.location)
214
+ }
215
+ activity_dict["locations"] = [{"displayName": activity_dto.location}] if activity_dto.location else []
216
+ if created and activity_dto.is_recurrent:
217
+ activity_dict.update(OutlookParser.serialize_recurring_activities(activity_dto))
218
+ return activity_dict
219
+
220
+ def _stream_deletion(self, activity_dto: ActivityDTO):
221
+ if event := self.get_external_event(activity_dto):
222
+ if tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email):
223
+ self.msgraph.delete_event(tenant_id, event["id"])
224
+
225
+ def _stream_creation(
226
+ self, activity_dto: ActivityDTO, activity_dict: dict[str, Any]
227
+ ) -> tuple[ActivityDTO, dict[str, Any]]:
228
+ if not self.get_external_event(activity_dto) and (
229
+ tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email)
230
+ ):
231
+ if event := self.msgraph.create_event(tenant_id, activity_dict):
232
+ metadata_list = self._get_metadata_from_event(activity_dto, event)
233
+ for act_dto, _metadata in metadata_list:
234
+ if act_dto.is_recurrent:
235
+ self._stream_extension_event(act_dto, _metadata)
236
+ return metadata_list
237
+
238
+ def _stream_update(
239
+ self,
240
+ activity_dto: ActivityDTO,
241
+ activity_dict: dict[str, Any],
242
+ only_participants_changed: bool = False,
243
+ external_participants: list = [],
244
+ keep_external_description: bool = False,
245
+ ) -> tuple[ActivityDTO, dict[str, Any]]:
246
+ if old_event := self.get_external_event(activity_dto):
247
+ if tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email):
248
+ # When a participant is removed, we send a complete update of the event to remove this person in outlook
249
+ activity_dict["attendees"] += external_participants
250
+ if only_participants_changed:
251
+ # An event update that includes only the attendees property in the request body sends a meeting update to only the attendees that have changed.
252
+ # An event update that removes an attendee specified as a member of a distribution list sends a meeting update to all the attendees.
253
+ activity_dict = {"attendees": activity_dict["attendees"]}
254
+ elif keep_external_description:
255
+ # preserve body when description is not synchronized especially meeting blob for online meeting.
256
+ activity_dict["body"]["content"] = old_event.get("body.content", "")
257
+
258
+ if (
259
+ activity_dto.is_recurrent
260
+ and activity_dto.propagate_for_all_children
261
+ and not activity_dto.is_leaf
262
+ and (old_master_event := self.get_external_event(activity_dto, master_event=True))
263
+ ):
264
+ if activity_dto.is_root:
265
+ self.msgraph.update_event(tenant_id, old_master_event["id"], activity_dict)
266
+ else:
267
+ return self._stream_update_from_occurrence(activity_dto, old_master_event, activity_dict)
268
+ else:
269
+ self.msgraph.update_event(tenant_id, old_event["id"], activity_dict)
270
+ elif not activity_dto.metadata.get(self.METADATA_KEY):
271
+ # creation of recurring activity is done only during the creation phase of the activity
272
+ if activity_dto.is_recurrent and activity_dict.get("recurrence"):
273
+ activity_dict.pop("recurrence")
274
+ return self._stream_creation(activity_dto, activity_dict)
275
+
276
+ def _stream_update_from_occurrence(self, activity_dto: ActivityDTO, old_master_event: dict, activity_dict: dict):
277
+ if (tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email)) and (
278
+ instances_dict := self._get_occurrences_from_master_event(
279
+ old_master_event, start_date_as_key=True, from_date=activity_dto.period.lower.date()
280
+ )
281
+ ):
282
+ # Update recurrence pattern from old root to new root
283
+ remaining_old_recurrence = {
284
+ "recurrence": flattened_dict_into_nested_dict(OutlookParser.serialize_event_keys(old_master_event))[
285
+ "recurrence"
286
+ ]
287
+ }
288
+ last_start_old = activity_dto.period.lower - timedelta(days=1)
289
+ if remaining_old_recurrence := OutlookParser.serialize_recurrence_range_with_end_date(
290
+ remaining_old_recurrence, last_start_old
291
+ ):
292
+ self.msgraph.update_event(tenant_id, old_master_event["id"], remaining_old_recurrence)
293
+ # Create recurrence from new root
294
+ new_recurrence = OutlookParser.serialize_recurring_activities(activity_dto)
295
+ last_start_new = sorted(instances_dict.keys()).pop()
296
+ if new_recurrence := OutlookParser.serialize_recurrence_range_with_end_date(
297
+ new_recurrence, last_start_new
298
+ ):
299
+ activity_dict.update(new_recurrence)
300
+ if new_master_event := self.msgraph.create_event(tenant_id, activity_dict):
301
+ # delete occurrences that had been deleted in the past
302
+ if new_instances_dict := self._get_occurrences_from_master_event(
303
+ new_master_event, start_date_as_key=True
304
+ ):
305
+ for key, value in new_instances_dict.items():
306
+ if not instances_dict.get(key):
307
+ self.msgraph.delete_event(tenant_id, value["id"])
308
+ metadata_list = self._get_metadata_from_event(activity_dto, new_master_event)
309
+ for act_dto, _metadata in metadata_list:
310
+ if act_dto.is_recurrent:
311
+ self._stream_extension_event(act_dto, _metadata)
312
+ return metadata_list
313
+
314
+ def _stream_forward(self, activity_dto: ActivityDTO, participants_dto: list[ParticipantStatusDTO]):
315
+ if (event := self.get_external_event(activity_dto)) and (
316
+ tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email)
317
+ ):
318
+ new_attendees = []
319
+ participants, _ = OutlookParser.deserialize_participants(event)
320
+ external_emails_participants = [participant.person.email for participant in participants]
321
+ for participant_dto in participants_dto:
322
+ # forward if it is not the organizer or if it is not already part of the participants
323
+ if (
324
+ participant_dto.person.email != activity_dto.creator.email
325
+ and participant_dto.person.email not in external_emails_participants
326
+ ):
327
+ new_attendees.append(OutlookParser.serialize_person(participant_dto.person))
328
+ self.msgraph.forward_event(tenant_id, event["id"], new_attendees)
329
+
330
+ def _stream_participant_change(
331
+ self, participant_dto: ParticipantStatusDTO, is_deleted: bool = False, wait_before_changing: bool = False
332
+ ):
333
+ if (organizer_event := self.get_external_event(participant_dto.activity)) and (
334
+ tenant_id := self.msgraph.get_tenant_id(participant_dto.person.email)
335
+ ):
336
+ if wait_before_changing:
337
+ time.sleep(10)
338
+ if invitation_event := self.msgraph.get_event_by_uid(tenant_id, organizer_event["uid"]):
339
+ if participant_dto.person.email == participant_dto.activity.creator.email:
340
+ if is_deleted or participant_dto.status == ParticipantStatusDTO.ParticipationStatus.CANCELLED.name:
341
+ pass # self.msgraph.cancel_event(tenant_id, invitation_event["id"]) # delete activity if organizer is removed of participants
342
+ else:
343
+ if is_deleted or participant_dto.status == ParticipantStatusDTO.ParticipationStatus.CANCELLED.name:
344
+ self.msgraph.decline_event(tenant_id, invitation_event["id"])
345
+ elif participant_dto.status == ParticipantStatusDTO.ParticipationStatus.MAYBE.name:
346
+ self.msgraph.tentatively_accept_event(tenant_id, invitation_event["id"])
347
+ elif participant_dto.status in [
348
+ ParticipantStatusDTO.ParticipationStatus.ATTENDS.name,
349
+ ParticipantStatusDTO.ParticipationStatus.ATTENDS_DIGITALLY.name,
350
+ ]:
351
+ self.msgraph.accept_event(tenant_id, invitation_event["id"])
352
+
353
+ def _stream_extension_event(self, activity_dto: ActivityDTO, metadata: dict | None = None) -> None:
354
+ metadata = metadata if metadata else activity_dto.metadata
355
+ if (
356
+ (tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email))
357
+ and (_metadata := metadata.get(self.METADATA_KEY))
358
+ and (occurrence_id := _metadata.get("occurrence_id"))
359
+ ):
360
+ self.msgraph.update_or_create_extension_event(tenant_id, occurrence_id, {"activity_id": activity_dto.id})
361
+
362
+ def _set_web_hook(self, user: "User") -> dict[str, dict[str, Any]]:
363
+ response = {}
364
+ self.open()
365
+ if (
366
+ (outlook := user.metadata.get(self.METADATA_KEY))
367
+ and (subscription := outlook.get("subscription"))
368
+ and (ms_subscription := self.msgraph.subscription(subscription.get("id")))
369
+ ):
370
+ response = self.msgraph._renew_subscription(ms_subscription.get("id"))
371
+ elif tenant_id := self.msgraph.get_tenant_id(user.email):
372
+ response = self.msgraph._subscribe(f"users/{tenant_id}/events/", "created, updated, deleted")
373
+ else:
374
+ raise Exception(gettext("Outlook TenantId not found for: ") + str(user))
375
+ self.close()
376
+ return {"subscription": response} if response else user.metadata.get(self.METADATA_KEY, {})
377
+
378
+ def _stop_web_hook(self, user: "User") -> dict[str, dict[str, Any]]:
379
+ if (
380
+ (outlook_backend := user.metadata.get(self.METADATA_KEY))
381
+ and (subscription := outlook_backend.get("subscription"))
382
+ and (subscription_id := subscription.get("id"))
383
+ ):
384
+ self.open()
385
+ self.msgraph._unsubscribe(subscription_id)
386
+ self.close()
387
+ del user.metadata[self.METADATA_KEY]["subscription"]
388
+ else:
389
+ raise ValueError(str(user) + gettext(" has no active webhook"))
390
+ return user.metadata.get(self.METADATA_KEY)
391
+
392
+ def check_web_hook(self, user: "User", raise_error: bool = True) -> bool:
393
+ error = ""
394
+ self.open()
395
+ if (outlook := user.metadata.get(self.METADATA_KEY)) and (subscription := outlook.get("subscription")):
396
+ if not self.msgraph.subscription(subscription.get("id")):
397
+ error = gettext("Webhook is invalid, Remove or Stop it and Set again please. ")
398
+ else:
399
+ error = gettext("Webhook not found. ")
400
+ if error and raise_error:
401
+ if user_tenant_id := self.msgraph.get_tenant_id(user.email):
402
+ tenant_ids = [
403
+ items[1]
404
+ for sub in self.msgraph.subscriptions()
405
+ if (resource := sub.get("resource")) and (items := resource.split("/")) and len(items) > 2
406
+ ]
407
+ error += gettext(
408
+ "Number of subscriptions found in outlook for {} out of the total number: {}/{}."
409
+ ).format(user.email, tenant_ids.count(user_tenant_id), len(tenant_ids))
410
+ else:
411
+ error += gettext("TenantId not found for ") + str(user)
412
+ raise Exception(error)
413
+ self.close()
414
+ return False if error else True
415
+
416
+ def renew_web_hooks(self) -> None:
417
+ self.open()
418
+ lookup = {f"metadata__{self.METADATA_KEY}__subscription__id__isnull": False}
419
+ for user in User.objects.filter(**lookup):
420
+ if response := self.msgraph._renew_subscription(user.metadata[self.METADATA_KEY]["subscription"]["id"]):
421
+ user.metadata[self.METADATA_KEY]["subscription"] = response
422
+ user.save()
423
+ self.close()
424
+
425
+ def _get_webhook_inconsistencies(self) -> str:
426
+ self.open()
427
+ subscriptions = self.msgraph.subscriptions()
428
+ calls = set(
429
+ list(
430
+ map(
431
+ lambda x: x["id"],
432
+ list(filter(lambda x: x["resource"] == "/communications/callRecords", subscriptions)),
433
+ )
434
+ )
435
+ )
436
+ calendars = set(
437
+ list(
438
+ map(
439
+ lambda x: x["id"],
440
+ list(filter(lambda x: bool(re.match(r"users\/.*\/events\/", x["resource"])), subscriptions)),
441
+ )
442
+ )
443
+ )
444
+ lookup = f"metadata__{self.METADATA_KEY}__subscription__id"
445
+ stored_calendars = set(User.objects.filter(**{f"{lookup}__isnull": False}).values_list(lookup, flat=True))
446
+ message = ""
447
+ if len(calls) == 0:
448
+ message += gettext_lazy("<li>No Call Record subscription found in Microsoft</li>")
449
+ if calendars != stored_calendars:
450
+ diff = calendars.difference(stored_calendars)
451
+ diff_inv = stored_calendars.difference(calendars)
452
+ message += gettext_lazy(
453
+ """
454
+ <li>Number of calendar subscription not found in our system : <b>{}</b></li>
455
+ <p>{}</p>
456
+
457
+ <li>Number of calendar subscription assumed to be active not found in outlook: <b>{}</b></li>
458
+ <p>{}</p>
459
+ """
460
+ ).format(len(diff), diff, len(diff_inv), diff_inv)
461
+ if message == "":
462
+ for subscription in subscriptions:
463
+ if expiration_date_time := subscription.get("expiration_date_time"):
464
+ date_time = parser.parse(expiration_date_time)
465
+ diff = date_time - timezone.now()
466
+ if diff.days < 1:
467
+ message += f"Resource: {subscription['resource']} expires on {date_time} (in {diff})"
468
+ self.close()
469
+ return message
470
+
471
+ def get_external_event(self, activity_dto: ActivityDTO, master_event: bool = False) -> dict:
472
+ event = None
473
+ metadata = activity_dto.metadata.get(self.METADATA_KEY, {})
474
+ if master_event or not activity_dto.is_recurrent:
475
+ if resource := metadata.get("organizer_resource"):
476
+ event = self.msgraph.get_event_by_resource(resource)
477
+ if (creator := activity_dto.creator) and (tenant_id := self.msgraph.get_tenant_id(creator.email)):
478
+ if not event and (event_id := metadata.get("event_id")):
479
+ event = self.msgraph.get_event(tenant_id, event_id)
480
+ if not event and (event_uid := metadata.get("event_uid")):
481
+ event = self.msgraph.get_event_by_uid(tenant_id, event_uid)
482
+ else:
483
+ if resource := metadata.get("occurrence_resource"):
484
+ event = self.msgraph.get_event_by_resource(resource)
485
+ elif (
486
+ not event
487
+ and (creator := activity_dto.creator)
488
+ and (tenant_id := self.msgraph.get_tenant_id(creator.email))
489
+ and metadata.get("occurrence_id")
490
+ ):
491
+ event = self.msgraph.get_event(tenant_id, metadata["occurrence_id"])
492
+ return event
493
+
494
+ def get_external_participants(
495
+ self, activity_dto: ActivityDTO, internal_participants_dto: list[ParticipantStatusDTO]
496
+ ) -> list:
497
+ external_participants = []
498
+ if event := self.get_external_event(activity_dto):
499
+ internal_emails = [
500
+ participant_dto.person.email for participant_dto in internal_participants_dto if participant_dto.person
501
+ ]
502
+ for participant in event.get("attendees", []):
503
+ if participant["emailAddress"]["address"] not in internal_emails:
504
+ external_participants.append(participant)
505
+ return external_participants
506
+
507
+ def _is_participant_valid(self, user: "User") -> bool:
508
+ try:
509
+ return super()._is_participant_valid(user) and self.check_web_hook(user, raise_error=False)
510
+ except Exception:
511
+ return False
512
+
513
+ def _generate_event_metadata(self, tenant_id: str, master_event: dict, occurrence_event: dict = {}):
514
+ resource = f"Users/{tenant_id}/Events/{master_event['id']}"
515
+ metadata = {
516
+ self.METADATA_KEY: {
517
+ "resources": [resource],
518
+ "organizer_resource": resource,
519
+ "event_uid": master_event["uid"],
520
+ "event_id": master_event["id"],
521
+ }
522
+ }
523
+ if occurrence_event:
524
+ metadata[self.METADATA_KEY].update(
525
+ {
526
+ "occurrence_id": occurrence_event["id"],
527
+ "occurrence_resource": f"Users/{tenant_id}/Events/{occurrence_event['id']}",
528
+ }
529
+ )
530
+ return metadata
531
+
532
+ def _get_metadata_from_event(self, activity_dto: ActivityDTO, event: dict) -> tuple[ActivityDTO, dict[str, Any]]:
533
+ metadata_list = []
534
+ if tenant_id := self.msgraph.get_tenant_id(activity_dto.creator.email):
535
+ if event.get("type") == "seriesMaster" and (
536
+ instances_dict := self._get_occurrences_from_master_event(event, start_date_as_key=True)
537
+ ):
538
+ occurrence = instances_dict.get(activity_dto.period.lower.date(), {})
539
+ metadata_list.append((activity_dto, self._generate_event_metadata(tenant_id, event, occurrence)))
540
+ instances_dto = activity_dto.recurring_activities + activity_dto.invalid_recurring_activities
541
+ for instance_dto in instances_dto:
542
+ if occurrence := instances_dict.get(instance_dto.period.lower.date()):
543
+ metadata_list.append(
544
+ (instance_dto, self._generate_event_metadata(tenant_id, event, occurrence))
545
+ )
546
+ else:
547
+ metadata_list.append((activity_dto, self._generate_event_metadata(tenant_id, event)))
548
+ return metadata_list
549
+
550
+ def _get_occurrences_from_master_event(
551
+ self, event: dict, start_date_as_key: bool = False, from_date: date = None
552
+ ) -> dict:
553
+ """
554
+ Dict of instances event whose key is id by default otherwise start date and the value is the occurrence event data.
555
+ """
556
+ start = (
557
+ from_date
558
+ if from_date
559
+ else OutlookParser.convert_string_to_datetime(event["start.date_time"], event["start.time_zone"]).date()
560
+ )
561
+ end = OutlookParser.convert_string_to_datetime(
562
+ event["end.date_time"], event["end.time_zone"]
563
+ ).date() + relativedelta(years=10)
564
+ tenant_id = self.msgraph.get_tenant_id(event["organizer.email_address.address"])
565
+ occurrences_dict = {}
566
+ if occurrences := self.msgraph.get_instances_event(tenant_id, event["id"], start, end):
567
+ if start_date_as_key:
568
+ occurrences_dict = {
569
+ OutlookParser.convert_string_to_datetime(
570
+ occurrence["start.date_time"], occurrence["start.time_zone"]
571
+ ).date(): occurrence
572
+ for occurrence in occurrences
573
+ }
574
+ else:
575
+ occurrences_dict = {occurrence["id"]: occurrence for occurrence in occurrences}
576
+ return occurrences_dict