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.
- wbcrm/__init__.py +1 -0
- wbcrm/admin/__init__.py +5 -0
- wbcrm/admin/accounts.py +60 -0
- wbcrm/admin/activities.py +104 -0
- wbcrm/admin/events.py +43 -0
- wbcrm/admin/groups.py +8 -0
- wbcrm/admin/products.py +9 -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 +57 -0
- wbcrm/factories/activities.py +124 -0
- wbcrm/factories/groups.py +24 -0
- wbcrm/factories/products.py +11 -0
- wbcrm/filters/__init__.py +10 -0
- wbcrm/filters/accounts.py +80 -0
- wbcrm/filters/activities.py +204 -0
- wbcrm/filters/groups.py +21 -0
- wbcrm/filters/products.py +38 -0
- wbcrm/filters/signals.py +95 -0
- wbcrm/fixtures/wbcrm.json +1215 -0
- wbcrm/kpi_handlers/activities.py +171 -0
- wbcrm/locale/de/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/de/LC_MESSAGES/django.po +1557 -0
- wbcrm/locale/de/LC_MESSAGES/django.po.translated +1630 -0
- wbcrm/locale/en/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/en/LC_MESSAGES/django.po +1466 -0
- wbcrm/locale/fr/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/fr/LC_MESSAGES/django.po +1467 -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/0017_event.py +40 -0
- wbcrm/migrations/0018_activity_search_vector.py +24 -0
- wbcrm/migrations/__init__.py +0 -0
- wbcrm/models/__init__.py +5 -0
- wbcrm/models/accounts.py +648 -0
- wbcrm/models/activities.py +1419 -0
- wbcrm/models/events.py +15 -0
- wbcrm/models/groups.py +119 -0
- wbcrm/models/llm/activity_summaries.py +41 -0
- wbcrm/models/llm/analyze_relationship.py +50 -0
- wbcrm/models/products.py +86 -0
- wbcrm/models/recurrence.py +280 -0
- wbcrm/preferences.py +13 -0
- wbcrm/report/activity_report.py +110 -0
- wbcrm/serializers/__init__.py +23 -0
- wbcrm/serializers/accounts.py +141 -0
- wbcrm/serializers/activities.py +525 -0
- wbcrm/serializers/groups.py +30 -0
- wbcrm/serializers/products.py +58 -0
- wbcrm/serializers/recurrence.py +91 -0
- wbcrm/serializers/signals.py +71 -0
- wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
- wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
- wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
- wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
- wbcrm/synchronization/__init__.py +0 -0
- wbcrm/synchronization/activity/__init__.py +0 -0
- wbcrm/synchronization/activity/admin.py +73 -0
- wbcrm/synchronization/activity/backend.py +214 -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 +406 -0
- wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
- wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +181 -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 +217 -0
- wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/backend.py +593 -0
- wbcrm/synchronization/activity/backends/outlook/msgraph.py +436 -0
- wbcrm/synchronization/activity/backends/outlook/parser.py +432 -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 +118 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +274 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +249 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +174 -0
- wbcrm/synchronization/activity/controller.py +627 -0
- wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
- wbcrm/synchronization/activity/preferences.py +27 -0
- wbcrm/synchronization/activity/shortcuts.py +16 -0
- wbcrm/synchronization/activity/tasks.py +21 -0
- wbcrm/synchronization/activity/urls.py +7 -0
- wbcrm/synchronization/activity/utils.py +46 -0
- wbcrm/synchronization/activity/views.py +41 -0
- wbcrm/synchronization/admin.py +1 -0
- wbcrm/synchronization/apps.py +14 -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 +264 -0
- wbcrm/templates/email/activity.html +98 -0
- wbcrm/templates/email/activity_report.html +6 -0
- wbcrm/templates/email/daily_summary.html +72 -0
- wbcrm/templates/email/global_daily_summary.html +85 -0
- wbcrm/tests/__init__.py +0 -0
- wbcrm/tests/accounts/__init__.py +0 -0
- wbcrm/tests/accounts/test_models.py +393 -0
- wbcrm/tests/accounts/test_viewsets.py +88 -0
- wbcrm/tests/conftest.py +76 -0
- wbcrm/tests/disable_signals.py +62 -0
- wbcrm/tests/e2e/__init__.py +1 -0
- wbcrm/tests/e2e/e2e_wbcrm_utility.py +83 -0
- wbcrm/tests/e2e/test_e2e.py +370 -0
- wbcrm/tests/test_assignee_methods.py +40 -0
- wbcrm/tests/test_chartviewsets.py +112 -0
- wbcrm/tests/test_dto.py +64 -0
- wbcrm/tests/test_filters.py +52 -0
- wbcrm/tests/test_models.py +217 -0
- wbcrm/tests/test_recurrence.py +292 -0
- wbcrm/tests/test_report.py +21 -0
- wbcrm/tests/test_serializers.py +171 -0
- wbcrm/tests/test_tasks.py +95 -0
- wbcrm/tests/test_viewsets.py +967 -0
- wbcrm/tests/tests.py +121 -0
- wbcrm/typings.py +109 -0
- wbcrm/urls.py +67 -0
- wbcrm/viewsets/__init__.py +22 -0
- wbcrm/viewsets/accounts.py +122 -0
- wbcrm/viewsets/activities.py +341 -0
- wbcrm/viewsets/buttons/__init__.py +7 -0
- wbcrm/viewsets/buttons/accounts.py +27 -0
- wbcrm/viewsets/buttons/activities.py +89 -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 +444 -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 +25 -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 +38 -0
- wbcrm/viewsets/menu/__init__.py +8 -0
- wbcrm/viewsets/menu/accounts.py +18 -0
- wbcrm/viewsets/menu/activities.py +49 -0
- wbcrm/viewsets/menu/groups.py +16 -0
- wbcrm/viewsets/menu/products.py +20 -0
- wbcrm/viewsets/mixins.py +35 -0
- wbcrm/viewsets/previews/__init__.py +1 -0
- wbcrm/viewsets/previews/activities.py +10 -0
- wbcrm/viewsets/products.py +57 -0
- wbcrm/viewsets/recurrence.py +27 -0
- wbcrm/viewsets/titles/__init__.py +13 -0
- wbcrm/viewsets/titles/accounts.py +23 -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-1.56.8.dist-info/METADATA +11 -0
- wbcrm-1.56.8.dist-info/RECORD +182 -0
- wbcrm-1.56.8.dist-info/WHEEL +5 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from datetime import date
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth import get_user_model
|
|
6
|
+
from django.http import HttpRequest, HttpResponse
|
|
7
|
+
from django.utils.translation import gettext_lazy
|
|
8
|
+
from wbcore.contrib.notifications.dispatch import send_notification
|
|
9
|
+
|
|
10
|
+
from wbcrm.typings import Activity as ActivityDTO
|
|
11
|
+
from wbcrm.typings import ParticipantStatus as ParticipantStatusDTO
|
|
12
|
+
from wbcrm.typings import User as UserDTO
|
|
13
|
+
|
|
14
|
+
User = get_user_model()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SyncBackend:
|
|
18
|
+
METADATA_KEY = None
|
|
19
|
+
|
|
20
|
+
def open(self):
|
|
21
|
+
"""
|
|
22
|
+
Allows to perform primary operations or to open a communication channel for synchronization,
|
|
23
|
+
such as defining the necessary configurations to send requests
|
|
24
|
+
"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def close(self):
|
|
28
|
+
"""
|
|
29
|
+
Close the communication channel and unset configuration
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def _validation_response(self, request: HttpRequest) -> HttpResponse:
|
|
34
|
+
"""
|
|
35
|
+
send a response to the external calendar if necessary to validate the endpoint
|
|
36
|
+
"""
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def _is_inbound_request_valid(self, request: HttpRequest) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Valid function to ensure that the request received meets expectations
|
|
42
|
+
"""
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def _get_events_from_request(self, request: HttpRequest) -> list[dict[str, Any]]:
|
|
46
|
+
"""
|
|
47
|
+
list of events following the notification received
|
|
48
|
+
"""
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def _deserialize(self, event: dict[str, Any]) -> tuple[ActivityDTO, bool, UserDTO]:
|
|
52
|
+
"""
|
|
53
|
+
convert the dictionary received to a valid format of an activity
|
|
54
|
+
"""
|
|
55
|
+
raise NotImplementedError()
|
|
56
|
+
|
|
57
|
+
def _serialize(self, activity_dto: ActivityDTO, created: bool = False) -> dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
convert activity data transfer object to event dictionary
|
|
60
|
+
"""
|
|
61
|
+
raise NotImplementedError()
|
|
62
|
+
|
|
63
|
+
def _stream_deletion(self, activity_dto: ActivityDTO):
|
|
64
|
+
"""
|
|
65
|
+
allow the deletion of the event in the external calendar
|
|
66
|
+
we use the event_id stored in activity_dto's metadata to retrieve the event
|
|
67
|
+
"""
|
|
68
|
+
raise NotImplementedError()
|
|
69
|
+
|
|
70
|
+
def _stream_creation(
|
|
71
|
+
self, activity_dto: ActivityDTO, activity_dict: dict[str, Any]
|
|
72
|
+
) -> tuple[ActivityDTO, dict[str, Any]]:
|
|
73
|
+
"""
|
|
74
|
+
allow the creation of the event in the external calendar
|
|
75
|
+
param: activity_dict: dictionary used to create the event
|
|
76
|
+
|
|
77
|
+
we return a tuple of activity, metadata which contains the external id to be store in the activity
|
|
78
|
+
"""
|
|
79
|
+
raise NotImplementedError()
|
|
80
|
+
|
|
81
|
+
def _stream_update(
|
|
82
|
+
self,
|
|
83
|
+
activity_dto: ActivityDTO,
|
|
84
|
+
activity_dict: dict[str, Any],
|
|
85
|
+
only_participants_changed: bool = False,
|
|
86
|
+
external_participants: list | None = None,
|
|
87
|
+
keep_external_description: bool = False,
|
|
88
|
+
) -> tuple[ActivityDTO, dict[str, Any]]:
|
|
89
|
+
"""
|
|
90
|
+
allow to update the event in the external calendar
|
|
91
|
+
param: activity_dict: dictionary used to update the event
|
|
92
|
+
activity_dto: we use the metadata of the activity to retrieve the event
|
|
93
|
+
only_participants_changed: boolean to know if only the participants need to be update
|
|
94
|
+
external_participants: list of external participants, that must be added to the current list of participants to avoid their deletion when the activity is updated
|
|
95
|
+
keep_external_description: boolean to know if the description must be deleted or not before the update of the event
|
|
96
|
+
"""
|
|
97
|
+
raise NotImplementedError()
|
|
98
|
+
|
|
99
|
+
def _stream_update_only_attendees(self, activity_dto: ActivityDTO, participants_dto: list[ParticipantStatusDTO]):
|
|
100
|
+
"""
|
|
101
|
+
allow to update only the attendees of the event in the external calendar
|
|
102
|
+
"""
|
|
103
|
+
raise NotImplementedError()
|
|
104
|
+
|
|
105
|
+
def _stream_extension_event(self, activity_dto: ActivityDTO) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Extend external event with custom data
|
|
108
|
+
this allows us for example to add additional information to the event to easily identify it for a recurring activities
|
|
109
|
+
"""
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def _stream_forward(self, activity_dto: ActivityDTO, participants_dto: list[ParticipantStatusDTO]):
|
|
113
|
+
"""
|
|
114
|
+
allow to forward an event to a new participant. the external calendar
|
|
115
|
+
send an invitation to all participants and avoid sending an update of the activity to all participants
|
|
116
|
+
"""
|
|
117
|
+
raise NotImplementedError()
|
|
118
|
+
|
|
119
|
+
def _stream_participant_change(
|
|
120
|
+
self, participant_dto: ParticipantStatusDTO, is_deleted: bool = False, wait_before_changing: bool = False
|
|
121
|
+
):
|
|
122
|
+
"""
|
|
123
|
+
allow to update the status of an event participant
|
|
124
|
+
"""
|
|
125
|
+
raise NotImplementedError()
|
|
126
|
+
|
|
127
|
+
def _set_web_hook(self, user: "User") -> dict[str, dict[str, Any]]:
|
|
128
|
+
"""
|
|
129
|
+
allows to activate the webhook for a user
|
|
130
|
+
returns a dictionary that will be stored in the metadata of the ser
|
|
131
|
+
"""
|
|
132
|
+
raise NotImplementedError()
|
|
133
|
+
|
|
134
|
+
def _stop_web_hook(self, user: "User") -> dict[str, dict[str, Any]]:
|
|
135
|
+
"""
|
|
136
|
+
allows to strop the webhook for a user and deletes the data stored in the metadata
|
|
137
|
+
"""
|
|
138
|
+
raise NotImplementedError()
|
|
139
|
+
|
|
140
|
+
def _check_web_hook(self, user: "User") -> bool:
|
|
141
|
+
"""
|
|
142
|
+
return a boolean to know if a subscription is activated or not for a user
|
|
143
|
+
"""
|
|
144
|
+
raise NotImplementedError()
|
|
145
|
+
|
|
146
|
+
def set_web_hook(self, user: "User"):
|
|
147
|
+
"""
|
|
148
|
+
allows to be sure that the metadata are saved by specifying the backend type.
|
|
149
|
+
"""
|
|
150
|
+
new_metadata = self._set_web_hook(user)
|
|
151
|
+
user.metadata.setdefault(self.METADATA_KEY, {})
|
|
152
|
+
user.metadata[self.METADATA_KEY] = new_metadata
|
|
153
|
+
user.save()
|
|
154
|
+
|
|
155
|
+
def stop_web_hook(self, user: "User"):
|
|
156
|
+
new_metadata = self._stop_web_hook(user)
|
|
157
|
+
user.metadata.setdefault(self.METADATA_KEY, {})
|
|
158
|
+
user.metadata[self.METADATA_KEY] = new_metadata
|
|
159
|
+
user.save()
|
|
160
|
+
|
|
161
|
+
def check_web_hook(self, user: "User") -> bool:
|
|
162
|
+
try:
|
|
163
|
+
return self._check_web_hook(user)
|
|
164
|
+
except NotImplementedError:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
def renew_web_hooks(self) -> None:
|
|
168
|
+
"""
|
|
169
|
+
Allows to renew existing webhooks of all users
|
|
170
|
+
"""
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
def _get_webhook_inconsistencies(self) -> str:
|
|
174
|
+
"""
|
|
175
|
+
return a message of anomalies that will be notified to the administrator/persons set in the preferences
|
|
176
|
+
"""
|
|
177
|
+
raise NotImplementedError()
|
|
178
|
+
|
|
179
|
+
def notify_admins_of_webhook_inconsistencies(self, emails: list) -> None:
|
|
180
|
+
"""
|
|
181
|
+
the purpose is to make sure that the authorized persons receive the messages in case a webhook has been deactivated or not renewed correctly.
|
|
182
|
+
"""
|
|
183
|
+
with suppress(NotImplementedError):
|
|
184
|
+
if emails and (message := self._get_webhook_inconsistencies()):
|
|
185
|
+
for recipient in User.objects.filter(email__in=emails):
|
|
186
|
+
send_notification(
|
|
187
|
+
code="wbcrm.activity_sync.admin",
|
|
188
|
+
title=gettext_lazy("Notify admins of event webhook inconsistencies - {}").format(date.today()),
|
|
189
|
+
body=f"<ul>{message}</ul>",
|
|
190
|
+
user=recipient,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def get_external_event(self, activity_dto: ActivityDTO) -> dict:
|
|
194
|
+
"""
|
|
195
|
+
Get an event of external calendar.
|
|
196
|
+
"""
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def get_external_participants(
|
|
200
|
+
self, activity_dto: ActivityDTO, internal_participants_dto: list[ParticipantStatusDTO]
|
|
201
|
+
) -> list[str, Any]:
|
|
202
|
+
"""
|
|
203
|
+
Get external participants of an external event
|
|
204
|
+
"""
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
def _is_participant_valid(self, user: "User") -> bool:
|
|
208
|
+
return user.is_active and user.is_register
|
|
209
|
+
|
|
210
|
+
def is_valid(self, activity: ActivityDTO) -> bool:
|
|
211
|
+
# Synchronize only if the creator or at least one participant has an active subscription
|
|
212
|
+
participants = [activity.creator.email] if activity.creator else []
|
|
213
|
+
participants.extend(list(map(lambda x: x.person.email, activity.participants)))
|
|
214
|
+
return any([self._is_participant_valid(user) for user in User.objects.filter(email__in=set(participants))])
|
|
File without changes
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import warnings
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
from django.http import HttpRequest, HttpResponse
|
|
7
|
+
from django.utils import timezone
|
|
8
|
+
from django.utils.translation import gettext
|
|
9
|
+
from django.utils.translation import gettext_lazy as _
|
|
10
|
+
from dynamic_preferences.registries import global_preferences_registry
|
|
11
|
+
from google.oauth2.service_account import Credentials
|
|
12
|
+
from googleapiclient.discovery import Resource, build
|
|
13
|
+
from wbcore.contrib.authentication.models import User
|
|
14
|
+
from wbcore.contrib.directory.models import Person
|
|
15
|
+
|
|
16
|
+
from wbcrm.models import Activity, ActivityParticipant
|
|
17
|
+
|
|
18
|
+
from .request_utils import (
|
|
19
|
+
create_internal_activity_based_on_google_event,
|
|
20
|
+
delete_recurring_activity,
|
|
21
|
+
delete_single_activity,
|
|
22
|
+
update_activities_from_new_parent,
|
|
23
|
+
update_all_activities,
|
|
24
|
+
update_all_recurring_events_from_new_parent,
|
|
25
|
+
update_all_recurring_events_from_parent,
|
|
26
|
+
update_single_activity,
|
|
27
|
+
update_single_event,
|
|
28
|
+
update_single_recurring_event,
|
|
29
|
+
)
|
|
30
|
+
from .typing_informations import GoogleEventType
|
|
31
|
+
from .utils import GoogleSyncUtils
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GoogleCalendarBackend:
|
|
35
|
+
error_messages = {
|
|
36
|
+
"missing_google_credentials": _(
|
|
37
|
+
"The Google credentials are not set. You cannot use the Google Calendar Backend without the Google credentials."
|
|
38
|
+
),
|
|
39
|
+
"service_build_error": _("Could not create the Google service. Exception: "),
|
|
40
|
+
"create_error": gettext("Could not create the external google event. Exception: "),
|
|
41
|
+
"delete_error": _("Could not delete a corresponding external event. Exception: "),
|
|
42
|
+
"update_error": gettext("Could not update the external google-event. Exception: "),
|
|
43
|
+
"send_participant_response_error": gettext(
|
|
44
|
+
"Could not update the participation status on the google-event. Exception: "
|
|
45
|
+
),
|
|
46
|
+
"could_not_sync": _("Couldn't sync with google calendar. Exception:"),
|
|
47
|
+
"could_not_set_webhook": _("Could not set the google web hook for the user: "),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
SCOPES = ["https://www.googleapis.com/auth/calendar"]
|
|
51
|
+
API_SERVICE_NAME = "calendar"
|
|
52
|
+
API_VERSION = "v3"
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def _get_service_account_file(cls) -> Dict:
|
|
56
|
+
global_preferences = global_preferences_registry.manager()
|
|
57
|
+
google_credentials = global_preferences.get("wbactivity_sync__google_sync_credentials")
|
|
58
|
+
if google_credentials and (serivce_account_file := json.loads(google_credentials)):
|
|
59
|
+
return serivce_account_file
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(cls.error_messages["missing_google_credentials"])
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _get_service_account_url(cls) -> str:
|
|
65
|
+
serivce_account_file = cls._get_service_account_file()
|
|
66
|
+
return serivce_account_file.get("url", "")
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def _get_service_user_email(cls, activity: Activity) -> str:
|
|
70
|
+
"""
|
|
71
|
+
This methods returns the email of the first activity participant with an active google-subscription.
|
|
72
|
+
If no participant with an active subscrition is found, the return value will be an empty string.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
now = timezone.now().replace(tzinfo=None)
|
|
76
|
+
primary_email_contact: str = ""
|
|
77
|
+
|
|
78
|
+
def get_email_str(person: Person) -> str:
|
|
79
|
+
if user_profile := User.objects.filter(profile=person).first():
|
|
80
|
+
user_google_backend: Dict = user_profile.metadata.get("google_backend", {})
|
|
81
|
+
expiration: str | None = user_google_backend.get("watch", {}).get("expiration")
|
|
82
|
+
if expiration and datetime.fromtimestamp(int(expiration) / 1000) > now:
|
|
83
|
+
return str(user_profile.email)
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
primary_email_contact = get_email_str(activity.creator) if activity.creator else ""
|
|
87
|
+
if not primary_email_contact and (
|
|
88
|
+
internal_participants := activity.participants.filter(
|
|
89
|
+
id__in=Person.objects.filter_only_internal().exclude(
|
|
90
|
+
id=activity.creator.id if activity.creator else None
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
):
|
|
94
|
+
for participant in internal_participants:
|
|
95
|
+
if primary_email_contact := get_email_str(participant):
|
|
96
|
+
return primary_email_contact
|
|
97
|
+
return primary_email_contact
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def _build_service(cls, user_email: str) -> Resource:
|
|
101
|
+
serivce_account_file = cls._get_service_account_file()
|
|
102
|
+
try:
|
|
103
|
+
credentials = Credentials.from_service_account_info(serivce_account_file, scopes=cls.SCOPES)
|
|
104
|
+
return build(cls.API_SERVICE_NAME, cls.API_VERSION, credentials=credentials.with_subject(user_email))
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"{msg}{exception}".format(msg=cls.error_messages["service_build_error"], exception=e)
|
|
108
|
+
) from e
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def create_external_activity(cls, activity: Activity) -> None:
|
|
112
|
+
now = timezone.now()
|
|
113
|
+
can_sync_past_activities: bool = global_preferences_registry.manager()["wbactivity_sync__sync_past_activity"]
|
|
114
|
+
if (
|
|
115
|
+
activity.parent_occurrence
|
|
116
|
+
or not (service_user_mail := cls._get_service_user_email(activity))
|
|
117
|
+
or not (service := cls._build_service(user_email=service_user_mail))
|
|
118
|
+
or (not can_sync_past_activities and now > activity.period.lower) # type: ignore
|
|
119
|
+
):
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
event_body = GoogleSyncUtils.convert_activity_to_event(activity, True)
|
|
123
|
+
event = service.events().insert(calendarId=service_user_mail, body=event_body).execute()
|
|
124
|
+
metadata = activity.metadata | {"google_backend": {"event": event}}
|
|
125
|
+
Activity.objects.filter(id=activity.id).update(external_id=event["id"], metadata=metadata)
|
|
126
|
+
if Activity.objects.filter(parent_occurrence=activity).exists():
|
|
127
|
+
instances = service.events().instances(calendarId=service_user_mail, eventId=event["id"]).execute()
|
|
128
|
+
google_event_items = instances["items"]
|
|
129
|
+
GoogleSyncUtils.add_instance_metadata(activity, google_event_items, metadata, True)
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
raise ValueError("{msg}{exception}".format(msg=cls.error_messages["create_error"], exception=e)) from e
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def delete_external_activity(cls, activity: Activity) -> None:
|
|
136
|
+
now = timezone.now()
|
|
137
|
+
can_sync_past_activities: bool = global_preferences_registry.manager()["wbactivity_sync__sync_past_activity"]
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
not (service_user_mail := cls._get_service_user_email(activity))
|
|
141
|
+
or not (service := cls._build_service(user_email=service_user_mail))
|
|
142
|
+
or (not can_sync_past_activities and now > activity.period.lower) # type: ignore
|
|
143
|
+
):
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
external_id = activity.external_id
|
|
148
|
+
if (
|
|
149
|
+
Activity.objects.filter(parent_occurrence=activity).exists()
|
|
150
|
+
and not not activity.propagate_for_all_children
|
|
151
|
+
and (google_backend := activity.metadata.get("google_backend"))
|
|
152
|
+
):
|
|
153
|
+
# This step must be done if you want to remove a parent activity without deleting the whole recurring chain.
|
|
154
|
+
# Therefore we use the instance ID instead of the event ID.
|
|
155
|
+
external_id = google_backend.get("instance", {}).get("id")
|
|
156
|
+
service.events().delete(calendarId=service_user_mail, eventId=external_id).execute()
|
|
157
|
+
except Exception as e:
|
|
158
|
+
warnings.warn("{msg}{exception}".format(msg=cls.error_messages["delete_error"], exception=e), stacklevel=2)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def update_external_activity(cls, activity: Activity) -> None:
|
|
162
|
+
if not activity.metadata.get("google_backend"):
|
|
163
|
+
cls.create_external_activity(activity)
|
|
164
|
+
activity.refresh_from_db()
|
|
165
|
+
|
|
166
|
+
if not (service_user_mail := cls._get_service_user_email(activity)) or not (
|
|
167
|
+
service := cls._build_service(user_email=service_user_mail)
|
|
168
|
+
):
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
updated_event_body = GoogleSyncUtils.convert_activity_to_event(activity)
|
|
172
|
+
try:
|
|
173
|
+
is_parent = Activity.objects.filter(parent_occurrence=activity).exists()
|
|
174
|
+
|
|
175
|
+
def update_all_recurring_events():
|
|
176
|
+
if activity.metadata.get("old_parent_id"):
|
|
177
|
+
update_all_recurring_events_from_new_parent(
|
|
178
|
+
service_user_mail, service, activity, updated_event_body
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
update_all_recurring_events_from_parent(service_user_mail, service, activity, updated_event_body)
|
|
182
|
+
|
|
183
|
+
if is_parent or activity.parent_occurrence:
|
|
184
|
+
if activity.propagate_for_all_children:
|
|
185
|
+
update_all_recurring_events()
|
|
186
|
+
else:
|
|
187
|
+
update_single_recurring_event(service_user_mail, service, activity, updated_event_body)
|
|
188
|
+
else:
|
|
189
|
+
update_single_event(service_user_mail, service, activity, updated_event_body)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
raise ValueError("{msg}{exception}".format(msg=cls.error_messages["update_error"], exception=e)) from e
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def send_participant_response_external_activity(
|
|
195
|
+
cls, activity_participant: ActivityParticipant, response_status: str
|
|
196
|
+
):
|
|
197
|
+
participant: Person | None = Person.objects.filter(id=activity_participant.participant.id).first()
|
|
198
|
+
activity: Activity | None = Activity.objects.filter(id=activity_participant.activity.id).first()
|
|
199
|
+
if not participant or not activity:
|
|
200
|
+
return
|
|
201
|
+
if Activity.objects.filter(parent_occurrence=activity).exists():
|
|
202
|
+
google_backend = activity.metadata.get("google_backend", {})
|
|
203
|
+
external_id: str | None = google_backend.get("instance", google_backend.get("event", {})).get("id", None)
|
|
204
|
+
else:
|
|
205
|
+
external_id: str | None = activity.external_id
|
|
206
|
+
|
|
207
|
+
creator_mail = str(activity.creator.primary_email_contact()) if activity.creator else ""
|
|
208
|
+
participant_mail = str(participant.primary_email_contact())
|
|
209
|
+
service: Resource = cls._build_service(user_email=creator_mail)
|
|
210
|
+
|
|
211
|
+
if not service or not external_id:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
google_status = GoogleSyncUtils.convert_participant_status_to_attendee_status(response_status)
|
|
215
|
+
instance: Dict = service.events().get(calendarId=creator_mail, eventId=external_id).execute()
|
|
216
|
+
attendees_list: list[Dict] = instance.get("attendees", [])
|
|
217
|
+
|
|
218
|
+
for index, attendee in enumerate(attendees_list):
|
|
219
|
+
if attendee.get("email") == participant_mail:
|
|
220
|
+
attendees_list[index]["responseStatus"] = google_status
|
|
221
|
+
break
|
|
222
|
+
metadata = activity.metadata
|
|
223
|
+
google_backend = metadata.get("google_backend", {})
|
|
224
|
+
event_metadata = google_backend.get("event", google_backend.get("instance", {"instance": {}}))
|
|
225
|
+
event_metadata |= instance
|
|
226
|
+
Activity.objects.filter(id=activity.id).update(metadata=metadata)
|
|
227
|
+
|
|
228
|
+
service.events().patch(calendarId=creator_mail, eventId=instance["id"], body=instance).execute()
|
|
229
|
+
|
|
230
|
+
except Exception as e:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"{msg}{exception}".format(msg=cls.error_messages["send_participant_response_error"], exception=e)
|
|
233
|
+
) from e
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def sync_with_external_calendar(cls, request: HttpRequest) -> HttpResponse:
|
|
237
|
+
if (
|
|
238
|
+
request.headers
|
|
239
|
+
and (channel_id := request.headers.get("X-Goog-Channel-Id"))
|
|
240
|
+
and User.objects.filter(pk=channel_id).exists()
|
|
241
|
+
):
|
|
242
|
+
pass # TODO handle_changes_as_task.delay(channel_id)
|
|
243
|
+
return HttpResponse({})
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def get_sync_token(cls, user: User) -> str | None:
|
|
247
|
+
if google_backend := user.metadata.get("google_backend"):
|
|
248
|
+
return google_backend.get("sync_token")
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def delete_internal_activity(cls, activity: Activity, **kwargs) -> None:
|
|
252
|
+
event = kwargs.get("event", {})
|
|
253
|
+
user_email = kwargs.get("user_email", "")
|
|
254
|
+
service = kwargs.get("service")
|
|
255
|
+
if Activity.objects.filter(parent_occurrence=activity).exists() or activity.parent_occurrence:
|
|
256
|
+
delete_recurring_activity(activity, event, user_email, service)
|
|
257
|
+
else:
|
|
258
|
+
delete_single_activity(activity)
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def update_internal_activity(cls, activity: Activity, **kwargs) -> None:
|
|
262
|
+
event: GoogleEventType = kwargs.get("event", {})
|
|
263
|
+
user_email = kwargs.get("user_email", "")
|
|
264
|
+
service = kwargs.get("service")
|
|
265
|
+
if event.get("recurringEventId"):
|
|
266
|
+
update_single_activity(event, activity)
|
|
267
|
+
elif event.get("recurrence"):
|
|
268
|
+
update_all_activities(activity, event, user_email, service)
|
|
269
|
+
else:
|
|
270
|
+
update_single_activity(event, activity)
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def create_internal_activity(cls, **kwargs) -> None:
|
|
274
|
+
event = kwargs.get("event", {})
|
|
275
|
+
user = kwargs.get("user")
|
|
276
|
+
service = kwargs.get("service")
|
|
277
|
+
create_internal_activity_based_on_google_event.si(event, user, service)
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def handle_changes(cls, user_id: int) -> None:
|
|
281
|
+
user = User.objects.get(id=user_id)
|
|
282
|
+
user_email = user.email
|
|
283
|
+
can_sync_past_activities: bool = global_preferences_registry.manager()["wbactivity_sync__sync_past_activity"]
|
|
284
|
+
service = cls._build_service(user_email=user_email)
|
|
285
|
+
now = timezone.now()
|
|
286
|
+
if not service:
|
|
287
|
+
return
|
|
288
|
+
external_event_list = []
|
|
289
|
+
page_token = None
|
|
290
|
+
while True:
|
|
291
|
+
request = service.events().list(
|
|
292
|
+
calendarId=user_email, pageToken=page_token, syncToken=cls.get_sync_token(user)
|
|
293
|
+
)
|
|
294
|
+
events = {}
|
|
295
|
+
try:
|
|
296
|
+
events: Dict = request.execute()
|
|
297
|
+
except Exception as e:
|
|
298
|
+
warnings.warn(
|
|
299
|
+
"{msg}{exception}".format(msg=cls.error_messages["could_not_sync"], exception=e), stacklevel=2
|
|
300
|
+
)
|
|
301
|
+
external_event_list += events.get("items", [])
|
|
302
|
+
page_token = events.get("nextPageToken")
|
|
303
|
+
if not page_token:
|
|
304
|
+
user.metadata.setdefault("google_backend", {})
|
|
305
|
+
user.metadata["google_backend"]["sync_token"] = events.get("nextSyncToken")
|
|
306
|
+
user.save()
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
for event in external_event_list:
|
|
310
|
+
if event.get("start") and not event.get("recurrence"):
|
|
311
|
+
event_start, _ = GoogleSyncUtils.get_start_and_end(event)
|
|
312
|
+
is_all_day_event = True if event["start"].get("date") else False
|
|
313
|
+
starts_in_past = now.date() > event_start.date() if is_all_day_event else now > event_start
|
|
314
|
+
if not can_sync_past_activities and starts_in_past:
|
|
315
|
+
return
|
|
316
|
+
external_id = event["id"]
|
|
317
|
+
# Note about how Google assigns IDs for events: A single, non-recurring event has an ID consisting of a unique string.
|
|
318
|
+
# As soon as an event has recurring subsequent events, this string is extended by the start date of the respective subsequent event. these two parts are connected by "_R".
|
|
319
|
+
# So if you look at the part before _R you get the ID for the parent event.
|
|
320
|
+
first_part_of_id = external_id.split("_R")[0] if "_R" in external_id else None
|
|
321
|
+
if (
|
|
322
|
+
(activity := Activity.objects.filter(external_id=external_id).first())
|
|
323
|
+
or (activity := Activity.objects.filter(metadata__google_backend__instance__id=external_id).first())
|
|
324
|
+
or (activity := Activity.objects.filter(metadata__google_backend__event__id=external_id).first())
|
|
325
|
+
):
|
|
326
|
+
# There are two ways we know an event was deleted. Either we receive the event-status "cancelled", or when the "recurrence" field changes.
|
|
327
|
+
# The second one can also indicate that an event was altered. But at this point we don't know if it was deleted or updated. If it was updated we can restore it later.
|
|
328
|
+
google_backend: Dict = activity.metadata["google_backend"]
|
|
329
|
+
metadata_event: Dict = google_backend.get("event", google_backend.get("instance", {}))
|
|
330
|
+
metadata_event_reccurence: list[str] | None = metadata_event.get("recurrence")
|
|
331
|
+
|
|
332
|
+
if event.get("status") == "cancelled" or (
|
|
333
|
+
event.get("recurrence") and event.get("recurrence") != metadata_event_reccurence
|
|
334
|
+
):
|
|
335
|
+
cls.delete_internal_activity(activity, event=event, user_email=user_email, service=service)
|
|
336
|
+
else:
|
|
337
|
+
cls.update_internal_activity(activity, event=event, user_email=user_email, service=service)
|
|
338
|
+
elif first_part_of_id and (
|
|
339
|
+
parent_occurrence := Activity.objects.filter(external_id=first_part_of_id).first()
|
|
340
|
+
):
|
|
341
|
+
update_activities_from_new_parent(event, parent_occurrence, user_email, service)
|
|
342
|
+
else:
|
|
343
|
+
cls.create_internal_activity(event=event, user=user, service=service)
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def get_external_activity(cls, activity: Activity):
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
@classmethod
|
|
350
|
+
def forward_external_activity(cls, activity: Activity, participants: list):
|
|
351
|
+
cls.update_external_activity(activity)
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
def set_web_hook(cls, user: User, expiration_in_ms: int = 0) -> None:
|
|
355
|
+
user_email = user.email
|
|
356
|
+
service = cls._build_service(user_email=user_email)
|
|
357
|
+
if service:
|
|
358
|
+
try:
|
|
359
|
+
watch_body = {
|
|
360
|
+
"id": user.id, # type: ignore
|
|
361
|
+
"type": "web_hook",
|
|
362
|
+
"address": cls._get_service_account_url(),
|
|
363
|
+
}
|
|
364
|
+
if expiration_in_ms > 0.0:
|
|
365
|
+
watch_body |= {"expiration": str(expiration_in_ms)}
|
|
366
|
+
response = service.events().watch(calendarId=user_email, body=watch_body).execute()
|
|
367
|
+
user.metadata.setdefault("google_backend", {})
|
|
368
|
+
user.metadata["google_backend"]["watch"] = response
|
|
369
|
+
user.save()
|
|
370
|
+
except Exception as e:
|
|
371
|
+
raise ValueError(
|
|
372
|
+
"{msg}{user}. Eception: {exception}".format(
|
|
373
|
+
msg=cls.error_messages["could_not_set_webhook"],
|
|
374
|
+
user=user.profile.computed_str,
|
|
375
|
+
exception=e, # type: ignore
|
|
376
|
+
)
|
|
377
|
+
) from e
|
|
378
|
+
|
|
379
|
+
@classmethod
|
|
380
|
+
def stop_web_hook(cls, user: User) -> None:
|
|
381
|
+
user_email = user.email
|
|
382
|
+
service = cls._build_service(user_email=user_email)
|
|
383
|
+
if service:
|
|
384
|
+
body = {
|
|
385
|
+
"id": user.metadata["google_backend"]["watch"]["id"],
|
|
386
|
+
"resourceId": user.metadata["google_backend"]["watch"]["resourceId"],
|
|
387
|
+
}
|
|
388
|
+
service.channels().stop(body=body).execute()
|
|
389
|
+
del user.metadata["google_backend"]["watch"]
|
|
390
|
+
user.save()
|
|
391
|
+
|
|
392
|
+
@classmethod
|
|
393
|
+
def check_web_hook(cls, user: User) -> None:
|
|
394
|
+
now = timezone.now().replace(tzinfo=None)
|
|
395
|
+
user_google_backend: Dict = user.metadata.get("google_backend", {})
|
|
396
|
+
expiration: str | None = user_google_backend.get("watch", {}).get("expiration")
|
|
397
|
+
if expiration and datetime.fromtimestamp(int(expiration) / 1000) > now:
|
|
398
|
+
warnings.warn(
|
|
399
|
+
_("Timestamp valid until:") + str(datetime.fromtimestamp(int(expiration) / 1000)), stacklevel=2
|
|
400
|
+
)
|
|
401
|
+
else:
|
|
402
|
+
raise Exception(_("No valid web hook found"))
|
|
403
|
+
|
|
404
|
+
def _get_webhook_inconsistencies(self) -> str: ...
|
|
405
|
+
|
|
406
|
+
def webhook_resubscription(self) -> None: ...
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .external_to_internal.create import create_internal_activity_based_on_google_event
|
|
2
|
+
from .external_to_internal.delete import (
|
|
3
|
+
delete_recurring_activity,
|
|
4
|
+
delete_single_activity,
|
|
5
|
+
)
|
|
6
|
+
from .external_to_internal.update import (
|
|
7
|
+
update_activities_from_new_parent,
|
|
8
|
+
update_all_activities,
|
|
9
|
+
update_single_activity,
|
|
10
|
+
)
|
|
11
|
+
from .internal_to_external.update import (
|
|
12
|
+
update_all_recurring_events_from_new_parent,
|
|
13
|
+
update_all_recurring_events_from_parent,
|
|
14
|
+
update_single_event,
|
|
15
|
+
update_single_recurring_event,
|
|
16
|
+
)
|