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