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.
- wbcrm/__init__.py +1 -0
- wbcrm/admin/__init__.py +5 -0
- wbcrm/admin/accounts.py +60 -0
- wbcrm/admin/activities.py +104 -0
- wbcrm/admin/events.py +43 -0
- wbcrm/admin/groups.py +8 -0
- wbcrm/admin/products.py +9 -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 +57 -0
- wbcrm/factories/activities.py +124 -0
- wbcrm/factories/groups.py +24 -0
- wbcrm/factories/products.py +11 -0
- wbcrm/filters/__init__.py +10 -0
- wbcrm/filters/accounts.py +80 -0
- wbcrm/filters/activities.py +204 -0
- wbcrm/filters/groups.py +21 -0
- wbcrm/filters/products.py +38 -0
- wbcrm/filters/signals.py +95 -0
- wbcrm/fixtures/wbcrm.json +1215 -0
- wbcrm/kpi_handlers/activities.py +171 -0
- wbcrm/locale/de/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/de/LC_MESSAGES/django.po +1557 -0
- wbcrm/locale/de/LC_MESSAGES/django.po.translated +1630 -0
- wbcrm/locale/en/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/en/LC_MESSAGES/django.po +1466 -0
- wbcrm/locale/fr/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/fr/LC_MESSAGES/django.po +1467 -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/0017_event.py +40 -0
- wbcrm/migrations/0018_activity_search_vector.py +24 -0
- wbcrm/migrations/__init__.py +0 -0
- wbcrm/models/__init__.py +5 -0
- wbcrm/models/accounts.py +648 -0
- wbcrm/models/activities.py +1419 -0
- wbcrm/models/events.py +15 -0
- wbcrm/models/groups.py +119 -0
- wbcrm/models/llm/activity_summaries.py +41 -0
- wbcrm/models/llm/analyze_relationship.py +50 -0
- wbcrm/models/products.py +86 -0
- wbcrm/models/recurrence.py +280 -0
- wbcrm/preferences.py +13 -0
- wbcrm/report/activity_report.py +110 -0
- wbcrm/serializers/__init__.py +23 -0
- wbcrm/serializers/accounts.py +141 -0
- wbcrm/serializers/activities.py +525 -0
- wbcrm/serializers/groups.py +30 -0
- wbcrm/serializers/products.py +58 -0
- wbcrm/serializers/recurrence.py +91 -0
- wbcrm/serializers/signals.py +71 -0
- wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
- wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
- wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
- wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
- wbcrm/synchronization/__init__.py +0 -0
- wbcrm/synchronization/activity/__init__.py +0 -0
- wbcrm/synchronization/activity/admin.py +73 -0
- wbcrm/synchronization/activity/backend.py +214 -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 +406 -0
- wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
- wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +181 -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 +217 -0
- wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/backend.py +593 -0
- wbcrm/synchronization/activity/backends/outlook/msgraph.py +436 -0
- wbcrm/synchronization/activity/backends/outlook/parser.py +432 -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 +118 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +274 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +249 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +174 -0
- wbcrm/synchronization/activity/controller.py +627 -0
- wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
- wbcrm/synchronization/activity/preferences.py +27 -0
- wbcrm/synchronization/activity/shortcuts.py +16 -0
- wbcrm/synchronization/activity/tasks.py +21 -0
- wbcrm/synchronization/activity/urls.py +7 -0
- wbcrm/synchronization/activity/utils.py +46 -0
- wbcrm/synchronization/activity/views.py +41 -0
- wbcrm/synchronization/admin.py +1 -0
- wbcrm/synchronization/apps.py +14 -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 +264 -0
- wbcrm/templates/email/activity.html +98 -0
- wbcrm/templates/email/activity_report.html +6 -0
- wbcrm/templates/email/daily_summary.html +72 -0
- wbcrm/templates/email/global_daily_summary.html +85 -0
- wbcrm/tests/__init__.py +0 -0
- wbcrm/tests/accounts/__init__.py +0 -0
- wbcrm/tests/accounts/test_models.py +393 -0
- wbcrm/tests/accounts/test_viewsets.py +88 -0
- wbcrm/tests/conftest.py +76 -0
- wbcrm/tests/disable_signals.py +62 -0
- wbcrm/tests/e2e/__init__.py +1 -0
- wbcrm/tests/e2e/e2e_wbcrm_utility.py +83 -0
- wbcrm/tests/e2e/test_e2e.py +370 -0
- wbcrm/tests/test_assignee_methods.py +40 -0
- wbcrm/tests/test_chartviewsets.py +112 -0
- wbcrm/tests/test_dto.py +64 -0
- wbcrm/tests/test_filters.py +52 -0
- wbcrm/tests/test_models.py +217 -0
- wbcrm/tests/test_recurrence.py +292 -0
- wbcrm/tests/test_report.py +21 -0
- wbcrm/tests/test_serializers.py +171 -0
- wbcrm/tests/test_tasks.py +95 -0
- wbcrm/tests/test_viewsets.py +967 -0
- wbcrm/tests/tests.py +121 -0
- wbcrm/typings.py +109 -0
- wbcrm/urls.py +67 -0
- wbcrm/viewsets/__init__.py +22 -0
- wbcrm/viewsets/accounts.py +122 -0
- wbcrm/viewsets/activities.py +341 -0
- wbcrm/viewsets/buttons/__init__.py +7 -0
- wbcrm/viewsets/buttons/accounts.py +27 -0
- wbcrm/viewsets/buttons/activities.py +89 -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 +444 -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 +25 -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 +38 -0
- wbcrm/viewsets/menu/__init__.py +8 -0
- wbcrm/viewsets/menu/accounts.py +18 -0
- wbcrm/viewsets/menu/activities.py +49 -0
- wbcrm/viewsets/menu/groups.py +16 -0
- wbcrm/viewsets/menu/products.py +20 -0
- wbcrm/viewsets/mixins.py +35 -0
- wbcrm/viewsets/previews/__init__.py +1 -0
- wbcrm/viewsets/previews/activities.py +10 -0
- wbcrm/viewsets/products.py +57 -0
- wbcrm/viewsets/recurrence.py +27 -0
- wbcrm/viewsets/titles/__init__.py +13 -0
- wbcrm/viewsets/titles/accounts.py +23 -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-1.56.8.dist-info/METADATA +11 -0
- wbcrm-1.56.8.dist-info/RECORD +182 -0
- 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,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
|
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))
|