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