wbcrm 2.2.1__py2.py3-none-any.whl

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

Potentially problematic release.


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

Files changed (155) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +4 -0
  3. wbcrm/admin/accounts.py +59 -0
  4. wbcrm/admin/activities.py +101 -0
  5. wbcrm/admin/groups.py +7 -0
  6. wbcrm/admin/products.py +8 -0
  7. wbcrm/apps.py +5 -0
  8. wbcrm/configurations/__init__.py +1 -0
  9. wbcrm/configurations/base.py +16 -0
  10. wbcrm/dynamic_preferences_registry.py +38 -0
  11. wbcrm/factories/__init__.py +14 -0
  12. wbcrm/factories/accounts.py +56 -0
  13. wbcrm/factories/activities.py +125 -0
  14. wbcrm/factories/groups.py +23 -0
  15. wbcrm/factories/products.py +10 -0
  16. wbcrm/filters/__init__.py +10 -0
  17. wbcrm/filters/accounts.py +67 -0
  18. wbcrm/filters/activities.py +181 -0
  19. wbcrm/filters/groups.py +20 -0
  20. wbcrm/filters/products.py +37 -0
  21. wbcrm/filters/signals.py +94 -0
  22. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  23. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  24. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  25. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  26. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  27. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  28. wbcrm/migrations/0007_alter_account_status.py +23 -0
  29. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  30. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  31. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  32. wbcrm/migrations/0011_activity_summary.py +22 -0
  33. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  34. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  35. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  36. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  37. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  38. wbcrm/migrations/__init__.py +0 -0
  39. wbcrm/models/__init__.py +4 -0
  40. wbcrm/models/accounts.py +637 -0
  41. wbcrm/models/activities.py +1335 -0
  42. wbcrm/models/groups.py +118 -0
  43. wbcrm/models/products.py +83 -0
  44. wbcrm/models/recurrence.py +279 -0
  45. wbcrm/preferences.py +14 -0
  46. wbcrm/serializers/__init__.py +23 -0
  47. wbcrm/serializers/accounts.py +126 -0
  48. wbcrm/serializers/activities.py +526 -0
  49. wbcrm/serializers/groups.py +30 -0
  50. wbcrm/serializers/products.py +57 -0
  51. wbcrm/serializers/recurrence.py +90 -0
  52. wbcrm/serializers/signals.py +70 -0
  53. wbcrm/synchronization/__init__.py +0 -0
  54. wbcrm/synchronization/activity/__init__.py +0 -0
  55. wbcrm/synchronization/activity/admin.py +72 -0
  56. wbcrm/synchronization/activity/backend.py +207 -0
  57. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  58. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  59. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
  60. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  61. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  62. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  63. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  64. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  65. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  66. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  67. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  68. wbcrm/synchronization/activity/backends/google/utils.py +216 -0
  69. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  70. wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
  71. wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
  72. wbcrm/synchronization/activity/backends/outlook/parser.py +423 -0
  73. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  74. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  75. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  76. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
  77. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
  78. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
  79. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
  80. wbcrm/synchronization/activity/controller.py +545 -0
  81. wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
  82. wbcrm/synchronization/activity/preferences.py +21 -0
  83. wbcrm/synchronization/activity/shortcuts.py +9 -0
  84. wbcrm/synchronization/activity/signals.py +28 -0
  85. wbcrm/synchronization/activity/tasks.py +21 -0
  86. wbcrm/synchronization/activity/urls.py +6 -0
  87. wbcrm/synchronization/activity/utils.py +46 -0
  88. wbcrm/synchronization/activity/views.py +37 -0
  89. wbcrm/synchronization/admin.py +1 -0
  90. wbcrm/synchronization/apps.py +15 -0
  91. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  92. wbcrm/synchronization/management.py +36 -0
  93. wbcrm/synchronization/tasks.py +1 -0
  94. wbcrm/synchronization/urls.py +5 -0
  95. wbcrm/tasks.py +312 -0
  96. wbcrm/tests/__init__.py +0 -0
  97. wbcrm/tests/accounts/__init__.py +0 -0
  98. wbcrm/tests/accounts/test_models.py +380 -0
  99. wbcrm/tests/accounts/test_viewsets.py +87 -0
  100. wbcrm/tests/conftest.py +76 -0
  101. wbcrm/tests/disable_signals.py +52 -0
  102. wbcrm/tests/e2e/__init__.py +1 -0
  103. wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
  104. wbcrm/tests/e2e/test_e2e.py +369 -0
  105. wbcrm/tests/test_assignee_methods.py +39 -0
  106. wbcrm/tests/test_chartviewsets.py +111 -0
  107. wbcrm/tests/test_dto.py +63 -0
  108. wbcrm/tests/test_filters.py +51 -0
  109. wbcrm/tests/test_models.py +216 -0
  110. wbcrm/tests/test_recurrence.py +291 -0
  111. wbcrm/tests/test_report.py +20 -0
  112. wbcrm/tests/test_serializers.py +170 -0
  113. wbcrm/tests/test_tasks.py +94 -0
  114. wbcrm/tests/test_viewsets.py +967 -0
  115. wbcrm/tests/tests.py +120 -0
  116. wbcrm/typings.py +107 -0
  117. wbcrm/urls.py +67 -0
  118. wbcrm/viewsets/__init__.py +22 -0
  119. wbcrm/viewsets/accounts.py +121 -0
  120. wbcrm/viewsets/activities.py +315 -0
  121. wbcrm/viewsets/buttons/__init__.py +7 -0
  122. wbcrm/viewsets/buttons/accounts.py +27 -0
  123. wbcrm/viewsets/buttons/activities.py +68 -0
  124. wbcrm/viewsets/buttons/signals.py +17 -0
  125. wbcrm/viewsets/display/__init__.py +12 -0
  126. wbcrm/viewsets/display/accounts.py +110 -0
  127. wbcrm/viewsets/display/activities.py +443 -0
  128. wbcrm/viewsets/display/groups.py +22 -0
  129. wbcrm/viewsets/display/products.py +105 -0
  130. wbcrm/viewsets/endpoints/__init__.py +8 -0
  131. wbcrm/viewsets/endpoints/accounts.py +32 -0
  132. wbcrm/viewsets/endpoints/activities.py +30 -0
  133. wbcrm/viewsets/endpoints/groups.py +7 -0
  134. wbcrm/viewsets/endpoints/products.py +9 -0
  135. wbcrm/viewsets/groups.py +37 -0
  136. wbcrm/viewsets/menu/__init__.py +8 -0
  137. wbcrm/viewsets/menu/accounts.py +18 -0
  138. wbcrm/viewsets/menu/activities.py +61 -0
  139. wbcrm/viewsets/menu/groups.py +16 -0
  140. wbcrm/viewsets/menu/products.py +20 -0
  141. wbcrm/viewsets/mixins.py +34 -0
  142. wbcrm/viewsets/previews/__init__.py +1 -0
  143. wbcrm/viewsets/previews/activities.py +10 -0
  144. wbcrm/viewsets/products.py +56 -0
  145. wbcrm/viewsets/recurrence.py +26 -0
  146. wbcrm/viewsets/titles/__init__.py +13 -0
  147. wbcrm/viewsets/titles/accounts.py +22 -0
  148. wbcrm/viewsets/titles/activities.py +61 -0
  149. wbcrm/viewsets/titles/products.py +13 -0
  150. wbcrm/viewsets/titles/utils.py +46 -0
  151. wbcrm/workflows/__init__.py +1 -0
  152. wbcrm/workflows/assignee_methods.py +25 -0
  153. wbcrm-2.2.1.dist-info/METADATA +11 -0
  154. wbcrm-2.2.1.dist-info/RECORD +155 -0
  155. wbcrm-2.2.1.dist-info/WHEEL +5 -0
@@ -0,0 +1,28 @@
1
+ from django.dispatch import receiver
2
+ from wbcore.contrib.agenda.signals import complete_post_delete, complete_post_save
3
+ from wbcrm.typings import Activity as ActivityDTO
4
+ from wbcrm.typings import ParticipantStatus as ParticipantStatusDTO
5
+
6
+ from .controller import ActivityController
7
+
8
+
9
+ @receiver(complete_post_save, sender="wbcrm.Activity")
10
+ def pre_save_activity(sender, activity_dto: ActivityDTO, pre_save_activity_dto: ActivityDTO = None, **kwargs):
11
+ ActivityController().handle_outbound(activity_dto, old_activity_dto=pre_save_activity_dto)
12
+
13
+
14
+ @receiver(complete_post_delete, sender="wbcrm.Activity")
15
+ def pre_delete_activity(sender, activity_dto: ActivityDTO, pre_delete_activity_dto: ActivityDTO, **kwargs):
16
+ ActivityController().handle_outbound(activity_dto, old_activity_dto=pre_delete_activity_dto, is_deleted=True)
17
+
18
+
19
+ @receiver(complete_post_save, sender="wbcrm.ActivityParticipant")
20
+ def pre_save_activity_participant(
21
+ sender, participant_dto: ParticipantStatusDTO, pre_save_participant_dto: ParticipantStatusDTO = None, **kwargs
22
+ ):
23
+ ActivityController().handle_outbound_participant(participant_dto, old_participant_dto=pre_save_participant_dto)
24
+
25
+
26
+ @receiver(complete_post_delete, sender="wbcrm.ActivityParticipant")
27
+ def pre_delete_activity_participant(sender, participant_dto: ParticipantStatusDTO, **kwargs):
28
+ ActivityController().handle_outbound_participant(participant_dto, is_deleted=True)
@@ -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 (backend := get_backend()):
12
+ 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 backend := get_backend():
21
+ backend().renew_web_hooks()
@@ -0,0 +1,6 @@
1
+ from django.urls import path
2
+ from wbcrm.synchronization.activity.views import event_watch
3
+
4
+ urlpatterns = [
5
+ path("event_watch", event_watch, name="event_watch"),
6
+ ]
@@ -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, v in nested_to_merge.items():
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,37 @@
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 .controller import ActivityController
7
+
8
+
9
+ @shared_task(
10
+ queue="synchronization",
11
+ default_retry_delay=5,
12
+ autoretry_for=(OperationalError,),
13
+ max_retries=4,
14
+ retry_backoff=True,
15
+ )
16
+ def handle_inbound_as_task(event: dict):
17
+ """
18
+ the events received from the webhook are handled in a task
19
+ which will allow to create, modify or delete the activity without interrupting the main server
20
+ """
21
+ ActivityController().handle_inbound(event)
22
+
23
+
24
+ @csrf_exempt
25
+ def event_watch(request: HttpRequest) -> HttpResponse:
26
+ # TODO this is unsecure as it is prone to DDOS attack
27
+ status_code = 200
28
+ try:
29
+ controller = ActivityController()
30
+ if response := controller.handle_inbound_validation_response(request):
31
+ return response
32
+ for event in controller.get_events_from_inbound_request(request):
33
+ handle_inbound_as_task.delay(event)
34
+ except Exception as e:
35
+ print(e) # noqa: T201
36
+ status_code = 500
37
+ return HttpResponse(status=status_code)
@@ -0,0 +1 @@
1
+ import wbcrm.synchronization.activity.admin # noqa
@@ -0,0 +1,15 @@
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
+ import wbcrm.synchronization.activity.signals # noqa
10
+ from wbcrm.synchronization.management import initialize_task
11
+
12
+ post_migrate.connect(
13
+ initialize_task,
14
+ dispatch_uid="wbcrm.synchronization.initialize_task",
15
+ )
@@ -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,312 @@
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.models import (
9
+ F,
10
+ FloatField,
11
+ Func,
12
+ Max,
13
+ OuterRef,
14
+ Q,
15
+ QuerySet,
16
+ Subquery,
17
+ Sum,
18
+ Value,
19
+ )
20
+ from django.db.models.functions import Cast, Coalesce, Least
21
+ from django.template.loader import render_to_string
22
+ from django.utils import timezone
23
+ from django.utils.timezone import make_aware
24
+ from django.utils.translation import gettext as _
25
+ from dynamic_preferences.registries import global_preferences_registry
26
+ from psycopg.types.range import TimestamptzRange
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
+ from wbcrm.models import Activity, ActivityType
31
+
32
+ logger = logging.getLogger()
33
+ User = get_user_model()
34
+
35
+
36
+ @shared_task
37
+ def notify(time_offset: int = 60, now: datetime | None = None):
38
+ """
39
+ Cron task that runs every 60s and checks which activities will happen during the notify interval
40
+
41
+ Arguments:
42
+ time_offset (int): The notification period. Defaults to 60.
43
+ now (datetime | None, optional): The time at which activity needs to be checked for a notification. Defaults to None.
44
+ """
45
+
46
+ if not now:
47
+ now = timezone.now()
48
+ base_queryset: QuerySet[Activity] = Activity.objects.filter(Q(status=Activity.Status.PLANNED))
49
+ reminder_choices = Activity.ReminderChoice.values
50
+
51
+ # we don't notify activity if Reminder is Never
52
+ reminder_choices.remove(Activity.ReminderChoice.NEVER)
53
+ for reminder in reminder_choices:
54
+ # get the reminder correspondance in minutes
55
+ reminder_minutes = Activity.ReminderChoice.get_minutes_correspondance(reminder)
56
+ reminder_range = TimestamptzRange(
57
+ now + timedelta(minutes=reminder_minutes),
58
+ now + timedelta(minutes=reminder_minutes) + timedelta(seconds=time_offset),
59
+ ) # type: ignore #ErrMsg: Expected no arguments to "TimestamptzRange" constructor
60
+ # get all incoming activity with same reminder that happen during the notify interval
61
+ upcoming_occurence = base_queryset.filter(
62
+ reminder_choice=reminder, period__startswith__contained_by=reminder_range
63
+ )
64
+ for activity in upcoming_occurence:
65
+ participants = activity.get_participants()
66
+
67
+ # For each Employee in the activity participants
68
+ for employee in Person.objects.filter_only_internal().filter(id__in=participants.values("id")).all():
69
+ # formant and create Notification
70
+ activity_type_label = activity.type.title
71
+ desc = (
72
+ activity.description
73
+ if activity.description and activity.description not in ["", "<p></p>"]
74
+ else None
75
+ )
76
+ message = render_to_string(
77
+ "email/activity.html",
78
+ {
79
+ "participants": participants,
80
+ "type": activity_type_label,
81
+ "title": activity.title,
82
+ "start": activity.period.lower,
83
+ "end": activity.period.upper,
84
+ "description": desc,
85
+ },
86
+ )
87
+ send_notification(
88
+ code="wbcrm.activity.reminder",
89
+ title=_("{type} in {reminder} Minutes").format(
90
+ type=activity_type_label, reminder=reminder_minutes
91
+ ),
92
+ body=message,
93
+ user=employee.user_account,
94
+ reverse_name="wbcrm:activity-detail",
95
+ reverse_args=[activity.pk],
96
+ )
97
+
98
+
99
+ @shared_task
100
+ def yesterdays_activity_summary(yesterday: date | None = None, report_receiver_user_ids: list[int] | None = None):
101
+ """A daily task that sends a summary of all employees' yesterday's activities to the users assigned to this task
102
+
103
+ Args:
104
+ yesterday (date | None, optional): Date of the previous day. Defaults to None.
105
+ """
106
+
107
+ if not yesterday:
108
+ yesterday = date.today() - timedelta(days=1)
109
+ yesterday = datetime.combine(yesterday, time(0, 0, 0)) # we convert the date to datetime
110
+ time_range = TimestamptzRange(make_aware(yesterday), make_aware(yesterday + timedelta(days=1))) # type: ignore #ErrMsg: Expected no arguments to "TimestamptzRange" constructor
111
+
112
+ # Create the list of all employees' activities for yesterday
113
+ employees_list: list[Person] = list(Person.objects.filter_only_internal())
114
+ internal_activities: QuerySet[Activity] = (
115
+ Activity.objects.exclude(status=Activity.Status.CANCELLED)
116
+ .filter(period__overlap=time_range, participants__in=employees_list)
117
+ .order_by("period__startswith")
118
+ )
119
+
120
+ if not (internal_activities.exists() or report_receiver_user_ids):
121
+ return
122
+
123
+ activity_lists: list[list[dict]] = [] # contains an activity list for each employee
124
+ employee_names = []
125
+ for employee in employees_list:
126
+ if internal_activities.filter(participants=employee).exists():
127
+ employees_activities = internal_activities.filter(participants=employee)
128
+ # Create activity list with formatted activity dictionaries for employee
129
+ activity_lists.append(
130
+ [
131
+ {
132
+ "type": activity.type.title,
133
+ "title": activity.title,
134
+ "start": activity.period.lower, # type: ignore #ErrMsg: Cannot access member "lower" for type "TimestamptzRange"
135
+ "end": activity.period.upper, # type: ignore #ErrMsg: Cannot access member "upper" for type "TimestamptzRange"
136
+ "endpoint": reverse("wbcrm:activity-detail", args=[activity.pk]),
137
+ }
138
+ for activity in employees_activities
139
+ ]
140
+ )
141
+ employee_names.append(employee.full_name)
142
+
143
+ # Create the notification for each person with the right permission
144
+ for user in User.objects.filter(id__in=report_receiver_user_ids).distinct():
145
+ context = {
146
+ "map_activities": zip(employee_names, activity_lists),
147
+ "activities_count": internal_activities.count(),
148
+ "report_date": yesterday.strftime("%d.%m.%Y"),
149
+ }
150
+ message = render_to_string("email/global_daily_summary.html", context)
151
+ send_notification(
152
+ code="wbcrm.activity.daily_summary",
153
+ title=_("Activity Summary {}").format(yesterday.strftime("%d.%m.%Y")),
154
+ body=message,
155
+ user=user,
156
+ )
157
+
158
+
159
+ @shared_task
160
+ def todays_activity_summary(today: date | None = None):
161
+ """Creates a summary of the daily upcoming activities for all employees
162
+
163
+ Args:
164
+ today (date | None, optional): Date of today. Defaults to None.
165
+ """
166
+
167
+ if not today:
168
+ today = date.today()
169
+ tz_info = timezone.get_current_timezone()
170
+ today_range = TimestamptzRange(
171
+ lower=datetime.combine(today, time(0, 0, 0)).replace(tzinfo=tz_info),
172
+ upper=datetime.combine(today + timedelta(days=1), time(0, 0, 0).replace(tzinfo=tz_info)),
173
+ )
174
+
175
+ for employee in Person.objects.filter_only_internal():
176
+ # Get all the employee's activities from that day
177
+ activity_qs: QuerySet[Activity] = (
178
+ Activity.objects.exclude(status=Activity.Status.CANCELLED)
179
+ .filter(period__overlap=today_range, participants=employee)
180
+ .order_by("period__startswith")
181
+ )
182
+
183
+ # Create the formatted activity dictionaries
184
+ activity_list = []
185
+ for activity in activity_qs:
186
+ activity_list.append(
187
+ {
188
+ "type": activity.type.title,
189
+ "title": activity.title,
190
+ "start": activity.period.lower,
191
+ "end": activity.period.upper,
192
+ "endpoint": reverse("wbcrm:activity-detail", args=[activity.pk]),
193
+ }
194
+ )
195
+
196
+ # Create the proper notification for each employee
197
+ if activity_list and employee.primary_email_contact():
198
+ context = {"activities": activity_list}
199
+ message = render_to_string("email/daily_summary.html", context)
200
+ send_notification(
201
+ code="wbcrm.activity.daily_summary",
202
+ title=_("Your Schedule for Today"),
203
+ body=message,
204
+ user=employee.user_account,
205
+ )
206
+
207
+
208
+ @shared_task
209
+ def finish(now: datetime | None = None):
210
+ """Cron task running every X Seconds. Checks all activities that have finished and sends a reminder to review the activity to the assigned person.
211
+
212
+ Args:
213
+ now (datetime | None, optional): Current datetime. Defaults to None.
214
+ """
215
+
216
+ if not now:
217
+ now = timezone.now()
218
+ # Get all finished activities during the cron task interval
219
+ finished_activities: QuerySet[Activity] = Activity.objects.filter(
220
+ Q(status=Activity.Status.PLANNED.name)
221
+ & Q(repeat_choice=Activity.ReoccuranceChoice.NEVER)
222
+ & Q(period__endswith__lte=now)
223
+ )
224
+
225
+ # For each of these activities, Send the Notification to the person in charge of that activity
226
+ for activity in finished_activities:
227
+ activity.finish()
228
+ activity.save()
229
+ if (assignee := activity.assigned_to) and assignee.is_internal and assignee.user_account:
230
+ send_notification(
231
+ code="wbcrm.activity.finished",
232
+ title=_("Activity Finished"),
233
+ body=_('The activity "{title}" just finished and you are in charge of it. Please review.').format(
234
+ title=activity.title
235
+ ),
236
+ user=assignee.user_account,
237
+ reverse_name="wbcrm:activity-detail",
238
+ reverse_args=[activity.pk],
239
+ )
240
+
241
+
242
+ @shared_task
243
+ def default_activity_heat_calculation(check_datetime: datetime | None = None):
244
+ """A script that calculates the activity heat of companies and
245
+ persons on a scale from 0.0 to 1.0. The type and time interval of
246
+ completed activities serve as the basis for the heat calculation for companies. A person's rating is based
247
+ on the score of the person's employer.
248
+
249
+ Args:
250
+ check_datetime (datetime | None, optional): The datetime of the activity heat check. Defaults to None.
251
+ """
252
+
253
+ if not check_datetime:
254
+ check_datetime = timezone.now()
255
+
256
+ class JulianDay(Func):
257
+ """
258
+ The Julian day is the continuous count of days since the beginning of the Julian period.
259
+ """
260
+
261
+ function = ""
262
+ output_field = FloatField() # type: ignore #ErrMsg: Expression of type "FloatField[float]" cannot be assigned to declared type "property"
263
+
264
+ def as_postgresql(self, compiler, connection):
265
+ self.template = "CAST (to_char(%(expressions)s, 'J') AS INTEGER)"
266
+ return self.as_sql(compiler, connection)
267
+
268
+ global_preferences_manager = global_preferences_registry.manager()
269
+ main_company: int = global_preferences_manager["directory__main_company"]
270
+
271
+ external_employees: QuerySet[Person] = Person.objects.exclude(id__in=Person.objects.filter_only_internal())
272
+ external_companies: QuerySet[Company] = Company.objects.exclude(id=main_company)
273
+ # Calculate the activity heat of a person in the last 180 days.
274
+ activity_score = (
275
+ Activity.objects.filter(
276
+ companies__id=OuterRef("id"),
277
+ status__in=["REVIEWED", "FINISHED"],
278
+ period__endswith__gte=check_datetime - timedelta(days=180),
279
+ period__endswith__lte=check_datetime,
280
+ )
281
+ .annotate(
282
+ ratio=JulianDay(Value(check_datetime)) - JulianDay(F("period__endswith")),
283
+ date_score=(Value(365) - F("ratio")) / Value(365.0),
284
+ score=Cast(F("type__score"), FloatField()) * F("date_score"),
285
+ )
286
+ .values("companies__id")
287
+ .annotate(sum_score=Sum(F("score")))
288
+ .values("sum_score")
289
+ )
290
+
291
+ company_score = (
292
+ Company.objects.filter(
293
+ id=OuterRef("id"),
294
+ )
295
+ .annotate(
296
+ norm_score=Coalesce(
297
+ Subquery(activity_score) / float(ActivityType.Score.MAX),
298
+ Value(0.0),
299
+ ),
300
+ abs_norm_score=Least(F("norm_score"), 1.0),
301
+ )
302
+ .values("abs_norm_score")
303
+ )
304
+ employer_max_score = (
305
+ external_companies.filter(employees__id=OuterRef("id"))
306
+ .values("employees__id")
307
+ .annotate(max_score=Max("activity_heat"))
308
+ .values("max_score")
309
+ )
310
+
311
+ external_companies.update(activity_heat=Subquery(company_score))
312
+ external_employees.filter(employers__id__in=external_companies).update(activity_heat=Subquery(employer_max_score))
File without changes
File without changes