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,1419 @@
1
+ import zoneinfo
2
+ from contextlib import suppress
3
+ from datetime import date, datetime, time, timedelta
4
+ from typing import Any
5
+
6
+ import arrow
7
+ import numpy as np
8
+ from celery import shared_task
9
+ from dateutil.rrule import rrulestr
10
+ from django.conf import settings
11
+ from django.contrib.postgres.aggregates import StringAgg
12
+ from django.contrib.postgres.indexes import GinIndex
13
+ from django.contrib.postgres.search import SearchVector, SearchVectorField
14
+ from django.db import models, transaction
15
+ from django.db.backends.postgresql.psycopg_any import DateTimeTZRange
16
+ from django.db.models import Exists, OuterRef, Q, Value
17
+ from django.db.models.query import QuerySet
18
+ from django.db.models.signals import m2m_changed, post_delete, post_save
19
+ from django.dispatch import receiver
20
+ from django.template.loader import render_to_string
21
+ from django.utils import timezone
22
+ from django.utils.translation import gettext, pgettext_lazy
23
+ from django.utils.translation import gettext_lazy as _
24
+ from django_fsm import FSMField, transition
25
+ from dynamic_preferences.registries import global_preferences_registry
26
+ from ics.alarm import DisplayAlarm
27
+ from psycopg.types.range import TimestamptzRange
28
+ from rest_framework.reverse import reverse
29
+ from slugify import slugify
30
+ from wbcore.contrib import workflow
31
+ from wbcore.contrib.agenda.models import CalendarItem
32
+ from wbcore.contrib.agenda.signals import draggable_calendar_item_ids
33
+ from wbcore.contrib.ai.llm.decorators import llm
34
+ from wbcore.contrib.color.enums import WBColor
35
+ from wbcore.contrib.directory.models import (
36
+ Company,
37
+ EmployerEmployeeRelationship,
38
+ Entry,
39
+ Person,
40
+ )
41
+ from wbcore.contrib.directory.preferences import get_main_company
42
+ from wbcore.contrib.icons import WBIcon
43
+ from wbcore.contrib.notifications.dispatch import send_notification
44
+ from wbcore.contrib.notifications.utils import create_notification_type
45
+ from wbcore.enums import RequestType
46
+ from wbcore.metadata.configs.buttons import ActionButton
47
+ from wbcore.metadata.configs.display.instance_display.shortcuts import (
48
+ create_simple_display,
49
+ )
50
+ from wbcore.models import WBModel
51
+ from wbcore.utils.models import (
52
+ CalendarItemTypeMixin,
53
+ ComplexToStringMixin,
54
+ DefaultMixin,
55
+ )
56
+ from wbhuman_resources.signals import add_employee_activity_to_daily_brief
57
+
58
+ from wbcrm.models.llm.activity_summaries import analyze_activity
59
+ from wbcrm.models.recurrence import Recurrence
60
+ from wbcrm.synchronization.activity.shortcuts import get_backend
61
+ from wbcrm.typings import Activity as ActivityDTO
62
+ from wbcrm.typings import ParticipantStatus as ParticipantStatusDTO
63
+
64
+
65
+ class DisplayAlarm2(DisplayAlarm):
66
+ def __hash__(self):
67
+ return hash(repr(self))
68
+
69
+
70
+ class ActivityType(DefaultMixin, CalendarItemTypeMixin, WBModel):
71
+ class Score(models.TextChoices):
72
+ HIGH = "4.0"
73
+ MEDIUM = "3.0"
74
+ LOW = "2.0"
75
+ NONE = "0.0"
76
+ MAX = "9.0"
77
+
78
+ title = models.CharField(
79
+ max_length=128,
80
+ verbose_name=_("Title"),
81
+ unique=True,
82
+ blank=False,
83
+ null=False,
84
+ )
85
+
86
+ slugify_title = models.CharField(
87
+ max_length=128,
88
+ unique=True,
89
+ verbose_name="Slugified Title",
90
+ blank=True,
91
+ null=True,
92
+ )
93
+
94
+ score = models.CharField(
95
+ max_length=8,
96
+ verbose_name=_("Activity Heat Multiplier"),
97
+ choices=Score.choices[:4],
98
+ default=Score.LOW.value,
99
+ unique=False,
100
+ blank=False,
101
+ null=False,
102
+ help_text=_(
103
+ "Used for the activity heat calculation. Multipliers range from low (i.e. e-mail) to medium (i.e. call) and high (i.e. meeting)."
104
+ ),
105
+ )
106
+
107
+ @classmethod
108
+ def get_endpoint_basename(cls):
109
+ return "wbcrm:activitytype"
110
+
111
+ @classmethod
112
+ def get_representation_value_key(cls):
113
+ return "id"
114
+
115
+ @classmethod
116
+ def get_representation_endpoint(cls):
117
+ return "wbcrm:activitytyperepresentation-list"
118
+
119
+ @classmethod
120
+ def get_representation_label_key(cls):
121
+ return "{{title}}"
122
+
123
+ def __str__(self) -> str:
124
+ return f"{self.title}"
125
+
126
+ @classmethod
127
+ def get_default_activity_type(cls):
128
+ with suppress(cls.DoesNotExist):
129
+ return cls.objects.get(default=True)
130
+
131
+ class Meta:
132
+ verbose_name = _("Activity Type")
133
+ verbose_name_plural = _("Activity Types")
134
+
135
+ def save(self, *args, **kwargs):
136
+ self.slugify_title = slugify(self.title, separator=" ")
137
+ super().save(*args, **kwargs)
138
+
139
+
140
+ @receiver(post_save, sender=ActivityType)
141
+ def trigger_activity_save(sender, instance: ActivityType, created: bool, raw: bool, **kwargs):
142
+ """
143
+ We need to trigger all activities' save methods to update their color and icon
144
+ """
145
+
146
+ if not raw:
147
+ for activity in instance.activity.all():
148
+ activity.save()
149
+
150
+
151
+ def has_permissions(instance, user): # type: ignore
152
+ if user.has_perm("wbcrm.change_activity"):
153
+ if instance.visibility == CalendarItem.Visibility.PRIVATE:
154
+ return instance.is_private_for_user(user)
155
+ elif instance.visibility == CalendarItem.Visibility.CONFIDENTIAL:
156
+ return instance.is_confidential_for_user(user)
157
+ else:
158
+ return True
159
+ else:
160
+ return False
161
+
162
+
163
+ @llm([analyze_activity])
164
+ @workflow.register(serializer_class="wbcrm.serializers.ActivityModelSerializer")
165
+ class Activity(Recurrence):
166
+ summary = models.TextField(default="", blank=True, verbose_name=_("LLM Summary"))
167
+ heat = models.PositiveIntegerField(null=True, blank=True)
168
+ online_meeting = models.BooleanField(
169
+ default=False,
170
+ verbose_name=_("Online Meeting"),
171
+ help_text=_("Check this if it happens online"),
172
+ )
173
+
174
+ class Status(models.TextChoices):
175
+ CANCELLED = "CANCELLED", _("Cancelled")
176
+ PLANNED = "PLANNED", _("Planned")
177
+ FINISHED = "FINISHED", _("Finished")
178
+ REVIEWED = "REVIEWED", _("Reviewed")
179
+
180
+ @classmethod
181
+ def get_color_map(cls):
182
+ colors = [
183
+ WBColor.RED_LIGHT.value,
184
+ WBColor.YELLOW_LIGHT.value,
185
+ WBColor.BLUE_LIGHT.value,
186
+ WBColor.GREEN_LIGHT.value,
187
+ ]
188
+ return [choice for choice in zip(cls, colors, strict=False)]
189
+
190
+ class Importance(models.TextChoices):
191
+ LOW = "LOW", _("Low")
192
+ MEDIUM = "MEDIUM", _("Medium")
193
+ HIGH = "HIGH", _("High")
194
+
195
+ class ReminderChoice(models.TextChoices):
196
+ NEVER = "NEVER", _("Never")
197
+ EVENT_TIME = "EVENT_TIME", _("At time of event")
198
+ MINUTES_5 = "MINUTES_5", _("5 minutes before")
199
+ MINUTES_15 = "MINUTES_15", _("15 minutes before")
200
+ MINUTES_30 = "MINUTES_30", _("30 minutes before")
201
+ HOURS_1 = "HOURS_1", _("1 hour before")
202
+ HOURS_2 = "HOURS_2", _("2 hour before")
203
+ HOURS_12 = "HOURS_12", _("12 hour before")
204
+ WEEKS_1 = "WEEKS_1", _("1 week before")
205
+
206
+ @classmethod
207
+ def get_minutes_correspondance(cls, name):
208
+ _map = {
209
+ "NEVER": -1,
210
+ "EVENT_TIME": 0,
211
+ "MINUTES_5": 5,
212
+ "MINUTES_15": 15,
213
+ "MINUTES_30": 30,
214
+ "HOURS_1": 60,
215
+ "HOURS_2": 120,
216
+ "HOURS_12": 720,
217
+ "WEEKS_1": 10080,
218
+ }
219
+
220
+ return _map[name]
221
+
222
+ class Meta:
223
+ verbose_name = _("Activity")
224
+ verbose_name_plural = _("Activities")
225
+ indexes = [
226
+ GinIndex(fields=["search_vector"], name="activity_sv_gin_idx"), # type: ignore
227
+ ]
228
+ notification_types = [
229
+ create_notification_type(
230
+ "wbcrm.activity.participant",
231
+ gettext("Activity Participant"),
232
+ gettext("User notification when addeded to an activity."),
233
+ ),
234
+ create_notification_type(
235
+ "wbcrm.activity.reminder",
236
+ gettext("Activity Reminder"),
237
+ gettext("Sends a reminder that an activity is starting soon."),
238
+ ),
239
+ create_notification_type(
240
+ "wbcrm.activity.finished",
241
+ gettext("Finished Activity"),
242
+ gettext("Notifies a user of a finished activity that can be reviewed."),
243
+ ),
244
+ create_notification_type(
245
+ "wbcrm.activity.global_daily_summary",
246
+ gettext("Daily Summary"),
247
+ gettext("Sends out a the global employees daily activities report"),
248
+ web=False,
249
+ mobile=False,
250
+ email=True,
251
+ is_lock=True,
252
+ ),
253
+ create_notification_type(
254
+ "wbcrm.activity.daily_brief",
255
+ gettext("Daily Brief"),
256
+ gettext("Sends out a daily brief for the user's upcoming day."),
257
+ web=False,
258
+ mobile=False,
259
+ email=True,
260
+ is_lock=True,
261
+ ),
262
+ create_notification_type(
263
+ "wbcrm.activity_sync.admin",
264
+ gettext("Activity Sync Irregularities"),
265
+ gettext("Admin notification to inform about irregularities of the activity sync."),
266
+ ),
267
+ ]
268
+
269
+ status = FSMField(default=Status.PLANNED, choices=Status.choices, verbose_name=_("Status"))
270
+
271
+ @transition(
272
+ field=status,
273
+ source=[Status.PLANNED],
274
+ target=Status.FINISHED,
275
+ permission=has_permissions,
276
+ custom={
277
+ "_transition_button": ActionButton(
278
+ method=RequestType.PATCH,
279
+ identifiers=("wbcrm:activity",),
280
+ icon=WBIcon.CONFIRM.icon,
281
+ key="finish",
282
+ label=_("Finish"),
283
+ action_label=_("Finish"),
284
+ description_fields=_("Are you sure you want to finish this activity?"),
285
+ )
286
+ },
287
+ )
288
+ def finish(self, by=None, description=None, **kwargs):
289
+ self.cancel_recurrence()
290
+
291
+ def can_finish(self):
292
+ errors = dict()
293
+
294
+ if not self.period:
295
+ errors["period"] = [_("In this status this has to be provided.")]
296
+
297
+ return errors
298
+
299
+ @transition(
300
+ field=status,
301
+ source=[Status.PLANNED, Status.FINISHED],
302
+ target=Status.REVIEWED,
303
+ permission=has_permissions,
304
+ custom={
305
+ "_transition_button": ActionButton(
306
+ method=RequestType.PATCH,
307
+ identifiers=("wbcrm:activity",),
308
+ icon=WBIcon.REVIEW.icon,
309
+ key="review",
310
+ label=pgettext_lazy("Transition button label for Reviews", "Review"),
311
+ action_label=pgettext_lazy("Transition action label for Reviews", "Review"),
312
+ description_fields="",
313
+ instance_display=create_simple_display([["result"], ["participants"], ["companies"]]),
314
+ )
315
+ },
316
+ )
317
+ def review(self, by=None, description=None, **kwargs):
318
+ self.cancel_recurrence()
319
+
320
+ def can_review(self):
321
+ errors = self.can_finish()
322
+ if not self.result or self.result == "" or self.result == "<p></p>":
323
+ errors["result"] = [_("When reviewing an activity a result has to be provided!")]
324
+
325
+ missing = self._check_employer_employees_entered()
326
+
327
+ if missing_companies := missing.get("missing_companies_by_participant", None):
328
+ participants_with_missing_companies = [participant.computed_str for participant in missing_companies]
329
+ errors["companies"] = [
330
+ _(
331
+ "You need to enter an employer for: {persons}",
332
+ ).format(persons=", ".join(participants_with_missing_companies))
333
+ ]
334
+
335
+ if missing_participants := missing.get("missing_participants_by_company", None):
336
+ companies_with_missing_participants = [company.name for company in missing_participants]
337
+ errors["participants"] = [
338
+ _(
339
+ "You need to enter an employee for: {companies}",
340
+ ).format(companies=", ".join(companies_with_missing_participants))
341
+ ]
342
+ return errors
343
+
344
+ @transition(
345
+ field=status,
346
+ source=[Status.PLANNED],
347
+ target=Status.CANCELLED,
348
+ permission=has_permissions,
349
+ custom={
350
+ "_transition_button": ActionButton(
351
+ method=RequestType.PATCH,
352
+ identifiers=("wbcrm:activity",),
353
+ icon=WBIcon.REJECT.icon,
354
+ key="cancel",
355
+ label=_("Cancel"),
356
+ action_label=_("Cancel"),
357
+ description_fields=_("Are you sure you want to cancel this activity?"),
358
+ )
359
+ },
360
+ )
361
+ def cancel(self, by=None, description=None, **kwargs):
362
+ self.cancel_recurrence()
363
+
364
+ description = models.TextField(default="", blank=True, verbose_name=_("Description"))
365
+ result = models.TextField(default="", blank=True, verbose_name=_("Review"))
366
+ type = models.ForeignKey(
367
+ "wbcrm.ActivityType",
368
+ related_name="activity",
369
+ on_delete=models.PROTECT,
370
+ verbose_name=_("Type"),
371
+ )
372
+ importance = models.CharField(
373
+ max_length=16, default=Importance.LOW, choices=Importance.choices, verbose_name=_("Importance")
374
+ )
375
+
376
+ start = models.DateTimeField(blank=True, null=True, verbose_name=_("Start"))
377
+ end = models.DateTimeField(blank=True, null=True, verbose_name=_("End"))
378
+
379
+ reminder_choice = models.CharField(
380
+ max_length=16,
381
+ default=ReminderChoice.MINUTES_15,
382
+ choices=ReminderChoice.choices,
383
+ verbose_name=_("Reminder"),
384
+ help_text=_(
385
+ "Sends a mail and system notification to all participating internal employees before the start of the activity."
386
+ ),
387
+ )
388
+ location = models.CharField(
389
+ max_length=2048, null=True, blank=True, verbose_name=_("Location")
390
+ ) # we increase the max lenght to 2048 to accomodate meeting URL (ICS and outlook uses the location field to share meeting link)
391
+ location_longitude = models.DecimalField(
392
+ max_digits=9, decimal_places=6, null=True, blank=True, verbose_name=_("Longitude")
393
+ )
394
+ location_latitude = models.DecimalField(
395
+ max_digits=9, decimal_places=6, null=True, blank=True, verbose_name=_("Latitude")
396
+ )
397
+ created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created"))
398
+ creator = models.ForeignKey(
399
+ "directory.Person",
400
+ related_name="activities_owned",
401
+ null=True,
402
+ blank=True,
403
+ on_delete=models.SET_NULL,
404
+ verbose_name=_("Creator"),
405
+ help_text=_("The creator of this activity"),
406
+ )
407
+ latest_reviewer = models.ForeignKey(
408
+ "directory.Person",
409
+ related_name="activities_reviewed",
410
+ null=True,
411
+ blank=True,
412
+ on_delete=models.SET_NULL,
413
+ verbose_name=_("Latest Reviewer"),
414
+ help_text=_("The latest person to review the activity"),
415
+ )
416
+ reviewed_at = models.DateTimeField(verbose_name=_("Reviewed at"), null=True, blank=True)
417
+ edited = models.DateTimeField(auto_now=True, verbose_name=_("Edited"))
418
+ assigned_to = models.ForeignKey(
419
+ "directory.Person",
420
+ related_name="activities",
421
+ null=True,
422
+ blank=True,
423
+ on_delete=models.SET_NULL,
424
+ verbose_name=_("Assigned to"),
425
+ help_text=_("The person in charge of handling this activity"),
426
+ )
427
+ companies = models.ManyToManyField(
428
+ "directory.Company",
429
+ related_name="company_participates",
430
+ blank=True,
431
+ verbose_name=_("Participating Companies"),
432
+ help_text=_("The list of companies other than the main company that participate in this activity"),
433
+ through="wbcrm.ActivityCompanyThroughModel",
434
+ through_fields=("activity", "company"),
435
+ )
436
+
437
+ participants = models.ManyToManyField(
438
+ "directory.Person",
439
+ related_name="participates",
440
+ blank=True,
441
+ verbose_name=_("Participating Persons"),
442
+ help_text=_("The list of participants"),
443
+ through="wbcrm.ActivityParticipant",
444
+ through_fields=("activity", "participant"),
445
+ )
446
+
447
+ groups = models.ManyToManyField(
448
+ "wbcrm.Group",
449
+ related_name="activities_for_group",
450
+ blank=True,
451
+ verbose_name=_("Groups"),
452
+ help_text=_("Each member of the group will be added to the list of participants and companies automatically."),
453
+ )
454
+ preceded_by = models.ForeignKey(
455
+ "self",
456
+ related_name="followed_by",
457
+ blank=True,
458
+ null=True,
459
+ on_delete=models.SET_NULL,
460
+ verbose_name=_("Preceded by"),
461
+ help_text=_("The preceding activity"),
462
+ )
463
+ disable_participant_check = models.BooleanField(
464
+ default=False,
465
+ verbose_name=_("Without Participating Company"),
466
+ )
467
+ metadata = models.JSONField(default=dict, blank=True)
468
+ search_vector = SearchVectorField(null=True)
469
+
470
+ def __str__(self):
471
+ return "%s" % (self.title,)
472
+
473
+ def update_search_vectors(self):
474
+ # Create the combined search vector manually
475
+ vector = (
476
+ SearchVector(Value(self.title), weight="A", config="english")
477
+ + SearchVector(Value(self.description), weight="B", config="english")
478
+ + SearchVector(Value(self.result), weight="B", config="english")
479
+ )
480
+ if self.id:
481
+ if participants_str := self.participants.aggregate(agg=StringAgg("computed_str", delimiter=" "))["agg"]:
482
+ vector += SearchVector(Value(participants_str), weight="C", config="english")
483
+ if companies_str := self.companies.aggregate(agg=StringAgg("computed_str", delimiter=" "))["agg"]:
484
+ vector += SearchVector(Value(companies_str), weight="C", config="english")
485
+ self.search_vector = vector
486
+
487
+ def is_private_for_user(self, user) -> bool:
488
+ return (
489
+ self.visibility == CalendarItem.Visibility.PRIVATE
490
+ and user.profile not in self.participants.all()
491
+ and user.profile != self.assigned_to
492
+ and user.profile != self.creator
493
+ )
494
+
495
+ def is_confidential_for_user(self, user) -> bool:
496
+ return (
497
+ self.visibility == CalendarItem.Visibility.CONFIDENTIAL
498
+ and not CalendarItem.has_user_administrate_permission(user)
499
+ )
500
+
501
+ def get_extra_ics_kwargs(self) -> dict[str, Any]:
502
+ res = {}
503
+ res["created"] = arrow.get(self.created)
504
+ if self.location:
505
+ res["location"] = "".join(self.location)
506
+ if self.description:
507
+ res["description"] = self.description
508
+ if self.period and self.reminder_choice:
509
+ reminder = self.period.lower - timedelta(
510
+ minutes=Activity.ReminderChoice.get_minutes_correspondance(self.reminder_choice)
511
+ )
512
+ a = DisplayAlarm2(trigger=reminder)
513
+ res["alarms"] = set([a])
514
+ return res
515
+
516
+ def get_color(self) -> str:
517
+ return self.type.color
518
+
519
+ def get_icon(self) -> str:
520
+ return self.type.icon
521
+
522
+ def save(self, synchronize: bool = True, *args, **kwargs):
523
+ pre_save_activity_dto = (
524
+ Activity.all_objects.get(id=self.id)._build_dto() if self.id else None
525
+ ) # we need to refetch to pre save activity from the database because self already contains the updated fields
526
+
527
+ # Set reviewed
528
+ if (
529
+ self.status not in [Activity.Status.REVIEWED, Activity.Status.CANCELLED]
530
+ and self.result
531
+ and self.result not in ["<p></p>", ""]
532
+ ):
533
+ self.status = Activity.Status.REVIEWED
534
+
535
+ if not self.period and self.start and self.end:
536
+ if self.start == self.end:
537
+ self.end = self.end + timedelta(seconds=1)
538
+ self.period = TimestamptzRange(self.start, self.end) # type: ignore
539
+
540
+ if not (self.period or self.start or self.end):
541
+ self.period = TimestamptzRange(timezone.now(), timezone.now() + timedelta(hours=1))
542
+
543
+ if self.period:
544
+ self.start, self.end = self.period.lower, self.period.upper # type: ignore
545
+
546
+ # If all day activity, we ensure period spans the full range
547
+ if self.all_day and self.period:
548
+ tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
549
+
550
+ self.period = TimestamptzRange(
551
+ lower=self.period.lower.astimezone(tz).replace(hour=0, minute=0, second=0),
552
+ upper=self.period.upper.astimezone(tz).replace(hour=23, minute=59, second=59),
553
+ ) # type
554
+ self.is_cancelled = self.status == self.Status.CANCELLED
555
+
556
+ self.update_search_vectors()
557
+ # Logic to be called after a save happens (e.g synchronization). We get the activity DTO before saving that we passed around in the signal
558
+ super().save(*args, **kwargs)
559
+
560
+ if synchronize and self.is_active:
561
+ if not self.is_recurrent or not (
562
+ self.is_recurrent
563
+ and self.parent_occurrence
564
+ and (self.parent_occurrence.propagate_for_all_children or not pre_save_activity_dto)
565
+ ):
566
+ # countdown of at least 20 seconds is necessary to get m2m
567
+ post_save_callback.apply_async(
568
+ (self.id,), {"pre_save_activity_dto": pre_save_activity_dto}, countdown=20
569
+ )
570
+
571
+ if not self.type:
572
+ self.type = ActivityType.get_default_activity_type()
573
+
574
+ def delete(self, synchronize: bool = True, **kwargs):
575
+ # Logic to be called after a deletion happens (e.g synchronization). We get the activity DTO before deletion that we passed around in the signal
576
+ if synchronize and Activity.objects.filter(id=self.id).exists():
577
+ pre_delete_activity_dto = Activity.objects.get(id=self.id)._build_dto()
578
+ super().delete(**kwargs)
579
+ post_delete_callback.apply_async(
580
+ (self.id,), {"pre_delete_activity_dto": pre_delete_activity_dto}, countdown=1
581
+ )
582
+ else:
583
+ super().delete(**kwargs)
584
+
585
+ def get_participants(self) -> QuerySet[Person]:
586
+ """
587
+ Get all participants for that activity.
588
+
589
+ Returns:
590
+ Queryset<Person> The participants
591
+ """
592
+ return Person.objects.filter(Q(participates__id=self.id)).distinct()
593
+
594
+ def get_companies(self):
595
+ """
596
+ Get all companies for that activity.
597
+
598
+ Returns:
599
+ Queryset<Company> The companies participating in the activity
600
+ """
601
+ return Company.objects.filter(Q(company_participates__id=self.id)).distinct()
602
+
603
+ def _check_employer_employees_entered(self) -> dict:
604
+ if not self.disable_participant_check:
605
+ participants = self.participants.all()
606
+ companies = self.companies.all()
607
+
608
+ missing_employers_for_participant = set()
609
+ missing_employees_for_company = set()
610
+
611
+ for participant in participants.exclude(
612
+ Q(id__in=Person.objects.filter_only_internal()) | Q(employers__isnull=True)
613
+ ):
614
+ if not participant.employers.filter(id__in=companies).exists():
615
+ missing_employers_for_participant.add(participant)
616
+
617
+ for company in companies.exclude(employees__isnull=True):
618
+ if not company.employees.filter(id__in=participants).exists():
619
+ missing_employees_for_company.add(company)
620
+
621
+ return {
622
+ "missing_employers_for_participant": missing_employers_for_participant,
623
+ "missing_employees_for_company": missing_employees_for_company,
624
+ }
625
+ return {}
626
+
627
+ def participants_company_check_message(self) -> str:
628
+ """Checks if the companies and participants fields have been filled in correctly.
629
+ A warning is generated if employees or employers are missing in the corresponding field.
630
+
631
+ Returns:
632
+ str: The warning string
633
+ """
634
+
635
+ missing = self._check_employer_employees_entered()
636
+ message = ""
637
+
638
+ if missing.get("missing_employers_for_participant"):
639
+ participants_with_missing_companies = [
640
+ participant.computed_str for participant in missing["missing_employers_for_participant"]
641
+ ]
642
+ message += _("For the following participants you did not supply an employer: {persons}<br />").format(
643
+ persons=", ".join(participants_with_missing_companies)
644
+ )
645
+
646
+ if missing.get("missing_employees_for_company"):
647
+ companies_with_missing_participants = [
648
+ company.computed_str for company in missing["missing_employees_for_company"]
649
+ ]
650
+ message += _("For the following companies you did not supply an employee: {companies}<br />").format(
651
+ companies=", ".join(companies_with_missing_participants)
652
+ )
653
+
654
+ return message
655
+
656
+ def get_occurrance_dates(self) -> list:
657
+ """
658
+ Returns a list with datetime values based on the recurrence options of an activity.
659
+
660
+ :return: list with datetime values
661
+ :rtype: list
662
+ """
663
+
664
+ occurrance_dates = []
665
+ # dd = self.start.date()
666
+ if not self.period:
667
+ raise AttributeError(_("Period needs to be set for recurrence to work!"))
668
+ dd = self.period.lower.date()
669
+ # weekday = calendar.day_abbr[dd.weekday()].upper()[:2]
670
+ # weekday_position = (dd.day + 6) // 7
671
+ # weekdaycounters = collections.Counter(
672
+ # [calendar.weekday(dd.year, dd.month, d) for d in range(1, calendar.monthrange(dd.year, dd.month)[1] + 1)]
673
+ # )
674
+ # total_number_of_weekday = weekdaycounters[dd.weekday()]
675
+ repeat_rule = self.repeat_choice
676
+ # if self.repeat_choice == Recurrence.ReoccuranceChoice.MONTHLY_WEEKDAY:
677
+ # repeat_rule = f"RRULE:FREQ=MONTHLY;BYDAY={weekday};BYSETPOS={weekday_position}"
678
+ # elif self.repeat_choice == Recurrence.ReoccuranceChoice.MONTHLY_LASTWEEKDAY:
679
+ # repeat_rule = (
680
+ # f"RRULE:FREQ=MONTHLY;BYDAY={weekday};BYSETPOS={weekday_position - total_number_of_weekday - 1}"
681
+ # )
682
+ end_date = (
683
+ self.recurrence_end + timedelta(days=1)
684
+ if self.recurrence_end
685
+ else global_preferences_registry.manager()["wbcrm__recurrence_activity_end_date"]
686
+ )
687
+ dstart = datetime.combine(dd, self.start.astimezone().time())
688
+ if self.recurrence_count:
689
+ occurrance_dates = list(rrulestr(repeat_rule + f";COUNT={self.recurrence_count+1}", dtstart=dstart))[1:]
690
+ else:
691
+ occurrance_dates = list(
692
+ rrulestr(
693
+ repeat_rule + f";UNTIL={end_date.strftime('%Y%m%d')}",
694
+ dtstart=dstart.replace(tzinfo=None),
695
+ )
696
+ )[1:]
697
+
698
+ return occurrance_dates
699
+
700
+ def update_last_event(self):
701
+ """
702
+ Updates the entries last activity
703
+ """
704
+
705
+ activities = (
706
+ Activity.objects.filter(visibility=CalendarItem.Visibility.PUBLIC)
707
+ .filter(Q(companies__id=self.id) | Q(participants__id=self.id))
708
+ .filter(
709
+ (
710
+ Q(period__endswith__lt=timezone.now())
711
+ & Q(status__in=[Activity.Status.FINISHED, Activity.Status.REVIEWED])
712
+ )
713
+ | Q(status=Activity.Status.REVIEWED)
714
+ )
715
+ )
716
+ if activities.exists() and (last_event := activities.latest("period__endswith")):
717
+ self.last_event_id = last_event.id
718
+ self.save()
719
+
720
+ def get_participants_for_employer(self, employer: Entry) -> QuerySet[Person]:
721
+ rels = (
722
+ ActivityParticipant.objects.filter(activity=self)
723
+ .annotate(
724
+ is_employee=Exists(
725
+ EmployerEmployeeRelationship.objects.filter(
726
+ employee=OuterRef("participant"), employer=employer, primary=True
727
+ )
728
+ )
729
+ )
730
+ .filter(is_employee=True)
731
+ )
732
+ return Person.objects.filter(id__in=rels.values("participant"))
733
+
734
+ @staticmethod
735
+ def get_inrange_activities(
736
+ queryset: QuerySet["Activity"], start_date: date, end_date: date
737
+ ) -> QuerySet["Activity"]:
738
+ """
739
+ Returns all activities taking place during the given interval. Accounts for the recurring activities as well.
740
+
741
+ Args:
742
+ queryset (Queryset[Activity]): The base queryset
743
+ start_date (date): The starting point of the interval
744
+ end_date (date): The end point of the interval
745
+
746
+ Returns:
747
+ queryset (Queryset[Activity]): A queryset of all activities with occurrences in the specified period
748
+ """
749
+ interval = TimestamptzRange(start_date, end_date) # type: ignore
750
+ return queryset.filter(period__overlap=interval)
751
+
752
+ @staticmethod
753
+ def get_companies_activities(queryset, value):
754
+ """
755
+ Return the activities whose companies are value.
756
+
757
+ Arguments:
758
+ queryset {Queryset<Activity>} -- The base queryset
759
+ value {list<Entry>} -- A list of entries considered as companies
760
+ Returns:
761
+ queryset {Queryset<Activity>} -- A queryset whose companies includes value
762
+ """
763
+ return queryset.filter(companies__in=value).distinct()
764
+
765
+ @classmethod
766
+ def get_activities_for_user(cls, user, base_qs=None):
767
+ if base_qs is None:
768
+ base_qs = Activity.objects
769
+ if user.is_superuser or user.profile.is_internal:
770
+ queryset = base_qs.all()
771
+ else:
772
+ queryset = base_qs.filter(
773
+ Q(creator=user.profile)
774
+ | Q(assigned_to=user.profile)
775
+ | Q(activity_participants__participant_id=user.profile.id)
776
+ | Q(activity_participants__participant__in=user.profile.clients.all())
777
+ | Q(companies__in=user.profile.clients.all())
778
+ )
779
+
780
+ return queryset.distinct()
781
+
782
+ # Overriden Function from the recurrence framework
783
+ def _handle_recurrence_m2m_forwarding(self, child):
784
+ child.groups.set(self.groups.union(child.groups.all()))
785
+ child.participants.set(self.participants.union(child.participants.all()))
786
+ child.companies.set(self.companies.union(child.companies.all()))
787
+
788
+ def does_recurrence_need_cancellation(self):
789
+ return self.status in [
790
+ Activity.Status.FINISHED,
791
+ Activity.Status.REVIEWED,
792
+ Activity.Status.CANCELLED,
793
+ ]
794
+
795
+ def get_recurrent_valid_children(self):
796
+ return super().get_recurrent_valid_children().filter(status=Activity.Status.PLANNED)
797
+
798
+ def _create_recurrence_child(self, start_datetime: datetime):
799
+ child = Activity(
800
+ assigned_to=self.assigned_to,
801
+ all_day=self.all_day,
802
+ conference_room=self.conference_room,
803
+ creator=self.creator,
804
+ description=self.description,
805
+ disable_participant_check=self.disable_participant_check,
806
+ importance=self.importance,
807
+ visibility=self.visibility,
808
+ location=self.location,
809
+ location_longitude=self.location_longitude,
810
+ location_latitude=self.location_latitude,
811
+ parent_occurrence=self,
812
+ period=TimestamptzRange(start_datetime, (start_datetime + self.duration)),
813
+ recurrence_end=self.recurrence_end,
814
+ recurrence_count=self.recurrence_count,
815
+ reminder_choice=self.reminder_choice,
816
+ repeat_choice=self.repeat_choice,
817
+ title=self.title,
818
+ type=self.type,
819
+ )
820
+ child.save(synchronize=False)
821
+ return child
822
+
823
+ def _build_dto(self):
824
+ return ActivityDTO(
825
+ metadata=self.metadata,
826
+ title=self.title,
827
+ period=self.period,
828
+ description=self.description,
829
+ participants=[
830
+ ParticipantStatusDTO(
831
+ status=rel.participation_status,
832
+ status_changed=rel.status_changed,
833
+ person=rel.participant._build_dto(),
834
+ )
835
+ for rel in self.activity_participants.all()
836
+ ],
837
+ creator=self.creator._build_dto() if self.creator else None,
838
+ visibility=self.visibility,
839
+ reminder_choice=self.reminder_choice,
840
+ is_cancelled=self.is_cancelled,
841
+ all_day=self.all_day,
842
+ online_meeting=self.online_meeting,
843
+ location=self.location,
844
+ conference_room=self.conference_room._build_dto() if self.conference_room else None,
845
+ id=self.id,
846
+ # parent_occurrence=self.parent_occurrence,
847
+ # recurring_activities=self.get_recurrent_valid_children(),
848
+ # invalid_recurring_activities=self.get_recurrent_invalid_children(),
849
+ is_root=self.is_root,
850
+ is_leaf=self.is_leaf,
851
+ propagate_for_all_children=self.propagate_for_all_children,
852
+ recurrence_end=self.recurrence_end,
853
+ recurrence_count=self.recurrence_count,
854
+ repeat_choice=self.repeat_choice,
855
+ )
856
+
857
+ @classmethod
858
+ def get_endpoint_basename(cls):
859
+ return "wbcrm:activity"
860
+
861
+ @classmethod
862
+ def get_representation_endpoint(cls):
863
+ return "wbcrm:activityrepresentation-list"
864
+
865
+ @classmethod
866
+ def get_representation_value_key(cls):
867
+ return "id"
868
+
869
+ @classmethod
870
+ def get_representation_label_key(cls):
871
+ return "{{title}}"
872
+
873
+
874
+ @receiver(m2m_changed, sender=Activity.participants.through)
875
+ def m2m_changed_participants(sender, instance: Activity, action: str, pk_set: set[int], **kwargs):
876
+ """
877
+ Handle the post custom logic when adding a participant. In that case, we call the relationship save method because we define a through model
878
+ """
879
+ if action in ["post_add", "pre_remove", "pre_clear"]:
880
+ for participant_id in pk_set:
881
+ rel = ActivityParticipant.objects.get(activity=instance, participant=participant_id)
882
+ if action == "post_add":
883
+ rel.save()
884
+ if not instance.parent_occurrence:
885
+ send_employee_notification.delay(instance.id, participant_id)
886
+ else:
887
+ rel.delete()
888
+ if action == "post_add":
889
+ instance.update_search_vectors()
890
+ Activity.objects.filter(id=instance.id).update(search_vector=instance.search_vector)
891
+
892
+
893
+ @receiver(m2m_changed, sender=Activity.companies.through)
894
+ def m2m_changed_companies(sender, instance: Activity, action: str, pk_set: set[int], **kwargs):
895
+ """
896
+ Send a notification whenever a user who did not create the activity is added as a participant.
897
+ """
898
+ if pk_set:
899
+ if "add" in action:
900
+ if (main_company := get_main_company()) and main_company.id in pk_set:
901
+ pk_set.remove(main_company.id)
902
+ if "post" in action:
903
+ for company_id in pk_set:
904
+ entry = Entry.all_objects.get(id=company_id)
905
+ if action == "post_add":
906
+ instance.entities.add(entry)
907
+ elif not instance.get_participants_for_employer(entry).exists():
908
+ instance.entities.remove(entry)
909
+ if action == "post_add":
910
+ for company_id in pk_set:
911
+ with suppress(
912
+ ActivityCompanyThroughModel.DoesNotExist
913
+ ): # we save to trigger the computed str computation. I don't know of any other choice as django only allow bulk create on m2m insertion
914
+ ActivityCompanyThroughModel.objects.get(
915
+ company_id=company_id,
916
+ activity=instance,
917
+ ).save()
918
+
919
+ if action == "post_add":
920
+ instance.update_search_vectors()
921
+ Activity.objects.filter(id=instance.id).update(search_vector=instance.search_vector)
922
+
923
+
924
+ @receiver(m2m_changed, sender=Activity.groups.through) # type: ignore
925
+ def m2m_changed_groups(sender, instance: Activity, action, pk_set, **kwargs):
926
+ from wbcrm.models.groups import Group
927
+
928
+ if action == "post_add" or action == "post_remove" and pk_set:
929
+ instance_participants = instance.participants.all()
930
+ instance_companies = instance.companies.all()
931
+ edited_groups = Group.objects.filter(id__in=pk_set)
932
+ edited_groups_members = edited_groups.values_list("members__id", flat=True)
933
+ edited_persons = Person.objects.filter(id__in=edited_groups_members)
934
+ edited_companies = Company.objects.filter(id__in=edited_groups_members)
935
+
936
+ if action == "post_add":
937
+ instance.participants.set(instance_participants.union(edited_persons))
938
+ instance.companies.set(instance_companies.union(edited_companies))
939
+ else:
940
+ # Get group members who are members in groups that are to be removed and are also members in groups that remain in the instance.
941
+ # These group members are not to be removed from the instance.
942
+ remaining_groups_members = instance.groups.exclude(id__in=pk_set).values_list("members__id", flat=True)
943
+ members_in_different_groups = np.intersect1d(edited_groups_members, remaining_groups_members) # type: ignore
944
+
945
+ instance.participants.set(
946
+ instance_participants.difference(edited_persons.exclude(id__in=members_in_different_groups))
947
+ )
948
+ instance.companies.set(
949
+ instance_companies.difference(edited_companies.exclude(id__in=members_in_different_groups))
950
+ )
951
+
952
+
953
+ @shared_task()
954
+ def post_save_callback(activity_id: int, pre_save_activity_dto: ActivityDTO = None):
955
+ with suppress(Activity.DoesNotExist):
956
+ activity = Activity.all_objects.get(id=activity_id)
957
+ # Set calendar item entities once all activity m2m relations are settled
958
+ activity_dto = activity._build_dto()
959
+
960
+ if activity.is_recurrent:
961
+ if activity.is_root and not pre_save_activity_dto: # create the recurring activities.
962
+ activity.generate_occurrences()
963
+ elif activity.propagate_for_all_children: # update occurrences from activity
964
+ period_time_changed = False
965
+ if (
966
+ activity_dto.period.lower.time() != pre_save_activity_dto.period.lower.time()
967
+ or activity_dto.period.upper.time() != pre_save_activity_dto.period.upper.time()
968
+ ):
969
+ period_time_changed = True
970
+ activity.forward_change(period_time_changed=period_time_changed)
971
+
972
+ activity_dto.recurring_activities = [
973
+ instance._build_dto() for instance in activity.get_recurrent_valid_children()
974
+ ]
975
+ activity_dto.invalid_recurring_activities = [
976
+ instance._build_dto() for instance in activity.get_recurrent_invalid_children()
977
+ ]
978
+
979
+ if controller := get_backend():
980
+ controller.handle_outbound(activity_dto, old_activity_dto=pre_save_activity_dto)
981
+
982
+
983
+ @shared_task()
984
+ def post_delete_callback(activity_id: int, pre_delete_activity_dto: ActivityDTO):
985
+ if controller := get_backend():
986
+ with suppress(Activity.DoesNotExist):
987
+ activity_dto = Activity.all_objects.get(id=activity_id)._build_dto()
988
+ controller.handle_outbound(activity_dto, old_activity_dto=pre_delete_activity_dto, is_deleted=True)
989
+
990
+
991
+ @shared_task
992
+ def send_employee_notification(activity_id: int, participant_id):
993
+ """Sends all employees that were added to the activity as participants a notification via system and mail
994
+
995
+ Args:
996
+ activity_id: The activity instance id to which participants were added
997
+ participant_id: id of the participant to send notification to
998
+ """
999
+ with suppress(Activity.DoesNotExist):
1000
+ activity = Activity.all_objects.get(id=activity_id)
1001
+ with suppress(Person.DoesNotExist):
1002
+ employee = Person.objects.filter_only_internal().get(id=participant_id)
1003
+ if activity.creator != employee:
1004
+ activity_type_label = activity.type.title.lower()
1005
+ description = (
1006
+ activity.description if activity.description and activity.description != "<p></p>" else None
1007
+ )
1008
+ message = render_to_string(
1009
+ "email/activity.html",
1010
+ {
1011
+ "participants": activity.participants.all(),
1012
+ "type": activity_type_label,
1013
+ "title": activity.title,
1014
+ "start": activity.period.lower if activity.period else "",
1015
+ "end": activity.period.upper if activity.period else "",
1016
+ "description": description,
1017
+ },
1018
+ )
1019
+ if activity.period:
1020
+ start_datetime: datetime = activity.period.lower
1021
+ datetime_string = _(" starting at the {} at {}").format(
1022
+ start_datetime.strftime("%d.%m.%Y"), start_datetime.strftime("%H:%M:%S")
1023
+ )
1024
+ else:
1025
+ datetime_string = ""
1026
+ creator_string = (
1027
+ _("{} added you").format(str(activity.creator))
1028
+ if activity.creator
1029
+ else _("You were automatically added")
1030
+ )
1031
+
1032
+ send_notification(
1033
+ code="wbcrm.activity.participant",
1034
+ title=_("{} as participant in a {}{}").format(
1035
+ creator_string, activity_type_label, datetime_string
1036
+ ),
1037
+ body=message,
1038
+ user=employee.user_account,
1039
+ reverse_name="wbcrm:activity-detail",
1040
+ reverse_args=[activity.id],
1041
+ )
1042
+
1043
+
1044
+ # /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
1045
+ # >>> Activity Participants <<<
1046
+ # /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
1047
+
1048
+
1049
+ def is_participant(instance, user):
1050
+ return instance.participant == user.profile and instance.activity.period.lower >= timezone.now()
1051
+
1052
+
1053
+ def conference_room_is_videoconference_capable(instance):
1054
+ return not instance.activity.conference_room or instance.activity.conference_room.is_videoconference_capable
1055
+
1056
+
1057
+ class ActivityCompanyThroughModel(ComplexToStringMixin, models.Model):
1058
+ activity = models.ForeignKey(
1059
+ on_delete=models.CASCADE,
1060
+ to="wbcrm.Activity",
1061
+ verbose_name=_("Activity"),
1062
+ related_name="activity_companies",
1063
+ )
1064
+ company = models.ForeignKey(
1065
+ on_delete=models.CASCADE,
1066
+ to="directory.Company",
1067
+ verbose_name=_("Company"),
1068
+ related_name="activity_companies",
1069
+ )
1070
+ customer_status = models.ForeignKey(
1071
+ to="directory.CustomerStatus",
1072
+ related_name="activity_companies",
1073
+ on_delete=models.SET_NULL,
1074
+ verbose_name=_("Initial Customer Status"),
1075
+ help_text=_("The Customer Status at activity creation time"),
1076
+ null=True,
1077
+ blank=True,
1078
+ )
1079
+
1080
+ def __str__(self) -> str:
1081
+ return f"{self.activity} - {self.company} ({self.customer_status})"
1082
+
1083
+ def save(self, *args, **kwargs):
1084
+ self.customer_status = self.company.customer_status
1085
+ super().save(*args, **kwargs)
1086
+
1087
+ def compute_str(self):
1088
+ rep = self.company.computed_str
1089
+ if self.customer_status:
1090
+ rep += f" ({self.customer_status.title})"
1091
+ return rep
1092
+
1093
+
1094
+ class ActivityParticipant(models.Model):
1095
+ class ParticipationStatus(models.TextChoices):
1096
+ CANCELLED = "CANCELLED", _("Cancelled")
1097
+ MAYBE = "MAYBE", _("Maybe")
1098
+ ATTENDS = "ATTENDS", _("Attends")
1099
+ NOTRESPONDED = "NOTRESPONDED", _("Not Responded")
1100
+ ATTENDS_DIGITALLY = "ATTENDS_DIGITALLY", _("Attends Digitally")
1101
+ PENDING_INVITATION = "PENDING_INVITATION", _("Pending Invitation")
1102
+
1103
+ activity = models.ForeignKey(
1104
+ on_delete=models.CASCADE,
1105
+ to="wbcrm.Activity",
1106
+ verbose_name=_("Activity"),
1107
+ related_name="activity_participants",
1108
+ )
1109
+ participant = models.ForeignKey(
1110
+ on_delete=models.CASCADE,
1111
+ to="directory.Person",
1112
+ verbose_name=_("Participant"),
1113
+ related_name="activity_participants",
1114
+ )
1115
+ participation_status = FSMField(
1116
+ default=ParticipationStatus.PENDING_INVITATION,
1117
+ choices=ParticipationStatus.choices,
1118
+ verbose_name=_("Participation Status"),
1119
+ )
1120
+ status_changed = models.DateTimeField(auto_now=True)
1121
+
1122
+ def __str__(self) -> str:
1123
+ return _("Status of {participant} for activity {title} is: {status}").format(
1124
+ participant=self.participant.computed_str,
1125
+ title=self.activity.title,
1126
+ status=self.ParticipationStatus[self.participation_status].label,
1127
+ )
1128
+
1129
+ class Meta:
1130
+ constraints = [models.UniqueConstraint(name="unique_participant", fields=["activity", "participant"])]
1131
+ verbose_name = _("Activity's Participant")
1132
+ verbose_name_plural = _("Activities' Participants")
1133
+
1134
+ def save(self, *args, **kwargs):
1135
+ self.activity.entities.add(self.participant)
1136
+ with suppress(EmployerEmployeeRelationship.DoesNotExist):
1137
+ rel = EmployerEmployeeRelationship.objects.get(employee=self.participant, primary=True)
1138
+ self.activity.companies.add(rel.employer)
1139
+ # Set the status 'Attends' by default for activity creator
1140
+ if self.activity.creator == self.participant:
1141
+ self.status = ActivityParticipant.ParticipationStatus.ATTENDS
1142
+
1143
+ pre_save_participant_dto = ActivityParticipant.objects.get(id=self.id)._build_dto() if self.id else None
1144
+ # Logic to be called after a save happens (e.g synchronization). We get the activity Participant DTO before saving that we passed around in the signal
1145
+ super().save(*args, **kwargs)
1146
+ # we activate synchronization only if the rel was already created (we expect synchronization on the activity creation itself)
1147
+ post_save_participant_callback.apply_async(
1148
+ (self.id,), {"pre_save_participant_dto": pre_save_participant_dto}, countdown=10
1149
+ )
1150
+
1151
+ def delete(self, *args, **kwargs):
1152
+ self.activity.entities.remove(self.participant.entry_ptr)
1153
+ with suppress(EmployerEmployeeRelationship.DoesNotExist):
1154
+ rel = EmployerEmployeeRelationship.objects.get(employee=self.participant, primary=True)
1155
+ # delete only if no other participants are of the same company
1156
+ if not self.activity.get_participants_for_employer(rel.employer).exclude(id=rel.employee.id).exists():
1157
+ self.activity.companies.remove(rel.employer)
1158
+ self.activity.entities.remove(rel.employer.entry_ptr)
1159
+ if self.activity.is_active:
1160
+ post_delete_participant_callback.apply_async((self.id,), countdown=10)
1161
+ super().delete(*args, **kwargs)
1162
+
1163
+ def _build_dto(self):
1164
+ return ParticipantStatusDTO(
1165
+ person=self.participant._build_dto(),
1166
+ status_changed=self.status_changed,
1167
+ status=self.participation_status,
1168
+ activity=self.activity._build_dto(),
1169
+ id=self.id,
1170
+ )
1171
+
1172
+ @transition(
1173
+ field=participation_status,
1174
+ source=[
1175
+ ParticipationStatus.PENDING_INVITATION,
1176
+ ParticipationStatus.NOTRESPONDED,
1177
+ ParticipationStatus.ATTENDS,
1178
+ ParticipationStatus.ATTENDS_DIGITALLY,
1179
+ ParticipationStatus.CANCELLED,
1180
+ ],
1181
+ target=ParticipationStatus.MAYBE,
1182
+ permission=is_participant,
1183
+ custom={
1184
+ "_transition_button": ActionButton(
1185
+ method=RequestType.PATCH,
1186
+ identifiers=("wbcrm:activityparticipant",),
1187
+ icon=WBIcon.QUESTION.icon,
1188
+ key="maybe",
1189
+ label=_("Maybe"),
1190
+ action_label=_("Setting Maybe"),
1191
+ )
1192
+ },
1193
+ )
1194
+ def maybe(self, by=None, description=None, **kwargs):
1195
+ pass
1196
+
1197
+ @transition(
1198
+ field=participation_status,
1199
+ source=[
1200
+ ParticipationStatus.PENDING_INVITATION,
1201
+ ParticipationStatus.NOTRESPONDED,
1202
+ ParticipationStatus.MAYBE,
1203
+ ParticipationStatus.ATTENDS_DIGITALLY,
1204
+ ParticipationStatus.CANCELLED,
1205
+ ],
1206
+ target=ParticipationStatus.ATTENDS,
1207
+ permission=is_participant,
1208
+ custom={
1209
+ "_transition_button": ActionButton(
1210
+ method=RequestType.PATCH,
1211
+ identifiers=("wbcrm:activityparticipant",),
1212
+ icon=WBIcon.APPROVE.icon,
1213
+ key="attends",
1214
+ label=_("Accept"),
1215
+ action_label=_("Accepting"),
1216
+ description_fields=_("Are you sure you want to participate in this activity?"),
1217
+ )
1218
+ },
1219
+ )
1220
+ def attends(self, by=None, description=None, **kwargs):
1221
+ pass
1222
+
1223
+ @transition(
1224
+ field=participation_status,
1225
+ source=[
1226
+ ParticipationStatus.PENDING_INVITATION,
1227
+ ParticipationStatus.NOTRESPONDED,
1228
+ ParticipationStatus.MAYBE,
1229
+ ParticipationStatus.ATTENDS,
1230
+ ParticipationStatus.CANCELLED,
1231
+ ],
1232
+ target=ParticipationStatus.ATTENDS_DIGITALLY,
1233
+ permission=is_participant,
1234
+ conditions=[conference_room_is_videoconference_capable],
1235
+ custom={
1236
+ "_transition_button": ActionButton(
1237
+ method=RequestType.PATCH,
1238
+ identifiers=("wbcrm:activityparticipant",),
1239
+ icon="laptop",
1240
+ key="attendsdigitally",
1241
+ label=_("Attend Digitally"),
1242
+ action_label=_("Setting Attendance"),
1243
+ description_fields=_("Are you sure you want to attend digitally in this activity?"),
1244
+ )
1245
+ },
1246
+ )
1247
+ def attendsdigitally(self, by=None, description=None, **kwargs):
1248
+ pass
1249
+
1250
+ @transition(
1251
+ field=participation_status,
1252
+ source=[
1253
+ ParticipationStatus.PENDING_INVITATION,
1254
+ ParticipationStatus.NOTRESPONDED,
1255
+ ParticipationStatus.MAYBE,
1256
+ ParticipationStatus.ATTENDS,
1257
+ ParticipationStatus.ATTENDS_DIGITALLY,
1258
+ ],
1259
+ target=ParticipationStatus.CANCELLED,
1260
+ permission=is_participant,
1261
+ custom={
1262
+ "_transition_button": ActionButton(
1263
+ method=RequestType.PATCH,
1264
+ identifiers=("wbcrm:activityparticipant",),
1265
+ icon=WBIcon.DENY.icon,
1266
+ key="cancelled",
1267
+ label=_("Decline"),
1268
+ action_label=_("Decline"),
1269
+ description_fields=_("Are you sure you want to decline to participate in this activity?"),
1270
+ )
1271
+ },
1272
+ )
1273
+ def cancelled(self, by=None, description=None, **kwargs):
1274
+ pass
1275
+
1276
+ @transition(
1277
+ field=participation_status,
1278
+ source=ParticipationStatus.CANCELLED,
1279
+ target=ParticipationStatus.NOTRESPONDED,
1280
+ permission=lambda instance, user: instance.activity.creator == user.profile != instance.participant,
1281
+ custom={
1282
+ "_transition_button": ActionButton(
1283
+ method=RequestType.PATCH,
1284
+ identifiers=("wbcrm:activityparticipant",),
1285
+ icon=WBIcon.REDO.icon,
1286
+ key="notresponded",
1287
+ label=_("Resend Invitation"),
1288
+ action_label=_("Resending Invitation"),
1289
+ description_fields=_(
1290
+ "Are you sure you want to send this person an invitation to participate in this activity again?"
1291
+ ),
1292
+ )
1293
+ },
1294
+ )
1295
+ def notresponded(self, by=None, description=None, **kwargs):
1296
+ pass
1297
+
1298
+ @classmethod
1299
+ def get_representation_value_key(cls):
1300
+ return "id"
1301
+
1302
+
1303
+ @receiver(post_save, sender=Activity)
1304
+ def post_save_activity(sender, instance, created, raw, **kwargs):
1305
+ # need to the post save because instance might not be created yet in the save method
1306
+ if not raw and created:
1307
+ if instance.creator:
1308
+ instance.entities.add(instance.creator)
1309
+ if instance.assigned_to:
1310
+ instance.entities.add(instance.assigned_to)
1311
+
1312
+
1313
+ @shared_task()
1314
+ def post_save_participant_callback(
1315
+ activity_participant_id: int, pre_save_participant_dto: ParticipantStatusDTO = None
1316
+ ):
1317
+ if controller := get_backend():
1318
+ with suppress(ActivityParticipant.DoesNotExist):
1319
+ participant_dto = ActivityParticipant.objects.get(id=activity_participant_id)._build_dto()
1320
+ controller.handle_outbound_participant(participant_dto, old_participant_dto=pre_save_participant_dto)
1321
+
1322
+
1323
+ @shared_task()
1324
+ def post_delete_participant_callback(activity_participant_id: int):
1325
+ if controller := get_backend():
1326
+ with suppress(ActivityParticipant.DoesNotExist):
1327
+ participant_dto = ActivityParticipant.objects.get(id=activity_participant_id)._build_dto()
1328
+ controller.handle_outbound_participant(participant_dto, is_deleted=True)
1329
+
1330
+
1331
+ @shared_task()
1332
+ def send_invitation_participant_as_task(activity_id: int):
1333
+ if controller := get_backend():
1334
+ with suppress(Activity.DoesNotExist):
1335
+ activity_dto = Activity.all_objects.get(id=activity_id)._build_dto()
1336
+ participants_dto = [
1337
+ participant._build_dto()
1338
+ for participant in ActivityParticipant.objects.filter(
1339
+ activity_id=activity_id,
1340
+ participation_status=ActivityParticipant.ParticipationStatus.PENDING_INVITATION,
1341
+ )
1342
+ ]
1343
+ controller.handle_outbound_external_participants(activity_dto, participants_dto)
1344
+
1345
+
1346
+ @receiver(draggable_calendar_item_ids, sender="agenda.CalendarItem")
1347
+ def activity_draggable_calendar_item_ids(sender, request, **kwargs) -> QuerySet[CalendarItem]:
1348
+ return Activity.objects.filter(
1349
+ (Q(creator=request.user.profile) | Q(assigned_to=request.user.profile)) & Q(status=Activity.Status.PLANNED)
1350
+ ).values("id")
1351
+
1352
+
1353
+ @receiver(post_save, sender=EmployerEmployeeRelationship)
1354
+ def post_save_eer(sender, instance: EmployerEmployeeRelationship, created, raw, **kwargs):
1355
+ """
1356
+ Post save EER signal: Triggers the post_save signals of the employee which updates his computed_str and adds the
1357
+ employer to future planned activities if it became the only employer
1358
+ """
1359
+
1360
+ if not raw and created and sender.objects.filter(employee=instance.employee, primary=True).count() == 1:
1361
+ transaction.on_commit(lambda: add_employer_to_activities.delay(instance.pk))
1362
+
1363
+
1364
+ @receiver(post_delete, sender=EmployerEmployeeRelationship)
1365
+ def post_delete_eer(sender, instance: EmployerEmployeeRelationship, **kwargs):
1366
+ """
1367
+ Post delete EER signal: Triggers the post_delete signals of the employee which updates his computed_str and adds the
1368
+ employer to future planned activities if it became the only employer
1369
+ """
1370
+ if sender.objects.filter(employee=instance.employee, primary=True).count() == 1:
1371
+ eer_obj = sender.objects.get(employee=instance.employee, primary=True)
1372
+ transaction.on_commit(lambda: add_employer_to_activities.delay(eer_obj.pk))
1373
+
1374
+
1375
+ @shared_task
1376
+ def add_employer_to_activities(eer_id: int):
1377
+ with suppress(EmployerEmployeeRelationship.DoesNotExist):
1378
+ eer_obj = EmployerEmployeeRelationship.objects.get(id=eer_id)
1379
+
1380
+ for activity in Activity.objects.filter(
1381
+ status=Activity.Status.PLANNED,
1382
+ start__gte=timezone.now(),
1383
+ participants=eer_obj.employee,
1384
+ ):
1385
+ if eer_obj.employer not in activity.entities.all():
1386
+ activity.entities.add(eer_obj.employer)
1387
+ if eer_obj.employer not in activity.companies.all():
1388
+ activity.companies.add(eer_obj.employer)
1389
+
1390
+
1391
+ @receiver(add_employee_activity_to_daily_brief, sender="directory.Person")
1392
+ def daily_activity_summary(sender, instance: Person, val_date: date, **kwargs) -> tuple[str, str] | None:
1393
+ tz_info = timezone.get_current_timezone()
1394
+ period = DateTimeTZRange(
1395
+ lower=datetime.combine(val_date, time(0, 0, 0), tzinfo=tz_info),
1396
+ upper=datetime.combine(val_date + timedelta(days=1), time(0, 0, 0), tzinfo=tz_info),
1397
+ )
1398
+
1399
+ # Get all the employee's activities from that day
1400
+ activity_qs: QuerySet[Activity] = (
1401
+ Activity.objects.exclude(status=Activity.Status.CANCELLED)
1402
+ .filter(period__overlap=period, participants=instance)
1403
+ .order_by("period__startswith")
1404
+ )
1405
+
1406
+ # Create the formatted activity dictionaries
1407
+ activity_list = []
1408
+ for activity in activity_qs:
1409
+ activity_list.append(
1410
+ {
1411
+ "type": activity.type.title,
1412
+ "title": activity.title,
1413
+ "start": activity.period.lower,
1414
+ "end": activity.period.upper,
1415
+ "endpoint": reverse("wbcrm:activity-detail", args=[activity.pk]),
1416
+ }
1417
+ )
1418
+ if activity_list:
1419
+ return "Daily Activity Summary", render_to_string("email/daily_summary.html", {"activities": activity_list})