wbcrm 1.56.8__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +5 -0
  3. wbcrm/admin/accounts.py +60 -0
  4. wbcrm/admin/activities.py +104 -0
  5. wbcrm/admin/events.py +43 -0
  6. wbcrm/admin/groups.py +8 -0
  7. wbcrm/admin/products.py +9 -0
  8. wbcrm/apps.py +5 -0
  9. wbcrm/configurations/__init__.py +1 -0
  10. wbcrm/configurations/base.py +16 -0
  11. wbcrm/dynamic_preferences_registry.py +38 -0
  12. wbcrm/factories/__init__.py +14 -0
  13. wbcrm/factories/accounts.py +57 -0
  14. wbcrm/factories/activities.py +124 -0
  15. wbcrm/factories/groups.py +24 -0
  16. wbcrm/factories/products.py +11 -0
  17. wbcrm/filters/__init__.py +10 -0
  18. wbcrm/filters/accounts.py +80 -0
  19. wbcrm/filters/activities.py +204 -0
  20. wbcrm/filters/groups.py +21 -0
  21. wbcrm/filters/products.py +38 -0
  22. wbcrm/filters/signals.py +95 -0
  23. wbcrm/fixtures/wbcrm.json +1215 -0
  24. wbcrm/kpi_handlers/activities.py +171 -0
  25. wbcrm/locale/de/LC_MESSAGES/django.mo +0 -0
  26. wbcrm/locale/de/LC_MESSAGES/django.po +1557 -0
  27. wbcrm/locale/de/LC_MESSAGES/django.po.translated +1630 -0
  28. wbcrm/locale/en/LC_MESSAGES/django.mo +0 -0
  29. wbcrm/locale/en/LC_MESSAGES/django.po +1466 -0
  30. wbcrm/locale/fr/LC_MESSAGES/django.mo +0 -0
  31. wbcrm/locale/fr/LC_MESSAGES/django.po +1467 -0
  32. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  33. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  34. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  35. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  36. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  37. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  38. wbcrm/migrations/0007_alter_account_status.py +23 -0
  39. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  40. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  41. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  42. wbcrm/migrations/0011_activity_summary.py +22 -0
  43. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  44. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  45. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  46. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  47. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  48. wbcrm/migrations/0017_event.py +40 -0
  49. wbcrm/migrations/0018_activity_search_vector.py +24 -0
  50. wbcrm/migrations/__init__.py +0 -0
  51. wbcrm/models/__init__.py +5 -0
  52. wbcrm/models/accounts.py +648 -0
  53. wbcrm/models/activities.py +1419 -0
  54. wbcrm/models/events.py +15 -0
  55. wbcrm/models/groups.py +119 -0
  56. wbcrm/models/llm/activity_summaries.py +41 -0
  57. wbcrm/models/llm/analyze_relationship.py +50 -0
  58. wbcrm/models/products.py +86 -0
  59. wbcrm/models/recurrence.py +280 -0
  60. wbcrm/preferences.py +13 -0
  61. wbcrm/report/activity_report.py +110 -0
  62. wbcrm/serializers/__init__.py +23 -0
  63. wbcrm/serializers/accounts.py +141 -0
  64. wbcrm/serializers/activities.py +525 -0
  65. wbcrm/serializers/groups.py +30 -0
  66. wbcrm/serializers/products.py +58 -0
  67. wbcrm/serializers/recurrence.py +91 -0
  68. wbcrm/serializers/signals.py +71 -0
  69. wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
  70. wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
  71. wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
  72. wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
  73. wbcrm/synchronization/__init__.py +0 -0
  74. wbcrm/synchronization/activity/__init__.py +0 -0
  75. wbcrm/synchronization/activity/admin.py +73 -0
  76. wbcrm/synchronization/activity/backend.py +214 -0
  77. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  78. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  79. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +406 -0
  80. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  81. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
  82. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
  83. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
  84. wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +181 -0
  85. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  86. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  87. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  88. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  89. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  90. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  91. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  92. wbcrm/synchronization/activity/backends/google/utils.py +217 -0
  93. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  94. wbcrm/synchronization/activity/backends/outlook/backend.py +593 -0
  95. wbcrm/synchronization/activity/backends/outlook/msgraph.py +436 -0
  96. wbcrm/synchronization/activity/backends/outlook/parser.py +432 -0
  97. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  98. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  99. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  100. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +118 -0
  101. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +274 -0
  102. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +249 -0
  103. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +174 -0
  104. wbcrm/synchronization/activity/controller.py +627 -0
  105. wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
  106. wbcrm/synchronization/activity/preferences.py +27 -0
  107. wbcrm/synchronization/activity/shortcuts.py +16 -0
  108. wbcrm/synchronization/activity/tasks.py +21 -0
  109. wbcrm/synchronization/activity/urls.py +7 -0
  110. wbcrm/synchronization/activity/utils.py +46 -0
  111. wbcrm/synchronization/activity/views.py +41 -0
  112. wbcrm/synchronization/admin.py +1 -0
  113. wbcrm/synchronization/apps.py +14 -0
  114. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  115. wbcrm/synchronization/management.py +36 -0
  116. wbcrm/synchronization/tasks.py +1 -0
  117. wbcrm/synchronization/urls.py +5 -0
  118. wbcrm/tasks.py +264 -0
  119. wbcrm/templates/email/activity.html +98 -0
  120. wbcrm/templates/email/activity_report.html +6 -0
  121. wbcrm/templates/email/daily_summary.html +72 -0
  122. wbcrm/templates/email/global_daily_summary.html +85 -0
  123. wbcrm/tests/__init__.py +0 -0
  124. wbcrm/tests/accounts/__init__.py +0 -0
  125. wbcrm/tests/accounts/test_models.py +393 -0
  126. wbcrm/tests/accounts/test_viewsets.py +88 -0
  127. wbcrm/tests/conftest.py +76 -0
  128. wbcrm/tests/disable_signals.py +62 -0
  129. wbcrm/tests/e2e/__init__.py +1 -0
  130. wbcrm/tests/e2e/e2e_wbcrm_utility.py +83 -0
  131. wbcrm/tests/e2e/test_e2e.py +370 -0
  132. wbcrm/tests/test_assignee_methods.py +40 -0
  133. wbcrm/tests/test_chartviewsets.py +112 -0
  134. wbcrm/tests/test_dto.py +64 -0
  135. wbcrm/tests/test_filters.py +52 -0
  136. wbcrm/tests/test_models.py +217 -0
  137. wbcrm/tests/test_recurrence.py +292 -0
  138. wbcrm/tests/test_report.py +21 -0
  139. wbcrm/tests/test_serializers.py +171 -0
  140. wbcrm/tests/test_tasks.py +95 -0
  141. wbcrm/tests/test_viewsets.py +967 -0
  142. wbcrm/tests/tests.py +121 -0
  143. wbcrm/typings.py +109 -0
  144. wbcrm/urls.py +67 -0
  145. wbcrm/viewsets/__init__.py +22 -0
  146. wbcrm/viewsets/accounts.py +122 -0
  147. wbcrm/viewsets/activities.py +341 -0
  148. wbcrm/viewsets/buttons/__init__.py +7 -0
  149. wbcrm/viewsets/buttons/accounts.py +27 -0
  150. wbcrm/viewsets/buttons/activities.py +89 -0
  151. wbcrm/viewsets/buttons/signals.py +17 -0
  152. wbcrm/viewsets/display/__init__.py +12 -0
  153. wbcrm/viewsets/display/accounts.py +110 -0
  154. wbcrm/viewsets/display/activities.py +444 -0
  155. wbcrm/viewsets/display/groups.py +22 -0
  156. wbcrm/viewsets/display/products.py +105 -0
  157. wbcrm/viewsets/endpoints/__init__.py +8 -0
  158. wbcrm/viewsets/endpoints/accounts.py +25 -0
  159. wbcrm/viewsets/endpoints/activities.py +30 -0
  160. wbcrm/viewsets/endpoints/groups.py +7 -0
  161. wbcrm/viewsets/endpoints/products.py +9 -0
  162. wbcrm/viewsets/groups.py +38 -0
  163. wbcrm/viewsets/menu/__init__.py +8 -0
  164. wbcrm/viewsets/menu/accounts.py +18 -0
  165. wbcrm/viewsets/menu/activities.py +49 -0
  166. wbcrm/viewsets/menu/groups.py +16 -0
  167. wbcrm/viewsets/menu/products.py +20 -0
  168. wbcrm/viewsets/mixins.py +35 -0
  169. wbcrm/viewsets/previews/__init__.py +1 -0
  170. wbcrm/viewsets/previews/activities.py +10 -0
  171. wbcrm/viewsets/products.py +57 -0
  172. wbcrm/viewsets/recurrence.py +27 -0
  173. wbcrm/viewsets/titles/__init__.py +13 -0
  174. wbcrm/viewsets/titles/accounts.py +23 -0
  175. wbcrm/viewsets/titles/activities.py +61 -0
  176. wbcrm/viewsets/titles/products.py +13 -0
  177. wbcrm/viewsets/titles/utils.py +46 -0
  178. wbcrm/workflows/__init__.py +1 -0
  179. wbcrm/workflows/assignee_methods.py +25 -0
  180. wbcrm-1.56.8.dist-info/METADATA +11 -0
  181. wbcrm-1.56.8.dist-info/RECORD +182 -0
  182. wbcrm-1.56.8.dist-info/WHEEL +5 -0
@@ -0,0 +1,75 @@
1
+ from datetime import timedelta
2
+
3
+ from celery import shared_task
4
+ from dynamic_preferences.registries import global_preferences_registry
5
+ from googleapiclient.discovery import Resource
6
+ from psycopg.types.range import TimestamptzRange
7
+ from wbcore.contrib.authentication.models import User
8
+ from wbcrm.models import Activity
9
+
10
+ from ...typing_informations import GoogleEventType
11
+ from ...utils import GoogleSyncUtils
12
+ from .update import update_activity_participant
13
+
14
+
15
+ @shared_task(queue="synchronization")
16
+ def create_internal_activity_based_on_google_event(
17
+ event: GoogleEventType, user: User, service: Resource, parent_occurrence: Activity | None = None, is_instance=False
18
+ ):
19
+ """
20
+ A method for creating a "Workbench" activity based on a "Google" event. If the google event is a recurring event, this method will also create the corresponding workbench activities.
21
+
22
+ :param event: A google event body.
23
+ :param user: The current workbench user.
24
+ :param service: Thee google Resource.
25
+ :param parent_occurrence: The parent activity. This is only used when the activity is part of a recurring chain. Per default it is None.
26
+ :param is_instance: True, when the event is a google instance (A instance is an event that is part of a recurring chain)
27
+ """
28
+
29
+ external_id = event["id"]
30
+ if event.get("status") == "cancelled" or Activity.objects.filter(external_id=event["id"]).exists():
31
+ return
32
+
33
+ event_creator = event.get("organizer", {})
34
+ event_creator_mail = event_creator.get("email", "")
35
+ event_creator_displayed_name = event_creator.get("displayName", "")
36
+ creator = GoogleSyncUtils.get_or_create_person(event_creator_mail, event_creator_displayed_name)
37
+ event_start, event_end = GoogleSyncUtils.get_start_and_end(event)
38
+ if event_start == event_end:
39
+ event_end = event_end + timedelta(seconds=1)
40
+ period = TimestamptzRange(event_start, event_end) # type: ignore
41
+ all_day: bool = True if event["start"].get("date") else False
42
+ metadata = {"google_backend": {"instance": event}} if is_instance else {"google_backend": {"event": event}}
43
+
44
+ act = Activity.objects.create(
45
+ external_id=external_id,
46
+ title=event.get("summary", "(No Subject)"),
47
+ assigned_to=creator,
48
+ creator=creator,
49
+ description=event.get("description", ""),
50
+ start=event_start,
51
+ end=event_end,
52
+ period=period,
53
+ all_day=all_day,
54
+ location=event.get("location"),
55
+ visibility=GoogleSyncUtils.convert_event_visibility_to_activity_visibility(event.get("visibility", "")),
56
+ metadata=metadata,
57
+ parent_occurrence=parent_occurrence,
58
+ )
59
+
60
+ update_activity_participant(event, act)
61
+
62
+ if event.get("recurrence") and not is_instance:
63
+ instances: dict = service.events().instances(calendarId=user.email, eventId=event["id"]).execute()
64
+ instance_items: list[GoogleEventType] = instances["items"]
65
+ global_preferences = global_preferences_registry.manager()
66
+ max_list_length: int = global_preferences["wbcrm__recurrence_maximum_count"]
67
+ instance_items = instance_items[:max_list_length]
68
+
69
+ for item in instance_items:
70
+ if item["start"] == act.metadata["google_backend"].get("event", {}).get("start"):
71
+ updated_metadata: dict = act.metadata
72
+ updated_metadata["google_backend"] |= {"instance": item}
73
+ Activity.objects.filter(id=act.id).update(metadata=updated_metadata)
74
+ else:
75
+ create_internal_activity_based_on_google_event(item, user, service, act, True)
@@ -0,0 +1,78 @@
1
+ from django.db.models import QuerySet
2
+ from dynamic_preferences.registries import global_preferences_registry
3
+ from googleapiclient.discovery import Resource
4
+ from wbcrm.models import Activity
5
+
6
+
7
+ def cancel_or_delete_activity(activity: "Activity") -> None:
8
+ # Activity is cancelled rather than disable if global preference is True
9
+ if global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_activity"]:
10
+ Activity.objects.filter(id=activity.id).update(status=Activity.Status.CANCELLED)
11
+ else:
12
+ activity.delete()
13
+
14
+
15
+ def cancel_or_delete_activity_queryset(activity_qs: QuerySet["Activity"]) -> None:
16
+ # Activities are cancelled rather than delete if global preference is True
17
+ if global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_activity"]:
18
+ activity_qs.update(status=Activity.Status.CANCELLED)
19
+ else:
20
+ activity_qs.delete()
21
+
22
+
23
+ def delete_single_activity(activity: Activity):
24
+ """
25
+ Deletes the activity that has the corresponding external ID
26
+ """
27
+ Activity.objects.filter(id=activity.id).update(external_id=None)
28
+ cancel_or_delete_activity(activity)
29
+
30
+
31
+ def delete_recurring_activity(activity: Activity, event: dict, user_mail: str, service: Resource):
32
+ """
33
+ Handles the deletion of recurring activities (either a single activity, a certain number of activities or all activities),
34
+ based on the changes done to the google event.
35
+
36
+ :param event: The google event dict that was deleted.
37
+ :param activity: The corresponding workbench activity.
38
+ :param user_mail: The e-mail address of the current user.
39
+ :param service: The google service to interact with googles resources.
40
+ """
41
+
42
+ parent_occurrence = activity.parent_occurrence if activity.parent_occurrence else activity
43
+ metadata_event = parent_occurrence.metadata["google_backend"].get(
44
+ "event", parent_occurrence.metadata["google_backend"].get("instance", {})
45
+ )
46
+ if event.get("recurringEventId"):
47
+ # Delete single event in event chain
48
+ if activity.status not in [Activity.Status.REVIEWED, Activity.Status.FINISHED]:
49
+ cancel_or_delete_activity(activity)
50
+
51
+ elif event.get("recurrence") != metadata_event.get("recurrence"):
52
+ # Delete all events after a certain event in the event chain
53
+
54
+ event_instances: dict = service.events().instances(calendarId=user_mail, eventId=event["id"]).execute()
55
+ external_id_list: list = [instance["id"] for instance in event_instances["items"]]
56
+ activities_to_remove = Activity.objects.filter(
57
+ metadata__google_backend__instance__recurringEventId__startswith=event["id"],
58
+ status=Activity.Status.PLANNED,
59
+ ).exclude(metadata__google_backend__instance__id__in=external_id_list)
60
+ first_in_list = activities_to_remove.order_by("start").first()
61
+ metadata = parent_occurrence.metadata
62
+ metadata["google_backend"] |= {"event": event}
63
+ Activity.objects.filter(id=parent_occurrence.id).update(metadata=metadata)
64
+ if (
65
+ first_in_list
66
+ and first_in_list.id == parent_occurrence.id
67
+ and parent_occurrence.status not in [Activity.Status.REVIEWED, Activity.Status.FINISHED]
68
+ ):
69
+ cancel_or_delete_activity(parent_occurrence)
70
+ cancel_or_delete_activity_queryset(activities_to_remove)
71
+
72
+ else:
73
+ # Delete all events in event chain
74
+ activities_to_cancel = Activity.objects.filter(
75
+ parent_occurrence=parent_occurrence, status=Activity.Status.PLANNED
76
+ )
77
+ cancel_or_delete_activity_queryset(activities_to_cancel)
78
+ cancel_or_delete_activity(activity)
@@ -0,0 +1,155 @@
1
+ from datetime import timedelta
2
+
3
+ from django.utils import timezone
4
+ from dynamic_preferences.registries import global_preferences_registry
5
+ from googleapiclient.discovery import Resource
6
+ from psycopg.types.range import TimestamptzRange
7
+ from wbcore.contrib.directory.models import Person
8
+ from wbcrm.models import Activity, ActivityParticipant
9
+
10
+ from ...typing_informations import GoogleEventType
11
+ from ...utils import GoogleSyncUtils
12
+
13
+
14
+ def update_activity_participant(event: GoogleEventType, activity: Activity):
15
+ """
16
+ Used to update the participants in a workbench activity.
17
+
18
+ :param event: The Google event dict with the participant informations.
19
+ :param activity:The corresponding activity.
20
+ """
21
+
22
+ can_sync_external_participants: bool = global_preferences_registry.manager()[
23
+ "wbactivity_sync__sync_external_participants"
24
+ ]
25
+ event_participants = GoogleSyncUtils.get_or_create_participants(event, activity.creator)
26
+
27
+ def update_or_add_participants():
28
+ for event_participant in event_participants:
29
+ if person := Person.objects.filter(id=event_participant["person_id"]).first():
30
+ activity.participants.add(person.id)
31
+ ActivityParticipant.objects.filter(activity=activity, participant=person).update(
32
+ participation_status=event_participant["status"]
33
+ )
34
+
35
+ if can_sync_external_participants or not activity.creator.is_internal: # type: ignore
36
+ update_or_add_participants()
37
+ else:
38
+ internal_activity_participants_set = set(
39
+ activity.participants.filter(id__in=Person.objects.filter_only_internal())
40
+ )
41
+ event_participants_set = set(Person.objects.filter(id__in=[x["person_id"] for x in event_participants]))
42
+ missing_activity_participants = internal_activity_participants_set - event_participants_set
43
+ update_or_add_participants()
44
+ if missing_activity_participants:
45
+ ActivityParticipant.objects.filter(
46
+ participant__in=missing_activity_participants, activity=activity
47
+ ).update(participation_status=ActivityParticipant.ParticipationStatus.CANCELLED)
48
+
49
+
50
+ def update_single_activity(event: GoogleEventType, activity: Activity, change_status=False):
51
+ """
52
+ Updates a single workbench activity based on changes done in a google event.
53
+
54
+ :param event: The google event dict that was updated.
55
+ :param activity: The corresponding workbench activity.
56
+ :param change_status: Information wheter the status of the event was updated or not.
57
+ """
58
+ if activity.external_id is None:
59
+ return
60
+ if activity.status == Activity.Status.REVIEWED or activity.status == Activity.Status.FINISHED:
61
+ metadata = activity.metadata
62
+ metadata["google_backend"] |= {"event": event} if not event.get("recurringEventId") else {"instance": event}
63
+ Activity.objects.filter(id=activity.id).update(
64
+ external_id=event["id"] if "_" in activity.external_id else activity.external_id,
65
+ metadata=metadata,
66
+ )
67
+ return
68
+ event_organizer = event.get("organizer", {})
69
+ event_organizer_mail = event_organizer.get("email", "")
70
+ event_organizer_displayed_name = event_organizer.get("displayName", "")
71
+ organizer = GoogleSyncUtils.get_or_create_person(event_organizer_mail, event_organizer_displayed_name)
72
+ event_start, event_end = GoogleSyncUtils.get_start_and_end(event)
73
+ if event_start == event_end:
74
+ event_end = event_end + timedelta(seconds=1)
75
+ period = TimestamptzRange(event_start, event_end) # type: ignore
76
+ all_day: bool = True if event["start"].get("date") else False
77
+
78
+ metadata = activity.metadata
79
+ metadata["google_backend"] |= {"event": event} if not event.get("recurringEventId") else {"instance": event}
80
+ Activity.objects.filter(id=activity.id).update(
81
+ title=event.get("summary", "(No Subject)"),
82
+ assigned_to=organizer,
83
+ description=event.get("description", ""),
84
+ start=event_start,
85
+ status=Activity.Status.PLANNED if change_status else activity.status,
86
+ end=event_end,
87
+ period=period,
88
+ all_day=all_day,
89
+ location=event.get("location"),
90
+ visibility=GoogleSyncUtils.convert_event_visibility_to_activity_visibility(event.get("visibility")),
91
+ metadata=metadata,
92
+ external_id=event["id"] if "_" in activity.external_id else activity.external_id,
93
+ )
94
+ update_activity_participant(event, activity)
95
+
96
+
97
+ def update_all_activities(activity: Activity, event: GoogleEventType, user_mail: str, service: Resource):
98
+ """
99
+ Updates all workbench activities in a recurrence chain based on changes done in a google event.
100
+
101
+ :param event: The google event dict that was updated.
102
+ :param activity: The corresponding workbench activity.
103
+ :param user_mail: The e-mail address of the current user.
104
+ :param service: The google service to interact with googles resources.
105
+ """
106
+
107
+ activity_instance = activity.metadata["google_backend"].get(
108
+ "instance", activity.metadata["google_backend"].get("event", {})
109
+ )
110
+ event_start, event_end = event["start"], event["end"]
111
+ activity_start, activity_end = activity_instance.get("start"), activity_instance.get("end")
112
+ event_instances: dict = service.events().instances(calendarId=user_mail, eventId=event["id"]).execute()
113
+ instance_items = event_instances["items"]
114
+ connected_activities = Activity.objects.filter(parent_occurrence=activity)
115
+ connected_activities |= Activity.objects.filter(metadata__google_backend__instance__recurringEventId=event["id"])
116
+ connected_activities |= Activity.objects.filter(id=activity.id)
117
+ if event_start != activity_start or event_end != activity_end:
118
+ # If the start/end time of the google event chain changes, google will also change the id of the event instances. That is why we need to handle this case seperatly
119
+ connected_activities = connected_activities.order_by("external_id")
120
+ activity_list = list(connected_activities)
121
+ instance_items.sort(key=lambda x: x["start"].get("date", x["start"]["dateTime"]))
122
+ for instance in instance_items:
123
+ if len(activity_list) == 0:
124
+ break
125
+ activity_instance = activity_list.pop(0)
126
+ update_single_activity(instance, activity_instance)
127
+ activity_instance.refresh_from_db()
128
+ else:
129
+ for instance in instance_items:
130
+ if activity_child := connected_activities.filter(external_id=instance["id"]).first() or (
131
+ activity_child := Activity.objects.filter(
132
+ metadata__google_backend__instance__id=instance["id"]
133
+ ).first()
134
+ ):
135
+ update_single_activity(instance, activity_child)
136
+
137
+
138
+ def update_activities_from_new_parent(event: dict, parent_occurrence: Activity, user_mail: str, service: Resource):
139
+ """
140
+ This methods updates child activities whose parent activity was altered.
141
+
142
+ :param event: The google event dict that was updated.
143
+ :param parent_occurrence: The corresponding workbench parent activity.
144
+ :param user_mail: The e-mail address of the current user.
145
+ :param service: The google service to interact with googles resources.
146
+
147
+ """
148
+ now = timezone.now()
149
+ canceled_child_activities = Activity.objects.filter(
150
+ parent_occurrence=parent_occurrence, period__startswith__gt=now
151
+ )
152
+ event_instances: dict = service.events().instances(calendarId=user_mail, eventId=event["id"]).execute()
153
+ for instance in event_instances["items"]:
154
+ if activity := canceled_child_activities.filter(external_id=instance["id"]).first():
155
+ update_single_activity(instance, activity, True)
@@ -0,0 +1,181 @@
1
+ import re
2
+ import warnings
3
+ from uuid import uuid4
4
+
5
+ from dateutil.rrule import rrule, rrulestr
6
+ from googleapiclient.discovery import Resource
7
+ from wbcrm.models import Activity
8
+
9
+ from ...utils import GoogleSyncUtils
10
+
11
+
12
+ def update_single_event(creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict):
13
+ """
14
+ Updates a single Google-Event.
15
+
16
+ After the external event has been updated, the metadata field of the internal activity is also updated with the information of the external event.
17
+
18
+ Note: Do not use for recurring events.
19
+
20
+ :param creator_mail: The e-mail address of the activities creator.
21
+ :param google_service: The google service to interact with googles resources.
22
+ :param internal_activity: The internal workbench activity that corresponds to the external google event.
23
+ :param updates: The update information.
24
+ :returns: None
25
+ """
26
+
27
+ updated_external_event = (
28
+ google_service.events()
29
+ .update(calendarId=creator_mail, eventId=internal_activity.external_id, body=updates)
30
+ .execute()
31
+ )
32
+ metadata = internal_activity.metadata | {"google_backend": {"event": updated_external_event}}
33
+ Activity.objects.filter(id=internal_activity.id).update(metadata=metadata)
34
+
35
+
36
+ def update_single_recurring_event(
37
+ creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict
38
+ ):
39
+ """
40
+ Updates a single recurring Google-Event.
41
+
42
+ After the external event has been updated, the metadata field of the internal activity is also updated with the information of the external event.
43
+
44
+ Note: Do not use for not recurring events.
45
+
46
+ :param creator_mail: The e-mail address of the activities creator.
47
+ :param google_service: The google service to interact with googles resources.
48
+ :param internal_activity: The internal workbench activity that corresponds to the external google event.
49
+ :param updates: The update information.
50
+ :returns: None
51
+ """
52
+ is_parent = Activity.objects.filter(parent_occurrence=internal_activity).exists()
53
+ external_id = (
54
+ internal_activity.metadata["google_backend"]["instance"].get("id")
55
+ if is_parent
56
+ else internal_activity.external_id
57
+ )
58
+ updated_event = google_service.events().patch(calendarId=creator_mail, eventId=external_id, body=updates).execute()
59
+ metadata = internal_activity.metadata
60
+ metadata["google_backend"] |= {"instance": updated_event}
61
+ Activity.objects.filter(id=internal_activity.id).update(metadata=metadata)
62
+
63
+
64
+ def update_all_recurring_events_from_parent(
65
+ creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict
66
+ ):
67
+ """
68
+ Updates all Google-Event-Instances belonging to the same recurring event chain.
69
+
70
+ After the external event instances have been updated, the metadata fields of the internal activities is also updated with the information of the corresponding external event instance.
71
+
72
+ Note: Do not use when creating a new parent activity from an existing child.
73
+
74
+ :param creator_mail: The e-mail address of the activities creator.
75
+ :param google_service: The google service to interact with googles resources.
76
+ :param internal_activity: The internal workbench parent activity that corresponds to the external google event.
77
+ :param updates: The update information.
78
+ :returns: None
79
+ """
80
+ updated_event = (
81
+ google_service.events()
82
+ .patch(calendarId=creator_mail, eventId=internal_activity.external_id, body=updates)
83
+ .execute()
84
+ )
85
+ metadata = internal_activity.metadata | {"google_backend": {"event": updated_event}}
86
+ instances = google_service.events().instances(calendarId=creator_mail, eventId=updated_event["id"]).execute()
87
+ google_event_items = instances["items"]
88
+ GoogleSyncUtils.add_instance_metadata(internal_activity, google_event_items, metadata)
89
+
90
+
91
+ def update_all_recurring_events_from_new_parent(
92
+ creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict
93
+ ):
94
+ """
95
+ Updates all Google-Event-Instances belonging to the same parent event.
96
+
97
+ After the external event instances have been updated, the metadata fields of the internal activities is also updated with the information of the corresponding external event instance.
98
+
99
+ Note: Do not use when updating from the original event chain parent.
100
+
101
+ :param creator_mail: The e-mail address of the activities creator.
102
+ :param google_service: The google service to interact with googles resources.
103
+ :param internal_activity: The internal workbench parent activity that corresponds to the external google event.
104
+ :param updates: The update information.
105
+ :returns: None
106
+ """
107
+
108
+ # If the old parent does not exist anymore, we cannot update the child.
109
+ if not (
110
+ current_parent_occurrence := Activity.objects.filter(
111
+ id=internal_activity.metadata.get("old_parent_id")
112
+ ).first()
113
+ ):
114
+ return warnings.warn(
115
+ "Could not update the recurring events on google, because the old parent activity was already deleted.",
116
+ stacklevel=2,
117
+ )
118
+
119
+ # Get the current parent event from google
120
+ current_google_parent_event: dict = (
121
+ google_service.events().get(calendarId=creator_mail, eventId=current_parent_occurrence.external_id).execute()
122
+ )
123
+
124
+ # Get the current recurrence rules. We need to modify them for both the current parent event and the new parent event.
125
+ current_parent_rrule_str: str = "\n".join(current_google_parent_event["recurrence"])
126
+
127
+ # We need to adjust the rrules to mimic the current status on the workbench. So we replace any until or count value with the current number of child activities.
128
+ current_parent_child_count = Activity.objects.filter(parent_occurrence=current_parent_occurrence).count()
129
+ new_parent_child_count = Activity.objects.filter(parent_occurrence=internal_activity).count()
130
+ current_parent_new_rrule: rrule = rrulestr(current_parent_rrule_str).replace( # type: ignore
131
+ count=current_parent_child_count + 1, until=None
132
+ )
133
+ new_parent_rrule: rrule = rrulestr(current_parent_rrule_str).replace(count=new_parent_child_count + 1, until=None) # type: ignore
134
+
135
+ # Converting the rrule back to str. Since the .__str__() method adds a DTSTART value, we need to remove this by using regex.
136
+ current_parent_new_rrule_str: str = re.sub("[DTSTART].*[\n]", "", current_parent_new_rrule.__str__()).split("T0")[
137
+ 0
138
+ ]
139
+ new_parent_rrule_str: str = re.sub("[DTSTART].*[\n]", "", new_parent_rrule.__str__()).split("T0")[0]
140
+
141
+ # Updating the current parent with the new rrules. This will remove all the child events on google that are not in the scope of the changed rrules anymore.
142
+ current_google_parent_event |= {"recurrence": [current_parent_new_rrule_str]}
143
+ updated_current_parent_event: dict = (
144
+ google_service.events()
145
+ .update(calendarId=creator_mail, eventId=current_google_parent_event["id"], body=current_google_parent_event)
146
+ .execute()
147
+ )
148
+
149
+ # Updating the corresponding metadata
150
+ current_parent_metadata = current_parent_occurrence.metadata | {
151
+ "google_backend": {"event": updated_current_parent_event}
152
+ }
153
+ Activity.objects.filter(id=current_parent_occurrence.id).update(metadata=current_parent_metadata)
154
+ current_instances = (
155
+ google_service.events()
156
+ .instances(calendarId=creator_mail, eventId=updated_current_parent_event["id"])
157
+ .execute()
158
+ )
159
+ current_instances_google_event_items = current_instances["items"]
160
+ current_parent_occurrence.refresh_from_db()
161
+ GoogleSyncUtils.add_instance_metadata(
162
+ current_parent_occurrence, current_instances_google_event_items, current_parent_metadata
163
+ )
164
+
165
+ # Updating the new parent with the created rrules. This will create the child events in google which are in the scope of the newly created rrules.
166
+
167
+ internal_activity_query = Activity.objects.filter(id=internal_activity.id)
168
+ external_id = uuid4().hex
169
+ updates |= {"recurrence": [new_parent_rrule_str], "id": external_id}
170
+ internal_activity_query.update(external_id=external_id)
171
+ new_parent_event: dict = google_service.events().insert(calendarId=creator_mail, body=updates).execute()
172
+
173
+ # Updating the corresponding metadata
174
+ metadata = internal_activity.metadata | {"google_backend": {"event": new_parent_event}, "old_parent_id": None}
175
+ internal_activity_query.update(metadata=metadata)
176
+ new_instances = (
177
+ google_service.events().instances(calendarId=creator_mail, eventId=new_parent_event["id"]).execute()
178
+ )
179
+ new_google_event_items = new_instances["items"]
180
+ internal_activity.refresh_from_db()
181
+ GoogleSyncUtils.add_instance_metadata(internal_activity, new_google_event_items, metadata, True)
@@ -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 EventService:
37
+ def insert(self, calendarId, body):
38
+ return ExecuteService(calendarId, body)
39
+
40
+ def instances(self, calendarId, eventId):
41
+ return ExecuteService(calendarId, eventId)
42
+
43
+ def delete(self, calendarId, eventId):
44
+ return ExecuteService(calendarId, eventId)
45
+
46
+ def update(self, calendarId, eventId, body=event):
47
+ return ExecuteService(calendarId, eventId)
48
+
49
+ def patch(self, calendarId, eventId, body=event):
50
+ return ExecuteService(calendarId, eventId)
51
+
52
+ def get(self, calendarId, eventId, body=event):
53
+ return ExecuteService(calendarId, eventId)
54
+
55
+ def list(self, calendarId, pageToken, syncToken):
56
+ return ExecuteService(calendarId, pageToken)
57
+
58
+ def watch(self, calendarId, body=event):
59
+ return ExecuteService(calendarId, event)
60
+
61
+
62
+ class ChannelsService:
63
+ def stop(self, body):
64
+ return ExecuteService("", body)
65
+
66
+
67
+ class ExecuteService:
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 ServiceData:
77
+ def events(self):
78
+ return EventService
79
+
80
+ def channels(self):
81
+ return ChannelsService