wbcrm 1.43.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wbcrm might be problematic. Click here for more details.

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