wbcrm 2.2.1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wbcrm might be problematic. Click here for more details.
- wbcrm/__init__.py +1 -0
- wbcrm/admin/__init__.py +4 -0
- wbcrm/admin/accounts.py +59 -0
- wbcrm/admin/activities.py +101 -0
- wbcrm/admin/groups.py +7 -0
- wbcrm/admin/products.py +8 -0
- wbcrm/apps.py +5 -0
- wbcrm/configurations/__init__.py +1 -0
- wbcrm/configurations/base.py +16 -0
- wbcrm/dynamic_preferences_registry.py +38 -0
- wbcrm/factories/__init__.py +14 -0
- wbcrm/factories/accounts.py +56 -0
- wbcrm/factories/activities.py +125 -0
- wbcrm/factories/groups.py +23 -0
- wbcrm/factories/products.py +10 -0
- wbcrm/filters/__init__.py +10 -0
- wbcrm/filters/accounts.py +67 -0
- wbcrm/filters/activities.py +181 -0
- wbcrm/filters/groups.py +20 -0
- wbcrm/filters/products.py +37 -0
- wbcrm/filters/signals.py +94 -0
- wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
- wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
- wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
- wbcrm/migrations/0004_alter_activity_status.py +28 -0
- wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
- wbcrm/migrations/0006_alter_activity_location.py +17 -0
- wbcrm/migrations/0007_alter_account_status.py +23 -0
- wbcrm/migrations/0008_alter_activity_options.py +16 -0
- wbcrm/migrations/0009_alter_account_is_public.py +19 -0
- wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
- wbcrm/migrations/0011_activity_summary.py +22 -0
- wbcrm/migrations/0012_alter_activity_summary.py +17 -0
- wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
- wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
- wbcrm/migrations/0015_alter_activity_type.py +23 -0
- wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
- wbcrm/migrations/__init__.py +0 -0
- wbcrm/models/__init__.py +4 -0
- wbcrm/models/accounts.py +637 -0
- wbcrm/models/activities.py +1335 -0
- wbcrm/models/groups.py +118 -0
- wbcrm/models/products.py +83 -0
- wbcrm/models/recurrence.py +279 -0
- wbcrm/preferences.py +14 -0
- wbcrm/serializers/__init__.py +23 -0
- wbcrm/serializers/accounts.py +126 -0
- wbcrm/serializers/activities.py +526 -0
- wbcrm/serializers/groups.py +30 -0
- wbcrm/serializers/products.py +57 -0
- wbcrm/serializers/recurrence.py +90 -0
- wbcrm/serializers/signals.py +70 -0
- wbcrm/synchronization/__init__.py +0 -0
- wbcrm/synchronization/activity/__init__.py +0 -0
- wbcrm/synchronization/activity/admin.py +72 -0
- wbcrm/synchronization/activity/backend.py +207 -0
- wbcrm/synchronization/activity/backends/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
- wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
- wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
- wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
- wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
- wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
- wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
- wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
- wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
- wbcrm/synchronization/activity/backends/google/utils.py +216 -0
- wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
- wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
- wbcrm/synchronization/activity/backends/outlook/parser.py +423 -0
- wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
- wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
- wbcrm/synchronization/activity/controller.py +545 -0
- wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
- wbcrm/synchronization/activity/preferences.py +21 -0
- wbcrm/synchronization/activity/shortcuts.py +9 -0
- wbcrm/synchronization/activity/signals.py +28 -0
- wbcrm/synchronization/activity/tasks.py +21 -0
- wbcrm/synchronization/activity/urls.py +6 -0
- wbcrm/synchronization/activity/utils.py +46 -0
- wbcrm/synchronization/activity/views.py +37 -0
- wbcrm/synchronization/admin.py +1 -0
- wbcrm/synchronization/apps.py +15 -0
- wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
- wbcrm/synchronization/management.py +36 -0
- wbcrm/synchronization/tasks.py +1 -0
- wbcrm/synchronization/urls.py +5 -0
- wbcrm/tasks.py +312 -0
- wbcrm/tests/__init__.py +0 -0
- wbcrm/tests/accounts/__init__.py +0 -0
- wbcrm/tests/accounts/test_models.py +380 -0
- wbcrm/tests/accounts/test_viewsets.py +87 -0
- wbcrm/tests/conftest.py +76 -0
- wbcrm/tests/disable_signals.py +52 -0
- wbcrm/tests/e2e/__init__.py +1 -0
- wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
- wbcrm/tests/e2e/test_e2e.py +369 -0
- wbcrm/tests/test_assignee_methods.py +39 -0
- wbcrm/tests/test_chartviewsets.py +111 -0
- wbcrm/tests/test_dto.py +63 -0
- wbcrm/tests/test_filters.py +51 -0
- wbcrm/tests/test_models.py +216 -0
- wbcrm/tests/test_recurrence.py +291 -0
- wbcrm/tests/test_report.py +20 -0
- wbcrm/tests/test_serializers.py +170 -0
- wbcrm/tests/test_tasks.py +94 -0
- wbcrm/tests/test_viewsets.py +967 -0
- wbcrm/tests/tests.py +120 -0
- wbcrm/typings.py +107 -0
- wbcrm/urls.py +67 -0
- wbcrm/viewsets/__init__.py +22 -0
- wbcrm/viewsets/accounts.py +121 -0
- wbcrm/viewsets/activities.py +315 -0
- wbcrm/viewsets/buttons/__init__.py +7 -0
- wbcrm/viewsets/buttons/accounts.py +27 -0
- wbcrm/viewsets/buttons/activities.py +68 -0
- wbcrm/viewsets/buttons/signals.py +17 -0
- wbcrm/viewsets/display/__init__.py +12 -0
- wbcrm/viewsets/display/accounts.py +110 -0
- wbcrm/viewsets/display/activities.py +443 -0
- wbcrm/viewsets/display/groups.py +22 -0
- wbcrm/viewsets/display/products.py +105 -0
- wbcrm/viewsets/endpoints/__init__.py +8 -0
- wbcrm/viewsets/endpoints/accounts.py +32 -0
- wbcrm/viewsets/endpoints/activities.py +30 -0
- wbcrm/viewsets/endpoints/groups.py +7 -0
- wbcrm/viewsets/endpoints/products.py +9 -0
- wbcrm/viewsets/groups.py +37 -0
- wbcrm/viewsets/menu/__init__.py +8 -0
- wbcrm/viewsets/menu/accounts.py +18 -0
- wbcrm/viewsets/menu/activities.py +61 -0
- wbcrm/viewsets/menu/groups.py +16 -0
- wbcrm/viewsets/menu/products.py +20 -0
- wbcrm/viewsets/mixins.py +34 -0
- wbcrm/viewsets/previews/__init__.py +1 -0
- wbcrm/viewsets/previews/activities.py +10 -0
- wbcrm/viewsets/products.py +56 -0
- wbcrm/viewsets/recurrence.py +26 -0
- wbcrm/viewsets/titles/__init__.py +13 -0
- wbcrm/viewsets/titles/accounts.py +22 -0
- wbcrm/viewsets/titles/activities.py +61 -0
- wbcrm/viewsets/titles/products.py +13 -0
- wbcrm/viewsets/titles/utils.py +46 -0
- wbcrm/workflows/__init__.py +1 -0
- wbcrm/workflows/assignee_methods.py +25 -0
- wbcrm-2.2.1.dist-info/METADATA +11 -0
- wbcrm-2.2.1.dist-info/RECORD +155 -0
- wbcrm-2.2.1.dist-info/WHEEL +5 -0
|
@@ -0,0 +1,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)
|
|
File without changes
|
|
@@ -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
|