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