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