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