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.
- wbcrm/__init__.py +1 -0
- wbcrm/admin/__init__.py +4 -0
- wbcrm/admin/accounts.py +59 -0
- wbcrm/admin/activities.py +101 -0
- wbcrm/admin/groups.py +7 -0
- wbcrm/admin/products.py +8 -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 +56 -0
- wbcrm/factories/activities.py +125 -0
- wbcrm/factories/groups.py +23 -0
- wbcrm/factories/products.py +10 -0
- wbcrm/filters/__init__.py +10 -0
- wbcrm/filters/accounts.py +67 -0
- wbcrm/filters/activities.py +181 -0
- wbcrm/filters/groups.py +20 -0
- wbcrm/filters/products.py +37 -0
- wbcrm/filters/signals.py +94 -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/__init__.py +0 -0
- wbcrm/models/__init__.py +4 -0
- wbcrm/models/accounts.py +637 -0
- wbcrm/models/activities.py +1335 -0
- wbcrm/models/groups.py +118 -0
- wbcrm/models/products.py +83 -0
- wbcrm/models/recurrence.py +279 -0
- wbcrm/preferences.py +14 -0
- wbcrm/serializers/__init__.py +23 -0
- wbcrm/serializers/accounts.py +126 -0
- wbcrm/serializers/activities.py +526 -0
- wbcrm/serializers/groups.py +30 -0
- wbcrm/serializers/products.py +57 -0
- wbcrm/serializers/recurrence.py +90 -0
- wbcrm/serializers/signals.py +70 -0
- wbcrm/synchronization/__init__.py +0 -0
- wbcrm/synchronization/activity/__init__.py +0 -0
- wbcrm/synchronization/activity/admin.py +72 -0
- wbcrm/synchronization/activity/backend.py +207 -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 +399 -0
- wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -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 +216 -0
- wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
- wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
- wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
- wbcrm/synchronization/activity/backends/outlook/parser.py +423 -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 +117 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
- wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
- wbcrm/synchronization/activity/controller.py +545 -0
- wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
- wbcrm/synchronization/activity/preferences.py +21 -0
- wbcrm/synchronization/activity/shortcuts.py +9 -0
- wbcrm/synchronization/activity/signals.py +28 -0
- wbcrm/synchronization/activity/tasks.py +21 -0
- wbcrm/synchronization/activity/urls.py +6 -0
- wbcrm/synchronization/activity/utils.py +46 -0
- wbcrm/synchronization/activity/views.py +37 -0
- wbcrm/synchronization/admin.py +1 -0
- wbcrm/synchronization/apps.py +15 -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 +312 -0
- wbcrm/tests/__init__.py +0 -0
- wbcrm/tests/accounts/__init__.py +0 -0
- wbcrm/tests/accounts/test_models.py +380 -0
- wbcrm/tests/accounts/test_viewsets.py +87 -0
- wbcrm/tests/conftest.py +76 -0
- wbcrm/tests/disable_signals.py +52 -0
- wbcrm/tests/e2e/__init__.py +1 -0
- wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
- wbcrm/tests/e2e/test_e2e.py +369 -0
- wbcrm/tests/test_assignee_methods.py +39 -0
- wbcrm/tests/test_chartviewsets.py +111 -0
- wbcrm/tests/test_dto.py +63 -0
- wbcrm/tests/test_filters.py +51 -0
- wbcrm/tests/test_models.py +216 -0
- wbcrm/tests/test_recurrence.py +291 -0
- wbcrm/tests/test_report.py +20 -0
- wbcrm/tests/test_serializers.py +170 -0
- wbcrm/tests/test_tasks.py +94 -0
- wbcrm/tests/test_viewsets.py +967 -0
- wbcrm/tests/tests.py +120 -0
- wbcrm/typings.py +107 -0
- wbcrm/urls.py +67 -0
- wbcrm/viewsets/__init__.py +22 -0
- wbcrm/viewsets/accounts.py +121 -0
- wbcrm/viewsets/activities.py +315 -0
- wbcrm/viewsets/buttons/__init__.py +7 -0
- wbcrm/viewsets/buttons/accounts.py +27 -0
- wbcrm/viewsets/buttons/activities.py +68 -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 +443 -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 +32 -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 +37 -0
- wbcrm/viewsets/menu/__init__.py +8 -0
- wbcrm/viewsets/menu/accounts.py +18 -0
- wbcrm/viewsets/menu/activities.py +61 -0
- wbcrm/viewsets/menu/groups.py +16 -0
- wbcrm/viewsets/menu/products.py +20 -0
- wbcrm/viewsets/mixins.py +34 -0
- wbcrm/viewsets/previews/__init__.py +1 -0
- wbcrm/viewsets/previews/activities.py +10 -0
- wbcrm/viewsets/products.py +56 -0
- wbcrm/viewsets/recurrence.py +26 -0
- wbcrm/viewsets/titles/__init__.py +13 -0
- wbcrm/viewsets/titles/accounts.py +22 -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-2.2.1.dist-info/METADATA +11 -0
- wbcrm-2.2.1.dist-info/RECORD +155 -0
- wbcrm-2.2.1.dist-info/WHEEL +5 -0
wbcrm/models/groups.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from celery import shared_task
|
|
2
|
+
from django.db import models
|
|
3
|
+
from django.db.models.signals import m2m_changed, pre_delete
|
|
4
|
+
from django.dispatch import receiver
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
from wbcore.contrib.agenda.models import CalendarItem
|
|
8
|
+
from wbcore.contrib.directory.models import Company, Entry, Person
|
|
9
|
+
from wbcore.models import WBModel
|
|
10
|
+
from wbcrm.models import Activity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Group(WBModel):
|
|
14
|
+
title = models.CharField(max_length=255, unique=True, verbose_name=_("Title"))
|
|
15
|
+
members = models.ManyToManyField("directory.Entry", related_name="groups", verbose_name=_("Members"))
|
|
16
|
+
|
|
17
|
+
class Meta:
|
|
18
|
+
verbose_name = _("Group")
|
|
19
|
+
verbose_name_plural = _("Groups")
|
|
20
|
+
|
|
21
|
+
def __str__(self):
|
|
22
|
+
return self.title
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def get_endpoint_basename(cls):
|
|
26
|
+
return "wbcrm:group"
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_representation_endpoint(cls):
|
|
30
|
+
return "wbcrm:grouprepresentation-list"
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def get_representation_value_key(cls):
|
|
34
|
+
return "id"
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def get_representation_label_key(cls):
|
|
38
|
+
return "{{title}}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@receiver(m2m_changed, sender=Group.members.through)
|
|
42
|
+
def m2m_changed_members(sender, instance, action, pk_set, **kwargs):
|
|
43
|
+
"""
|
|
44
|
+
M2m changed Group signal: Change participants, companies and entities of future planned activities/calendar items
|
|
45
|
+
if a group's members get updated
|
|
46
|
+
"""
|
|
47
|
+
if action == "post_add":
|
|
48
|
+
add_changed_group_members(instance, pk_set)
|
|
49
|
+
|
|
50
|
+
if action == "post_remove":
|
|
51
|
+
remove_changed_group_members(instance, pk_set)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@receiver(pre_delete, sender=Group)
|
|
55
|
+
def pre_delete_group(sender, instance, **kwargs):
|
|
56
|
+
"""
|
|
57
|
+
Post delete Group signal: Remove members from future planned activities/calendar items if a group was deleted
|
|
58
|
+
"""
|
|
59
|
+
remove_deleted_groups_members(instance)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@shared_task
|
|
63
|
+
def add_changed_group_members(instance, pk_set):
|
|
64
|
+
for activity in Activity.objects.filter(
|
|
65
|
+
status=Activity.Status.PLANNED, start__gte=timezone.now(), groups=instance
|
|
66
|
+
):
|
|
67
|
+
item = CalendarItem.objects.get(id=activity.id)
|
|
68
|
+
for member in pk_set:
|
|
69
|
+
entry = Entry.objects.get(id=member)
|
|
70
|
+
if entry not in item.entities.all():
|
|
71
|
+
item.entities.add(entry)
|
|
72
|
+
if entry.is_company:
|
|
73
|
+
company = Company.objects.get(id=entry.id)
|
|
74
|
+
if company not in activity.companies.all():
|
|
75
|
+
activity.companies.add(company)
|
|
76
|
+
else:
|
|
77
|
+
person = Person.objects.get(id=entry.id)
|
|
78
|
+
if person not in activity.participants.all():
|
|
79
|
+
activity.participants.add(person)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@shared_task
|
|
83
|
+
def remove_changed_group_members(instance, pk_set):
|
|
84
|
+
for activity in Activity.objects.filter(
|
|
85
|
+
status=Activity.Status.PLANNED, start__gte=timezone.now(), groups=instance
|
|
86
|
+
):
|
|
87
|
+
item = CalendarItem.objects.get(id=activity.id)
|
|
88
|
+
for member in pk_set:
|
|
89
|
+
entry = Entry.objects.get(id=member)
|
|
90
|
+
if entry in item.entities.all():
|
|
91
|
+
item.entities.remove(entry)
|
|
92
|
+
if entry.is_company:
|
|
93
|
+
company = Company.objects.get(id=entry.id)
|
|
94
|
+
if company in activity.companies.all():
|
|
95
|
+
activity.companies.remove(company)
|
|
96
|
+
else:
|
|
97
|
+
person = Person.objects.get(id=entry.id)
|
|
98
|
+
if person in activity.participants.all():
|
|
99
|
+
activity.participants.remove(person)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@shared_task
|
|
103
|
+
def remove_deleted_groups_members(instance):
|
|
104
|
+
for activity in Activity.objects.filter(
|
|
105
|
+
status=Activity.Status.PLANNED, start__gte=timezone.now(), groups=instance
|
|
106
|
+
):
|
|
107
|
+
item = CalendarItem.objects.get(id=activity.id)
|
|
108
|
+
for member in instance.members.all():
|
|
109
|
+
if member in item.entities.all():
|
|
110
|
+
item.entities.remove(member)
|
|
111
|
+
if member.is_company:
|
|
112
|
+
company = Company.objects.get(id=member.id)
|
|
113
|
+
if company in activity.companies.all():
|
|
114
|
+
activity.companies.remove(company)
|
|
115
|
+
else:
|
|
116
|
+
person = Person.objects.get(id=member.id)
|
|
117
|
+
if person in activity.participants.all():
|
|
118
|
+
activity.participants.remove(person)
|
wbcrm/models/products.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
|
+
from slugify import slugify
|
|
4
|
+
from wbcore.models import WBModel
|
|
5
|
+
from wbcore.utils.models import ComplexToStringMixin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProductCompanyRelationship(models.Model):
|
|
9
|
+
product = models.ForeignKey(
|
|
10
|
+
on_delete=models.CASCADE,
|
|
11
|
+
to="wbcrm.Product",
|
|
12
|
+
verbose_name=_("Product"),
|
|
13
|
+
related_name="product_company_relationships",
|
|
14
|
+
)
|
|
15
|
+
company = models.ForeignKey(
|
|
16
|
+
on_delete=models.CASCADE,
|
|
17
|
+
to="directory.Company",
|
|
18
|
+
verbose_name=_("Company"),
|
|
19
|
+
related_name="product_company_relationships",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
constraints = [
|
|
24
|
+
models.UniqueConstraint(name="unique_company_product_relationship", fields=["product", "company"])
|
|
25
|
+
]
|
|
26
|
+
verbose_name = _("Company-Product Relationship")
|
|
27
|
+
verbose_name_plural = _("Company-Product Relationships")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Product(ComplexToStringMixin, WBModel):
|
|
31
|
+
title = models.CharField(
|
|
32
|
+
max_length=128,
|
|
33
|
+
verbose_name=_("Title"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
slugify_title = models.CharField(
|
|
37
|
+
max_length=128,
|
|
38
|
+
verbose_name="Slugified Title",
|
|
39
|
+
blank=True,
|
|
40
|
+
null=True,
|
|
41
|
+
)
|
|
42
|
+
is_competitor = models.BooleanField(
|
|
43
|
+
verbose_name=_("Is Competitor"),
|
|
44
|
+
default=False,
|
|
45
|
+
help_text=_("Indicates wether this is a competitor's product"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
prospects = models.ManyToManyField(
|
|
49
|
+
"directory.Company",
|
|
50
|
+
related_name="interested_products",
|
|
51
|
+
blank=True,
|
|
52
|
+
verbose_name=_("Prospects"),
|
|
53
|
+
help_text=_("The list of prospects"),
|
|
54
|
+
through="wbcrm.ProductCompanyRelationship",
|
|
55
|
+
through_fields=("product", "company"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
return self.title
|
|
60
|
+
|
|
61
|
+
def compute_str(self) -> str:
|
|
62
|
+
return _("{} (Competitor)").format(self.title) if self.is_competitor else self.title
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def get_endpoint_basename(cls):
|
|
66
|
+
return "wbcrm:product"
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def get_representation_value_key(cls):
|
|
70
|
+
return "id"
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def get_representation_endpoint(cls):
|
|
74
|
+
return "wbcrm:productrepresentation-list"
|
|
75
|
+
|
|
76
|
+
class Meta:
|
|
77
|
+
verbose_name = _("Product")
|
|
78
|
+
verbose_name_plural = _("Products")
|
|
79
|
+
unique_together = [["slugify_title", "is_competitor"]]
|
|
80
|
+
|
|
81
|
+
def save(self, *args, **kwargs):
|
|
82
|
+
self.slugify_title = slugify(self.title, separator=" ")
|
|
83
|
+
super().save(*args, **kwargs)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import pytz
|
|
4
|
+
from dateutil import rrule
|
|
5
|
+
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django.db.models import Q
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
from psycopg.types.range import TimestamptzRange
|
|
10
|
+
from wbcore.contrib.agenda.models import CalendarItem
|
|
11
|
+
from wbcore.utils.rrules import convert_rrulestr_to_dict
|
|
12
|
+
from wbcrm.preferences import (
|
|
13
|
+
get_maximum_allowed_recurrent_date,
|
|
14
|
+
get_recurrence_maximum_count,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Recurrence(CalendarItem):
|
|
19
|
+
class ReoccuranceChoice(models.TextChoices):
|
|
20
|
+
NEVER = "NEVER", _("Never")
|
|
21
|
+
BUSINESS_DAILY = "RRULE:FREQ=DAILY;INTERVAL=1;WKST=MO;BYDAY=MO,TU,WE,TH,FR", _("Business Daily")
|
|
22
|
+
DAILY = "RRULE:FREQ=DAILY", _("Daily")
|
|
23
|
+
WEEKLY = "RRULE:FREQ=WEEKLY", _("Weekly")
|
|
24
|
+
BIWEEKLY = "RRULE:FREQ=WEEKLY;INTERVAL=2", _("Bi-Weekly")
|
|
25
|
+
MONTHLY = "RRULE:FREQ=MONTHLY", _("Monthly")
|
|
26
|
+
QUARTERLY = "RRULE:FREQ=MONTHLY;INTERVAL=3", _("Quarterly")
|
|
27
|
+
YEARLY = "RRULE:FREQ=YEARLY", _("Annually")
|
|
28
|
+
|
|
29
|
+
parent_occurrence = models.ForeignKey(
|
|
30
|
+
to="self",
|
|
31
|
+
related_name="child_activities",
|
|
32
|
+
null=True,
|
|
33
|
+
blank=True,
|
|
34
|
+
verbose_name=_("Parent Activity"),
|
|
35
|
+
on_delete=models.deletion.DO_NOTHING,
|
|
36
|
+
)
|
|
37
|
+
propagate_for_all_children = models.BooleanField(
|
|
38
|
+
default=False,
|
|
39
|
+
verbose_name=_("Propagate for all following activities?"),
|
|
40
|
+
help_text=_("If this is checked, changes will be propagated to the following activities."),
|
|
41
|
+
)
|
|
42
|
+
exclude_from_propagation = models.BooleanField(
|
|
43
|
+
default=False,
|
|
44
|
+
verbose_name=_("Exclude occurrence from propagation?"),
|
|
45
|
+
help_text=_("If this is checked, changes will not be propagated on this activity."),
|
|
46
|
+
)
|
|
47
|
+
recurrence_end = models.DateField(
|
|
48
|
+
verbose_name=_("Date"),
|
|
49
|
+
null=True,
|
|
50
|
+
blank=True,
|
|
51
|
+
help_text=_(
|
|
52
|
+
"Specifies until when an event is to be repeated. Is mutually exclusive with the Recurrence Count."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
recurrence_count = models.IntegerField(
|
|
56
|
+
validators=[MinValueValidator(1), MaxValueValidator(365)],
|
|
57
|
+
null=True,
|
|
58
|
+
blank=True,
|
|
59
|
+
verbose_name=_("Count"),
|
|
60
|
+
help_text=_(
|
|
61
|
+
"Specifies how often an activity should be repeated excluding the original activity. Is mutually exclusive with the end date. Limited to a maximum of 365 recurrences."
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
repeat_choice = models.CharField(
|
|
65
|
+
max_length=56,
|
|
66
|
+
default=ReoccuranceChoice.NEVER,
|
|
67
|
+
choices=ReoccuranceChoice.choices,
|
|
68
|
+
verbose_name=_("Recurrence Frequency"),
|
|
69
|
+
help_text=_("Repeat activity at the specified frequency"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
class Meta:
|
|
73
|
+
abstract = True
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_recurrent(self):
|
|
77
|
+
return self.repeat_choice != Recurrence.ReoccuranceChoice.NEVER
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_root(self):
|
|
81
|
+
return self.is_recurrent and not self.parent_occurrence
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_leaf(self):
|
|
85
|
+
return self.is_recurrent and not self.next_occurrence
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def next_occurrence(self):
|
|
89
|
+
if self.is_recurrent:
|
|
90
|
+
parent_occurrence = self if self.is_root else self.parent_occurrence
|
|
91
|
+
if qs := parent_occurrence.child_activities.filter(
|
|
92
|
+
is_active=True, period__startswith__gt=self.period.lower
|
|
93
|
+
):
|
|
94
|
+
return qs.earliest("period__startswith")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def previous_occurrence(self):
|
|
98
|
+
if self.is_recurrent and not self.is_root:
|
|
99
|
+
if qs := self.parent_occurrence.child_activities.filter(
|
|
100
|
+
is_active=True, period__startswith__lt=self.period.lower
|
|
101
|
+
):
|
|
102
|
+
return qs.latest("period__startswith")
|
|
103
|
+
else:
|
|
104
|
+
return self.parent_occurrence
|
|
105
|
+
# Else, it is a pivot and therefore, no previous occurrence should exist
|
|
106
|
+
|
|
107
|
+
def save(self, *args, **kwargs):
|
|
108
|
+
if not self.recurrence_end:
|
|
109
|
+
self.recurrence_end = get_maximum_allowed_recurrent_date()
|
|
110
|
+
if self.does_recurrence_need_cancellation():
|
|
111
|
+
self.cancel_recurrence()
|
|
112
|
+
super().save(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
def delete(self, **kwargs):
|
|
115
|
+
if self.propagate_for_all_children:
|
|
116
|
+
self.forward_deletion()
|
|
117
|
+
elif self.is_root and (next_occurrence := self.next_occurrence):
|
|
118
|
+
next_occurrence.claim_parent_hood()
|
|
119
|
+
super().delete(**kwargs)
|
|
120
|
+
|
|
121
|
+
def _get_occurrence_start_datetimes(self, include_self: bool = False) -> list:
|
|
122
|
+
"""
|
|
123
|
+
Returns a list with datetime values based on the recurrence options of an activity.
|
|
124
|
+
|
|
125
|
+
:return: list with datetime values
|
|
126
|
+
"""
|
|
127
|
+
if self.is_recurrent:
|
|
128
|
+
max_allowed_date = get_maximum_allowed_recurrent_date()
|
|
129
|
+
max_allowed_count = get_recurrence_maximum_count()
|
|
130
|
+
occurrence_count = min(
|
|
131
|
+
self.recurrence_count + 1 if self.recurrence_count else max_allowed_count, max_allowed_count
|
|
132
|
+
)
|
|
133
|
+
end_date = min(
|
|
134
|
+
self.recurrence_end + timedelta(days=1) if self.recurrence_end else max_allowed_date, max_allowed_date
|
|
135
|
+
)
|
|
136
|
+
end_date_time = datetime(end_date.year, end_date.month, end_date.day).astimezone(pytz.utc)
|
|
137
|
+
rule_dict = convert_rrulestr_to_dict(
|
|
138
|
+
self.repeat_choice, dtstart=self.period.lower, count=occurrence_count, until=end_date_time
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
start_datetimes = list(rrule.rrule(**rule_dict))
|
|
142
|
+
if not include_self:
|
|
143
|
+
start_date = self.period.lower.replace(microsecond=0)
|
|
144
|
+
if self.period.lower in start_datetimes:
|
|
145
|
+
start_datetimes.remove(self.period.lower)
|
|
146
|
+
elif start_date in start_datetimes:
|
|
147
|
+
start_datetimes.remove(start_date)
|
|
148
|
+
return start_datetimes
|
|
149
|
+
|
|
150
|
+
def _create_recurrence_child(self, *args):
|
|
151
|
+
"""
|
|
152
|
+
Return a new child object based on self (parent/root)
|
|
153
|
+
"""
|
|
154
|
+
raise NotImplementedError()
|
|
155
|
+
|
|
156
|
+
def get_recurrent_valid_children(self):
|
|
157
|
+
"""
|
|
158
|
+
Return a valid queryset representing the recurrent children
|
|
159
|
+
"""
|
|
160
|
+
return self.child_activities.filter(exclude_from_propagation=False).order_by("period__startswith")
|
|
161
|
+
|
|
162
|
+
def get_recurrent_invalid_children(self):
|
|
163
|
+
valid = self.get_recurrent_valid_children().values_list("id", flat=True)
|
|
164
|
+
return self.child_activities.all().exclude(id__in=valid).order_by("period__startswith")
|
|
165
|
+
|
|
166
|
+
def _handle_recurrence_m2m_forwarding(self, child):
|
|
167
|
+
"""
|
|
168
|
+
Call this when m2m data needs to be forward from the parent (self) to the passed child
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
child: Child to get the m2m data from
|
|
172
|
+
"""
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
def does_recurrence_need_cancellation(self) -> bool:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
def cancel_recurrence(self):
|
|
179
|
+
"""
|
|
180
|
+
keep the link to the root but exclude the occurrence from the list of valid children's to maintain, propagations will no longer be applied to this activity
|
|
181
|
+
|
|
182
|
+
its a transition method, save needs to be called
|
|
183
|
+
"""
|
|
184
|
+
self.exclude_from_propagation = True
|
|
185
|
+
|
|
186
|
+
def claim_parent_hood(self):
|
|
187
|
+
if self.is_recurrent and not self.is_root:
|
|
188
|
+
new_batch = self._meta.model.objects.filter(
|
|
189
|
+
Q(parent_occurrence=self.parent_occurrence) & Q(period__startswith__gt=self.period.lower)
|
|
190
|
+
).exclude(id=self.id)
|
|
191
|
+
if new_batch.exists():
|
|
192
|
+
new_batch.update(parent_occurrence=self)
|
|
193
|
+
self._meta.model.objects.filter(id=self.id).update(parent_occurrence=None)
|
|
194
|
+
|
|
195
|
+
def generate_occurrences(self, allow_reclaiming_root: bool = True):
|
|
196
|
+
"""
|
|
197
|
+
Creation of the occurrences
|
|
198
|
+
Existing occurrences whose start date is part of the list of occurrences dates obtained from the recurrence pattern are excluded
|
|
199
|
+
Those not in the list are deleted
|
|
200
|
+
"""
|
|
201
|
+
if self.is_recurrent:
|
|
202
|
+
if not self.is_root and allow_reclaiming_root:
|
|
203
|
+
self.claim_parent_hood()
|
|
204
|
+
self.refresh_from_db()
|
|
205
|
+
if self.is_root:
|
|
206
|
+
if old_occurrences_dict := {occ.period.lower: occ.id for occ in self.child_activities.all()}:
|
|
207
|
+
occurrence_dates = set(self._get_occurrence_start_datetimes())
|
|
208
|
+
old_occurrences_dates = set(old_occurrences_dict.keys())
|
|
209
|
+
new_occurrence_dates = occurrence_dates.difference(old_occurrences_dates)
|
|
210
|
+
if diff_inv := old_occurrences_dates.difference(occurrence_dates):
|
|
211
|
+
self.forward_deletion(child_ids=[old_occurrences_dict[x] for x in diff_inv])
|
|
212
|
+
else:
|
|
213
|
+
new_occurrence_dates = self._get_occurrence_start_datetimes()
|
|
214
|
+
for start_datetime in new_occurrence_dates:
|
|
215
|
+
child = self._create_recurrence_child(start_datetime)
|
|
216
|
+
self._handle_recurrence_m2m_forwarding(child)
|
|
217
|
+
return self.get_recurrent_valid_children()
|
|
218
|
+
|
|
219
|
+
def forward_change(
|
|
220
|
+
self,
|
|
221
|
+
allow_reclaiming_root: bool = True,
|
|
222
|
+
fields_to_forward: list = [
|
|
223
|
+
"online_meeting",
|
|
224
|
+
"visibility",
|
|
225
|
+
"conference_room",
|
|
226
|
+
"title",
|
|
227
|
+
"description",
|
|
228
|
+
"type",
|
|
229
|
+
"importance",
|
|
230
|
+
"reminder_choice",
|
|
231
|
+
"location",
|
|
232
|
+
"location_longitude",
|
|
233
|
+
"location_latitude",
|
|
234
|
+
"assigned_to",
|
|
235
|
+
],
|
|
236
|
+
period_time_changed: bool = False,
|
|
237
|
+
):
|
|
238
|
+
"""
|
|
239
|
+
Forward the changes to the following occurrences
|
|
240
|
+
param: fields_to_forward: allows you to specify the fields to update
|
|
241
|
+
allow_reclaiming_root: if True we split the occurrences and this occurrence becomes the parent of the following occurrences
|
|
242
|
+
period_time_changed: boolean field to know if we need to update the period time or not
|
|
243
|
+
"""
|
|
244
|
+
if self.is_recurrent and self.propagate_for_all_children:
|
|
245
|
+
if not self.is_root and allow_reclaiming_root:
|
|
246
|
+
self.claim_parent_hood()
|
|
247
|
+
self.refresh_from_db()
|
|
248
|
+
if self.is_root:
|
|
249
|
+
for child in self.get_recurrent_valid_children().order_by("period__startswith"):
|
|
250
|
+
for field in fields_to_forward:
|
|
251
|
+
setattr(child, field, getattr(self, field))
|
|
252
|
+
if period_time_changed:
|
|
253
|
+
tz = child.period.lower.tzinfo
|
|
254
|
+
start_datetime = datetime.combine(
|
|
255
|
+
child.period.lower.date(), self.period.lower.time()
|
|
256
|
+
).astimezone(tz)
|
|
257
|
+
child.period = TimestamptzRange(start_datetime, (start_datetime + self.duration))
|
|
258
|
+
child.propagate_for_all_children = False
|
|
259
|
+
child.save()
|
|
260
|
+
self._handle_recurrence_m2m_forwarding(child)
|
|
261
|
+
self._meta.model.objects.filter(id=self.id).update(propagate_for_all_children=False)
|
|
262
|
+
|
|
263
|
+
def forward_deletion(self, child_ids: list["str"] = []):
|
|
264
|
+
if self.is_recurrent:
|
|
265
|
+
if self.is_root:
|
|
266
|
+
occurrences = self.get_recurrent_valid_children()
|
|
267
|
+
else:
|
|
268
|
+
occurrences = self.parent_occurrence.get_recurrent_valid_children().filter(
|
|
269
|
+
period__startswith__gt=self.period.lower
|
|
270
|
+
)
|
|
271
|
+
if child_ids:
|
|
272
|
+
occurrences = occurrences.filter(id__in=child_ids)
|
|
273
|
+
if self.propagate_for_all_children:
|
|
274
|
+
# We don't call delete but update to is_active=False in order to silently delete the child activities without trggering the signals
|
|
275
|
+
occurrences.update(is_active=False)
|
|
276
|
+
self._meta.model.objects.filter(id=self.id).update(propagate_for_all_children=False)
|
|
277
|
+
else:
|
|
278
|
+
for occurrence in occurrences:
|
|
279
|
+
occurrence.delete()
|
wbcrm/preferences.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
from datetime import date, timedelta
|
|
3
|
+
|
|
4
|
+
from dynamic_preferences.registries import global_preferences_registry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_maximum_allowed_recurrent_date():
|
|
8
|
+
global_preferences = global_preferences_registry.manager()
|
|
9
|
+
return date.today() + timedelta(days=global_preferences["wbcrm__recurrence_maximum_allowed_days"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_recurrence_maximum_count():
|
|
13
|
+
global_preferences = global_preferences_registry.manager()
|
|
14
|
+
return global_preferences["wbcrm__recurrence_maximum_count"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .accounts import (
|
|
2
|
+
AccountModelSerializer,
|
|
3
|
+
AccountRepresentationSerializer,
|
|
4
|
+
TerminalAccountRepresentationSerializer,
|
|
5
|
+
AccountRoleModelSerializer,
|
|
6
|
+
AccountRoleTypeRepresentationSerializer,
|
|
7
|
+
)
|
|
8
|
+
from .activities import (
|
|
9
|
+
ActivityModelListSerializer,
|
|
10
|
+
ActivityModelSerializer,
|
|
11
|
+
ReadOnlyActivityModelSerializer,
|
|
12
|
+
ActivityParticipantModelSerializer,
|
|
13
|
+
ActivityRepresentationSerializer,
|
|
14
|
+
ActivityTypeModelSerializer,
|
|
15
|
+
ActivityTypeRepresentationSerializer,
|
|
16
|
+
)
|
|
17
|
+
from .groups import GroupModelSerializer, GroupRepresentationSerializer
|
|
18
|
+
from .products import (
|
|
19
|
+
ProductCompanyRelationshipModelSerializer,
|
|
20
|
+
ProductModelSerializer,
|
|
21
|
+
ProductRepresentationSerializer,
|
|
22
|
+
)
|
|
23
|
+
from .signals import *
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from django.apps import apps
|
|
2
|
+
from rest_framework.reverse import reverse
|
|
3
|
+
from rest_framework.validators import UniqueValidator
|
|
4
|
+
from wbcore import serializers as wb_serializers
|
|
5
|
+
from wbcore.contrib.authentication.serializers import UserRepresentationSerializer
|
|
6
|
+
from wbcore.contrib.directory.serializers import EntryRepresentationSerializer
|
|
7
|
+
from wbcrm.models.accounts import Account, AccountRole, AccountRoleType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AccountRoleTypeRepresentationSerializer(wb_serializers.RepresentationSerializer):
|
|
11
|
+
class Meta:
|
|
12
|
+
model = AccountRoleType
|
|
13
|
+
fields = ("id", "title")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AccountRepresentationSerializer(wb_serializers.RepresentationSerializer):
|
|
17
|
+
_detail = wb_serializers.HyperlinkField(reverse_name="wbcrm:account-detail")
|
|
18
|
+
|
|
19
|
+
class Meta:
|
|
20
|
+
model = Account
|
|
21
|
+
fields = ("id", "computed_str", "_detail")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TerminalAccountRepresentationSerializer(AccountRepresentationSerializer):
|
|
25
|
+
def get_filter_params(self, request):
|
|
26
|
+
filter_params = {"is_terminal_account": True, "status": "OPEN", "is_active": True}
|
|
27
|
+
if view := request.parser_context.get("view", None):
|
|
28
|
+
if entry_id := view.kwargs.get("entry_id", None):
|
|
29
|
+
filter_params["customer"] = entry_id
|
|
30
|
+
if account_id := view.kwargs.get("account_id", None):
|
|
31
|
+
filter_params["account"] = account_id
|
|
32
|
+
return filter_params
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AccountModelSerializer(wb_serializers.ModelSerializer):
|
|
36
|
+
_parent = AccountRepresentationSerializer(source="parent")
|
|
37
|
+
_owner = EntryRepresentationSerializer(source="owner")
|
|
38
|
+
_group_key = wb_serializers.CharField(read_only=True)
|
|
39
|
+
reference_id = wb_serializers.IntegerField(
|
|
40
|
+
label="Reference ID",
|
|
41
|
+
default=lambda: Account.get_next_available_reference_id(),
|
|
42
|
+
read_only=lambda view: not view.new_mode,
|
|
43
|
+
validators=[UniqueValidator(queryset=Account.objects.all())],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@wb_serializers.register_resource()
|
|
47
|
+
def additional_resources(self, instance, request, user):
|
|
48
|
+
request = self.context["request"]
|
|
49
|
+
|
|
50
|
+
custom_buttons = {
|
|
51
|
+
"childaccounts": reverse("wbcrm:account-childaccount-list", args=[instance.id], request=request),
|
|
52
|
+
"accountroles": reverse("wbcrm:account-accountrole-list", args=[instance.id], request=request),
|
|
53
|
+
"inheritedaccountroles": reverse("wbcrm:account-inheritedrole-list", args=[instance.id], request=request),
|
|
54
|
+
}
|
|
55
|
+
if apps.is_installed("wbportfolio"): # TODO move as signal
|
|
56
|
+
custom_buttons["claims"] = reverse("wbportfolio:account-claim-list", args=[instance.id], request=request)
|
|
57
|
+
|
|
58
|
+
return custom_buttons
|
|
59
|
+
|
|
60
|
+
relationship_status = wb_serializers.RangeSelectField(
|
|
61
|
+
color="rgb(220,20,60)",
|
|
62
|
+
label="Relationship Status",
|
|
63
|
+
start=1,
|
|
64
|
+
end=5,
|
|
65
|
+
step_size=1,
|
|
66
|
+
read_only=True,
|
|
67
|
+
required=False,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
class Meta:
|
|
71
|
+
model = Account
|
|
72
|
+
fields = (
|
|
73
|
+
"id",
|
|
74
|
+
"_group_key",
|
|
75
|
+
"title",
|
|
76
|
+
"status",
|
|
77
|
+
"is_active",
|
|
78
|
+
"is_terminal_account",
|
|
79
|
+
"is_public",
|
|
80
|
+
"_parent",
|
|
81
|
+
"parent",
|
|
82
|
+
"_owner",
|
|
83
|
+
"owner",
|
|
84
|
+
"reference_id",
|
|
85
|
+
"computed_str",
|
|
86
|
+
"relationship_status",
|
|
87
|
+
"relationship_summary",
|
|
88
|
+
"action_plan",
|
|
89
|
+
"_additional_resources",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AccountRoleModelSerializer(wb_serializers.ModelSerializer):
|
|
94
|
+
_entry = EntryRepresentationSerializer(source="entry")
|
|
95
|
+
_account = AccountRepresentationSerializer(source="account")
|
|
96
|
+
_role_type = AccountRoleTypeRepresentationSerializer(source="role_type")
|
|
97
|
+
is_currently_valid = wb_serializers.BooleanField(read_only=True, default=False)
|
|
98
|
+
|
|
99
|
+
_authorized_hidden_users = UserRepresentationSerializer(source="authorized_hidden_users", many=True)
|
|
100
|
+
|
|
101
|
+
def create(self, validated_data):
|
|
102
|
+
# We return a get or create role because we don't want to leak information on already existing role that the user might not be able to see
|
|
103
|
+
# this way, the account role (even if already existing) will be returned and the permission mixin will take over
|
|
104
|
+
authorized_hidden_users = validated_data.pop("authorized_hidden_users", [])
|
|
105
|
+
instance, _ = AccountRole.objects.get_or_create(
|
|
106
|
+
entry=validated_data.pop("entry"), account=validated_data.pop("account"), defaults=validated_data
|
|
107
|
+
)
|
|
108
|
+
if authorized_hidden_users:
|
|
109
|
+
instance.authorized_hidden_users.set(authorized_hidden_users)
|
|
110
|
+
return instance
|
|
111
|
+
|
|
112
|
+
class Meta:
|
|
113
|
+
model = AccountRole
|
|
114
|
+
fields = (
|
|
115
|
+
"id",
|
|
116
|
+
"role_type",
|
|
117
|
+
"_role_type",
|
|
118
|
+
"entry",
|
|
119
|
+
"_entry",
|
|
120
|
+
"account",
|
|
121
|
+
"_account",
|
|
122
|
+
"is_currently_valid",
|
|
123
|
+
"is_hidden",
|
|
124
|
+
"authorized_hidden_users",
|
|
125
|
+
"_authorized_hidden_users",
|
|
126
|
+
)
|