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