wbcrm 1.56.8__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wbcrm/__init__.py +1 -0
- wbcrm/admin/__init__.py +5 -0
- wbcrm/admin/accounts.py +60 -0
- wbcrm/admin/activities.py +104 -0
- wbcrm/admin/events.py +43 -0
- wbcrm/admin/groups.py +8 -0
- wbcrm/admin/products.py +9 -0
- wbcrm/apps.py +5 -0
- wbcrm/configurations/__init__.py +1 -0
- wbcrm/configurations/base.py +16 -0
- wbcrm/dynamic_preferences_registry.py +38 -0
- wbcrm/factories/__init__.py +14 -0
- wbcrm/factories/accounts.py +57 -0
- wbcrm/factories/activities.py +124 -0
- wbcrm/factories/groups.py +24 -0
- wbcrm/factories/products.py +11 -0
- wbcrm/filters/__init__.py +10 -0
- wbcrm/filters/accounts.py +80 -0
- wbcrm/filters/activities.py +204 -0
- wbcrm/filters/groups.py +21 -0
- wbcrm/filters/products.py +38 -0
- wbcrm/filters/signals.py +95 -0
- wbcrm/fixtures/wbcrm.json +1215 -0
- wbcrm/kpi_handlers/activities.py +171 -0
- wbcrm/locale/de/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/de/LC_MESSAGES/django.po +1557 -0
- wbcrm/locale/de/LC_MESSAGES/django.po.translated +1630 -0
- wbcrm/locale/en/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/en/LC_MESSAGES/django.po +1466 -0
- wbcrm/locale/fr/LC_MESSAGES/django.mo +0 -0
- wbcrm/locale/fr/LC_MESSAGES/django.po +1467 -0
- wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
- wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
- wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
- wbcrm/migrations/0004_alter_activity_status.py +28 -0
- wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
- wbcrm/migrations/0006_alter_activity_location.py +17 -0
- wbcrm/migrations/0007_alter_account_status.py +23 -0
- wbcrm/migrations/0008_alter_activity_options.py +16 -0
- wbcrm/migrations/0009_alter_account_is_public.py +19 -0
- wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
- wbcrm/migrations/0011_activity_summary.py +22 -0
- wbcrm/migrations/0012_alter_activity_summary.py +17 -0
- wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
- wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
- wbcrm/migrations/0015_alter_activity_type.py +23 -0
- wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
- wbcrm/migrations/0017_event.py +40 -0
- wbcrm/migrations/0018_activity_search_vector.py +24 -0
- wbcrm/migrations/__init__.py +0 -0
- wbcrm/models/__init__.py +5 -0
- wbcrm/models/accounts.py +648 -0
- wbcrm/models/activities.py +1419 -0
- wbcrm/models/events.py +15 -0
- wbcrm/models/groups.py +119 -0
- wbcrm/models/llm/activity_summaries.py +41 -0
- wbcrm/models/llm/analyze_relationship.py +50 -0
- wbcrm/models/products.py +86 -0
- wbcrm/models/recurrence.py +280 -0
- wbcrm/preferences.py +13 -0
- wbcrm/report/activity_report.py +110 -0
- wbcrm/serializers/__init__.py +23 -0
- wbcrm/serializers/accounts.py +141 -0
- wbcrm/serializers/activities.py +525 -0
- wbcrm/serializers/groups.py +30 -0
- wbcrm/serializers/products.py +58 -0
- wbcrm/serializers/recurrence.py +91 -0
- wbcrm/serializers/signals.py +71 -0
- wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
- wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
- wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
- wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
- wbcrm/synchronization/__init__.py +0 -0
- wbcrm/synchronization/activity/__init__.py +0 -0
- wbcrm/synchronization/activity/admin.py +73 -0
- wbcrm/synchronization/activity/backend.py +214 -0
- wbcrm/synchronization/activity/backends/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
- wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +406 -0
- wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
- wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
- wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +181 -0
- wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
- wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
- wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
- wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
- wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
- wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
- wbcrm/synchronization/activity/backends/google/utils.py +217 -0
- wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/backend.py +593 -0
- wbcrm/synchronization/activity/backends/outlook/msgraph.py +436 -0
- wbcrm/synchronization/activity/backends/outlook/parser.py +432 -0
- wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
- wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +118 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +274 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +249 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +174 -0
- wbcrm/synchronization/activity/controller.py +627 -0
- wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
- wbcrm/synchronization/activity/preferences.py +27 -0
- wbcrm/synchronization/activity/shortcuts.py +16 -0
- wbcrm/synchronization/activity/tasks.py +21 -0
- wbcrm/synchronization/activity/urls.py +7 -0
- wbcrm/synchronization/activity/utils.py +46 -0
- wbcrm/synchronization/activity/views.py +41 -0
- wbcrm/synchronization/admin.py +1 -0
- wbcrm/synchronization/apps.py +14 -0
- wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
- wbcrm/synchronization/management.py +36 -0
- wbcrm/synchronization/tasks.py +1 -0
- wbcrm/synchronization/urls.py +5 -0
- wbcrm/tasks.py +264 -0
- wbcrm/templates/email/activity.html +98 -0
- wbcrm/templates/email/activity_report.html +6 -0
- wbcrm/templates/email/daily_summary.html +72 -0
- wbcrm/templates/email/global_daily_summary.html +85 -0
- wbcrm/tests/__init__.py +0 -0
- wbcrm/tests/accounts/__init__.py +0 -0
- wbcrm/tests/accounts/test_models.py +393 -0
- wbcrm/tests/accounts/test_viewsets.py +88 -0
- wbcrm/tests/conftest.py +76 -0
- wbcrm/tests/disable_signals.py +62 -0
- wbcrm/tests/e2e/__init__.py +1 -0
- wbcrm/tests/e2e/e2e_wbcrm_utility.py +83 -0
- wbcrm/tests/e2e/test_e2e.py +370 -0
- wbcrm/tests/test_assignee_methods.py +40 -0
- wbcrm/tests/test_chartviewsets.py +112 -0
- wbcrm/tests/test_dto.py +64 -0
- wbcrm/tests/test_filters.py +52 -0
- wbcrm/tests/test_models.py +217 -0
- wbcrm/tests/test_recurrence.py +292 -0
- wbcrm/tests/test_report.py +21 -0
- wbcrm/tests/test_serializers.py +171 -0
- wbcrm/tests/test_tasks.py +95 -0
- wbcrm/tests/test_viewsets.py +967 -0
- wbcrm/tests/tests.py +121 -0
- wbcrm/typings.py +109 -0
- wbcrm/urls.py +67 -0
- wbcrm/viewsets/__init__.py +22 -0
- wbcrm/viewsets/accounts.py +122 -0
- wbcrm/viewsets/activities.py +341 -0
- wbcrm/viewsets/buttons/__init__.py +7 -0
- wbcrm/viewsets/buttons/accounts.py +27 -0
- wbcrm/viewsets/buttons/activities.py +89 -0
- wbcrm/viewsets/buttons/signals.py +17 -0
- wbcrm/viewsets/display/__init__.py +12 -0
- wbcrm/viewsets/display/accounts.py +110 -0
- wbcrm/viewsets/display/activities.py +444 -0
- wbcrm/viewsets/display/groups.py +22 -0
- wbcrm/viewsets/display/products.py +105 -0
- wbcrm/viewsets/endpoints/__init__.py +8 -0
- wbcrm/viewsets/endpoints/accounts.py +25 -0
- wbcrm/viewsets/endpoints/activities.py +30 -0
- wbcrm/viewsets/endpoints/groups.py +7 -0
- wbcrm/viewsets/endpoints/products.py +9 -0
- wbcrm/viewsets/groups.py +38 -0
- wbcrm/viewsets/menu/__init__.py +8 -0
- wbcrm/viewsets/menu/accounts.py +18 -0
- wbcrm/viewsets/menu/activities.py +49 -0
- wbcrm/viewsets/menu/groups.py +16 -0
- wbcrm/viewsets/menu/products.py +20 -0
- wbcrm/viewsets/mixins.py +35 -0
- wbcrm/viewsets/previews/__init__.py +1 -0
- wbcrm/viewsets/previews/activities.py +10 -0
- wbcrm/viewsets/products.py +57 -0
- wbcrm/viewsets/recurrence.py +27 -0
- wbcrm/viewsets/titles/__init__.py +13 -0
- wbcrm/viewsets/titles/accounts.py +23 -0
- wbcrm/viewsets/titles/activities.py +61 -0
- wbcrm/viewsets/titles/products.py +13 -0
- wbcrm/viewsets/titles/utils.py +46 -0
- wbcrm/workflows/__init__.py +1 -0
- wbcrm/workflows/assignee_methods.py +25 -0
- wbcrm-1.56.8.dist-info/METADATA +11 -0
- wbcrm-1.56.8.dist-info/RECORD +182 -0
- wbcrm-1.56.8.dist-info/WHEEL +5 -0
|
@@ -0,0 +1,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})
|