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,119 @@
1
+ from django.utils.translation import gettext as _
2
+ from dynamic_preferences.preferences import Section
3
+ from dynamic_preferences.registries import global_preferences_registry
4
+ from dynamic_preferences.types import BooleanPreference, StringPreference
5
+
6
+ general = Section("wbactivity_sync")
7
+
8
+
9
+ @global_preferences_registry.register
10
+ class BackendCalendarPreference(StringPreference):
11
+ section = general
12
+ name = "sync_backend_calendar"
13
+ default = ""
14
+
15
+ verbose_name = _("Synchronization Backend Calendar")
16
+ help_text = _("The Backend Calendar to synchronize activities with an external calendar.")
17
+
18
+
19
+ @global_preferences_registry.register
20
+ class SyncPastActivity(BooleanPreference):
21
+ section = general
22
+ name = "sync_past_activity"
23
+ default = False
24
+
25
+ verbose_name = _("Synchronization Past Activity")
26
+
27
+
28
+ @global_preferences_registry.register
29
+ class SyncCancelledActivity(BooleanPreference):
30
+ section = general
31
+ name = "sync_cancelled_activity"
32
+ default = True
33
+
34
+ verbose_name = _("Cancel Internal Activity Instead Of Deleting")
35
+ help_text = _(
36
+ "When an activity is deleted in an external calendar the corresponding workbench activity can be cancelled (default) or also deleted."
37
+ )
38
+
39
+
40
+ @global_preferences_registry.register
41
+ class SyncCancelledExternalActivity(BooleanPreference):
42
+ section = general
43
+ name = "sync_cancelled_external_activity"
44
+ default = False
45
+
46
+ verbose_name = _("Cancel External Activity With One Non-Attending Internal Participant")
47
+ help_text = _(
48
+ "When an activity was created by an external person and has only one internal participant the activity in the workbench can be canceled if this participant doesn't choose to attend."
49
+ )
50
+
51
+
52
+ @global_preferences_registry.register
53
+ class SyncActivityDescription(BooleanPreference):
54
+ section = general
55
+ name = "sync_activity_description"
56
+ default = True
57
+
58
+ verbose_name = _("Synchronize Activity Description")
59
+
60
+
61
+ @global_preferences_registry.register
62
+ class SyncExternalParticipants(BooleanPreference):
63
+ section = general
64
+ name = "sync_external_participants"
65
+ default = False
66
+
67
+ verbose_name = _("Synchronize External Participants From Internal Calendar To External Calendar")
68
+
69
+
70
+ @global_preferences_registry.register
71
+ class SyncReplannedReviewedActivityCreatesNewActivity(BooleanPreference):
72
+ section = general
73
+ name = "sync_create_new_activity_on_replanned_reviewed_activity"
74
+ default = False
75
+
76
+ verbose_name = _("Create New Activity When Replanning Passed Reviewed Activities")
77
+ help_text = _(
78
+ "If an activity with a past end date (already passed and reviewed) is moved to a future date, a new activity will automatically be created for the updated schedule."
79
+ )
80
+
81
+
82
+ @global_preferences_registry.register
83
+ class GoogleSyncCredentials(StringPreference):
84
+ section = general
85
+ name = "google_sync_credentials"
86
+ default = ""
87
+ verbose_name = _("Google Synchronization Credentials")
88
+ help_text = "Dict. Keys: 'url', 'type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'client_id', 'auth_uri', 'token_uri', 'auth_provider_x509_cert_url', 'client_x509_cert_url'"
89
+
90
+
91
+ @global_preferences_registry.register
92
+ class OutlookSyncCredentials(StringPreference):
93
+ section = general
94
+ name = "outlook_sync_credentials"
95
+ default = ""
96
+ verbose_name = _("Outlook Synchronization Credentials")
97
+ help_text = '{"notification_url": "", "authority": "", "client_id": "", "client_secret": "", "token_endpoint": "", "graph_url": ""}'
98
+
99
+
100
+ @global_preferences_registry.register
101
+ class OutlookSyncAccesToken(StringPreference):
102
+ section = general
103
+ name = "outlook_sync_access_token"
104
+ default = ""
105
+
106
+ verbose_name = _("Microsoft Graph Access Token")
107
+ help_text = _("The access token obtained from subscriptions to Microsoft used for authentication pruposes")
108
+
109
+
110
+ @global_preferences_registry.register
111
+ class OutlookSyncClientState(StringPreference):
112
+ section = general
113
+ name = "outlook_sync_client_state"
114
+ default = "secretClientValue"
115
+
116
+ verbose_name = _("Microsoft Graph Webhook Secret Client State")
117
+ help_text = _(
118
+ "Secret Client Value defined during subscription, it will be injected into the webhook notification against spoofing"
119
+ )
@@ -0,0 +1,27 @@
1
+ from dynamic_preferences.registries import global_preferences_registry
2
+
3
+
4
+ def can_sync_past_activity() -> bool:
5
+ return global_preferences_registry.manager()["wbactivity_sync__sync_past_activity"]
6
+
7
+
8
+ def can_sync_cancelled_activity() -> bool:
9
+ return global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_activity"]
10
+
11
+
12
+ def can_sync_cancelled_external_activity() -> bool:
13
+ return global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_external_activity"]
14
+
15
+
16
+ def can_sync_create_new_activity_on_replanned_reviewed_activity() -> bool:
17
+ return global_preferences_registry.manager()[
18
+ "wbactivity_sync__sync_create_new_activity_on_replanned_reviewed_activity"
19
+ ]
20
+
21
+
22
+ def can_synchronize_activity_description() -> bool:
23
+ return global_preferences_registry.manager()["wbactivity_sync__sync_activity_description"]
24
+
25
+
26
+ def can_synchronize_external_participants() -> bool:
27
+ return global_preferences_registry.manager()["wbactivity_sync__sync_external_participants"]
@@ -0,0 +1,16 @@
1
+ from contextlib import suppress
2
+
3
+ from django.conf import settings
4
+ from dynamic_preferences.exceptions import NotFoundInRegistry
5
+ from dynamic_preferences.registries import global_preferences_registry
6
+ from wbcore.utils.importlib import import_from_dotted_path
7
+
8
+
9
+ def get_backend():
10
+ from wbcrm.synchronization.activity.controller import ActivityController
11
+
12
+ if not settings.DEBUG:
13
+ with suppress(NotFoundInRegistry):
14
+ if backend := global_preferences_registry.manager()["wbactivity_sync__sync_backend_calendar"]:
15
+ backend = import_from_dotted_path(backend)
16
+ return ActivityController(backend=backend)
@@ -0,0 +1,21 @@
1
+ from celery import shared_task
2
+
3
+ from .shortcuts import get_backend
4
+
5
+
6
+ @shared_task(queue="synchronization")
7
+ def periodic_notify_admins_of_webhook_inconsistencies_task(emails: list | None = None):
8
+ """
9
+ Periodic tasks to notify webhook inconsistencies
10
+ """
11
+ if emails and (controller := get_backend()):
12
+ controller.backend.notify_admins_of_webhook_inconsistencies(emails)
13
+
14
+
15
+ @shared_task(queue="synchronization")
16
+ def periodic_renew_web_hooks_task():
17
+ """
18
+ Periodic tasks to renew active webhooks
19
+ """
20
+ if controller := get_backend():
21
+ controller.backend.renew_web_hooks()
@@ -0,0 +1,7 @@
1
+ from django.urls import path
2
+
3
+ from wbcrm.synchronization.activity.views import event_watch
4
+
5
+ urlpatterns = [
6
+ path("event_watch", event_watch, name="event_watch"),
7
+ ]
@@ -0,0 +1,46 @@
1
+ from typing import Any
2
+
3
+
4
+ def flattened_metadata_lookup(obj: dict, key_string: str = "") -> tuple[str, Any]:
5
+ """
6
+ allows to flatten in nested dictionary which can be used in a lookup query
7
+ """
8
+ if isinstance(obj, dict):
9
+ key_string = key_string + "__" if key_string else key_string
10
+ for k in obj:
11
+ yield from flattened_metadata_lookup(obj[k], key_string + str(k))
12
+ else:
13
+ if isinstance(obj, list):
14
+ yield key_string + "__contains", obj
15
+ else:
16
+ yield key_string, obj
17
+
18
+
19
+ def merge_nested_dict(dct: dict, nested_to_merge: dict):
20
+ """
21
+ allows to merge 2 nested dictionaries
22
+ """
23
+ for k in nested_to_merge.keys():
24
+ if k in dct:
25
+ if isinstance(dct[k], dict) and isinstance(nested_to_merge[k], dict): # noqa
26
+ merge_nested_dict(dct[k], nested_to_merge[k])
27
+ else:
28
+ if not isinstance(dct[k], list):
29
+ dct[k] = [dct[k]]
30
+ dct[k].append(nested_to_merge[k])
31
+ else:
32
+ dct[k] = nested_to_merge[k]
33
+
34
+
35
+ def flattened_dict_into_nested_dict(obj: dict) -> dict:
36
+ """
37
+ allows to nest a flattened dictionary
38
+ """
39
+ nested = {}
40
+ for key, value in obj.items():
41
+ keys = key.split(".")
42
+ dct = {keys.pop(): value}
43
+ while keys:
44
+ dct = {keys.pop(): dct}
45
+ merge_nested_dict(nested, dct)
46
+ return nested
@@ -0,0 +1,41 @@
1
+ from celery import shared_task
2
+ from django.db.utils import OperationalError
3
+ from django.http import HttpRequest, HttpResponse
4
+ from django.views.decorators.csrf import csrf_exempt
5
+
6
+ from wbcrm.models.events import Event
7
+
8
+ from .shortcuts import get_backend
9
+
10
+
11
+ @shared_task(
12
+ queue="synchronization",
13
+ default_retry_delay=5,
14
+ autoretry_for=(OperationalError,),
15
+ max_retries=4,
16
+ retry_backoff=True,
17
+ )
18
+ def handle_inbound_as_task(event: dict):
19
+ """
20
+ the events received from the webhook are handled in a task
21
+ which will allow to create, modify or delete the activity without interrupting the main server
22
+ """
23
+ if controller := get_backend():
24
+ event_object = Event.objects.create(data=event)
25
+ controller.handle_inbound(event=event, event_object_id=event_object.id)
26
+
27
+
28
+ @csrf_exempt
29
+ def event_watch(request: HttpRequest) -> HttpResponse:
30
+ # TODO this is unsecure as it is prone to DDOS attack
31
+ status_code = 200
32
+ try:
33
+ if controller := get_backend():
34
+ if response := controller.handle_inbound_validation_response(request):
35
+ return response
36
+ for event in controller.get_events_from_inbound_request(request):
37
+ handle_inbound_as_task.delay(event)
38
+ except Exception as e:
39
+ print(e) # noqa: T201
40
+ status_code = 500
41
+ return HttpResponse(status=status_code)
@@ -0,0 +1 @@
1
+ import wbcrm.synchronization.activity.admin # noqa
@@ -0,0 +1,14 @@
1
+ from django.apps import AppConfig
2
+ from django.db.models.signals import post_migrate
3
+
4
+
5
+ class WbSyncConfig(AppConfig):
6
+ name = "wbcrm.synchronization"
7
+
8
+ def ready(self) -> None:
9
+ from wbcrm.synchronization.management import initialize_task
10
+
11
+ post_migrate.connect(
12
+ initialize_task,
13
+ dispatch_uid="wbcrm.synchronization.initialize_task",
14
+ )
@@ -0,0 +1 @@
1
+ import wbcrm.synchronization.activity.dynamic_preferences_registry # noqa
@@ -0,0 +1,36 @@
1
+ from django.apps import apps as global_apps
2
+ from django.db import DEFAULT_DB_ALIAS
3
+ from django_celery_beat.models import CrontabSchedule, PeriodicTask
4
+
5
+
6
+ def initialize_task(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, apps=global_apps, **kwargs):
7
+ crontab1, _ = CrontabSchedule.objects.get_or_create(
8
+ minute="0",
9
+ hour="1",
10
+ day_of_week="*",
11
+ day_of_month="*",
12
+ month_of_year="*",
13
+ )
14
+ crontab2, _ = CrontabSchedule.objects.get_or_create(
15
+ minute="0",
16
+ hour="7",
17
+ day_of_week="*",
18
+ day_of_month="*",
19
+ month_of_year="*",
20
+ )
21
+
22
+ # Automatically register the utility periodic tasks
23
+ PeriodicTask.objects.update_or_create(
24
+ task="wbcrm.synchronization.activity.tasks.periodic_renew_web_hooks_task",
25
+ defaults={
26
+ "name": "Wbactivity_sync: Renewal of the Activity Sync webhook",
27
+ "crontab": crontab1,
28
+ },
29
+ )
30
+ PeriodicTask.objects.update_or_create(
31
+ task="wbcrm.synchronization.activity.tasks.periodic_notify_admins_of_webhook_inconsistencies_task",
32
+ defaults={
33
+ "name": "Wbactivity_sync: Notification of webhook inconsistencies",
34
+ "crontab": crontab2,
35
+ },
36
+ )
@@ -0,0 +1 @@
1
+ from .activity.tasks import * # noqa
@@ -0,0 +1,5 @@
1
+ from django.urls import include, path
2
+
3
+ urlpatterns = [
4
+ path("activity/", include(("wbcrm.synchronization.activity.urls", "activity"), namespace="activity")),
5
+ ]
wbcrm/tasks.py ADDED
@@ -0,0 +1,264 @@
1
+ from __future__ import absolute_import, unicode_literals
2
+
3
+ import logging
4
+ from datetime import date, datetime, time, timedelta
5
+
6
+ from celery import shared_task
7
+ from django.contrib.auth import get_user_model
8
+ from django.db.backends.postgresql.psycopg_any import DateTimeTZRange
9
+ from django.db.models import (
10
+ F,
11
+ FloatField,
12
+ Func,
13
+ Max,
14
+ OuterRef,
15
+ Q,
16
+ QuerySet,
17
+ Subquery,
18
+ Sum,
19
+ Value,
20
+ )
21
+ from django.db.models.functions import Cast, Coalesce, Least
22
+ from django.template.loader import render_to_string
23
+ from django.utils import timezone
24
+ from django.utils.timezone import make_aware
25
+ from django.utils.translation import gettext as _
26
+ from dynamic_preferences.registries import global_preferences_registry
27
+ from rest_framework.reverse import reverse
28
+ from wbcore.contrib.directory.models import Company, Person
29
+ from wbcore.contrib.notifications.dispatch import send_notification
30
+
31
+ from wbcrm.models import Activity, ActivityType
32
+
33
+ logger = logging.getLogger()
34
+ User = get_user_model()
35
+
36
+
37
+ @shared_task
38
+ def notify(time_offset: int = 60, now: datetime | None = None):
39
+ """
40
+ Cron task that runs every 60s and checks which activities will happen during the notify interval
41
+
42
+ Arguments:
43
+ time_offset (int): The notification period. Defaults to 60.
44
+ now (datetime | None, optional): The time at which activity needs to be checked for a notification. Defaults to None.
45
+ """
46
+
47
+ if not now:
48
+ now = timezone.now()
49
+ base_queryset: QuerySet[Activity] = Activity.objects.filter(Q(status=Activity.Status.PLANNED))
50
+ reminder_choices = Activity.ReminderChoice.values
51
+
52
+ # we don't notify activity if Reminder is Never
53
+ reminder_choices.remove(Activity.ReminderChoice.NEVER)
54
+ for reminder in reminder_choices:
55
+ # get the reminder correspondance in minutes
56
+ reminder_minutes = Activity.ReminderChoice.get_minutes_correspondance(reminder)
57
+ reminder_range = DateTimeTZRange(
58
+ now + timedelta(minutes=reminder_minutes),
59
+ now + timedelta(minutes=reminder_minutes) + timedelta(seconds=time_offset),
60
+ ) # type: ignore #ErrMsg: Expected no arguments to "DateTimeTZRange" constructor
61
+ # get all incoming activity with same reminder that happen during the notify interval
62
+ upcoming_occurence = base_queryset.filter(
63
+ reminder_choice=reminder, period__startswith__contained_by=reminder_range
64
+ )
65
+ for activity in upcoming_occurence:
66
+ participants = activity.get_participants()
67
+
68
+ # For each Employee in the activity participants
69
+ for employee in Person.objects.filter_only_internal().filter(id__in=participants.values("id")).all():
70
+ # formant and create Notification
71
+ activity_type_label = activity.type.title
72
+ desc = (
73
+ activity.description
74
+ if activity.description and activity.description not in ["", "<p></p>"]
75
+ else None
76
+ )
77
+ message = render_to_string(
78
+ "email/activity.html",
79
+ {
80
+ "participants": participants,
81
+ "type": activity_type_label,
82
+ "title": activity.title,
83
+ "start": activity.period.lower,
84
+ "end": activity.period.upper,
85
+ "description": desc,
86
+ },
87
+ )
88
+ send_notification(
89
+ code="wbcrm.activity.reminder",
90
+ title=_("{type} in {reminder} Minutes").format(
91
+ type=activity_type_label, reminder=reminder_minutes
92
+ ),
93
+ body=message,
94
+ user=employee.user_account,
95
+ reverse_name="wbcrm:activity-detail",
96
+ reverse_args=[activity.pk],
97
+ )
98
+
99
+
100
+ @shared_task
101
+ def yesterdays_activity_summary(yesterday: date | None = None, report_receiver_user_ids: list[int] | None = None):
102
+ """A daily task that sends a summary of all employees' yesterday's activities to the users assigned to this task
103
+
104
+ Args:
105
+ yesterday (date | None, optional): Date of the previous day. Defaults to None.
106
+ """
107
+
108
+ if not yesterday:
109
+ yesterday = date.today() - timedelta(days=1)
110
+ yesterday = datetime.combine(yesterday, time(0, 0, 0)) # we convert the date to datetime
111
+ time_range = DateTimeTZRange(make_aware(yesterday), make_aware(yesterday + timedelta(days=1))) # type: ignore #ErrMsg: Expected no arguments to "DateTimeTZRange" constructor
112
+
113
+ # Create the list of all employees' activities for yesterday
114
+ employees_list: list[Person] = list(Person.objects.filter_only_internal())
115
+ internal_activities: QuerySet[Activity] = (
116
+ Activity.objects.exclude(status=Activity.Status.CANCELLED)
117
+ .filter(period__overlap=time_range, participants__in=employees_list)
118
+ .order_by("period__startswith")
119
+ )
120
+
121
+ if not (internal_activities.exists() or report_receiver_user_ids):
122
+ return
123
+
124
+ activity_lists: list[list[dict]] = [] # contains an activity list for each employee
125
+ employee_names = []
126
+ for employee in employees_list:
127
+ if internal_activities.filter(participants=employee).exists():
128
+ employees_activities = internal_activities.filter(participants=employee)
129
+ # Create activity list with formatted activity dictionaries for employee
130
+ activity_lists.append(
131
+ [
132
+ {
133
+ "type": activity.type.title,
134
+ "title": activity.title,
135
+ "start": activity.period.lower, # type: ignore #ErrMsg: Cannot access member "lower" for type "DateTimeTZRange"
136
+ "end": activity.period.upper, # type: ignore #ErrMsg: Cannot access member "upper" for type "DateTimeTZRange"
137
+ "endpoint": reverse("wbcrm:activity-detail", args=[activity.pk]),
138
+ }
139
+ for activity in employees_activities
140
+ ]
141
+ )
142
+ employee_names.append(employee.full_name)
143
+
144
+ # Create the notification for each person with the right permission
145
+ for user in User.objects.filter(id__in=report_receiver_user_ids).distinct():
146
+ context = {
147
+ "map_activities": zip(employee_names, activity_lists, strict=False),
148
+ "activities_count": internal_activities.count(),
149
+ "report_date": yesterday.strftime("%d.%m.%Y"),
150
+ }
151
+ message = render_to_string("email/global_daily_summary.html", context)
152
+ send_notification(
153
+ code="wbcrm.activity.global_daily_summary",
154
+ title=_("Activity Summary {}").format(yesterday.strftime("%d.%m.%Y")),
155
+ body=message,
156
+ user=user,
157
+ )
158
+
159
+
160
+ @shared_task
161
+ def finish(now: datetime | None = None):
162
+ """Cron task running every X Seconds. Checks all activities that have finished and sends a reminder to review the activity to the assigned person.
163
+
164
+ Args:
165
+ now (datetime | None, optional): Current datetime. Defaults to None.
166
+ """
167
+
168
+ if not now:
169
+ now = timezone.now()
170
+ # Get all finished activities during the cron task interval
171
+ finished_activities: QuerySet[Activity] = Activity.objects.filter(
172
+ Q(status=Activity.Status.PLANNED.name)
173
+ & Q(repeat_choice=Activity.ReoccuranceChoice.NEVER)
174
+ & Q(period__endswith__lte=now)
175
+ )
176
+
177
+ # For each of these activities, Send the Notification to the person in charge of that activity
178
+ for activity in finished_activities:
179
+ activity.finish()
180
+ activity.save()
181
+ if (assignee := activity.assigned_to) and assignee.is_internal and assignee.user_account:
182
+ send_notification(
183
+ code="wbcrm.activity.finished",
184
+ title=_("Activity Finished"),
185
+ body=_('The activity "{title}" just finished and you are in charge of it. Please review.').format(
186
+ title=activity.title
187
+ ),
188
+ user=assignee.user_account,
189
+ reverse_name="wbcrm:activity-detail",
190
+ reverse_args=[activity.pk],
191
+ )
192
+
193
+
194
+ @shared_task
195
+ def default_activity_heat_calculation(check_datetime: datetime | None = None):
196
+ """A script that calculates the activity heat of companies and
197
+ persons on a scale from 0.0 to 1.0. The type and time interval of
198
+ completed activities serve as the basis for the heat calculation for companies. A person's rating is based
199
+ on the score of the person's employer.
200
+
201
+ Args:
202
+ check_datetime (datetime | None, optional): The datetime of the activity heat check. Defaults to None.
203
+ """
204
+
205
+ if not check_datetime:
206
+ check_datetime = timezone.now()
207
+
208
+ class JulianDay(Func):
209
+ """
210
+ The Julian day is the continuous count of days since the beginning of the Julian period.
211
+ """
212
+
213
+ function = ""
214
+ output_field = FloatField() # type: ignore #ErrMsg: Expression of type "FloatField[float]" cannot be assigned to declared type "property"
215
+
216
+ def as_postgresql(self, compiler, connection):
217
+ self.template = "CAST (to_char(%(expressions)s, 'J') AS INTEGER)"
218
+ return self.as_sql(compiler, connection)
219
+
220
+ global_preferences_manager = global_preferences_registry.manager()
221
+ main_company: int = global_preferences_manager["directory__main_company"]
222
+
223
+ external_employees: QuerySet[Person] = Person.objects.exclude(id__in=Person.objects.filter_only_internal())
224
+ external_companies: QuerySet[Company] = Company.objects.exclude(id=main_company)
225
+ # Calculate the activity heat of a person in the last 180 days.
226
+ activity_score = (
227
+ Activity.objects.filter(
228
+ companies__id=OuterRef("id"),
229
+ status__in=["REVIEWED", "FINISHED"],
230
+ period__endswith__gte=check_datetime - timedelta(days=180),
231
+ period__endswith__lte=check_datetime,
232
+ )
233
+ .annotate(
234
+ ratio=JulianDay(Value(check_datetime)) - JulianDay(F("period__endswith")),
235
+ date_score=(Value(365) - F("ratio")) / Value(365.0),
236
+ score=Cast(F("type__score"), FloatField()) * F("date_score"),
237
+ )
238
+ .values("companies__id")
239
+ .annotate(sum_score=Sum(F("score")))
240
+ .values("sum_score")
241
+ )
242
+
243
+ company_score = (
244
+ Company.objects.filter(
245
+ id=OuterRef("id"),
246
+ )
247
+ .annotate(
248
+ norm_score=Coalesce(
249
+ Subquery(activity_score) / float(ActivityType.Score.MAX),
250
+ Value(0.0),
251
+ ),
252
+ abs_norm_score=Least(F("norm_score"), 1.0),
253
+ )
254
+ .values("abs_norm_score")
255
+ )
256
+ employer_max_score = (
257
+ external_companies.filter(employees__id=OuterRef("id"))
258
+ .values("employees__id")
259
+ .annotate(max_score=Max("activity_heat"))
260
+ .values("max_score")
261
+ )
262
+
263
+ external_companies.update(activity_heat=Subquery(company_score))
264
+ external_employees.filter(employers__id__in=external_companies).update(activity_heat=Subquery(employer_max_score))