wbcrm 1.56.8__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +5 -0
  3. wbcrm/admin/accounts.py +60 -0
  4. wbcrm/admin/activities.py +104 -0
  5. wbcrm/admin/events.py +43 -0
  6. wbcrm/admin/groups.py +8 -0
  7. wbcrm/admin/products.py +9 -0
  8. wbcrm/apps.py +5 -0
  9. wbcrm/configurations/__init__.py +1 -0
  10. wbcrm/configurations/base.py +16 -0
  11. wbcrm/dynamic_preferences_registry.py +38 -0
  12. wbcrm/factories/__init__.py +14 -0
  13. wbcrm/factories/accounts.py +57 -0
  14. wbcrm/factories/activities.py +124 -0
  15. wbcrm/factories/groups.py +24 -0
  16. wbcrm/factories/products.py +11 -0
  17. wbcrm/filters/__init__.py +10 -0
  18. wbcrm/filters/accounts.py +80 -0
  19. wbcrm/filters/activities.py +204 -0
  20. wbcrm/filters/groups.py +21 -0
  21. wbcrm/filters/products.py +38 -0
  22. wbcrm/filters/signals.py +95 -0
  23. wbcrm/fixtures/wbcrm.json +1215 -0
  24. wbcrm/kpi_handlers/activities.py +171 -0
  25. wbcrm/locale/de/LC_MESSAGES/django.mo +0 -0
  26. wbcrm/locale/de/LC_MESSAGES/django.po +1557 -0
  27. wbcrm/locale/de/LC_MESSAGES/django.po.translated +1630 -0
  28. wbcrm/locale/en/LC_MESSAGES/django.mo +0 -0
  29. wbcrm/locale/en/LC_MESSAGES/django.po +1466 -0
  30. wbcrm/locale/fr/LC_MESSAGES/django.mo +0 -0
  31. wbcrm/locale/fr/LC_MESSAGES/django.po +1467 -0
  32. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  33. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  34. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  35. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  36. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  37. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  38. wbcrm/migrations/0007_alter_account_status.py +23 -0
  39. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  40. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  41. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  42. wbcrm/migrations/0011_activity_summary.py +22 -0
  43. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  44. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  45. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  46. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  47. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  48. wbcrm/migrations/0017_event.py +40 -0
  49. wbcrm/migrations/0018_activity_search_vector.py +24 -0
  50. wbcrm/migrations/__init__.py +0 -0
  51. wbcrm/models/__init__.py +5 -0
  52. wbcrm/models/accounts.py +648 -0
  53. wbcrm/models/activities.py +1419 -0
  54. wbcrm/models/events.py +15 -0
  55. wbcrm/models/groups.py +119 -0
  56. wbcrm/models/llm/activity_summaries.py +41 -0
  57. wbcrm/models/llm/analyze_relationship.py +50 -0
  58. wbcrm/models/products.py +86 -0
  59. wbcrm/models/recurrence.py +280 -0
  60. wbcrm/preferences.py +13 -0
  61. wbcrm/report/activity_report.py +110 -0
  62. wbcrm/serializers/__init__.py +23 -0
  63. wbcrm/serializers/accounts.py +141 -0
  64. wbcrm/serializers/activities.py +525 -0
  65. wbcrm/serializers/groups.py +30 -0
  66. wbcrm/serializers/products.py +58 -0
  67. wbcrm/serializers/recurrence.py +91 -0
  68. wbcrm/serializers/signals.py +71 -0
  69. wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
  70. wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
  71. wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
  72. wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
  73. wbcrm/synchronization/__init__.py +0 -0
  74. wbcrm/synchronization/activity/__init__.py +0 -0
  75. wbcrm/synchronization/activity/admin.py +73 -0
  76. wbcrm/synchronization/activity/backend.py +214 -0
  77. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  78. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  79. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +406 -0
  80. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  81. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
  82. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
  83. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
  84. wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +181 -0
  85. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  86. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  87. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  88. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  89. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  90. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  91. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  92. wbcrm/synchronization/activity/backends/google/utils.py +217 -0
  93. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  94. wbcrm/synchronization/activity/backends/outlook/backend.py +593 -0
  95. wbcrm/synchronization/activity/backends/outlook/msgraph.py +436 -0
  96. wbcrm/synchronization/activity/backends/outlook/parser.py +432 -0
  97. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  98. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  99. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  100. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +118 -0
  101. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +274 -0
  102. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +249 -0
  103. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +174 -0
  104. wbcrm/synchronization/activity/controller.py +627 -0
  105. wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
  106. wbcrm/synchronization/activity/preferences.py +27 -0
  107. wbcrm/synchronization/activity/shortcuts.py +16 -0
  108. wbcrm/synchronization/activity/tasks.py +21 -0
  109. wbcrm/synchronization/activity/urls.py +7 -0
  110. wbcrm/synchronization/activity/utils.py +46 -0
  111. wbcrm/synchronization/activity/views.py +41 -0
  112. wbcrm/synchronization/admin.py +1 -0
  113. wbcrm/synchronization/apps.py +14 -0
  114. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  115. wbcrm/synchronization/management.py +36 -0
  116. wbcrm/synchronization/tasks.py +1 -0
  117. wbcrm/synchronization/urls.py +5 -0
  118. wbcrm/tasks.py +264 -0
  119. wbcrm/templates/email/activity.html +98 -0
  120. wbcrm/templates/email/activity_report.html +6 -0
  121. wbcrm/templates/email/daily_summary.html +72 -0
  122. wbcrm/templates/email/global_daily_summary.html +85 -0
  123. wbcrm/tests/__init__.py +0 -0
  124. wbcrm/tests/accounts/__init__.py +0 -0
  125. wbcrm/tests/accounts/test_models.py +393 -0
  126. wbcrm/tests/accounts/test_viewsets.py +88 -0
  127. wbcrm/tests/conftest.py +76 -0
  128. wbcrm/tests/disable_signals.py +62 -0
  129. wbcrm/tests/e2e/__init__.py +1 -0
  130. wbcrm/tests/e2e/e2e_wbcrm_utility.py +83 -0
  131. wbcrm/tests/e2e/test_e2e.py +370 -0
  132. wbcrm/tests/test_assignee_methods.py +40 -0
  133. wbcrm/tests/test_chartviewsets.py +112 -0
  134. wbcrm/tests/test_dto.py +64 -0
  135. wbcrm/tests/test_filters.py +52 -0
  136. wbcrm/tests/test_models.py +217 -0
  137. wbcrm/tests/test_recurrence.py +292 -0
  138. wbcrm/tests/test_report.py +21 -0
  139. wbcrm/tests/test_serializers.py +171 -0
  140. wbcrm/tests/test_tasks.py +95 -0
  141. wbcrm/tests/test_viewsets.py +967 -0
  142. wbcrm/tests/tests.py +121 -0
  143. wbcrm/typings.py +109 -0
  144. wbcrm/urls.py +67 -0
  145. wbcrm/viewsets/__init__.py +22 -0
  146. wbcrm/viewsets/accounts.py +122 -0
  147. wbcrm/viewsets/activities.py +341 -0
  148. wbcrm/viewsets/buttons/__init__.py +7 -0
  149. wbcrm/viewsets/buttons/accounts.py +27 -0
  150. wbcrm/viewsets/buttons/activities.py +89 -0
  151. wbcrm/viewsets/buttons/signals.py +17 -0
  152. wbcrm/viewsets/display/__init__.py +12 -0
  153. wbcrm/viewsets/display/accounts.py +110 -0
  154. wbcrm/viewsets/display/activities.py +444 -0
  155. wbcrm/viewsets/display/groups.py +22 -0
  156. wbcrm/viewsets/display/products.py +105 -0
  157. wbcrm/viewsets/endpoints/__init__.py +8 -0
  158. wbcrm/viewsets/endpoints/accounts.py +25 -0
  159. wbcrm/viewsets/endpoints/activities.py +30 -0
  160. wbcrm/viewsets/endpoints/groups.py +7 -0
  161. wbcrm/viewsets/endpoints/products.py +9 -0
  162. wbcrm/viewsets/groups.py +38 -0
  163. wbcrm/viewsets/menu/__init__.py +8 -0
  164. wbcrm/viewsets/menu/accounts.py +18 -0
  165. wbcrm/viewsets/menu/activities.py +49 -0
  166. wbcrm/viewsets/menu/groups.py +16 -0
  167. wbcrm/viewsets/menu/products.py +20 -0
  168. wbcrm/viewsets/mixins.py +35 -0
  169. wbcrm/viewsets/previews/__init__.py +1 -0
  170. wbcrm/viewsets/previews/activities.py +10 -0
  171. wbcrm/viewsets/products.py +57 -0
  172. wbcrm/viewsets/recurrence.py +27 -0
  173. wbcrm/viewsets/titles/__init__.py +13 -0
  174. wbcrm/viewsets/titles/accounts.py +23 -0
  175. wbcrm/viewsets/titles/activities.py +61 -0
  176. wbcrm/viewsets/titles/products.py +13 -0
  177. wbcrm/viewsets/titles/utils.py +46 -0
  178. wbcrm/workflows/__init__.py +1 -0
  179. wbcrm/workflows/assignee_methods.py +25 -0
  180. wbcrm-1.56.8.dist-info/METADATA +11 -0
  181. wbcrm-1.56.8.dist-info/RECORD +182 -0
  182. wbcrm-1.56.8.dist-info/WHEEL +5 -0
@@ -0,0 +1,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,2 @@
1
+ from .google_calendar_backend import GoogleCalendarBackend
2
+ from .tasks import google_webhook_resubscription
@@ -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
+ )