wbcrm 1.56.8__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +5 -0
  3. wbcrm/admin/accounts.py +60 -0
  4. wbcrm/admin/activities.py +104 -0
  5. wbcrm/admin/events.py +43 -0
  6. wbcrm/admin/groups.py +8 -0
  7. wbcrm/admin/products.py +9 -0
  8. wbcrm/apps.py +5 -0
  9. wbcrm/configurations/__init__.py +1 -0
  10. wbcrm/configurations/base.py +16 -0
  11. wbcrm/dynamic_preferences_registry.py +38 -0
  12. wbcrm/factories/__init__.py +14 -0
  13. wbcrm/factories/accounts.py +57 -0
  14. wbcrm/factories/activities.py +124 -0
  15. wbcrm/factories/groups.py +24 -0
  16. wbcrm/factories/products.py +11 -0
  17. wbcrm/filters/__init__.py +10 -0
  18. wbcrm/filters/accounts.py +80 -0
  19. wbcrm/filters/activities.py +204 -0
  20. wbcrm/filters/groups.py +21 -0
  21. wbcrm/filters/products.py +38 -0
  22. wbcrm/filters/signals.py +95 -0
  23. wbcrm/fixtures/wbcrm.json +1215 -0
  24. wbcrm/kpi_handlers/activities.py +171 -0
  25. wbcrm/locale/de/LC_MESSAGES/django.mo +0 -0
  26. wbcrm/locale/de/LC_MESSAGES/django.po +1557 -0
  27. wbcrm/locale/de/LC_MESSAGES/django.po.translated +1630 -0
  28. wbcrm/locale/en/LC_MESSAGES/django.mo +0 -0
  29. wbcrm/locale/en/LC_MESSAGES/django.po +1466 -0
  30. wbcrm/locale/fr/LC_MESSAGES/django.mo +0 -0
  31. wbcrm/locale/fr/LC_MESSAGES/django.po +1467 -0
  32. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  33. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  34. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  35. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  36. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  37. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  38. wbcrm/migrations/0007_alter_account_status.py +23 -0
  39. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  40. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  41. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  42. wbcrm/migrations/0011_activity_summary.py +22 -0
  43. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  44. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  45. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  46. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  47. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  48. wbcrm/migrations/0017_event.py +40 -0
  49. wbcrm/migrations/0018_activity_search_vector.py +24 -0
  50. wbcrm/migrations/__init__.py +0 -0
  51. wbcrm/models/__init__.py +5 -0
  52. wbcrm/models/accounts.py +648 -0
  53. wbcrm/models/activities.py +1419 -0
  54. wbcrm/models/events.py +15 -0
  55. wbcrm/models/groups.py +119 -0
  56. wbcrm/models/llm/activity_summaries.py +41 -0
  57. wbcrm/models/llm/analyze_relationship.py +50 -0
  58. wbcrm/models/products.py +86 -0
  59. wbcrm/models/recurrence.py +280 -0
  60. wbcrm/preferences.py +13 -0
  61. wbcrm/report/activity_report.py +110 -0
  62. wbcrm/serializers/__init__.py +23 -0
  63. wbcrm/serializers/accounts.py +141 -0
  64. wbcrm/serializers/activities.py +525 -0
  65. wbcrm/serializers/groups.py +30 -0
  66. wbcrm/serializers/products.py +58 -0
  67. wbcrm/serializers/recurrence.py +91 -0
  68. wbcrm/serializers/signals.py +71 -0
  69. wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
  70. wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
  71. wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
  72. wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
  73. wbcrm/synchronization/__init__.py +0 -0
  74. wbcrm/synchronization/activity/__init__.py +0 -0
  75. wbcrm/synchronization/activity/admin.py +73 -0
  76. wbcrm/synchronization/activity/backend.py +214 -0
  77. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  78. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  79. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +406 -0
  80. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  81. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
  82. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
  83. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
  84. wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +181 -0
  85. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  86. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  87. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  88. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  89. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  90. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  91. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  92. wbcrm/synchronization/activity/backends/google/utils.py +217 -0
  93. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  94. wbcrm/synchronization/activity/backends/outlook/backend.py +593 -0
  95. wbcrm/synchronization/activity/backends/outlook/msgraph.py +436 -0
  96. wbcrm/synchronization/activity/backends/outlook/parser.py +432 -0
  97. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  98. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  99. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  100. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +118 -0
  101. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +274 -0
  102. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +249 -0
  103. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +174 -0
  104. wbcrm/synchronization/activity/controller.py +627 -0
  105. wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
  106. wbcrm/synchronization/activity/preferences.py +27 -0
  107. wbcrm/synchronization/activity/shortcuts.py +16 -0
  108. wbcrm/synchronization/activity/tasks.py +21 -0
  109. wbcrm/synchronization/activity/urls.py +7 -0
  110. wbcrm/synchronization/activity/utils.py +46 -0
  111. wbcrm/synchronization/activity/views.py +41 -0
  112. wbcrm/synchronization/admin.py +1 -0
  113. wbcrm/synchronization/apps.py +14 -0
  114. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  115. wbcrm/synchronization/management.py +36 -0
  116. wbcrm/synchronization/tasks.py +1 -0
  117. wbcrm/synchronization/urls.py +5 -0
  118. wbcrm/tasks.py +264 -0
  119. wbcrm/templates/email/activity.html +98 -0
  120. wbcrm/templates/email/activity_report.html +6 -0
  121. wbcrm/templates/email/daily_summary.html +72 -0
  122. wbcrm/templates/email/global_daily_summary.html +85 -0
  123. wbcrm/tests/__init__.py +0 -0
  124. wbcrm/tests/accounts/__init__.py +0 -0
  125. wbcrm/tests/accounts/test_models.py +393 -0
  126. wbcrm/tests/accounts/test_viewsets.py +88 -0
  127. wbcrm/tests/conftest.py +76 -0
  128. wbcrm/tests/disable_signals.py +62 -0
  129. wbcrm/tests/e2e/__init__.py +1 -0
  130. wbcrm/tests/e2e/e2e_wbcrm_utility.py +83 -0
  131. wbcrm/tests/e2e/test_e2e.py +370 -0
  132. wbcrm/tests/test_assignee_methods.py +40 -0
  133. wbcrm/tests/test_chartviewsets.py +112 -0
  134. wbcrm/tests/test_dto.py +64 -0
  135. wbcrm/tests/test_filters.py +52 -0
  136. wbcrm/tests/test_models.py +217 -0
  137. wbcrm/tests/test_recurrence.py +292 -0
  138. wbcrm/tests/test_report.py +21 -0
  139. wbcrm/tests/test_serializers.py +171 -0
  140. wbcrm/tests/test_tasks.py +95 -0
  141. wbcrm/tests/test_viewsets.py +967 -0
  142. wbcrm/tests/tests.py +121 -0
  143. wbcrm/typings.py +109 -0
  144. wbcrm/urls.py +67 -0
  145. wbcrm/viewsets/__init__.py +22 -0
  146. wbcrm/viewsets/accounts.py +122 -0
  147. wbcrm/viewsets/activities.py +341 -0
  148. wbcrm/viewsets/buttons/__init__.py +7 -0
  149. wbcrm/viewsets/buttons/accounts.py +27 -0
  150. wbcrm/viewsets/buttons/activities.py +89 -0
  151. wbcrm/viewsets/buttons/signals.py +17 -0
  152. wbcrm/viewsets/display/__init__.py +12 -0
  153. wbcrm/viewsets/display/accounts.py +110 -0
  154. wbcrm/viewsets/display/activities.py +444 -0
  155. wbcrm/viewsets/display/groups.py +22 -0
  156. wbcrm/viewsets/display/products.py +105 -0
  157. wbcrm/viewsets/endpoints/__init__.py +8 -0
  158. wbcrm/viewsets/endpoints/accounts.py +25 -0
  159. wbcrm/viewsets/endpoints/activities.py +30 -0
  160. wbcrm/viewsets/endpoints/groups.py +7 -0
  161. wbcrm/viewsets/endpoints/products.py +9 -0
  162. wbcrm/viewsets/groups.py +38 -0
  163. wbcrm/viewsets/menu/__init__.py +8 -0
  164. wbcrm/viewsets/menu/accounts.py +18 -0
  165. wbcrm/viewsets/menu/activities.py +49 -0
  166. wbcrm/viewsets/menu/groups.py +16 -0
  167. wbcrm/viewsets/menu/products.py +20 -0
  168. wbcrm/viewsets/mixins.py +35 -0
  169. wbcrm/viewsets/previews/__init__.py +1 -0
  170. wbcrm/viewsets/previews/activities.py +10 -0
  171. wbcrm/viewsets/products.py +57 -0
  172. wbcrm/viewsets/recurrence.py +27 -0
  173. wbcrm/viewsets/titles/__init__.py +13 -0
  174. wbcrm/viewsets/titles/accounts.py +23 -0
  175. wbcrm/viewsets/titles/activities.py +61 -0
  176. wbcrm/viewsets/titles/products.py +13 -0
  177. wbcrm/viewsets/titles/utils.py +46 -0
  178. wbcrm/workflows/__init__.py +1 -0
  179. wbcrm/workflows/assignee_methods.py +25 -0
  180. wbcrm-1.56.8.dist-info/METADATA +11 -0
  181. wbcrm-1.56.8.dist-info/RECORD +182 -0
  182. wbcrm-1.56.8.dist-info/WHEEL +5 -0
@@ -0,0 +1,525 @@
1
+ from collections import OrderedDict
2
+ from datetime import date, timedelta
3
+
4
+ import pandas as pd
5
+ from django.contrib.messages import warning
6
+ from django.db.models import Q
7
+ from django.utils import timezone
8
+ from django.utils.functional import cached_property
9
+ from django.utils.translation import gettext as _
10
+ from django.utils.translation import gettext_lazy
11
+ from psycopg.types.range import TimestamptzRange
12
+ from rest_framework.exceptions import ValidationError
13
+ from rest_framework.reverse import reverse
14
+ from slugify import slugify
15
+ from wbcore import serializers as wb_serializers
16
+ from wbcore.contrib.agenda.models import CalendarItem
17
+ from wbcore.contrib.agenda.serializers import ConferenceRoomRepresentationSerializer
18
+ from wbcore.contrib.authentication.models import User
19
+ from wbcore.contrib.directory.models import Company, Entry, Person
20
+ from wbcore.contrib.directory.preferences import get_main_company
21
+ from wbcore.contrib.directory.serializers import PersonRepresentationSerializer
22
+ from wbcore.utils.date import calendar_item_shortcuts
23
+
24
+ from wbcrm.models import Activity, ActivityType
25
+ from wbcrm.models.activities import ActivityCompanyThroughModel, ActivityParticipant
26
+
27
+ from .groups import GroupRepresentationSerializer
28
+ from .recurrence import RecurrenceModelSerializerMixin
29
+
30
+
31
+ class ActivityTypeModelSerializer(wb_serializers.ModelSerializer):
32
+ class Meta:
33
+ model = ActivityType
34
+ fields = (
35
+ "id",
36
+ "title",
37
+ "icon",
38
+ "color",
39
+ "score",
40
+ "default",
41
+ "_additional_resources",
42
+ )
43
+
44
+ def validate(self, data):
45
+ title = data.get("title", None)
46
+ if title:
47
+ type = ActivityType.objects.filter(slugify_title=slugify(title, separator=" "))
48
+ if self.instance:
49
+ type = type.exclude(id=self.instance.id)
50
+ if type.exists():
51
+ raise ValidationError({"title": _("Cannot add a duplicate activity type.")})
52
+
53
+ return data
54
+
55
+
56
+ class ActivityTypeRepresentationSerializer(wb_serializers.RepresentationSerializer):
57
+ endpoint = "wbcrm:activitytyperepresentation-list"
58
+ _detail = wb_serializers.HyperlinkField(reverse_name="wbcrm:activitytype-detail")
59
+
60
+ class Meta:
61
+ model = ActivityType
62
+ fields = (
63
+ "id",
64
+ "title",
65
+ "icon",
66
+ "_detail",
67
+ "color",
68
+ "score",
69
+ )
70
+
71
+
72
+ class ActivityRepresentationSerializer(wb_serializers.RepresentationSerializer):
73
+ _detail = wb_serializers.HyperlinkField(reverse_name="wbcrm:activity-detail")
74
+ _detail_preview = wb_serializers.HyperlinkField(reverse_name="wbcrm:activity-detail")
75
+ end__date = wb_serializers.SerializerMethodField()
76
+ title = wb_serializers.CharField(max_length=255, read_only=True)
77
+
78
+ label_key = "{{end__date}}: {{title}}"
79
+
80
+ def get_end__date(self, obj):
81
+ if obj.end is not None:
82
+ return obj.end.strftime("%d.%m.%Y")
83
+ return ""
84
+
85
+ @wb_serializers.register_resource()
86
+ def activity(self, instance, request, user):
87
+ return {"activity": f'{reverse("wbcrm:activity-list")}?participants={instance.id}'}
88
+
89
+ class Meta:
90
+ model = Activity
91
+ fields = ("id", "_detail", "_detail_preview", "end", "end__date", "start", "title", "_additional_resources")
92
+
93
+
94
+ def get_default_period(*args, **kwargs):
95
+ return TimestamptzRange(timezone.now(), timezone.now() + timedelta(hours=1)) # type: ignore
96
+
97
+
98
+ def handle_representation(representation: OrderedDict) -> OrderedDict:
99
+ """
100
+ This method is used to remove certain representation values if the representation is for private/confidential use.
101
+ By removing these values, the corresponding fields in the Workbench appear empty.
102
+
103
+ :param representation: Dict of primitive datatypes representing the serializer fields.
104
+ :return: Either the unchanged representation or a privatized version of the representation
105
+ """
106
+ keys_to_preserve = ["id", "period", "status"]
107
+ private_keys_to_preserve = [
108
+ "participants",
109
+ "_participants",
110
+ "companies",
111
+ "_companies",
112
+ "groups",
113
+ "_groups",
114
+ "assigned_to",
115
+ "_assigned_to",
116
+ "creator",
117
+ "_creator",
118
+ ]
119
+ is_private: bool
120
+ if not ((is_private := representation.get("is_private", False)) or representation.get("is_confidential")):
121
+ return representation
122
+ hidden_representation = OrderedDict.fromkeys(representation, None)
123
+ hidden_representation |= {key: representation.get(key) for key in keys_to_preserve}
124
+ if is_private:
125
+ hidden_representation |= {key: representation.get(key) for key in private_keys_to_preserve}
126
+ hidden_representation["title"] = _("Private Activity") if is_private else _("Confidential Activity")
127
+ return hidden_representation
128
+
129
+
130
+ def _get_default_recurrence_end():
131
+ """
132
+ Default to 6 months in the future
133
+ """
134
+ return (date.today() + pd.tseries.offsets.DateOffset(months=6)).date()
135
+
136
+
137
+ class ActivityCompanyThroughModelRepresentationSerializer(wb_serializers.RepresentationSerializer):
138
+ endpoint = "wbcore:directory:companyrepresentation-list"
139
+ value_key = "id"
140
+
141
+ def to_representation(self, value):
142
+ rep = super().to_representation(value)
143
+ rep["id"] = value.company_id
144
+ return rep
145
+
146
+ class Meta:
147
+ model = ActivityCompanyThroughModel
148
+ fields = (
149
+ "id",
150
+ # "company",
151
+ "computed_str",
152
+ )
153
+
154
+
155
+ class ActivityModelListSerializer(RecurrenceModelSerializerMixin, wb_serializers.ModelSerializer):
156
+ _companies = ActivityCompanyThroughModelRepresentationSerializer(
157
+ source="activity_companies", related_key="companies", many=True
158
+ )
159
+
160
+ _participants = PersonRepresentationSerializer(source="participants", many=True)
161
+
162
+ heat = wb_serializers.EmojiRatingField(label="Sentiment")
163
+ _groups = GroupRepresentationSerializer(source="groups", many=True)
164
+ _type = ActivityTypeRepresentationSerializer(source="type")
165
+ _latest_reviewer = PersonRepresentationSerializer(source="latest_reviewer")
166
+ is_private = wb_serializers.BooleanField(default=False, read_only=True)
167
+ is_confidential = wb_serializers.BooleanField(default=False, read_only=True)
168
+
169
+ def to_representation(self, instance):
170
+ return handle_representation(super().to_representation(instance))
171
+
172
+ def validate(self, data):
173
+ main_company = get_main_company()
174
+ request = self.context["request"]
175
+ companies = data.get("companies", [])
176
+ if main_company and main_company in companies:
177
+ warning(
178
+ request,
179
+ f"The main company {main_company} was removed from the list of companies",
180
+ )
181
+ return data
182
+
183
+ class Meta:
184
+ model = Activity
185
+ fields = (
186
+ "id",
187
+ "_additional_resources",
188
+ "type",
189
+ "_type",
190
+ "title",
191
+ "status",
192
+ "period",
193
+ "companies",
194
+ "_companies",
195
+ "participants",
196
+ "_participants",
197
+ "groups",
198
+ "_groups",
199
+ "edited",
200
+ "created",
201
+ "description",
202
+ "result",
203
+ "latest_reviewer",
204
+ "is_private",
205
+ "is_confidential",
206
+ "summary",
207
+ "heat",
208
+ "_latest_reviewer",
209
+ )
210
+ read_only_fields = list(filter(lambda x: x not in ["result"], fields))
211
+
212
+
213
+ class ActivityModelSerializer(ActivityModelListSerializer):
214
+ type = wb_serializers.PrimaryKeyRelatedField(
215
+ queryset=ActivityType.objects.all(), default=lambda: ActivityType.get_default_activity_type()
216
+ )
217
+ repeat_choice = wb_serializers.ChoiceField(
218
+ help_text="Repeat activity at the specified frequency",
219
+ label="Recurrence Frequency",
220
+ required=False,
221
+ read_only=lambda view: not view.new_mode,
222
+ choices=Activity.ReoccuranceChoice.choices,
223
+ )
224
+
225
+ recurrence_end = wb_serializers.DateField(
226
+ required=False, label=gettext_lazy("Recurrence End"), default=_get_default_recurrence_end
227
+ )
228
+ visibility = wb_serializers.ChoiceField(
229
+ choices=Activity.Visibility.choices,
230
+ help_text=gettext_lazy(
231
+ "Set to private for the activity to hide sensitive information from anyone but the assignee and participants. Set to confidential to hide from anyone but users with manager permissions."
232
+ ),
233
+ label=gettext_lazy("Visibility"),
234
+ )
235
+ assigned_to = wb_serializers.PrimaryKeyRelatedField(
236
+ default=wb_serializers.CurrentUserDefault("profile"),
237
+ queryset=Person.objects.all(),
238
+ label=gettext_lazy("Assigned to"),
239
+ )
240
+ _assigned_to = PersonRepresentationSerializer(source="assigned_to")
241
+ creator = wb_serializers.PrimaryKeyRelatedField(
242
+ default=wb_serializers.CurrentUserDefault("profile"),
243
+ many=False,
244
+ read_only=True,
245
+ )
246
+ _creator = PersonRepresentationSerializer(
247
+ source="creator",
248
+ )
249
+ companies = wb_serializers.PrimaryKeyRelatedField(
250
+ default=wb_serializers.DefaultFromGET("companies", many=True),
251
+ many=True,
252
+ queryset=Company.objects.all(),
253
+ label=gettext_lazy("Companies"),
254
+ )
255
+ participants = wb_serializers.PrimaryKeyRelatedField(
256
+ default=wb_serializers.DefaultFromGET("participants", many=True),
257
+ many=True,
258
+ queryset=Person.objects.all(),
259
+ label=gettext_lazy("Participants"),
260
+ )
261
+ period = wb_serializers.DateTimeRangeField(
262
+ label=gettext_lazy("Period"),
263
+ default=get_default_period,
264
+ shortcuts=calendar_item_shortcuts,
265
+ read_only=lambda view: view.is_external_activity,
266
+ )
267
+
268
+ is_private = wb_serializers.BooleanField(default=False, read_only=True)
269
+ is_confidential = wb_serializers.BooleanField(default=False, read_only=True)
270
+ _conference_room = ConferenceRoomRepresentationSerializer(source="conference_room")
271
+
272
+ title = wb_serializers.CharField(
273
+ default=wb_serializers.DefaultFromGET("title"),
274
+ label=gettext_lazy("Title"),
275
+ read_only=lambda view: view.is_external_activity,
276
+ )
277
+ all_day = wb_serializers.BooleanField(read_only=lambda view: view.is_external_activity)
278
+ description = wb_serializers.TextField(read_only=lambda view: view.is_external_activity)
279
+
280
+ @cached_property
281
+ def user(self) -> User | None:
282
+ if request := self.context.get("request"):
283
+ return request.user
284
+ return None
285
+
286
+ @wb_serializers.register_resource()
287
+ def activity_participants_table(self, instance, request, user):
288
+ return {
289
+ "activity_participants_table": reverse(
290
+ "wbcrm:activity-participant-list", kwargs={"activity_id": instance.id}, request=request
291
+ )
292
+ }
293
+
294
+ def to_representation(self, instance):
295
+ return handle_representation(super().to_representation(instance))
296
+
297
+ def validate(self, data): # noqa: C901
298
+ if (
299
+ (result := data.get("result", None))
300
+ and result not in ["", "<p></p>"]
301
+ and result != getattr(self.instance, "result", "")
302
+ ):
303
+ data["latest_reviewer"] = self.user.profile
304
+ data["reviewed_at"] = timezone.now()
305
+ if data.get("creator", None) is None:
306
+ data["creator"] = self.user.profile
307
+
308
+ if not data.get("title", self.instance.title if self.instance else None):
309
+ raise ValidationError({"title": "You need to specify a title for this activity."})
310
+
311
+ period = data.get("period", self.instance.period if self.instance else None)
312
+ if not data.get("type", self.instance.type if self.instance else None):
313
+ raise ValidationError({"type": _("Please add an activity type.")})
314
+
315
+ if room := data.get("conference_room", self.instance.conference_room if self.instance else None):
316
+ qs = Activity.objects.filter(status=Activity.Status.PLANNED, conference_room=room)
317
+ if self.instance:
318
+ qs = qs.exclude(id=self.instance.id)
319
+ conference_room_activity = Activity.get_inrange_activities(
320
+ qs, period.lower + timedelta(seconds=60), period.upper - timedelta(seconds=60)
321
+ ).first()
322
+ if conference_room_activity and (user := self.user):
323
+ if (
324
+ conference_room_activity.visibility == Activity.Visibility.PRIVATE
325
+ and user.profile not in conference_room_activity.participants.all()
326
+ and user.profile != conference_room_activity.assigned_to
327
+ ):
328
+ raise ValidationError(
329
+ {
330
+ "non_field_errors": _(
331
+ "A private activity already uses this conference room at the same time."
332
+ )
333
+ }
334
+ )
335
+ elif (
336
+ conference_room_activity.visibility == Activity.Visibility.CONFIDENTIAL
337
+ and not CalendarItem.has_user_administrate_permission(user)
338
+ ):
339
+ raise ValidationError(
340
+ {
341
+ "non_field_errors": _(
342
+ "A confidential activity already uses this conference room at the same time."
343
+ )
344
+ }
345
+ )
346
+ else:
347
+ raise ValidationError(
348
+ {
349
+ "non_field_errors": _(
350
+ 'The activity "{title}" already uses this conference room at the same time.'
351
+ ).format(title=conference_room_activity.title)
352
+ }
353
+ )
354
+
355
+ # In the following section we validate the groups and the group members. If a user trys to remove a group member from the participants/companies fields, we want to raise an validation error.
356
+ if self.instance and self.instance.groups.exists() and not ("groups" in data and data["groups"] == []):
357
+ # If there are no companies or participants in the current instance, you cannot remove a group member; therefore there is no need to validate the groups. This can happen if you add to an existing and empty activity a group.
358
+ if self.instance.companies.exists() or self.instance.participants.exists():
359
+ companies = data.get("companies", [])
360
+ participants = data.get("participants", [])
361
+ # If there are new companies or participants in data, we need to check if existing group members have been removed.
362
+ if "companies" in data or "participants" in data:
363
+ new_groups = data.get("groups", [])
364
+ new_groups_id_list = [group.id for group in new_groups]
365
+ old_groups = self.instance.groups.exclude(id__in=new_groups_id_list)
366
+
367
+ # If there are no new groups in data, we need to check if members of the old group have been removed.
368
+ # If there are new groups and also old groups, we need to check for the old groups if members have been removed.
369
+ # If only new groups are present, we don't need to validate.
370
+ if not new_groups or old_groups.exists():
371
+ new_companies_id_list = (
372
+ [companies.id for companies in companies]
373
+ if "companies" in data
374
+ else self.instance.companies.values_list("id", flat=True)
375
+ )
376
+ new_participants_id_list = (
377
+ [participant.id for participant in participants]
378
+ if "participants" in data
379
+ else self.instance.participants.values_list("id", flat=True)
380
+ )
381
+ new_entries = Entry.objects.filter(
382
+ Q(id__in=new_companies_id_list) | Q(id__in=new_participants_id_list)
383
+ ).distinct()
384
+ missing_members = (
385
+ Entry.objects.filter(groups__in=old_groups).exclude(id__in=new_entries).distinct()
386
+ )
387
+ # If there are group members who are not in the new entries field, we can assume, that a group member has been removed and we need to throw an error.
388
+ if missing_members.exists():
389
+ missing_members_computed_strs = ", ".join(
390
+ list(missing_members.values_list("computed_str", flat=True))
391
+ )
392
+ util_str = _("is a member")
393
+ if missing_members.count() > 1:
394
+ start, x, end = missing_members_computed_strs.rpartition(",")
395
+ missing_members_computed_strs = start + _(" and") + end
396
+ util_str = _("are members")
397
+
398
+ groups = ", ".join(
399
+ list(dict.fromkeys(missing_members.values_list("groups__title", flat=True)))
400
+ )
401
+ error_message = _(
402
+ "{missing_members} {util_str} of the following group(s): {groups}\n. You cannot remove members of selected groups.",
403
+ ).format(missing_members=missing_members_computed_strs, util_str=util_str, groups=groups)
404
+
405
+ raise ValidationError({"groups": error_message})
406
+ return super().validate(data)
407
+
408
+ class Meta:
409
+ model = Activity
410
+ required_fields = ("importance", "period", "reminder_choice", "repeat_choice", "start", "title", "type")
411
+ read_only_fields = (
412
+ "creator",
413
+ "edited",
414
+ "latest_reviewer",
415
+ "reviewed_at",
416
+ "is_cancelled",
417
+ )
418
+ fields = (
419
+ "id",
420
+ "_additional_resources",
421
+ "all_day",
422
+ "assigned_to",
423
+ "_assigned_to",
424
+ "companies",
425
+ "_companies",
426
+ "conference_room",
427
+ "_conference_room",
428
+ "created",
429
+ "creator",
430
+ "_creator",
431
+ "description",
432
+ "disable_participant_check",
433
+ "edited",
434
+ "end",
435
+ "groups",
436
+ "_groups",
437
+ "importance",
438
+ "visibility",
439
+ "latest_reviewer",
440
+ "_latest_reviewer",
441
+ "location",
442
+ "location_latitude",
443
+ "location_longitude",
444
+ "online_meeting",
445
+ "participants",
446
+ "_participants",
447
+ "summary",
448
+ "period",
449
+ "propagate_for_all_children",
450
+ "recurrence_count",
451
+ "recurrence_end",
452
+ "reminder_choice",
453
+ "repeat_choice",
454
+ "result",
455
+ "reviewed_at",
456
+ "start",
457
+ "status",
458
+ "title",
459
+ "type",
460
+ "_type",
461
+ "is_cancelled",
462
+ "is_private",
463
+ "is_confidential",
464
+ "_buttons",
465
+ )
466
+
467
+
468
+ class ReadOnlyActivityModelSerializer(ActivityModelSerializer):
469
+ class Meta(ActivityModelSerializer.Meta):
470
+ read_only_fields = list(filter(lambda x: x not in ["result"], ActivityModelSerializer.Meta.fields))
471
+
472
+
473
+ class ActivityParticipantModelSerializer(wb_serializers.ModelSerializer):
474
+ _activity = ActivityRepresentationSerializer(source="activity")
475
+ activity = wb_serializers.PrimaryKeyRelatedField(
476
+ default=wb_serializers.DefaultFromGET("activity_id"),
477
+ queryset=Activity.objects.all(),
478
+ label=gettext_lazy("Activity"),
479
+ )
480
+ customer_status = wb_serializers.CharField(
481
+ default="", label=gettext_lazy("Status"), allow_null=True, read_only=True
482
+ )
483
+ position = wb_serializers.CharField(default="", label=gettext_lazy("Position"), read_only=True)
484
+ primary_telephone = wb_serializers.TelephoneField(
485
+ default="", label=gettext_lazy("Primary Telephone"), allow_null=True, read_only=True
486
+ )
487
+ primary_email = wb_serializers.CharField(
488
+ allow_null=True, default="", label=gettext_lazy("Primary Email"), read_only=True
489
+ )
490
+ _participant = PersonRepresentationSerializer(source="participant")
491
+ is_occupied = wb_serializers.BooleanField(default=False)
492
+
493
+ def validate(self, data):
494
+ data_activity = data.get("activity", None)
495
+ data_participant = data.get("participant", None)
496
+
497
+ if ActivityParticipant.objects.filter(
498
+ activity=data_activity,
499
+ participant=data_participant,
500
+ ).exists():
501
+ raise ValidationError({"participant": _("The person is already a participant in this activity.")})
502
+
503
+ return super().validate(data)
504
+
505
+ def create(self, validated_data):
506
+ validated_data.pop("is_occupied", None)
507
+ return super().create(validated_data)
508
+
509
+ class Meta:
510
+ model = ActivityParticipant
511
+ fields = (
512
+ "_activity",
513
+ "_additional_resources",
514
+ "_participant",
515
+ "activity",
516
+ "customer_status",
517
+ "position",
518
+ "id",
519
+ "is_occupied",
520
+ "participant",
521
+ "participation_status",
522
+ "status_changed",
523
+ "primary_telephone",
524
+ "primary_email",
525
+ )
@@ -0,0 +1,30 @@
1
+ from wbcore import serializers as wb_serializers
2
+ from wbcore.contrib.directory.serializers import EntryRepresentationSerializer
3
+
4
+ from ..models import Group
5
+
6
+
7
+ class GroupRepresentationSerializer(wb_serializers.RepresentationSerializer):
8
+ endpoint = "wbcrm:grouprepresentation-list"
9
+ _detail = wb_serializers.HyperlinkField(reverse_name="wbcrm:group-detail")
10
+
11
+ class Meta:
12
+ model = Group
13
+ fields = (
14
+ "id",
15
+ "_detail",
16
+ "title",
17
+ )
18
+
19
+
20
+ class GroupModelSerializer(wb_serializers.ModelSerializer):
21
+ _members = EntryRepresentationSerializer(source="members", many=True)
22
+
23
+ class Meta:
24
+ model = Group
25
+ fields = (
26
+ "id",
27
+ "title",
28
+ "members",
29
+ "_members",
30
+ )
@@ -0,0 +1,58 @@
1
+ from django.forms import ValidationError
2
+ from django.utils.translation import gettext_lazy as _
3
+ from slugify import slugify
4
+ from wbcore import serializers
5
+ from wbcore import serializers as wb_serializers
6
+ from wbcore.contrib.directory.serializers import CompanyRepresentationSerializer
7
+
8
+ from wbcrm.models import Product, ProductCompanyRelationship
9
+
10
+
11
+ class ProductRepresentationSerializer(wb_serializers.RepresentationSerializer):
12
+ endpoint = "wbcrm:productrepresentation-list"
13
+ _detail = wb_serializers.HyperlinkField(reverse_name="wbcrm:product-detail")
14
+
15
+ class Meta:
16
+ model = Product
17
+ fields = (
18
+ "id",
19
+ "title",
20
+ "computed_str",
21
+ "_detail",
22
+ "is_competitor",
23
+ )
24
+
25
+
26
+ class ProductCompanyRelationshipModelSerializer(serializers.ModelSerializer):
27
+ _product = ProductRepresentationSerializer(source="product")
28
+ competitor_product = wb_serializers.BooleanField(read_only=True)
29
+
30
+ class Meta:
31
+ model = ProductCompanyRelationship
32
+ fields = ("id", "product", "_product", "company", "competitor_product")
33
+
34
+
35
+ class ProductModelSerializer(serializers.ModelSerializer):
36
+ _prospects = CompanyRepresentationSerializer(source="prospects", many=True)
37
+
38
+ def validate(self, data):
39
+ title = data.get("title")
40
+ competitor = data.get("is_competitor")
41
+ if title:
42
+ product = Product.objects.filter(is_competitor=competitor, slugify_title=slugify(title, separator=" "))
43
+ if obj := self.instance:
44
+ product = product.exclude(id=obj.id)
45
+ if product.exists():
46
+ product_type = _("competitor ") if competitor else ""
47
+ raise ValidationError({"title": _("Cannot add a duplicate {}product.").format(product_type)})
48
+ return data
49
+
50
+ class Meta:
51
+ model = Product
52
+ fields = (
53
+ "id",
54
+ "title",
55
+ "prospects",
56
+ "_prospects",
57
+ "is_competitor",
58
+ )