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.
- wbcrm/__init__.py +1 -0
- wbcrm/admin/__init__.py +4 -0
- wbcrm/admin/accounts.py +59 -0
- wbcrm/admin/activities.py +101 -0
- wbcrm/admin/groups.py +7 -0
- wbcrm/admin/products.py +8 -0
- wbcrm/apps.py +5 -0
- wbcrm/configurations/__init__.py +1 -0
- wbcrm/configurations/base.py +16 -0
- wbcrm/dynamic_preferences_registry.py +38 -0
- wbcrm/factories/__init__.py +14 -0
- wbcrm/factories/accounts.py +56 -0
- wbcrm/factories/activities.py +125 -0
- wbcrm/factories/groups.py +23 -0
- wbcrm/factories/products.py +10 -0
- wbcrm/filters/__init__.py +10 -0
- wbcrm/filters/accounts.py +67 -0
- wbcrm/filters/activities.py +181 -0
- wbcrm/filters/groups.py +20 -0
- wbcrm/filters/products.py +37 -0
- wbcrm/filters/signals.py +94 -0
- wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
- wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
- wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
- wbcrm/migrations/0004_alter_activity_status.py +28 -0
- wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
- wbcrm/migrations/0006_alter_activity_location.py +17 -0
- wbcrm/migrations/0007_alter_account_status.py +23 -0
- wbcrm/migrations/0008_alter_activity_options.py +16 -0
- wbcrm/migrations/0009_alter_account_is_public.py +19 -0
- wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
- wbcrm/migrations/0011_activity_summary.py +22 -0
- wbcrm/migrations/0012_alter_activity_summary.py +17 -0
- wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
- wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
- wbcrm/migrations/0015_alter_activity_type.py +23 -0
- wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
- wbcrm/migrations/__init__.py +0 -0
- wbcrm/models/__init__.py +4 -0
- wbcrm/models/accounts.py +637 -0
- wbcrm/models/activities.py +1335 -0
- wbcrm/models/groups.py +118 -0
- wbcrm/models/products.py +83 -0
- wbcrm/models/recurrence.py +279 -0
- wbcrm/preferences.py +14 -0
- wbcrm/serializers/__init__.py +23 -0
- wbcrm/serializers/accounts.py +126 -0
- wbcrm/serializers/activities.py +526 -0
- wbcrm/serializers/groups.py +30 -0
- wbcrm/serializers/products.py +57 -0
- wbcrm/serializers/recurrence.py +90 -0
- wbcrm/serializers/signals.py +70 -0
- wbcrm/synchronization/__init__.py +0 -0
- wbcrm/synchronization/activity/__init__.py +0 -0
- wbcrm/synchronization/activity/admin.py +72 -0
- wbcrm/synchronization/activity/backend.py +207 -0
- wbcrm/synchronization/activity/backends/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
- wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
- wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
- wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
- wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
- wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
- wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
- wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
- wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
- wbcrm/synchronization/activity/backends/google/utils.py +216 -0
- wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
- wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
- wbcrm/synchronization/activity/backends/outlook/parser.py +423 -0
- wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
- wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
- wbcrm/synchronization/activity/controller.py +545 -0
- wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
- wbcrm/synchronization/activity/preferences.py +21 -0
- wbcrm/synchronization/activity/shortcuts.py +9 -0
- wbcrm/synchronization/activity/signals.py +28 -0
- wbcrm/synchronization/activity/tasks.py +21 -0
- wbcrm/synchronization/activity/urls.py +6 -0
- wbcrm/synchronization/activity/utils.py +46 -0
- wbcrm/synchronization/activity/views.py +37 -0
- wbcrm/synchronization/admin.py +1 -0
- wbcrm/synchronization/apps.py +15 -0
- wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
- wbcrm/synchronization/management.py +36 -0
- wbcrm/synchronization/tasks.py +1 -0
- wbcrm/synchronization/urls.py +5 -0
- wbcrm/tasks.py +312 -0
- wbcrm/tests/__init__.py +0 -0
- wbcrm/tests/accounts/__init__.py +0 -0
- wbcrm/tests/accounts/test_models.py +380 -0
- wbcrm/tests/accounts/test_viewsets.py +87 -0
- wbcrm/tests/conftest.py +76 -0
- wbcrm/tests/disable_signals.py +52 -0
- wbcrm/tests/e2e/__init__.py +1 -0
- wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
- wbcrm/tests/e2e/test_e2e.py +369 -0
- wbcrm/tests/test_assignee_methods.py +39 -0
- wbcrm/tests/test_chartviewsets.py +111 -0
- wbcrm/tests/test_dto.py +63 -0
- wbcrm/tests/test_filters.py +51 -0
- wbcrm/tests/test_models.py +216 -0
- wbcrm/tests/test_recurrence.py +291 -0
- wbcrm/tests/test_report.py +20 -0
- wbcrm/tests/test_serializers.py +170 -0
- wbcrm/tests/test_tasks.py +94 -0
- wbcrm/tests/test_viewsets.py +967 -0
- wbcrm/tests/tests.py +120 -0
- wbcrm/typings.py +107 -0
- wbcrm/urls.py +67 -0
- wbcrm/viewsets/__init__.py +22 -0
- wbcrm/viewsets/accounts.py +121 -0
- wbcrm/viewsets/activities.py +315 -0
- wbcrm/viewsets/buttons/__init__.py +7 -0
- wbcrm/viewsets/buttons/accounts.py +27 -0
- wbcrm/viewsets/buttons/activities.py +68 -0
- wbcrm/viewsets/buttons/signals.py +17 -0
- wbcrm/viewsets/display/__init__.py +12 -0
- wbcrm/viewsets/display/accounts.py +110 -0
- wbcrm/viewsets/display/activities.py +443 -0
- wbcrm/viewsets/display/groups.py +22 -0
- wbcrm/viewsets/display/products.py +105 -0
- wbcrm/viewsets/endpoints/__init__.py +8 -0
- wbcrm/viewsets/endpoints/accounts.py +32 -0
- wbcrm/viewsets/endpoints/activities.py +30 -0
- wbcrm/viewsets/endpoints/groups.py +7 -0
- wbcrm/viewsets/endpoints/products.py +9 -0
- wbcrm/viewsets/groups.py +37 -0
- wbcrm/viewsets/menu/__init__.py +8 -0
- wbcrm/viewsets/menu/accounts.py +18 -0
- wbcrm/viewsets/menu/activities.py +61 -0
- wbcrm/viewsets/menu/groups.py +16 -0
- wbcrm/viewsets/menu/products.py +20 -0
- wbcrm/viewsets/mixins.py +34 -0
- wbcrm/viewsets/previews/__init__.py +1 -0
- wbcrm/viewsets/previews/activities.py +10 -0
- wbcrm/viewsets/products.py +56 -0
- wbcrm/viewsets/recurrence.py +26 -0
- wbcrm/viewsets/titles/__init__.py +13 -0
- wbcrm/viewsets/titles/accounts.py +22 -0
- wbcrm/viewsets/titles/activities.py +61 -0
- wbcrm/viewsets/titles/products.py +13 -0
- wbcrm/viewsets/titles/utils.py +46 -0
- wbcrm/workflows/__init__.py +1 -0
- wbcrm/workflows/assignee_methods.py +25 -0
- wbcrm-2.2.1.dist-info/METADATA +11 -0
- wbcrm-2.2.1.dist-info/RECORD +155 -0
- 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)
|