wbcrm 2.2.1__py2.py3-none-any.whl

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

Potentially problematic release.


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

Files changed (155) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +4 -0
  3. wbcrm/admin/accounts.py +59 -0
  4. wbcrm/admin/activities.py +101 -0
  5. wbcrm/admin/groups.py +7 -0
  6. wbcrm/admin/products.py +8 -0
  7. wbcrm/apps.py +5 -0
  8. wbcrm/configurations/__init__.py +1 -0
  9. wbcrm/configurations/base.py +16 -0
  10. wbcrm/dynamic_preferences_registry.py +38 -0
  11. wbcrm/factories/__init__.py +14 -0
  12. wbcrm/factories/accounts.py +56 -0
  13. wbcrm/factories/activities.py +125 -0
  14. wbcrm/factories/groups.py +23 -0
  15. wbcrm/factories/products.py +10 -0
  16. wbcrm/filters/__init__.py +10 -0
  17. wbcrm/filters/accounts.py +67 -0
  18. wbcrm/filters/activities.py +181 -0
  19. wbcrm/filters/groups.py +20 -0
  20. wbcrm/filters/products.py +37 -0
  21. wbcrm/filters/signals.py +94 -0
  22. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  23. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  24. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  25. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  26. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  27. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  28. wbcrm/migrations/0007_alter_account_status.py +23 -0
  29. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  30. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  31. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  32. wbcrm/migrations/0011_activity_summary.py +22 -0
  33. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  34. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  35. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  36. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  37. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  38. wbcrm/migrations/__init__.py +0 -0
  39. wbcrm/models/__init__.py +4 -0
  40. wbcrm/models/accounts.py +637 -0
  41. wbcrm/models/activities.py +1335 -0
  42. wbcrm/models/groups.py +118 -0
  43. wbcrm/models/products.py +83 -0
  44. wbcrm/models/recurrence.py +279 -0
  45. wbcrm/preferences.py +14 -0
  46. wbcrm/serializers/__init__.py +23 -0
  47. wbcrm/serializers/accounts.py +126 -0
  48. wbcrm/serializers/activities.py +526 -0
  49. wbcrm/serializers/groups.py +30 -0
  50. wbcrm/serializers/products.py +57 -0
  51. wbcrm/serializers/recurrence.py +90 -0
  52. wbcrm/serializers/signals.py +70 -0
  53. wbcrm/synchronization/__init__.py +0 -0
  54. wbcrm/synchronization/activity/__init__.py +0 -0
  55. wbcrm/synchronization/activity/admin.py +72 -0
  56. wbcrm/synchronization/activity/backend.py +207 -0
  57. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  58. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  59. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
  60. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  61. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  62. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  63. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  64. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  65. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  66. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  67. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  68. wbcrm/synchronization/activity/backends/google/utils.py +216 -0
  69. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  70. wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
  71. wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
  72. wbcrm/synchronization/activity/backends/outlook/parser.py +423 -0
  73. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  74. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  75. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  76. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
  77. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
  78. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
  79. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
  80. wbcrm/synchronization/activity/controller.py +545 -0
  81. wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
  82. wbcrm/synchronization/activity/preferences.py +21 -0
  83. wbcrm/synchronization/activity/shortcuts.py +9 -0
  84. wbcrm/synchronization/activity/signals.py +28 -0
  85. wbcrm/synchronization/activity/tasks.py +21 -0
  86. wbcrm/synchronization/activity/urls.py +6 -0
  87. wbcrm/synchronization/activity/utils.py +46 -0
  88. wbcrm/synchronization/activity/views.py +37 -0
  89. wbcrm/synchronization/admin.py +1 -0
  90. wbcrm/synchronization/apps.py +15 -0
  91. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  92. wbcrm/synchronization/management.py +36 -0
  93. wbcrm/synchronization/tasks.py +1 -0
  94. wbcrm/synchronization/urls.py +5 -0
  95. wbcrm/tasks.py +312 -0
  96. wbcrm/tests/__init__.py +0 -0
  97. wbcrm/tests/accounts/__init__.py +0 -0
  98. wbcrm/tests/accounts/test_models.py +380 -0
  99. wbcrm/tests/accounts/test_viewsets.py +87 -0
  100. wbcrm/tests/conftest.py +76 -0
  101. wbcrm/tests/disable_signals.py +52 -0
  102. wbcrm/tests/e2e/__init__.py +1 -0
  103. wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
  104. wbcrm/tests/e2e/test_e2e.py +369 -0
  105. wbcrm/tests/test_assignee_methods.py +39 -0
  106. wbcrm/tests/test_chartviewsets.py +111 -0
  107. wbcrm/tests/test_dto.py +63 -0
  108. wbcrm/tests/test_filters.py +51 -0
  109. wbcrm/tests/test_models.py +216 -0
  110. wbcrm/tests/test_recurrence.py +291 -0
  111. wbcrm/tests/test_report.py +20 -0
  112. wbcrm/tests/test_serializers.py +170 -0
  113. wbcrm/tests/test_tasks.py +94 -0
  114. wbcrm/tests/test_viewsets.py +967 -0
  115. wbcrm/tests/tests.py +120 -0
  116. wbcrm/typings.py +107 -0
  117. wbcrm/urls.py +67 -0
  118. wbcrm/viewsets/__init__.py +22 -0
  119. wbcrm/viewsets/accounts.py +121 -0
  120. wbcrm/viewsets/activities.py +315 -0
  121. wbcrm/viewsets/buttons/__init__.py +7 -0
  122. wbcrm/viewsets/buttons/accounts.py +27 -0
  123. wbcrm/viewsets/buttons/activities.py +68 -0
  124. wbcrm/viewsets/buttons/signals.py +17 -0
  125. wbcrm/viewsets/display/__init__.py +12 -0
  126. wbcrm/viewsets/display/accounts.py +110 -0
  127. wbcrm/viewsets/display/activities.py +443 -0
  128. wbcrm/viewsets/display/groups.py +22 -0
  129. wbcrm/viewsets/display/products.py +105 -0
  130. wbcrm/viewsets/endpoints/__init__.py +8 -0
  131. wbcrm/viewsets/endpoints/accounts.py +32 -0
  132. wbcrm/viewsets/endpoints/activities.py +30 -0
  133. wbcrm/viewsets/endpoints/groups.py +7 -0
  134. wbcrm/viewsets/endpoints/products.py +9 -0
  135. wbcrm/viewsets/groups.py +37 -0
  136. wbcrm/viewsets/menu/__init__.py +8 -0
  137. wbcrm/viewsets/menu/accounts.py +18 -0
  138. wbcrm/viewsets/menu/activities.py +61 -0
  139. wbcrm/viewsets/menu/groups.py +16 -0
  140. wbcrm/viewsets/menu/products.py +20 -0
  141. wbcrm/viewsets/mixins.py +34 -0
  142. wbcrm/viewsets/previews/__init__.py +1 -0
  143. wbcrm/viewsets/previews/activities.py +10 -0
  144. wbcrm/viewsets/products.py +56 -0
  145. wbcrm/viewsets/recurrence.py +26 -0
  146. wbcrm/viewsets/titles/__init__.py +13 -0
  147. wbcrm/viewsets/titles/accounts.py +22 -0
  148. wbcrm/viewsets/titles/activities.py +61 -0
  149. wbcrm/viewsets/titles/products.py +13 -0
  150. wbcrm/viewsets/titles/utils.py +46 -0
  151. wbcrm/workflows/__init__.py +1 -0
  152. wbcrm/workflows/assignee_methods.py +25 -0
  153. wbcrm-2.2.1.dist-info/METADATA +11 -0
  154. wbcrm-2.2.1.dist-info/RECORD +155 -0
  155. wbcrm-2.2.1.dist-info/WHEEL +5 -0
@@ -0,0 +1,637 @@
1
+ from contextlib import suppress
2
+ from datetime import date, timedelta
3
+ from typing import Iterable
4
+
5
+ from django.contrib.postgres.constraints import ExclusionConstraint
6
+ from django.contrib.postgres.fields import DateRangeField, RangeOperators
7
+ from django.core.validators import MaxValueValidator, MinValueValidator
8
+ from django.db import models, transaction
9
+ from django.db.models import Exists, OuterRef, Q, QuerySet, Subquery
10
+ from django.db.models.constraints import UniqueConstraint
11
+ from django.db.models.signals import post_delete, post_save
12
+ from django.dispatch import receiver
13
+ from django.utils.translation import gettext_lazy as _
14
+ from django_fsm import FSMField, transition
15
+ from mptt.models import MPTTModel, TreeForeignKey, TreeManager
16
+ from psycopg.types.range import DateRange
17
+ from slugify import slugify
18
+ from wbcore.contrib.ai.llm.decorators import llm
19
+ from wbcore.contrib.authentication.models import User
20
+ from wbcore.contrib.directory.models import EmployerEmployeeRelationship, Entry
21
+ from wbcore.contrib.directory.signals import deactivate_profile
22
+ from wbcore.contrib.icons import WBIcon
23
+ from wbcore.enums import RequestType
24
+ from wbcore.metadata.configs.buttons import ActionButton
25
+ from wbcore.models import WBModel
26
+ from wbcore.signals import pre_merge
27
+ from wbcore.utils.models import (
28
+ ActiveObjectManager,
29
+ ComplexToStringMixin,
30
+ DeleteToDisableMixin,
31
+ )
32
+ from wbcrm.models.llm.analyze_relationship import analyze_relationship
33
+
34
+
35
+ class AccountDefaultQueryset(QuerySet):
36
+ def filter_for_user(self, user: User, validity_date: date | None = None, strict: bool = False) -> QuerySet:
37
+ """
38
+ Filters related accounts based on the user's permissions and roles.
39
+
40
+ Args:
41
+ user (User): The user for whom related accounts need to be filtered.
42
+ validity_date (date | None, optional): The validity date for role filtering. Defaults to None.
43
+ strict (bool, optional): If True, filtering will be strict based on roles; otherwise, relaxed. Defaults to False.
44
+
45
+ Returns:
46
+ QuerySet: A queryset of related accounts filtered based on the user's permissions and roles.
47
+ """
48
+ if user.has_perm("wbcrm.administrate_account"):
49
+ return self
50
+ if not validity_date:
51
+ validity_date = date.today()
52
+
53
+ valid_roles = AccountRole.objects.filter_for_user(user, validity_date=validity_date, strict=strict).filter(
54
+ is_currently_valid=True
55
+ )
56
+ if (user.profile.is_internal or user.is_superuser) and not strict:
57
+ return self.filter(Q(id__in=valid_roles.values("account")) | Q(is_public=True))
58
+ return self.annotate(
59
+ has_direct_role=Exists(valid_roles.filter(account=OuterRef("id"))),
60
+ has_descending_roles=Exists(
61
+ valid_roles.filter(
62
+ account__tree_id=OuterRef("tree_id"),
63
+ account__lft__lte=OuterRef("lft"),
64
+ account__rght__gte=OuterRef("rght"),
65
+ )
66
+ ),
67
+ ).filter((Q(is_public=True) & Q(has_descending_roles=True)) | Q(has_direct_role=True))
68
+
69
+
70
+ class AccountManager(ActiveObjectManager):
71
+ def get_queryset(self) -> AccountDefaultQueryset:
72
+ return AccountDefaultQueryset(self.model).filter(is_active=True)
73
+
74
+ def filter_for_user(self, user: User, validity_date: date | None = None, strict: bool = False) -> QuerySet:
75
+ return self.get_queryset().filter_for_user(user, validity_date=validity_date, strict=strict)
76
+
77
+
78
+ class OpenAccountObjectManager(AccountManager):
79
+ def get_queryset(self):
80
+ return super().get_queryset().filter(status=Account.Status.OPEN, is_active=True)
81
+
82
+
83
+ @llm([analyze_relationship])
84
+ class Account(ComplexToStringMixin, DeleteToDisableMixin, WBModel, MPTTModel):
85
+ tree_id: int
86
+
87
+ class Status(models.TextChoices):
88
+ PENDING = "PENDING", _("Pending")
89
+ OPEN = "OPEN", _("Open")
90
+ CLOSE = "CLOSE", _("Close")
91
+
92
+ relationship_status = models.PositiveIntegerField(
93
+ validators=[MinValueValidator(1), MaxValueValidator(5)],
94
+ verbose_name=_("Relationship Status"),
95
+ help_text=_("The Relationship Status from 1 to 5. 1 being the cold and 5 being the hot."),
96
+ blank=True,
97
+ null=True,
98
+ )
99
+ relationship_summary = models.TextField(default="", blank=True)
100
+ action_plan = models.TextField(default="", blank=True)
101
+
102
+ reference_id = models.PositiveIntegerField(unique=True, blank=True)
103
+ title = models.CharField(max_length=255, verbose_name="Title")
104
+ status = FSMField(default=Status.OPEN, choices=Status.choices, verbose_name="Status")
105
+ parent = TreeForeignKey(
106
+ "wbcrm.Account",
107
+ related_name="children",
108
+ null=True,
109
+ blank=True,
110
+ on_delete=models.CASCADE,
111
+ verbose_name=_("Parent Account"),
112
+ )
113
+ is_terminal_account = models.BooleanField(
114
+ default=False,
115
+ verbose_name="Terminal Account",
116
+ help_text="If true, sales or revenue can happen in this account",
117
+ )
118
+ is_public = models.BooleanField(
119
+ default=True, verbose_name="Public", help_text="If True, all internal users can access this account"
120
+ )
121
+ owner = models.ForeignKey(
122
+ "directory.Entry",
123
+ related_name="accounts",
124
+ null=True,
125
+ blank=True,
126
+ on_delete=models.PROTECT,
127
+ verbose_name=_("Owner"),
128
+ )
129
+
130
+ @transition(
131
+ status,
132
+ Status.PENDING,
133
+ Status.OPEN,
134
+ permission=lambda account, user: account.can_administrate(user),
135
+ custom={
136
+ "_transition_button": ActionButton(
137
+ method=RequestType.PATCH,
138
+ identifiers=("wbcrm:account",),
139
+ icon=WBIcon.APPROVE.icon,
140
+ key="approve",
141
+ label="Approve",
142
+ action_label="Approve",
143
+ description_fields="<p>Are you sure you want to open this account?</p>",
144
+ )
145
+ },
146
+ )
147
+ def approve(self, **kwargs):
148
+ pass
149
+
150
+ @transition(
151
+ status,
152
+ Status.PENDING,
153
+ Status.CLOSE,
154
+ permission=lambda account, user: account.can_administrate(user),
155
+ custom={
156
+ "_transition_button": ActionButton(
157
+ method=RequestType.PATCH,
158
+ identifiers=("wbcrm:account",),
159
+ icon=WBIcon.DENY.icon,
160
+ key="deny",
161
+ label="Deny",
162
+ action_label="Deny",
163
+ description_fields="<p>Are you sure you want to close this account?</p>",
164
+ )
165
+ },
166
+ )
167
+ def deny(self, **kwargs):
168
+ pass
169
+
170
+ @transition(
171
+ status,
172
+ Status.OPEN,
173
+ Status.CLOSE,
174
+ permission=lambda account, user: account.can_administrate(user),
175
+ custom={
176
+ "_transition_button": ActionButton(
177
+ method=RequestType.PATCH,
178
+ identifiers=("wbcrm:account",),
179
+ icon=WBIcon.LOCK.icon,
180
+ key="close",
181
+ label="Close",
182
+ action_label="Close",
183
+ description_fields="<p>Are you sure you want to close this account?</p>",
184
+ )
185
+ },
186
+ )
187
+ def close(self, **kwargs):
188
+ pass
189
+
190
+ @transition(
191
+ status,
192
+ Status.CLOSE,
193
+ Status.PENDING,
194
+ custom={
195
+ "_transition_button": ActionButton(
196
+ method=RequestType.PATCH,
197
+ identifiers=("wbcrm:account",),
198
+ icon=WBIcon.LOCK.icon,
199
+ key="reopen",
200
+ label="Reopen",
201
+ action_label="Reopen",
202
+ description_fields="<p>Are you sure you want to reopen this account?</p>",
203
+ )
204
+ },
205
+ )
206
+ def reopen(self, **kwargs):
207
+ pass
208
+
209
+ def can_administrate(self, user: User):
210
+ """Every superuser, valid manager and valid pm can lock a"""
211
+ return user.has_perm("wbcrm.administrate_account")
212
+
213
+ def compute_str(self) -> str:
214
+ title = self.title or self.reference_id
215
+ if self.parent:
216
+ title += f" ({self.parent.computed_str})"
217
+ return title
218
+
219
+ def merge(self, merged_account):
220
+ if not merged_account.children.exists() and (
221
+ merged_account.parent == self or merged_account.parent == self.parent
222
+ ): # we can merge only sibling accounts or child to parent
223
+ with transaction.atomic(): # We want this to either succeed fully or fail
224
+ for role in merged_account.roles.all():
225
+ try:
226
+ new_role = AccountRole.objects.get(entry=role.entry, account=self)
227
+ for validity in role.validity_set.all():
228
+ if not AccountRoleValidity.objects.filter(
229
+ role=new_role, timespan__overlap=validity.timespan
230
+ ).exists():
231
+ validity.role = new_role
232
+ validity.save()
233
+ for user in role.authorized_hidden_users.all():
234
+ new_role.authorized_hidden_users.add(user)
235
+ role.delete()
236
+ except AccountRole.DoesNotExist:
237
+ role.account = self
238
+ role.save()
239
+
240
+ # Get the base
241
+ pre_merge.send(
242
+ sender=Account, merged_object=merged_account, main_object=self
243
+ ) # default signal dispatch for the Account class
244
+ # We delete finally the merged account. All unlikage should have been done in the signal receivers function ( we refresh to be sure that no receiver modified the given object )
245
+ self.refresh_from_db()
246
+ merged_account.refresh_from_db()
247
+ merged_account.delete(no_deletion=False)
248
+
249
+ # copy fields
250
+
251
+ # trigger save for post save logic (if any)
252
+ self.save()
253
+
254
+ def save(self, *args, **kwargs):
255
+ if self.parent and not self.owner:
256
+ self.owner = self.parent.owner
257
+ if not self.reference_id:
258
+ self.reference_id = Account.get_next_available_reference_id()
259
+ # if not Account.objects.filter(parent=self).exists():
260
+ # self.is_terminal_account = True
261
+ # else:
262
+ # self.is_terminal_account = False
263
+ self.is_terminal_account = self.is_leaf_node()
264
+ super().save(*args, **kwargs)
265
+ # self.children.update() TODO recompute str for all children
266
+ # Account.objects.filter(id=self.id).update(computed_str=self.compute_str())
267
+
268
+ def get_inherited_roles_for_account(self, include_self: bool = False) -> QuerySet:
269
+ """
270
+ Return account role from the parent accounts
271
+
272
+ Args:
273
+ include_self: MPTT argument
274
+
275
+ Returns:
276
+ The parent account roles
277
+ """
278
+ return AccountRole.objects.filter(account__in=self.get_ancestors(include_self=include_self))
279
+
280
+ def can_see_account(self, user: User, validity_date: date | None = None) -> bool:
281
+ """
282
+ Checks if the user can see the account based on their permissions and roles.
283
+
284
+ Args:
285
+ user (User): The user for whom account visibility needs to be checked.
286
+ validity_date (date | None, optional): The validity date for role filtering. Defaults to None.
287
+
288
+ Returns:
289
+ bool: True if the user can see the account, False otherwise.
290
+ """
291
+ if not validity_date:
292
+ validity_date = date.today()
293
+ return Account.objects.filter(id=self.id).filter_for_user(user, validity_date=validity_date).exists()
294
+
295
+ @classmethod
296
+ def get_next_available_reference_id(cls) -> int:
297
+ if Account.objects.exists():
298
+ reference_id = Account.all_objects.latest("reference_id").reference_id + 1
299
+ else:
300
+ reference_id = 1
301
+ return reference_id
302
+
303
+ @classmethod
304
+ def annotate_root_account_info(cls, queryset: QuerySet) -> QuerySet:
305
+ """
306
+ Utility classmethod to annotate a queryset for the root account and its owner
307
+
308
+ Args:
309
+ queryset: Queryset to annotate
310
+
311
+ Returns:
312
+ A annotated queryset
313
+ """
314
+ return queryset.annotate(
315
+ root_account=Subquery(
316
+ Account.all_objects.filter(tree_id=OuterRef("account__tree_id"), level=0).values("id")[:1]
317
+ ),
318
+ root_account_repr=Subquery(
319
+ Account.all_objects.filter(tree_id=OuterRef("account__tree_id"), level=0).values("computed_str")[:1]
320
+ ),
321
+ root_account_owner=Subquery(
322
+ Account.all_objects.filter(tree_id=OuterRef("account__tree_id"), level=0).values("owner")[:1]
323
+ ),
324
+ root_account_owner_repr=Subquery(
325
+ Account.all_objects.filter(tree_id=OuterRef("account__tree_id"), level=0).values(
326
+ "owner__computed_str"
327
+ )[:1]
328
+ ),
329
+ )
330
+
331
+ class Meta:
332
+ verbose_name = _("Account")
333
+ verbose_name_plural = _("Accounts")
334
+ permissions = [("administrate_account", "Administrate Account")]
335
+
336
+ objects = AccountManager()
337
+ open_objects = OpenAccountObjectManager()
338
+ tree_objects = TreeManager()
339
+
340
+ @classmethod
341
+ def get_endpoint_basename(cls) -> str:
342
+ return "wbcrm:account"
343
+
344
+ @classmethod
345
+ def get_representation_endpoint(cls) -> str:
346
+ return "wbcrm:accountrepresentation-list"
347
+
348
+ @classmethod
349
+ def get_representation_value_key(cls) -> str:
350
+ return "id"
351
+
352
+ @classmethod
353
+ def get_accounts_for_customer(cls, entries: Entry | Iterable[Entry]) -> QuerySet:
354
+ """
355
+ Retrieves accounts associated with the given entry's ownership.
356
+
357
+ Args:
358
+ entries (Entry): The entry for which owned accounts are to be retrieved. Can be an iterable or an entry.
359
+
360
+ Returns:
361
+ QuerySet: A queryset of accounts owned by the provided entry.
362
+ """
363
+ if not isinstance(entries, Iterable):
364
+ entries = [entries]
365
+
366
+ # Get all root accounts owned by the entry
367
+ root_accounts = cls.objects.filter(
368
+ Q(owner__in=entries)
369
+ | Q(
370
+ owner__in=EmployerEmployeeRelationship.objects.filter(employee__id__in=[o.id for o in entries]).values(
371
+ "employer"
372
+ )
373
+ )
374
+ )
375
+
376
+ # Get root account and descendants account ids
377
+ return cls.objects.annotate(
378
+ is_direct_owner=Exists(root_accounts.filter(id=OuterRef("id"))),
379
+ is_owner_of_descending_account=Exists(
380
+ root_accounts.filter(tree_id=OuterRef("tree_id"), lft__lte=OuterRef("lft"), rght__gte=OuterRef("rght"))
381
+ ),
382
+ ).filter(Q(is_owner_of_descending_account=True) | Q(is_direct_owner=True))
383
+
384
+ @classmethod
385
+ def get_managed_accounts_for_entry(cls, entry: Entry) -> QuerySet:
386
+ """
387
+ Retrieves managed accounts associated with the given entry.
388
+
389
+ Args:
390
+ entry (Entry): The entry for which managed accounts are to be retrieved.
391
+
392
+ Returns:
393
+ QuerySet: A queryset of managed accounts associated with the provided entry.
394
+ """
395
+ roles = AccountRole.objects.filter(entry=entry)
396
+
397
+ return cls.objects.annotate(
398
+ has_direct_role=Exists(roles.filter(account=OuterRef("id"))),
399
+ has_descending_roles=Exists(
400
+ roles.filter(
401
+ account__tree_id=OuterRef("tree_id"),
402
+ account__lft__lte=OuterRef("lft"),
403
+ account__rght__gte=OuterRef("rght"),
404
+ )
405
+ ),
406
+ ).filter((Q(is_public=True) & Q(has_descending_roles=True)) | Q(has_direct_role=True))
407
+
408
+
409
+ class AccountRoleType(models.Model):
410
+ title = models.CharField(max_length=126, verbose_name="Title")
411
+ key = models.CharField(max_length=126, unique=True)
412
+
413
+ def __str__(self):
414
+ return self.title
415
+
416
+ def save(self, *args, **kwargs):
417
+ if not self.key:
418
+ self.key = slugify(self.title)
419
+ super().save(*args, **kwargs)
420
+
421
+ @classmethod
422
+ def get_representation_label_key(cls) -> str:
423
+ return "{{title}}"
424
+
425
+ @classmethod
426
+ def get_representation_endpoint(cls) -> str:
427
+ return "wbcrm:accountroletyperepresentation-list"
428
+
429
+ @classmethod
430
+ def get_representation_value_key(cls) -> str:
431
+ return "id"
432
+
433
+
434
+ class AccountRoleDefaultQueryset(QuerySet):
435
+ def filter_for_user(self, user: User, validity_date: date | None = None, strict: bool = False) -> QuerySet:
436
+ """
437
+ Filters account roles related to the user based on permissions and roles.
438
+
439
+ Args:
440
+ user (User): The user for whom account roles need to be filtered.
441
+ validity_date (date | None, optional): The validity date for role filtering. Defaults to None.
442
+ strict (bool, optional): If True, filtering will be strict based on roles; otherwise, relaxed. Defaults to False.
443
+
444
+ Returns:
445
+ QuerySet: A queryset of account roles related to the user, filtered based on permissions and roles.
446
+ """
447
+ if not validity_date:
448
+ validity_date = date.today()
449
+ qs = self.annotate(is_currently_valid=AccountRoleValidity.get_role_validity_subquery(validity_date))
450
+ if user.has_perm("wbcrm.administrate_account"):
451
+ return qs
452
+ if user.profile.is_internal and not strict:
453
+ qs = qs.filter(
454
+ Q(entry_id=user.profile.id)
455
+ | (Q(account__is_public=True) & Q(is_hidden=False))
456
+ | (Q(is_hidden=True) & Q(authorized_hidden_users=user))
457
+ )
458
+ else:
459
+ qs = qs.filter(entry_id=user.profile.id)
460
+ return qs
461
+
462
+
463
+ class AccountRoleManager(models.Manager):
464
+ # Necessary because otherwise pyright cannot find method
465
+
466
+ def get_queryset(self) -> AccountRoleDefaultQueryset:
467
+ return AccountRoleDefaultQueryset(self.model)
468
+
469
+ def filter_for_user(self, user: User, validity_date: date | None = None, strict: bool = False) -> QuerySet:
470
+ return self.get_queryset().filter_for_user(user, validity_date=validity_date, strict=strict)
471
+
472
+
473
+ class AccountRole(ComplexToStringMixin, models.Model):
474
+ """Model for Account Roles"""
475
+
476
+ class Meta:
477
+ verbose_name = "Account Role"
478
+ verbose_name_plural = "Account Roles"
479
+ constraints = [UniqueConstraint(fields=["account", "entry"], name="unique_account_entry_relationship")]
480
+
481
+ role_type = models.ForeignKey(
482
+ "wbcrm.AccountRoleType", related_name="roles", on_delete=models.PROTECT, verbose_name="Role Type"
483
+ )
484
+ entry = models.ForeignKey(
485
+ "directory.Entry", related_name="account_roles", on_delete=models.PROTECT, verbose_name="Entry"
486
+ )
487
+ account = models.ForeignKey(
488
+ "wbcrm.Account", related_name="roles", on_delete=models.CASCADE, verbose_name="Account"
489
+ )
490
+
491
+ is_hidden = models.BooleanField(
492
+ default=False,
493
+ verbose_name="Hidden",
494
+ help_text="If True, this role is hidden and can be seen only by authorized people",
495
+ )
496
+ authorized_hidden_users = models.ManyToManyField(
497
+ "authentication.User",
498
+ related_name="authorized_hidden_roles",
499
+ blank=True,
500
+ verbose_name=_("authorized Hidden Users"),
501
+ help_text=_("List of users that are allowed to see this hidden account role"),
502
+ )
503
+
504
+ weighting = models.FloatField(default=1, verbose_name="Weight")
505
+
506
+ objects = AccountRoleManager()
507
+
508
+ def compute_str(self) -> str:
509
+ rel = f"Role {self.role_type} for {self.entry} on {self.account}"
510
+ if self.is_hidden:
511
+ rel += " (Hidden)"
512
+ return rel
513
+
514
+ def save(self, *args, **kwargs):
515
+ # if the role is hidden and the account is public, we ensure it becomes private so that the hidden rule is respected
516
+ if self.is_hidden and self.account.is_public:
517
+ self.account.is_public = False
518
+ self.account.save()
519
+
520
+ super().save(*args, **kwargs)
521
+
522
+ def deactivate(self, deactivation_date: date | None = None):
523
+ """
524
+ Utility function to disable a account role at a given time
525
+
526
+ Args:
527
+ deactivation_date: The time at which the role will be deactivated. Default to today
528
+ """
529
+ if not deactivation_date:
530
+ deactivation_date = date.today()
531
+ with suppress(AccountRoleValidity.DoesNotExist):
532
+ val = AccountRoleValidity.objects.get(
533
+ role=self,
534
+ timespan__startswith__lte=deactivation_date,
535
+ timespan__endswith__gt=deactivation_date,
536
+ )
537
+ val.timespan = DateRange(val.timespan.lower, deactivation_date)
538
+ val.save()
539
+
540
+
541
+ class AccountRoleValidity(models.Model):
542
+ role = models.ForeignKey(
543
+ "wbcrm.AccountRole", related_name="validity_set", on_delete=models.CASCADE, verbose_name="Account Role"
544
+ )
545
+ timespan = DateRangeField(verbose_name="Timespan")
546
+
547
+ class Meta:
548
+ constraints = [
549
+ ExclusionConstraint(
550
+ name="exclude_overlapping_roles",
551
+ expressions=[
552
+ ("timespan", RangeOperators.OVERLAPS),
553
+ ("role", RangeOperators.EQUAL),
554
+ ],
555
+ ),
556
+ ]
557
+
558
+ def __str__(self):
559
+ return f"[{self.timespan.lower} - {self.timespan.upper}[" # type: ignore
560
+
561
+ @classmethod
562
+ def get_role_validity_subquery(cls, validity_date: date, role_label_key: str = "pk") -> Subquery:
563
+ """
564
+ Return a subquery that will define wether a account role is valid
565
+ Args:
566
+ validity_date: The validity date
567
+ role_label_key: The related name for the account role foreign key
568
+
569
+ Returns:
570
+ A subquery expression of type boolean
571
+ """
572
+ return Exists(
573
+ AccountRoleValidity.objects.filter(
574
+ role=OuterRef(role_label_key),
575
+ timespan__startswith__lte=validity_date,
576
+ timespan__endswith__gt=validity_date,
577
+ )
578
+ )
579
+
580
+
581
+ @receiver(deactivate_profile)
582
+ def handle_user_deactivation(sender, instance, substitute_profile=None, **kwargs):
583
+ deactivation_date = date.today() - timedelta(days=1)
584
+ for profile_role in AccountRole.objects.filter(entry_id=instance.id):
585
+ for validity in profile_role.validity_set.all():
586
+ if validity.timespan.upper >= deactivation_date: # type: ignore
587
+ validity.timespan = DateRange(
588
+ validity.timespan.lower, max([deactivation_date, validity.timespan.lower]) # type: ignore
589
+ )
590
+ validity.save()
591
+ if substitute_profile and validity.timespan.lower <= deactivation_date: # type: ignore
592
+ substitute_role, created = AccountRole.objects.get_or_create(
593
+ account=profile_role.account,
594
+ entry_id=substitute_profile.id,
595
+ defaults={"role_type": profile_role.role_type},
596
+ )
597
+ if created:
598
+ v = substitute_role.validity_set.filter(
599
+ timespan__startswith__lt=deactivation_date,
600
+ timespan__endswith__gt=deactivation_date,
601
+ ).first()
602
+ v.timespan = DateRange(deactivation_date, date.max) # type: ignore
603
+ v.save()
604
+
605
+
606
+ @receiver(post_save, sender="wbcrm.Account")
607
+ def post_account_creation(sender, instance, created, **kwargs):
608
+ # disabling parent account disable children as well
609
+ if not instance.is_active:
610
+ instance.get_descendants().update(is_active=False)
611
+ # check that if an account is private, all its children are private as well
612
+ if not instance.is_public:
613
+ instance.get_descendants().update(is_public=False)
614
+ # if an new account is created and it's a leaf node, we assume it's a terminal account. Can be changed afterwards
615
+ if created:
616
+ # we create a role for the owner by default upon creation
617
+ if instance.owner:
618
+ owner_role_type = AccountRoleType.objects.get_or_create(key="customer", defaults={"title": "Customer"})[0]
619
+ AccountRole.objects.get_or_create(
620
+ account=instance, entry=instance.owner, defaults={"role_type": owner_role_type}
621
+ )
622
+ if instance.is_terminal_account and instance.parent:
623
+ instance.get_ancestors().update(is_terminal_account=False)
624
+
625
+
626
+ @receiver(post_delete, sender="wbcrm.Account")
627
+ def post_delete_account(sender, instance, **kwargs):
628
+ if (parent := instance.parent) and not parent.children.exists():
629
+ parent.is_terminal_account = True
630
+ parent.save()
631
+
632
+
633
+ @receiver(post_save, sender="wbcrm.AccountRole")
634
+ def post_account_role_creation(sender, instance, created, **kwargs):
635
+ # if an new account is created and it's a leaf node, we assume it's a terminal account. Can be changed afterwards
636
+ if created:
637
+ AccountRoleValidity.objects.create(role=instance, timespan=DateRange(date.min, date.max)) # type: ignore