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,545 @@
1
+ import copy
2
+ import dataclasses
3
+ import operator
4
+ from functools import reduce
5
+ from typing import Any
6
+
7
+ from django.conf import settings
8
+ from django.db import transaction
9
+ from django.db.models import Q, QuerySet
10
+ from django.http import HttpRequest, HttpResponse
11
+ from django.utils import timezone
12
+ from wbcore.contrib.agenda.models import Building, ConferenceRoom
13
+ from wbcore.contrib.directory.models import EmailContact, Person
14
+ from wbcrm.models import Activity, ActivityParticipant, ActivityType
15
+ from wbcrm.synchronization.activity.utils import flattened_metadata_lookup
16
+ from wbcrm.typings import Activity as ActivityDTO
17
+ from wbcrm.typings import ConferenceRoom as ConferenceRoomDTO
18
+ from wbcrm.typings import ParticipantStatus as ParticipantStatusDTO
19
+ from wbcrm.typings import Person as PersonDTO
20
+ from wbcrm.typings import User as UserDTO
21
+
22
+ from .preferences import (
23
+ can_sync_cancelled_activity,
24
+ can_sync_cancelled_external_activity,
25
+ can_sync_past_activity,
26
+ can_synchronize_activity_description,
27
+ can_synchronize_external_participants,
28
+ )
29
+ from .shortcuts import get_backend
30
+
31
+
32
+ class ActivityController:
33
+ update_fields = [
34
+ "title",
35
+ "period",
36
+ "visibility",
37
+ "creator",
38
+ "conference_room",
39
+ "reminder_choice",
40
+ "is_cancelled",
41
+ "all_day",
42
+ "online_meeting",
43
+ "location",
44
+ "description",
45
+ ]
46
+
47
+ def __init__(self):
48
+ if _backend := get_backend():
49
+ self.backend = _backend()
50
+ else:
51
+ self.backend = None
52
+
53
+ def _is_valid(self, activity_dto: ActivityDTO) -> bool:
54
+ """
55
+ Check if the activity can be synchronized or not.
56
+ - Past activity is not synchronized if global preference is False
57
+ - Activity is synchronized if at least one of the participants has an active webhook
58
+ """
59
+ if period := activity_dto.period:
60
+ if period.upper < timezone.now() and not can_sync_past_activity():
61
+ return False
62
+ else:
63
+ return self.backend.is_valid(activity_dto)
64
+ else:
65
+ qs_activities = self.get_activities(activity_dto)
66
+ with transaction.atomic():
67
+ return qs_activities.exists() and any(
68
+ [self._is_valid(act._build_dto()) for act in qs_activities if act.period]
69
+ )
70
+
71
+ def handle_inbound_validation_response(self, request: HttpRequest) -> HttpResponse:
72
+ """
73
+ allows to send a response to the external calendar if it is required before receiving the events in the webhook
74
+ """
75
+ if self.backend:
76
+ return self.backend._validation_response(request)
77
+
78
+ def get_events_from_inbound_request(self, request: HttpRequest) -> list[dict[str, Any]]:
79
+ """
80
+ allows to get list of event following the notification
81
+ """
82
+ events = []
83
+ if self.backend:
84
+ self.backend.open()
85
+ if self.backend._is_inbound_request_valid(request):
86
+ for event in self.backend._get_events_from_request(request):
87
+ events.append(event)
88
+ self.backend.close()
89
+ return events
90
+
91
+ def handle_inbound(self, event: dict) -> None:
92
+ """
93
+ Each event received in the webhook is processed here, it allows to delete or create/update the corresponding activity
94
+ """
95
+ if not settings.DEBUG and self.backend:
96
+ self.backend.open()
97
+ activity_dto, is_deleted, user_dto = self.backend._deserialize(event)
98
+ if activity_dto.is_recurrent or self._is_valid(activity_dto):
99
+ if is_deleted:
100
+ self.delete_activity(activity_dto, user_dto)
101
+ else:
102
+ self.update_or_create_activity(activity_dto)
103
+ self.backend.close()
104
+
105
+ def _handle_outbound_data_preferences(self, activity_dto: ActivityDTO) -> tuple[ActivityDTO, list]:
106
+ """
107
+ Activity data is parsed to take into account the company's preferences
108
+ this allows you to exclude the description or exclude external participants from the data if global preference is True
109
+ """
110
+ if not can_synchronize_activity_description():
111
+ activity_dto.description = ""
112
+ valid_participants = []
113
+ internal_participants = []
114
+ for participant_dto in activity_dto.participants:
115
+ # External person are removed of the list if global preference is True
116
+ if Person.objects.get(id=participant_dto.person.id).is_internal or can_synchronize_external_participants():
117
+ internal_participants.append(participant_dto)
118
+ if participant_dto.status != ActivityParticipant.ParticipationStatus.CANCELLED:
119
+ valid_participants.append(participant_dto)
120
+
121
+ activity_dto.participants = valid_participants
122
+
123
+ # list of external participants present in the external event, they will be added to the participants before updating the event to avoid deleting them
124
+ external_participants = (
125
+ self.backend.get_external_participants(activity_dto, internal_participants)
126
+ if not can_synchronize_external_participants()
127
+ else []
128
+ )
129
+ return activity_dto, external_participants
130
+
131
+ def handle_outbound(
132
+ self, activity_dto: ActivityDTO, old_activity_dto: ActivityDTO = None, is_deleted: bool = False
133
+ ):
134
+ """
135
+ Requests sent to the external calendar are processed here
136
+ It allows to send requests to delete, modify or create the event in the external calendar corresponding to the activity
137
+ """
138
+ if not settings.DEBUG and self.backend and self._is_valid(activity_dto):
139
+ self.backend.open()
140
+ if is_deleted or activity_dto.is_cancelled:
141
+ self.backend._stream_deletion(activity_dto)
142
+ else:
143
+ activities_metadata = []
144
+ created = True if not old_activity_dto else False
145
+ # dataclasses.replace returns a new copy of the object without passing in any changes, return a copy with no modification
146
+ activity_dto_preference, external_participants = self._handle_outbound_data_preferences(
147
+ dataclasses.replace(activity_dto)
148
+ )
149
+ activity_dict = self.backend._serialize(activity_dto_preference, created=created)
150
+ if created: # then it's a creation
151
+ activities_metadata = self.backend._stream_creation(activity_dto, activity_dict)
152
+ elif self._has_changed(activity_dto, old_activity_dto):
153
+ keep_external_description = not can_synchronize_activity_description()
154
+ only_participants_changed = self._has_changed(
155
+ activity_dto, old_activity_dto, update_fields=["participants"]
156
+ ) and not self._has_changed(activity_dto, old_activity_dto, exclude_fields=["participants"])
157
+ activities_metadata = self.backend._stream_update(
158
+ activity_dto,
159
+ activity_dict,
160
+ only_participants_changed,
161
+ external_participants,
162
+ keep_external_description,
163
+ )
164
+
165
+ if activities_metadata:
166
+ for act_dto, act_metadata in activities_metadata:
167
+ self.update_activity_metadata(act_dto, act_metadata)
168
+ self.backend.close()
169
+
170
+ def handle_outbound_participant(
171
+ self,
172
+ participant_dto: ParticipantStatusDTO,
173
+ old_participant_dto: ParticipantStatusDTO = None,
174
+ is_deleted: bool = False,
175
+ ):
176
+ """
177
+ allows to update the status of the event in the external calendar to match the one updated in the internal activity
178
+ """
179
+ if (
180
+ not settings.DEBUG
181
+ and self.backend
182
+ and self._is_valid(participant_dto.activity)
183
+ and (
184
+ Person.objects.get(id=participant_dto.person.id).is_internal or can_synchronize_external_participants()
185
+ )
186
+ ):
187
+ self.backend.open()
188
+ was_cancelled = (
189
+ True
190
+ if old_participant_dto
191
+ and old_participant_dto.status
192
+ == ActivityParticipant.ParticipationStatus.CANCELLED
193
+ != participant_dto.status
194
+ else False
195
+ )
196
+ status_changed = (
197
+ True
198
+ if old_participant_dto
199
+ and old_participant_dto.status != participant_dto.status
200
+ and (participant_dto.status != ActivityParticipant.ParticipationStatus.NOTRESPONDED)
201
+ else False
202
+ )
203
+ wait_before_changing_status = False
204
+ if not is_deleted and not old_participant_dto or was_cancelled:
205
+ self.backend._stream_forward(participant_dto.activity, [participant_dto])
206
+ if status_changed:
207
+ wait_before_changing_status = True
208
+ if is_deleted or status_changed:
209
+ self.backend._stream_participant_change(
210
+ participant_dto, is_deleted, wait_before_changing=wait_before_changing_status
211
+ )
212
+ self.backend.close()
213
+
214
+ def _changed_participants(
215
+ self, participants: list[ParticipantStatusDTO], old_participants: list[ParticipantStatusDTO]
216
+ ) -> bool:
217
+ """
218
+ Comparison of 2 lists of participants, returns false if they are identical
219
+ """
220
+ d1 = {elt.person.email: elt.status for elt in participants}
221
+ d2 = {elt.person.email: elt.status for elt in old_participants}
222
+ if set(d1.keys()) == set(d2.keys()):
223
+ return any([d1[key] != d2[key] for key in d1.keys()])
224
+ return True
225
+
226
+ def _has_changed(
227
+ self,
228
+ activity_dto: ActivityDTO,
229
+ old_activity_dto: ActivityDTO,
230
+ update_fields: list = [],
231
+ exclude_fields: list = [],
232
+ ) -> bool:
233
+ """
234
+ Comparison of 2 activities, returns false if they are identical
235
+
236
+ :param update_fields: allows to specify the list of fields taken into account in the comparison,
237
+ if not specified we use the list of the controller
238
+
239
+ :param exclude_fields: allows you to exclude fields from the comparison list
240
+ """
241
+ if not can_synchronize_activity_description():
242
+ exclude_fields.append("description")
243
+ update_fields = (
244
+ update_fields
245
+ if update_fields
246
+ else self.update_fields + ["propagate_for_all_children", "exclude_from_propagation"]
247
+ if self.update_fields
248
+ else activity_dto.__dataclass_fields__
249
+ )
250
+ fields = list(set(update_fields) - set(exclude_fields))
251
+ if "participants" in fields:
252
+ fields.remove("participants")
253
+ participants_changed = self._changed_participants(activity_dto.participants, old_activity_dto.participants)
254
+ else:
255
+ participants_changed = False
256
+ is_new_activity = True if activity_dto and not old_activity_dto else False
257
+ return (
258
+ participants_changed
259
+ or is_new_activity
260
+ or (
261
+ activity_dto
262
+ and old_activity_dto
263
+ and any(
264
+ [getattr(activity_dto, field, None) != getattr(old_activity_dto, field, None) for field in fields]
265
+ )
266
+ )
267
+ )
268
+
269
+ def get_activities(self, activity_dto: ActivityDTO, _operator: operator = operator.or_) -> QuerySet["Activity"]:
270
+ """
271
+ Received events are deserialized into data transfer object of activity,
272
+ we use the metadata construct to identify the activity
273
+ the operator allows to know which operation combination to perform during the filter
274
+ """
275
+ if conditions := [
276
+ Q(**{key: value}) for key, value in flattened_metadata_lookup(activity_dto.metadata, key_string="metadata")
277
+ ]:
278
+ return Activity.all_objects.select_for_update().filter(reduce(_operator, conditions))
279
+ return Activity.objects.none()
280
+
281
+ def get_activity_participant(self, user_dto: UserDTO) -> Person:
282
+ """
283
+ Attendees of the external event are deserialized into person data transfer objects
284
+ we use the metadata construct to identify the person
285
+ """
286
+ if conditions := [
287
+ Q(**{key: value})
288
+ for key, value in flattened_metadata_lookup(user_dto.metadata, key_string="user_account__metadata")
289
+ ]:
290
+ try:
291
+ return Person.objects.get(reduce(operator.and_, conditions))
292
+ except Exception:
293
+ return None
294
+ return None
295
+
296
+ def _get_data_from_activity_dto(self, activity_dto: ActivityDTO, parent_occurrence: Activity = None) -> dict:
297
+ """
298
+ Data transfer object of activity obtained from the external event is parsed into a dict to allow the creation or update of the object in the database
299
+ """
300
+ activity_data = {}
301
+ fields = self.update_fields if self.update_fields else activity_dto.__dataclass_fields__
302
+ if not can_synchronize_activity_description() and "description" in fields:
303
+ fields.remove("description")
304
+
305
+ for field in fields:
306
+ activity_data[field] = getattr(activity_dto, field)
307
+ if activity_data.get("creator"):
308
+ activity_data["creator"] = self.get_or_create_person(activity_dto.creator)
309
+ if activity_data.get("conference_room"):
310
+ activity_data["conference_room"] = self.get_or_create_conference_room(activity_dto.conference_room)
311
+ if activity_dto.is_recurrent:
312
+ activity_data.update(
313
+ {
314
+ "recurrence_end": activity_dto.recurrence_end,
315
+ "recurrence_count": activity_dto.recurrence_count,
316
+ "repeat_choice": activity_dto.repeat_choice,
317
+ }
318
+ )
319
+ if parent_occurrence:
320
+ activity_data["parent_occurrence"] = parent_occurrence
321
+ return activity_data
322
+
323
+ def _create_activity(self, activity_dto: ActivityDTO, parent_occurrence: Activity = None) -> Activity:
324
+ """
325
+ Uses the Data transfer object obtained from the external event to create the activity in the database
326
+ """
327
+ activity_data = self._get_data_from_activity_dto(activity_dto, parent_occurrence)
328
+ activity_type, created = ActivityType.objects.get_or_create(
329
+ slugify_title="meeting", defaults={"title": "Meeting"}
330
+ )
331
+ activity = Activity(**activity_data, type=activity_type)
332
+ activity.save(synchronize=False)
333
+ if activity.period.lower < activity.created:
334
+ # A created past event should not appear at the top of the list.
335
+ Activity.objects.filter(id=activity.id).update(created=activity.period.lower, edited=activity.period.lower)
336
+ self.update_or_create_participants(activity, activity_dto.participants)
337
+ self.update_activity_metadata(activity._build_dto(), activity_dto.metadata)
338
+ return activity
339
+
340
+ def _update_activity(
341
+ self, activity: Activity, activity_dto: ActivityDTO, parent_occurrence: Activity = None
342
+ ) -> Activity:
343
+ """
344
+ Convert the data transfer object obtained from the external event into a dict to update the activity in the database.
345
+ """
346
+ activity_data = self._get_data_from_activity_dto(activity_dto, parent_occurrence)
347
+ if self._has_changed(activity_dto, activity._build_dto(), update_fields=activity_data.keys()):
348
+ # When previously canceled by the only internal participant is finally accepted by the latter
349
+ if (
350
+ (creator := activity.creator)
351
+ and not creator.is_internal
352
+ and activity.status == Activity.Status.CANCELLED
353
+ ):
354
+ activity_data["status"] = Activity.Status.PLANNED
355
+ Activity.objects.filter(id=activity.id).update(**activity_data)
356
+ activity.refresh_from_db()
357
+ activity.save(synchronize=False)
358
+ self.update_or_create_participants(activity, activity_dto.participants)
359
+ self.update_activity_metadata(activity._build_dto(), activity_dto.metadata)
360
+ return activity
361
+
362
+ @transaction.atomic
363
+ def update_or_create_activity(self, activity_dto: ActivityDTO) -> None:
364
+ """
365
+ allows you to create or update a single or recurring activity from a data transfer object obtained from an external event
366
+ """
367
+ qs_activities = self.get_activities(activity_dto)
368
+ if activity_dto.is_recurrent:
369
+ ids_dto_dict = {}
370
+ dates_dto_dict = {}
371
+ for _dto in activity_dto.recurring_activities:
372
+ if _dto.id:
373
+ ids_dto_dict[_dto.id] = _dto
374
+ dates_dto_dict[_dto.period.lower.date()] = _dto
375
+ ids_dto_list = set(ids_dto_dict.keys())
376
+ dates_dto_list = set(dates_dto_dict.keys())
377
+
378
+ for act in qs_activities.exclude(Q(id__in=ids_dto_list) | Q(period__startswith__date__in=dates_dto_list)):
379
+ act.delete(synchronize=False)
380
+ for act in qs_activities.filter(Q(id__in=ids_dto_list) | Q(period__startswith__date__in=dates_dto_list)):
381
+ act_dto = (
382
+ _act_dto if (_act_dto := ids_dto_dict.get(act.id)) else dates_dto_dict[act.period.lower.date()]
383
+ )
384
+ activity_updated = self._update_activity(act, act_dto)
385
+ if not act_dto.id:
386
+ self.backend._stream_extension_event(activity_updated._build_dto())
387
+
388
+ dates_act_dict = {act.period.lower.date(): act for act in qs_activities}
389
+ if dates_to_create := dates_dto_list.difference(set(dates_act_dict.keys())):
390
+ parent_start = sorted(dates_dto_list)[0]
391
+ if dates_act_dict.get(parent_start):
392
+ parent_act = dates_act_dict[parent_start]
393
+ else:
394
+ parent_dto = dates_dto_dict[parent_start]
395
+ if parent_dto.id and (instance := Activity.all_objects.filter(id=parent_dto.id).first()):
396
+ Activity.all_objects.filter(id=parent_dto.id).update(parent_occurrence=None, is_active=True)
397
+ parent_act = self._update_activity(instance, parent_dto)
398
+ else:
399
+ if parent_act := self._create_activity(parent_dto):
400
+ qs_activities.exclude(id=parent_act.id).update(parent_occurrence=parent_act)
401
+ self.backend._stream_extension_event(parent_act._build_dto())
402
+ if parent_start in dates_to_create:
403
+ dates_to_create.remove(parent_start)
404
+ for _start_date in sorted(dates_to_create):
405
+ instance_dto = dates_dto_dict[_start_date]
406
+ if (instance_dto.id and (instance := Activity.all_objects.filter(id=instance_dto.id).first())) or (
407
+ instance := parent_act.child_activities.filter(period__startswith__date=_start_date).first()
408
+ ):
409
+ new_activity = self._update_activity(instance, instance_dto, parent_occurrence=parent_act)
410
+ else:
411
+ new_activity = self._create_activity(instance_dto, parent_occurrence=parent_act)
412
+ self.backend._stream_extension_event(new_activity._build_dto())
413
+ else:
414
+ if qs_activities.exists():
415
+ for activity in qs_activities:
416
+ self._update_activity(activity, activity_dto)
417
+ else:
418
+ self._create_activity(activity_dto)
419
+
420
+ def update_activity_metadata(self, activity_dto: ActivityDTO, new_metadata):
421
+ """
422
+ allows to update the metadata used to save the external event id which is used to retrieve the event/activity
423
+ """
424
+ old_metadata = activity_dto.metadata.get(self.backend.METADATA_KEY, {})
425
+ metadata = copy.deepcopy(old_metadata)
426
+ new_metadata = new_metadata.get(self.backend.METADATA_KEY, {})
427
+ for key, new_value in new_metadata.items():
428
+ if old_value := metadata.get(key):
429
+ if isinstance(old_value, list):
430
+ values = set(new_value) if isinstance(new_value, list) else {new_value}
431
+ if new_values := [_value for _value in values if _value not in old_value]:
432
+ metadata[key] = sorted(old_value + new_values)
433
+ else:
434
+ metadata[key] = new_value
435
+ else:
436
+ metadata[key] = new_value
437
+ if old_metadata != metadata:
438
+ activity_dto.metadata[self.backend.METADATA_KEY] = metadata
439
+ Activity.objects.filter(id=activity_dto.id).update(metadata=activity_dto.metadata)
440
+
441
+ def _cancel_or_delete_activity(self, activity: "Activity") -> None:
442
+ """
443
+ when an event is deleted in the external calendar, we cancel or delete the activity according to global preferences
444
+ """
445
+ if can_sync_cancelled_activity():
446
+ if activity.status != Activity.Status.CANCELLED:
447
+ activity.cancel()
448
+ activity.save()
449
+ else:
450
+ activity.delete(synchronize=False)
451
+
452
+ @transaction.atomic
453
+ def delete_activity(self, activity_dto: ActivityDTO, user_dto: UserDTO) -> None:
454
+ if participant := self.get_activity_participant(user_dto):
455
+ qs_invitation_activities = self.get_activities(activity_dto) # activities deleted by a participant
456
+ qs_activities = self.get_activities(activity_dto, operator.and_) # activities deleted by the organizer
457
+ qs, exact_identification = (
458
+ (qs_activities, True) if qs_activities.exists() else (qs_invitation_activities, False)
459
+ )
460
+ for activity in qs:
461
+ is_creator_internal = activity.creator.is_internal if activity.creator else False
462
+ if exact_identification and is_creator_internal and activity.creator == participant:
463
+ # delete instance activity if it's not the parent
464
+ if activity.parent_occurrence:
465
+ activity.delete(synchronize=False)
466
+ else:
467
+ self._cancel_or_delete_activity(activity)
468
+ else:
469
+ activity.activity_participants.filter(participant=participant).update(
470
+ participation_status=ActivityParticipant.ParticipationStatus.CANCELLED
471
+ )
472
+
473
+ internal_participants_exists = (
474
+ activity.activity_participants.exclude(
475
+ participation_status=ActivityParticipant.ParticipationStatus.CANCELLED
476
+ )
477
+ .filter(participant__in=Person.objects.filter_only_internal())
478
+ .exists()
479
+ )
480
+ if (
481
+ can_sync_cancelled_external_activity()
482
+ and not is_creator_internal
483
+ and not internal_participants_exists
484
+ ):
485
+ self._cancel_or_delete_activity(activity)
486
+
487
+ def get_or_create_person(self, person_dto: PersonDTO) -> Person:
488
+ """
489
+ A method to get or create the internal person of a "External" event. Returns a Person objects of the internal database.
490
+
491
+ :param mail: person mail.
492
+ """
493
+ potential_persons = Person.objects.filter(emails__address=person_dto.email.lower()).order_by(
494
+ "-emails__primary"
495
+ )
496
+ if potential_persons.exists():
497
+ person = potential_persons.first() # TODO change with owner feature
498
+ else:
499
+ person = Person.objects.create(
500
+ last_name=person_dto.last_name, first_name=person_dto.first_name, is_draft_entry=True
501
+ )
502
+ if (
503
+ potential_contact := EmailContact.objects.filter(entry__isnull=True, address=person_dto.email.lower())
504
+ .order_by("-primary")
505
+ .first()
506
+ ):
507
+ EmailContact.objects.filter(id=potential_contact.id).update(entry=person)
508
+ else:
509
+ EmailContact.objects.create(entry=person, address=person_dto.email, primary=True)
510
+ return person
511
+
512
+ def get_or_create_conference_room(self, conference_room: ConferenceRoomDTO) -> ConferenceRoom:
513
+ if ConferenceRoom.objects.filter(email=conference_room.email).exists():
514
+ conference_room = ConferenceRoom.objects.get(email=conference_room.email)
515
+ else:
516
+ name_building = conference_room.name_building if conference_room.name_building else conference_room.email
517
+ name_conference_room = conference_room.name if conference_room.name else conference_room.email
518
+ building, _ = Building.objects.get_or_create(name=name_building)
519
+ conference_room = ConferenceRoom.objects.create(
520
+ name=name_conference_room, email=conference_room.email, building=building
521
+ )
522
+ return conference_room
523
+
524
+ def update_or_create_participants(
525
+ self, activity: Activity, participants_dto: list[ParticipantStatusDTO]
526
+ ) -> list[ActivityParticipant]:
527
+ """
528
+ allows to create or update the status of the participants of an activity
529
+ """
530
+ activity_participants = []
531
+ for participant_dto in participants_dto:
532
+ person = self.get_or_create_person(participant_dto.person)
533
+ kwargs = {"participation_status": participant_dto.status} if participant_dto.status else {}
534
+
535
+ if activity_participant := ActivityParticipant.objects.filter(
536
+ activity=activity, participant=person
537
+ ).first():
538
+ if activity_participant.participation_status != participant_dto.status:
539
+ ActivityParticipant.objects.filter(activity=activity, participant=person).update(**kwargs)
540
+ else:
541
+ activity_participant = ActivityParticipant.objects.create(
542
+ activity=activity, participant=person, **kwargs
543
+ )
544
+ activity_participants.append(activity_participant)
545
+ return activity_participants
@@ -0,0 +1,107 @@
1
+ from django.utils.translation import gettext as _
2
+ from dynamic_preferences.preferences import Section
3
+ from dynamic_preferences.registries import global_preferences_registry
4
+ from dynamic_preferences.types import BooleanPreference, StringPreference
5
+
6
+ general = Section("wbactivity_sync")
7
+
8
+
9
+ @global_preferences_registry.register
10
+ class BackendCalendarPreference(StringPreference):
11
+ section = general
12
+ name = "sync_backend_calendar"
13
+ default = ""
14
+
15
+ verbose_name = _("Synchronization Backend Calendar")
16
+ help_text = _("The Backend Calendar to synchronize activities with an external calendar.")
17
+
18
+
19
+ @global_preferences_registry.register
20
+ class SyncPastActivity(BooleanPreference):
21
+ section = general
22
+ name = "sync_past_activity"
23
+ default = False
24
+
25
+ verbose_name = _("Synchronization Past Activity")
26
+
27
+
28
+ @global_preferences_registry.register
29
+ class SyncCancelledActivity(BooleanPreference):
30
+ section = general
31
+ name = "sync_cancelled_activity"
32
+ default = True
33
+
34
+ verbose_name = _("Cancel Internal Activity Instead Of Deleting")
35
+ help_text = _(
36
+ "When an activity is deleted in an external calendar the corresponding workbench activity can be cancelled (default) or also deleted."
37
+ )
38
+
39
+
40
+ @global_preferences_registry.register
41
+ class SyncCancelledExternalActivity(BooleanPreference):
42
+ section = general
43
+ name = "sync_cancelled_external_activity"
44
+ default = False
45
+
46
+ verbose_name = _("Cancel External Activity With One Non-Attending Internal Participant")
47
+ help_text = _(
48
+ "When an activity was created by an external person and has only one internal participant the activity in the workbench can be canceled if this participant doesn't choose to attend."
49
+ )
50
+
51
+
52
+ @global_preferences_registry.register
53
+ class SyncActivityDescription(BooleanPreference):
54
+ section = general
55
+ name = "sync_activity_description"
56
+ default = True
57
+
58
+ verbose_name = _("Synchronize Activity Description")
59
+
60
+
61
+ @global_preferences_registry.register
62
+ class SyncExternalParticipants(BooleanPreference):
63
+ section = general
64
+ name = "sync_external_participants"
65
+ default = False
66
+
67
+ verbose_name = _("Synchronize External Participants From Internal Calendar To External Calendar")
68
+
69
+
70
+ @global_preferences_registry.register
71
+ class GoogleSyncCredentials(StringPreference):
72
+ section = general
73
+ name = "google_sync_credentials"
74
+ default = ""
75
+ verbose_name = _("Google Synchronization Credentials")
76
+ help_text = "Dict. Keys: 'url', 'type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'client_id', 'auth_uri', 'token_uri', 'auth_provider_x509_cert_url', 'client_x509_cert_url'"
77
+
78
+
79
+ @global_preferences_registry.register
80
+ class OutlookSyncCredentials(StringPreference):
81
+ section = general
82
+ name = "outlook_sync_credentials"
83
+ default = ""
84
+ verbose_name = _("Outlook Synchronization Credentials")
85
+ help_text = '{"notification_url": "", "authority": "", "client_id": "", "client_secret": "", "token_endpoint": "", "graph_url": ""}'
86
+
87
+
88
+ @global_preferences_registry.register
89
+ class OutlookSyncAccesToken(StringPreference):
90
+ section = general
91
+ name = "outlook_sync_access_token"
92
+ default = ""
93
+
94
+ verbose_name = _("Microsoft Graph Access Token")
95
+ help_text = _("The access token obtained from subscriptions to Microsoft used for authentication pruposes")
96
+
97
+
98
+ @global_preferences_registry.register
99
+ class OutlookSyncClientState(StringPreference):
100
+ section = general
101
+ name = "outlook_sync_client_state"
102
+ default = "secretClientValue"
103
+
104
+ verbose_name = _("Microsoft Graph Webhook Secret Client State")
105
+ help_text = _(
106
+ "Secret Client Value defined during subscription, it will be injected into the webhook notification against spoofing"
107
+ )
@@ -0,0 +1,21 @@
1
+ from dynamic_preferences.registries import global_preferences_registry
2
+
3
+
4
+ def can_sync_past_activity() -> bool:
5
+ return global_preferences_registry.manager()["wbactivity_sync__sync_past_activity"]
6
+
7
+
8
+ def can_sync_cancelled_activity() -> bool:
9
+ return global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_activity"]
10
+
11
+
12
+ def can_sync_cancelled_external_activity() -> bool:
13
+ return global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_external_activity"]
14
+
15
+
16
+ def can_synchronize_activity_description() -> bool:
17
+ return global_preferences_registry.manager()["wbactivity_sync__sync_activity_description"]
18
+
19
+
20
+ def can_synchronize_external_participants() -> bool:
21
+ return global_preferences_registry.manager()["wbactivity_sync__sync_external_participants"]
@@ -0,0 +1,9 @@
1
+ from dynamic_preferences.registries import global_preferences_registry
2
+ from wbcore.utils.importlib import import_from_dotted_path
3
+
4
+ from .backend import SyncBackend
5
+
6
+
7
+ def get_backend() -> "SyncBackend":
8
+ if backend := global_preferences_registry.manager()["wbactivity_sync__sync_backend_calendar"]:
9
+ return import_from_dotted_path(backend)