wbcrm 2.2.1__py2.py3-none-any.whl

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

Potentially problematic release.


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

Files changed (155) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +4 -0
  3. wbcrm/admin/accounts.py +59 -0
  4. wbcrm/admin/activities.py +101 -0
  5. wbcrm/admin/groups.py +7 -0
  6. wbcrm/admin/products.py +8 -0
  7. wbcrm/apps.py +5 -0
  8. wbcrm/configurations/__init__.py +1 -0
  9. wbcrm/configurations/base.py +16 -0
  10. wbcrm/dynamic_preferences_registry.py +38 -0
  11. wbcrm/factories/__init__.py +14 -0
  12. wbcrm/factories/accounts.py +56 -0
  13. wbcrm/factories/activities.py +125 -0
  14. wbcrm/factories/groups.py +23 -0
  15. wbcrm/factories/products.py +10 -0
  16. wbcrm/filters/__init__.py +10 -0
  17. wbcrm/filters/accounts.py +67 -0
  18. wbcrm/filters/activities.py +181 -0
  19. wbcrm/filters/groups.py +20 -0
  20. wbcrm/filters/products.py +37 -0
  21. wbcrm/filters/signals.py +94 -0
  22. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  23. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  24. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  25. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  26. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  27. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  28. wbcrm/migrations/0007_alter_account_status.py +23 -0
  29. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  30. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  31. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  32. wbcrm/migrations/0011_activity_summary.py +22 -0
  33. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  34. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  35. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  36. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  37. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  38. wbcrm/migrations/__init__.py +0 -0
  39. wbcrm/models/__init__.py +4 -0
  40. wbcrm/models/accounts.py +637 -0
  41. wbcrm/models/activities.py +1335 -0
  42. wbcrm/models/groups.py +118 -0
  43. wbcrm/models/products.py +83 -0
  44. wbcrm/models/recurrence.py +279 -0
  45. wbcrm/preferences.py +14 -0
  46. wbcrm/serializers/__init__.py +23 -0
  47. wbcrm/serializers/accounts.py +126 -0
  48. wbcrm/serializers/activities.py +526 -0
  49. wbcrm/serializers/groups.py +30 -0
  50. wbcrm/serializers/products.py +57 -0
  51. wbcrm/serializers/recurrence.py +90 -0
  52. wbcrm/serializers/signals.py +70 -0
  53. wbcrm/synchronization/__init__.py +0 -0
  54. wbcrm/synchronization/activity/__init__.py +0 -0
  55. wbcrm/synchronization/activity/admin.py +72 -0
  56. wbcrm/synchronization/activity/backend.py +207 -0
  57. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  58. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  59. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
  60. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  61. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  62. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  63. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  64. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  65. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  66. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  67. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  68. wbcrm/synchronization/activity/backends/google/utils.py +216 -0
  69. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  70. wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
  71. wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
  72. wbcrm/synchronization/activity/backends/outlook/parser.py +423 -0
  73. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  74. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  75. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  76. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
  77. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
  78. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
  79. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
  80. wbcrm/synchronization/activity/controller.py +545 -0
  81. wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
  82. wbcrm/synchronization/activity/preferences.py +21 -0
  83. wbcrm/synchronization/activity/shortcuts.py +9 -0
  84. wbcrm/synchronization/activity/signals.py +28 -0
  85. wbcrm/synchronization/activity/tasks.py +21 -0
  86. wbcrm/synchronization/activity/urls.py +6 -0
  87. wbcrm/synchronization/activity/utils.py +46 -0
  88. wbcrm/synchronization/activity/views.py +37 -0
  89. wbcrm/synchronization/admin.py +1 -0
  90. wbcrm/synchronization/apps.py +15 -0
  91. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  92. wbcrm/synchronization/management.py +36 -0
  93. wbcrm/synchronization/tasks.py +1 -0
  94. wbcrm/synchronization/urls.py +5 -0
  95. wbcrm/tasks.py +312 -0
  96. wbcrm/tests/__init__.py +0 -0
  97. wbcrm/tests/accounts/__init__.py +0 -0
  98. wbcrm/tests/accounts/test_models.py +380 -0
  99. wbcrm/tests/accounts/test_viewsets.py +87 -0
  100. wbcrm/tests/conftest.py +76 -0
  101. wbcrm/tests/disable_signals.py +52 -0
  102. wbcrm/tests/e2e/__init__.py +1 -0
  103. wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
  104. wbcrm/tests/e2e/test_e2e.py +369 -0
  105. wbcrm/tests/test_assignee_methods.py +39 -0
  106. wbcrm/tests/test_chartviewsets.py +111 -0
  107. wbcrm/tests/test_dto.py +63 -0
  108. wbcrm/tests/test_filters.py +51 -0
  109. wbcrm/tests/test_models.py +216 -0
  110. wbcrm/tests/test_recurrence.py +291 -0
  111. wbcrm/tests/test_report.py +20 -0
  112. wbcrm/tests/test_serializers.py +170 -0
  113. wbcrm/tests/test_tasks.py +94 -0
  114. wbcrm/tests/test_viewsets.py +967 -0
  115. wbcrm/tests/tests.py +120 -0
  116. wbcrm/typings.py +107 -0
  117. wbcrm/urls.py +67 -0
  118. wbcrm/viewsets/__init__.py +22 -0
  119. wbcrm/viewsets/accounts.py +121 -0
  120. wbcrm/viewsets/activities.py +315 -0
  121. wbcrm/viewsets/buttons/__init__.py +7 -0
  122. wbcrm/viewsets/buttons/accounts.py +27 -0
  123. wbcrm/viewsets/buttons/activities.py +68 -0
  124. wbcrm/viewsets/buttons/signals.py +17 -0
  125. wbcrm/viewsets/display/__init__.py +12 -0
  126. wbcrm/viewsets/display/accounts.py +110 -0
  127. wbcrm/viewsets/display/activities.py +443 -0
  128. wbcrm/viewsets/display/groups.py +22 -0
  129. wbcrm/viewsets/display/products.py +105 -0
  130. wbcrm/viewsets/endpoints/__init__.py +8 -0
  131. wbcrm/viewsets/endpoints/accounts.py +32 -0
  132. wbcrm/viewsets/endpoints/activities.py +30 -0
  133. wbcrm/viewsets/endpoints/groups.py +7 -0
  134. wbcrm/viewsets/endpoints/products.py +9 -0
  135. wbcrm/viewsets/groups.py +37 -0
  136. wbcrm/viewsets/menu/__init__.py +8 -0
  137. wbcrm/viewsets/menu/accounts.py +18 -0
  138. wbcrm/viewsets/menu/activities.py +61 -0
  139. wbcrm/viewsets/menu/groups.py +16 -0
  140. wbcrm/viewsets/menu/products.py +20 -0
  141. wbcrm/viewsets/mixins.py +34 -0
  142. wbcrm/viewsets/previews/__init__.py +1 -0
  143. wbcrm/viewsets/previews/activities.py +10 -0
  144. wbcrm/viewsets/products.py +56 -0
  145. wbcrm/viewsets/recurrence.py +26 -0
  146. wbcrm/viewsets/titles/__init__.py +13 -0
  147. wbcrm/viewsets/titles/accounts.py +22 -0
  148. wbcrm/viewsets/titles/activities.py +61 -0
  149. wbcrm/viewsets/titles/products.py +13 -0
  150. wbcrm/viewsets/titles/utils.py +46 -0
  151. wbcrm/workflows/__init__.py +1 -0
  152. wbcrm/workflows/assignee_methods.py +25 -0
  153. wbcrm-2.2.1.dist-info/METADATA +11 -0
  154. wbcrm-2.2.1.dist-info/RECORD +155 -0
  155. wbcrm-2.2.1.dist-info/WHEEL +5 -0
@@ -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
+ )
@@ -0,0 +1,21 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ from celery import shared_task
4
+ from wbcore.contrib.authentication.models import User
5
+
6
+
7
+ @shared_task(queue="synchronization")
8
+ def google_webhook_resubscription() -> None:
9
+ """
10
+ A task to renew the google webhook subscriptions. The expiration date will be increased by 8 days.
11
+ Only the subscriptions of users who still have a valid subscription will be renewed.
12
+ """
13
+
14
+ from .google import GoogleCalendarBackend
15
+
16
+ user: User
17
+ for user in User.objects.filter(metadata__google_backend__watch__isnull=False):
18
+ GoogleCalendarBackend.stop_web_hook(user)
19
+ user.refresh_from_db()
20
+ new_timestamp_ms = round((datetime.now() + timedelta(days=8)).timestamp() * 1000)
21
+ GoogleCalendarBackend.set_web_hook(user, new_timestamp_ms)
@@ -0,0 +1 @@
1
+ from wbcrm.tests.conftest import *
@@ -0,0 +1,81 @@
1
+ import calendar
2
+ import datetime as dt
3
+
4
+ week_ahead = dt.date.today() + dt.timedelta(days=7)
5
+ week_before = dt.date.today() - dt.timedelta(days=7)
6
+ week_ahead_timestamp = calendar.timegm(week_ahead.timetuple()) * 1000
7
+ week_before_timestamp = calendar.timegm(week_before.timetuple()) * 1000
8
+ credentials = '{"url": "https://fake_url.io", "type": "service_account", "project_id": "fake_project_id", "private_key_id": "fake_private_key_id", "private_key": "fake_private_key", "client_email": "client_mail@serviceaccount.com", "client_id": "fake_client_id", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://fake_auth_url", "client_x509_cert_url": "https://fake_cert_url"}'
9
+
10
+ person_metadata = {"google_backend": {"watch": {"expiration": str(week_ahead_timestamp)}}}
11
+ person_metadata_expired = {"google_backend": {"watch": {"expiration": str(week_before_timestamp)}, "expired": True}}
12
+
13
+ event = {
14
+ "attendees": [
15
+ {"displayName": "Foo", "email": "Foo@Foo.com", "responseStatus": "accepted"},
16
+ {"displayName": "Bar", "email": "Bar@Bar.com", "responseStatus": "declined"},
17
+ {"displayName": "Foo Bar", "email": "Foo@Bar.com", "responseStatus": "tentative"},
18
+ {"email": "Bar@Foo.com", "responseStatus": "tentative"},
19
+ ]
20
+ }
21
+
22
+ event_data = {
23
+ "id": "test",
24
+ "items": [],
25
+ "start": {"date": "2022-12-06", "dateTime": "2022-12-06T17:25:00+0200", "timeZone": "UTC"},
26
+ "end": {"date": "2022-12-06", "dateTime": "2022-12-06T18:25:00+0200", "timeZone": "UTC"},
27
+ }
28
+ event_list = [
29
+ {"id": "1", "metaTest": "Parent", "originalStartTime": {"dateTime": "Fake Date Time"}},
30
+ {"id": "2", "metaTest": "Child A", "originalStartTime": {"dateTime": "Fake Date Time A"}},
31
+ {"id": "3", "metaTest": "Child B", "originalStartTime": {"dateTime": "Fake Date Time B"}},
32
+ {"id": "4", "metaTest": "Child C", "originalStartTime": {"dateTime": "Fake Date Time C"}},
33
+ ]
34
+
35
+
36
+ class event_service:
37
+ def insert(calendarId, body):
38
+ return execute_service(calendarId, body)
39
+
40
+ def instances(calendarId, eventId):
41
+ return execute_service(calendarId, eventId)
42
+
43
+ def delete(calendarId, eventId):
44
+ return execute_service(calendarId, eventId)
45
+
46
+ def update(calendarId, eventId, body=event):
47
+ return execute_service(calendarId, eventId)
48
+
49
+ def patch(calendarId, eventId, body=event):
50
+ return execute_service(calendarId, eventId)
51
+
52
+ def get(calendarId, eventId, body=event):
53
+ return execute_service(calendarId, eventId)
54
+
55
+ def list(calendarId, pageToken, syncToken):
56
+ return execute_service(calendarId, pageToken)
57
+
58
+ def watch(calendarId, body=event):
59
+ return execute_service(calendarId, event)
60
+
61
+
62
+ class channels_service:
63
+ def stop(body):
64
+ return execute_service("", body)
65
+
66
+
67
+ class execute_service:
68
+ def __init__(self, calendarId, body):
69
+ self.calendarId = calendarId
70
+ self.body = body
71
+
72
+ def execute(self):
73
+ return event_data
74
+
75
+
76
+ class service_data:
77
+ def events(self):
78
+ return event_service
79
+
80
+ def channels(self):
81
+ return channels_service