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,627 @@
1
+ import copy
2
+ import dataclasses
3
+ import operator
4
+ from functools import reduce
5
+ from typing import Any
6
+
7
+ from django.db import transaction
8
+ from django.db.models import Q, QuerySet
9
+ from django.http import HttpRequest, HttpResponse
10
+ from django.utils import timezone
11
+ from wbcore.contrib.agenda.models import Building, ConferenceRoom
12
+ from wbcore.contrib.directory.models import EmailContact, Person
13
+
14
+ from wbcrm.models import Activity, ActivityParticipant, ActivityType, Event
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 .backend import SyncBackend
23
+ from .preferences import (
24
+ can_sync_cancelled_activity,
25
+ can_sync_cancelled_external_activity,
26
+ can_sync_create_new_activity_on_replanned_reviewed_activity,
27
+ can_sync_past_activity,
28
+ can_synchronize_activity_description,
29
+ can_synchronize_external_participants,
30
+ )
31
+
32
+
33
+ class ActivityController:
34
+ update_fields = [
35
+ "title",
36
+ "period",
37
+ "visibility",
38
+ "creator",
39
+ "conference_room",
40
+ "reminder_choice",
41
+ "is_cancelled",
42
+ "all_day",
43
+ "online_meeting",
44
+ "location",
45
+ "description",
46
+ ]
47
+
48
+ def __init__(self, backend: SyncBackend):
49
+ self.backend = backend()
50
+
51
+ def _is_valid(self, activity_dto: ActivityDTO) -> bool:
52
+ """
53
+ Check if the activity can be synchronized or not.
54
+ - Past activity is not synchronized if global preference is False
55
+ - Activity is synchronized if at least one of the participants has an active webhook
56
+ """
57
+ if period := activity_dto.period:
58
+ if period.upper < timezone.now() and not can_sync_past_activity():
59
+ return False
60
+ else:
61
+ return self.backend.is_valid(activity_dto)
62
+ else:
63
+ qs_activities = self.get_activities(activity_dto)
64
+ with transaction.atomic():
65
+ return qs_activities.exists() and any(
66
+ [self._is_valid(act._build_dto()) for act in qs_activities if act.period]
67
+ )
68
+
69
+ def handle_inbound_validation_response(self, request: HttpRequest) -> HttpResponse:
70
+ """
71
+ allows to send a response to the external calendar if it is required before receiving the events in the webhook
72
+ """
73
+ return self.backend._validation_response(request)
74
+
75
+ def get_events_from_inbound_request(self, request: HttpRequest) -> list[dict[str, Any]]:
76
+ """
77
+ allows to get list of event following the notification
78
+ """
79
+ events = []
80
+ self.backend.open()
81
+ if self.backend._is_inbound_request_valid(request):
82
+ for event in self.backend._get_events_from_request(request):
83
+ events.append(event)
84
+ self.backend.close()
85
+ return events
86
+
87
+ def handle_inbound(self, event: dict, event_object_id: int) -> None:
88
+ """
89
+ Each event received in the webhook is processed here, it allows to delete or create/update the corresponding activity
90
+ """
91
+ self.backend.open()
92
+ activity_dto, is_deleted, user_dto = self.backend._deserialize(event)
93
+ if activity_dto.is_recurrent or self._is_valid(activity_dto):
94
+ if is_deleted:
95
+ self.delete_activity(activity_dto, user_dto, event_object_id)
96
+ else:
97
+ self.update_or_create_activity(activity_dto, event_object_id)
98
+ self.backend.close()
99
+
100
+ def _handle_outbound_data_preferences(self, activity_dto: ActivityDTO) -> tuple[ActivityDTO, list]:
101
+ """
102
+ Activity data is parsed to take into account the company's preferences
103
+ this allows you to exclude the description or exclude external participants from the data if global preference is True
104
+ """
105
+ if not can_synchronize_activity_description():
106
+ activity_dto.description = ""
107
+ valid_participants = []
108
+ internal_participants = []
109
+ for participant_dto in activity_dto.participants:
110
+ # External person are removed of the list if global preference is True
111
+ if (
112
+ Person.all_objects.get(id=participant_dto.person.id).is_internal
113
+ or can_synchronize_external_participants()
114
+ ):
115
+ internal_participants.append(participant_dto)
116
+ if participant_dto.status != ActivityParticipant.ParticipationStatus.CANCELLED:
117
+ valid_participants.append(participant_dto)
118
+
119
+ activity_dto.participants = valid_participants
120
+
121
+ # list of external participants present in the external event, they will be added to the participants before updating the event to avoid deleting them
122
+ external_participants = (
123
+ self.backend.get_external_participants(activity_dto, internal_participants)
124
+ if not can_synchronize_external_participants()
125
+ else []
126
+ )
127
+ return activity_dto, external_participants
128
+
129
+ def handle_outbound(
130
+ self, activity_dto: ActivityDTO, old_activity_dto: ActivityDTO = None, is_deleted: bool = False
131
+ ):
132
+ """
133
+ Requests sent to the external calendar are processed here
134
+ It allows to send requests to delete, modify or create the event in the external calendar corresponding to the activity
135
+ """
136
+ if not self._is_valid(activity_dto):
137
+ return
138
+ self.backend.open()
139
+ if is_deleted or activity_dto.is_cancelled:
140
+ self.backend._stream_deletion(activity_dto)
141
+ else:
142
+ activities_metadata = []
143
+ created = True if not old_activity_dto else False
144
+ # dataclasses.replace returns a new copy of the object without passing in any changes, return a copy with no modification
145
+ activity_dto_preference, external_participants = self._handle_outbound_data_preferences(
146
+ dataclasses.replace(activity_dto)
147
+ )
148
+ activity_dict = self.backend._serialize(activity_dto_preference, created=created)
149
+ if created: # then it's a creation
150
+ activities_metadata = self.backend._stream_creation(activity_dto, activity_dict)
151
+ elif self._has_changed(activity_dto, old_activity_dto):
152
+ keep_external_description = not can_synchronize_activity_description()
153
+ only_participants_changed = self._has_changed(
154
+ activity_dto, old_activity_dto, update_fields=["participants"]
155
+ ) and not self._has_changed(activity_dto, old_activity_dto, exclude_fields=["participants"])
156
+ activities_metadata = self.backend._stream_update(
157
+ activity_dto,
158
+ activity_dict,
159
+ only_participants_changed,
160
+ external_participants,
161
+ keep_external_description,
162
+ )
163
+
164
+ if activities_metadata:
165
+ for act_dto, act_metadata in activities_metadata:
166
+ self.update_activity_metadata(act_dto, act_metadata)
167
+ self.backend.close()
168
+
169
+ def handle_outbound_participant(
170
+ self,
171
+ participant_dto: ParticipantStatusDTO,
172
+ old_participant_dto: ParticipantStatusDTO = None,
173
+ is_deleted: bool = False,
174
+ ):
175
+ """
176
+ allows to update the status of the event in the external calendar to match the one updated in the internal activity
177
+ """
178
+ # check if activity creator is internal or activity is not passed according to global preference
179
+ if not self._is_valid(participant_dto.activity):
180
+ return
181
+
182
+ # check if participant is internal or can sync external participant is allowed according to global preference
183
+ if not (
184
+ Person.all_objects.get(id=participant_dto.person.id).is_internal or can_synchronize_external_participants()
185
+ ):
186
+ return
187
+
188
+ self.backend.open()
189
+ was_cancelled = False
190
+ status_changed = False
191
+ if old_participant_dto and old_participant_dto.status != participant_dto.status:
192
+ was_cancelled = old_participant_dto.status == ActivityParticipant.ParticipationStatus.CANCELLED
193
+ status_changed = participant_dto.status not in [
194
+ ActivityParticipant.ParticipationStatus.NOTRESPONDED,
195
+ ActivityParticipant.ParticipationStatus.PENDING_INVITATION,
196
+ ]
197
+
198
+ wait_before_changing_status = False
199
+ if (not is_deleted and not old_participant_dto) or was_cancelled:
200
+ # self.backend._stream_forward(participant_dto.activity, [participant_dto])
201
+ # forward event doesn't work when user is an external, following the documentation it's recommended to only include the attendees property in the request body
202
+ # It will only send notifications to newly added attendees.
203
+ self.backend._stream_update_only_attendees(
204
+ activity_dto=participant_dto.activity, participants_dto=[participant_dto]
205
+ )
206
+ wait_before_changing_status = status_changed
207
+ if is_deleted or status_changed:
208
+ self.backend._stream_participant_change(
209
+ participant_dto, is_deleted, wait_before_changing=wait_before_changing_status
210
+ )
211
+ self.backend.close()
212
+
213
+ def handle_outbound_external_participants(self, activity_dto, participants_dto: list[ParticipantStatusDTO]):
214
+ """
215
+ allows to update the status of the event in the external calendar to match the one updated in the internal activity
216
+ """
217
+ # check if activity creator is internal or activity is not passed according to global preference
218
+ if not self._is_valid(activity_dto):
219
+ return
220
+
221
+ self.backend.open()
222
+ self.backend._stream_update_only_attendees(activity_dto=activity_dto, participants_dto=participants_dto)
223
+ self.backend.close()
224
+
225
+ def _changed_participants(
226
+ self, participants: list[ParticipantStatusDTO], old_participants: list[ParticipantStatusDTO]
227
+ ) -> bool:
228
+ """
229
+ Comparison of 2 lists of participants, returns false if they are identical
230
+ """
231
+ d1 = {elt.person.email: elt.status for elt in participants}
232
+ d2 = {elt.person.email: elt.status for elt in old_participants}
233
+ if set(d1.keys()) == set(d2.keys()):
234
+ return any([d1[key] != d2[key] for key in d1.keys()])
235
+ return True
236
+
237
+ def _has_changed(
238
+ self,
239
+ activity_dto: ActivityDTO,
240
+ old_activity_dto: ActivityDTO,
241
+ update_fields: list | None = None,
242
+ exclude_fields: list | None = None,
243
+ ) -> bool:
244
+ """
245
+ Comparison of 2 activities, returns false if they are identical
246
+
247
+ :param update_fields: allows to specify the list of fields taken into account in the comparison,
248
+ if not specified we use the list of the controller
249
+
250
+ :param exclude_fields: allows you to exclude fields from the comparison list
251
+ """
252
+ if exclude_fields is None:
253
+ exclude_fields = []
254
+ if not can_synchronize_activity_description():
255
+ exclude_fields.append("description")
256
+ update_fields = (
257
+ update_fields
258
+ if update_fields
259
+ else (
260
+ self.update_fields + ["propagate_for_all_children", "exclude_from_propagation"]
261
+ if self.update_fields
262
+ else activity_dto.__dataclass_fields__
263
+ )
264
+ )
265
+ fields = list(set(update_fields) - set(exclude_fields))
266
+ if "participants" in fields:
267
+ fields.remove("participants")
268
+ participants_changed = self._changed_participants(activity_dto.participants, old_activity_dto.participants)
269
+ else:
270
+ participants_changed = False
271
+ is_new_activity = True if activity_dto and not old_activity_dto else False
272
+ return (
273
+ participants_changed
274
+ or is_new_activity
275
+ or (
276
+ activity_dto
277
+ and old_activity_dto
278
+ and any(
279
+ [getattr(activity_dto, field, None) != getattr(old_activity_dto, field, None) for field in fields]
280
+ )
281
+ )
282
+ )
283
+
284
+ def get_activities(self, activity_dto: ActivityDTO, _operator: operator = operator.or_) -> QuerySet["Activity"]:
285
+ """
286
+ Received events are deserialized into data transfer object of activity,
287
+ we use the metadata construct to identify the activity
288
+ the operator allows to know which operation combination to perform during the filter
289
+ """
290
+ if conditions := [
291
+ Q(**{key: value}) for key, value in flattened_metadata_lookup(activity_dto.metadata, key_string="metadata")
292
+ ]:
293
+ return Activity.all_objects.select_for_update().filter(reduce(_operator, conditions))
294
+ return Activity.objects.none()
295
+
296
+ def get_activity_participant(self, user_dto: UserDTO) -> Person:
297
+ """
298
+ Attendees of the external event are deserialized into person data transfer objects
299
+ we use the metadata construct to identify the person
300
+ """
301
+ if conditions := [
302
+ Q(**{key: value})
303
+ for key, value in flattened_metadata_lookup(user_dto.metadata, key_string="user_account__metadata")
304
+ ]:
305
+ try:
306
+ return Person.objects.get(reduce(operator.and_, conditions))
307
+ except Exception:
308
+ return None
309
+ return None
310
+
311
+ def _get_data_from_activity_dto(self, activity_dto: ActivityDTO, parent_occurrence: Activity = None) -> dict:
312
+ """
313
+ 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
314
+ """
315
+ activity_data = {}
316
+ fields = self.update_fields if self.update_fields else activity_dto.__dataclass_fields__
317
+ if not can_synchronize_activity_description() and "description" in fields:
318
+ fields.remove("description")
319
+
320
+ for field in fields:
321
+ activity_data[field] = getattr(activity_dto, field)
322
+ if activity_data.get("creator"):
323
+ activity_data["creator"] = self.get_or_create_person(activity_dto.creator)
324
+ if activity_data.get("conference_room"):
325
+ activity_data["conference_room"] = self.get_or_create_conference_room(activity_dto.conference_room)
326
+ if activity_dto.is_recurrent:
327
+ activity_data.update(
328
+ {
329
+ "recurrence_end": activity_dto.recurrence_end,
330
+ "recurrence_count": activity_dto.recurrence_count,
331
+ "repeat_choice": activity_dto.repeat_choice,
332
+ }
333
+ )
334
+ if parent_occurrence:
335
+ activity_data["parent_occurrence"] = parent_occurrence
336
+ return activity_data
337
+
338
+ def _create_activity(self, activity_dto: ActivityDTO, parent_occurrence: Activity = None) -> Activity:
339
+ """
340
+ Uses the Data transfer object obtained from the external event to create the activity in the database
341
+ """
342
+ activity_data = self._get_data_from_activity_dto(activity_dto, parent_occurrence)
343
+ activity_type, created = ActivityType.objects.get_or_create(
344
+ slugify_title="meeting", defaults={"title": "Meeting"}
345
+ )
346
+ activity = Activity(**activity_data, type=activity_type)
347
+ activity.save(synchronize=False)
348
+ if activity.period.lower < activity.created:
349
+ # A created past event should not appear at the top of the list.
350
+ Activity.objects.filter(id=activity.id).update(created=activity.period.lower, edited=activity.period.lower)
351
+ self.update_or_create_participants(activity, activity_dto.participants)
352
+ self.update_activity_metadata(activity._build_dto(), activity_dto.metadata)
353
+ return activity
354
+
355
+ def _update_activity(
356
+ self, activity: Activity, activity_dto: ActivityDTO, parent_occurrence: Activity = None
357
+ ) -> tuple[Activity, str]:
358
+ """
359
+ Convert the data transfer object obtained from the external event into a dict to update the activity in the database.
360
+ """
361
+ activity_data = self._get_data_from_activity_dto(activity_dto, parent_occurrence)
362
+ if has_changed := self._has_changed(activity_dto, activity._build_dto(), update_fields=activity_data.keys()):
363
+ # When previously canceled by the only internal participant is finally accepted by the latter
364
+ if (
365
+ (creator := activity.creator)
366
+ and not creator.is_internal
367
+ and activity.status == Activity.Status.CANCELLED
368
+ ):
369
+ activity_data["status"] = Activity.Status.PLANNED
370
+ Activity.objects.filter(id=activity.id).update(**activity_data)
371
+ activity.refresh_from_db()
372
+ activity.save(synchronize=False)
373
+ _, participant_changed = self.update_or_create_participants(activity, activity_dto.participants)
374
+ self.update_activity_metadata(activity._build_dto(), activity_dto.metadata)
375
+ return activity, f"activity_changes: {has_changed}, participants_changes: {participant_changed or False}; "
376
+
377
+ @transaction.atomic
378
+ def update_or_create_activity(self, activity_dto: ActivityDTO, event_object_id: int | None = None) -> None: # noqa: C901
379
+ """
380
+ allows you to create or update a single or recurring activity from a data transfer object obtained from an external event
381
+ """
382
+ qs_activities = self.get_activities(activity_dto)
383
+ if activity_dto.is_recurrent:
384
+ event_result = {"action_type": "update or create recurring activities", "action": ""}
385
+ ids_dto_dict = {}
386
+ dates_dto_dict = {}
387
+ for _dto in activity_dto.recurring_activities:
388
+ if _dto.id:
389
+ ids_dto_dict[_dto.id] = _dto
390
+ dates_dto_dict[_dto.period.lower.date()] = _dto
391
+ ids_dto_list = set(ids_dto_dict.keys())
392
+ dates_dto_list = set(dates_dto_dict.keys())
393
+
394
+ for act in qs_activities.exclude(Q(id__in=ids_dto_list) | Q(period__startswith__date__in=dates_dto_list)):
395
+ act.delete(synchronize=False)
396
+ for act in qs_activities.filter(Q(id__in=ids_dto_list) | Q(period__startswith__date__in=dates_dto_list)):
397
+ act_dto = (
398
+ _act_dto if (_act_dto := ids_dto_dict.get(act.id)) else dates_dto_dict[act.period.lower.date()]
399
+ )
400
+ activity_updated, _ = self._update_activity(act, act_dto)
401
+ if not act_dto.id:
402
+ self.backend._stream_extension_event(activity_updated._build_dto())
403
+
404
+ dates_act_dict = {act.period.lower.date(): act for act in qs_activities}
405
+ if dates_to_create := dates_dto_list.difference(set(dates_act_dict.keys())):
406
+ parent_start = sorted(dates_dto_list)[0]
407
+ if dates_act_dict.get(parent_start):
408
+ parent_act = dates_act_dict[parent_start]
409
+ else:
410
+ parent_dto = dates_dto_dict[parent_start]
411
+ if parent_dto.id and (instance := Activity.all_objects.filter(id=parent_dto.id).first()):
412
+ Activity.all_objects.filter(id=parent_dto.id).update(parent_occurrence=None, is_active=True)
413
+ parent_act, _ = self._update_activity(instance, parent_dto)
414
+ else:
415
+ if parent_act := self._create_activity(parent_dto):
416
+ qs_activities.exclude(id=parent_act.id).update(parent_occurrence=parent_act)
417
+ self.backend._stream_extension_event(parent_act._build_dto())
418
+ if parent_start in dates_to_create:
419
+ dates_to_create.remove(parent_start)
420
+ for _start_date in sorted(dates_to_create):
421
+ instance_dto = dates_dto_dict[_start_date]
422
+ if (instance_dto.id and (instance := Activity.all_objects.filter(id=instance_dto.id).first())) or (
423
+ instance := parent_act.child_activities.filter(period__startswith__date=_start_date).first()
424
+ ):
425
+ new_activity, _ = self._update_activity(instance, instance_dto, parent_occurrence=parent_act)
426
+ else:
427
+ new_activity = self._create_activity(instance_dto, parent_occurrence=parent_act)
428
+ self.backend._stream_extension_event(new_activity._build_dto())
429
+ else:
430
+ if qs_activities.exists():
431
+ event_result = {"action_type": "update", "action": ""}
432
+ for activity in qs_activities:
433
+ previous_status = activity.status
434
+ if not (
435
+ previous_status == Activity.Status.REVIEWED
436
+ and can_sync_create_new_activity_on_replanned_reviewed_activity()
437
+ and activity._build_dto().period != activity_dto.period
438
+ ):
439
+ activity_updated, participant_updates_info = self._update_activity(activity, activity_dto)
440
+ msg = f"{activity.id} => {previous_status} activity, {participant_updates_info} -> {activity_updated.status} activity; "
441
+ event_result["action"] += msg
442
+ else:
443
+ new_activity = self._create_activity(activity_dto)
444
+ event_result["action"] += (
445
+ f"reviewed activity {activity.id} replanned to {new_activity.id} -> new activity {new_activity.status} created"
446
+ )
447
+ # remove metadata since the old activity is not sync anymore
448
+ Activity.objects.filter(id=activity.id).update(
449
+ metadata={
450
+ self.backend.METADATA_KEY: {
451
+ "info": f"reviewed activity has been replanned to {new_activity.id}"
452
+ }
453
+ }
454
+ )
455
+ else:
456
+ new_activity = self._create_activity(activity_dto)
457
+ event_result = {
458
+ "action_type": "create",
459
+ "action": f"{new_activity.id} -> new activity {new_activity.status} created",
460
+ }
461
+ Event.objects.filter(id=event_object_id).update(result=event_result)
462
+
463
+ def update_activity_metadata(self, activity_dto: ActivityDTO, new_metadata):
464
+ """
465
+ allows to update the metadata used to save the external event id which is used to retrieve the event/activity
466
+ """
467
+ old_metadata = activity_dto.metadata.get(self.backend.METADATA_KEY, {})
468
+ metadata = copy.deepcopy(old_metadata)
469
+ new_metadata = new_metadata.get(self.backend.METADATA_KEY, {})
470
+ for key, new_value in new_metadata.items():
471
+ if old_value := metadata.get(key):
472
+ if isinstance(old_value, list):
473
+ values = set(new_value) if isinstance(new_value, list) else {new_value}
474
+ if new_values := [_value for _value in values if _value not in old_value]:
475
+ metadata[key] = sorted(old_value + new_values)
476
+ else:
477
+ metadata[key] = new_value
478
+ else:
479
+ metadata[key] = new_value
480
+ if old_metadata != metadata:
481
+ activity_dto.metadata[self.backend.METADATA_KEY] = metadata
482
+ Activity.objects.filter(id=activity_dto.id).update(metadata=activity_dto.metadata)
483
+
484
+ def _cancel_or_delete_activity(self, activity: "Activity") -> None:
485
+ """
486
+ when an event is deleted in the external calendar, we cancel or delete the activity according to global preferences
487
+ """
488
+ if can_sync_cancelled_activity():
489
+ if activity.status == Activity.Status.PLANNED:
490
+ activity.cancel()
491
+ activity.save(synchronize=False)
492
+ else:
493
+ activity.delete(synchronize=False)
494
+
495
+ def _delete_activity(self, activity: Activity, participant: Person, exact_match: bool):
496
+ previous_status = activity.status
497
+ internal_activity = activity.creator.is_internal if activity.creator else False
498
+ is_internal_creator = internal_activity and activity.creator == participant
499
+ if exact_match and is_internal_creator:
500
+ if activity.parent_occurrence:
501
+ # delete instance activity if it's not the parent of recurring activities
502
+ activity.delete(synchronize=False)
503
+ else:
504
+ # single activity or first parent of recurring activity
505
+ self._cancel_or_delete_activity(activity)
506
+ msg = f"{activity.id} => {previous_status} activity {activity.status}; "
507
+ else:
508
+ activity.activity_participants.filter(participant=participant).update(
509
+ participation_status=ActivityParticipant.ParticipationStatus.CANCELLED
510
+ )
511
+ msg = f"{activity.id} => participant status cancelled; "
512
+
513
+ # Handle external creator activity cancellation if no internal participants exist
514
+ internal_participants_exist = (
515
+ activity.activity_participants.exclude(
516
+ participation_status=ActivityParticipant.ParticipationStatus.CANCELLED
517
+ )
518
+ .exclude(participant=participant)
519
+ .filter(participant__in=Person.objects.filter_only_internal())
520
+ .exists()
521
+ )
522
+ if not internal_activity and can_sync_cancelled_external_activity() and not internal_participants_exist:
523
+ self._cancel_or_delete_activity(activity)
524
+ msg += f"external {previous_status} activity {activity.status}; "
525
+
526
+ return msg
527
+
528
+ @transaction.atomic
529
+ def delete_activity(
530
+ self, activity_dto: ActivityDTO, user_dto: UserDTO, event_object_id: int | None = None
531
+ ) -> None:
532
+ event_result = {"action_type": "delete", "action": ""}
533
+
534
+ # Try to get the participant, return if not found
535
+ participant = self.get_activity_participant(user_dto)
536
+ if not participant:
537
+ Event.objects.filter(id=event_object_id).update(result=event_result)
538
+ return
539
+
540
+ # Prepare query sets for activities deletion
541
+ qs_activities = self.get_activities(activity_dto, operator.and_) # activities deleted by the organizer
542
+ qs_invitation_activities = self.get_activities(activity_dto) # activities deleted by a participant
543
+
544
+ # skip deletion when notification received is not a deletion
545
+ if not activity_dto.delete_notification:
546
+ activities_ids = list(qs_invitation_activities.values_list("id", "status"))
547
+ event_result["action"] += f"{activities_ids} => skip deletion; wait for delete notification"
548
+ Event.objects.filter(id=event_object_id).update(result=event_result)
549
+ return
550
+
551
+ # Handle activities deleted by organizer
552
+ if qs_activities.exists():
553
+ for activity in qs_activities:
554
+ event_result["action"] += self._delete_activity(activity, participant, exact_match=True)
555
+
556
+ # Handle activities with participant invitation
557
+ elif qs_invitation_activities.exists():
558
+ for activity in qs_invitation_activities:
559
+ event_result["action"] += self._delete_activity(activity, participant, exact_match=False)
560
+
561
+ event_result["participant"] = participant.id
562
+ Event.objects.filter(id=event_object_id).update(result=event_result)
563
+
564
+ def get_or_create_person(self, person_dto: PersonDTO) -> Person:
565
+ """
566
+ A method to get or create the internal person of a "External" event. Returns a Person objects of the internal database.
567
+
568
+ :param mail: person mail.
569
+ """
570
+ potential_persons = Person.objects.filter(emails__address=person_dto.email.lower()).order_by(
571
+ "-emails__primary"
572
+ )
573
+ if potential_persons.exists():
574
+ person = potential_persons.first() # TODO change with owner feature
575
+ else:
576
+ person = Person.objects.create(
577
+ last_name=person_dto.last_name, first_name=person_dto.first_name, is_draft_entry=True
578
+ )
579
+ if (
580
+ potential_contact := EmailContact.objects.filter(entry__isnull=True, address=person_dto.email.lower())
581
+ .order_by("-primary")
582
+ .first()
583
+ ):
584
+ EmailContact.objects.filter(id=potential_contact.id).update(entry=person)
585
+ else:
586
+ EmailContact.objects.create(entry=person, address=person_dto.email, primary=True)
587
+ return person
588
+
589
+ def get_or_create_conference_room(self, conference_room: ConferenceRoomDTO) -> ConferenceRoom:
590
+ if ConferenceRoom.objects.filter(email=conference_room.email).exists():
591
+ conference_room = ConferenceRoom.objects.get(email=conference_room.email)
592
+ else:
593
+ name_building = conference_room.name_building if conference_room.name_building else conference_room.email
594
+ name_conference_room = conference_room.name if conference_room.name else conference_room.email
595
+ building, _ = Building.objects.get_or_create(name=name_building)
596
+ conference_room = ConferenceRoom.objects.create(
597
+ name=name_conference_room, email=conference_room.email, building=building
598
+ )
599
+ return conference_room
600
+
601
+ def update_or_create_participants(
602
+ self, activity: Activity, participants_dto: list[ParticipantStatusDTO]
603
+ ) -> tuple[list[ActivityParticipant], str]:
604
+ """
605
+ allows to create or update the status of the participants of an activity
606
+ """
607
+ activity_participants = []
608
+ participants_changed = ""
609
+ for participant_dto in participants_dto:
610
+ person = self.get_or_create_person(participant_dto.person)
611
+ kwargs = {"participation_status": participant_dto.status} if participant_dto.status else {}
612
+
613
+ if activity_participant := ActivityParticipant.objects.filter(
614
+ activity=activity, participant=person
615
+ ).first():
616
+ if activity_participant.participation_status != participant_dto.status:
617
+ ActivityParticipant.objects.filter(activity=activity, participant=person).update(**kwargs)
618
+ participants_changed += (
619
+ f"- {person}: {activity_participant.participation_status} -> {participant_dto.status}"
620
+ )
621
+ else:
622
+ activity_participant = ActivityParticipant.objects.create(
623
+ activity=activity, participant=person, **kwargs
624
+ )
625
+ participants_changed += f"- new participant {person}: {participant_dto.status}"
626
+ activity_participants.append(activity_participant)
627
+ return activity_participants, participants_changed