wbcrm 2.2.1__py2.py3-none-any.whl

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

Potentially problematic release.


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

Files changed (155) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +4 -0
  3. wbcrm/admin/accounts.py +59 -0
  4. wbcrm/admin/activities.py +101 -0
  5. wbcrm/admin/groups.py +7 -0
  6. wbcrm/admin/products.py +8 -0
  7. wbcrm/apps.py +5 -0
  8. wbcrm/configurations/__init__.py +1 -0
  9. wbcrm/configurations/base.py +16 -0
  10. wbcrm/dynamic_preferences_registry.py +38 -0
  11. wbcrm/factories/__init__.py +14 -0
  12. wbcrm/factories/accounts.py +56 -0
  13. wbcrm/factories/activities.py +125 -0
  14. wbcrm/factories/groups.py +23 -0
  15. wbcrm/factories/products.py +10 -0
  16. wbcrm/filters/__init__.py +10 -0
  17. wbcrm/filters/accounts.py +67 -0
  18. wbcrm/filters/activities.py +181 -0
  19. wbcrm/filters/groups.py +20 -0
  20. wbcrm/filters/products.py +37 -0
  21. wbcrm/filters/signals.py +94 -0
  22. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  23. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  24. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  25. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  26. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  27. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  28. wbcrm/migrations/0007_alter_account_status.py +23 -0
  29. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  30. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  31. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  32. wbcrm/migrations/0011_activity_summary.py +22 -0
  33. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  34. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  35. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  36. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  37. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  38. wbcrm/migrations/__init__.py +0 -0
  39. wbcrm/models/__init__.py +4 -0
  40. wbcrm/models/accounts.py +637 -0
  41. wbcrm/models/activities.py +1335 -0
  42. wbcrm/models/groups.py +118 -0
  43. wbcrm/models/products.py +83 -0
  44. wbcrm/models/recurrence.py +279 -0
  45. wbcrm/preferences.py +14 -0
  46. wbcrm/serializers/__init__.py +23 -0
  47. wbcrm/serializers/accounts.py +126 -0
  48. wbcrm/serializers/activities.py +526 -0
  49. wbcrm/serializers/groups.py +30 -0
  50. wbcrm/serializers/products.py +57 -0
  51. wbcrm/serializers/recurrence.py +90 -0
  52. wbcrm/serializers/signals.py +70 -0
  53. wbcrm/synchronization/__init__.py +0 -0
  54. wbcrm/synchronization/activity/__init__.py +0 -0
  55. wbcrm/synchronization/activity/admin.py +72 -0
  56. wbcrm/synchronization/activity/backend.py +207 -0
  57. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  58. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  59. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
  60. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  61. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  62. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  63. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  64. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  65. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  66. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  67. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  68. wbcrm/synchronization/activity/backends/google/utils.py +216 -0
  69. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  70. wbcrm/synchronization/activity/backends/outlook/backend.py +576 -0
  71. wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
  72. wbcrm/synchronization/activity/backends/outlook/parser.py +423 -0
  73. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  74. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  75. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  76. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
  77. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +269 -0
  78. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +237 -0
  79. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
  80. wbcrm/synchronization/activity/controller.py +545 -0
  81. wbcrm/synchronization/activity/dynamic_preferences_registry.py +107 -0
  82. wbcrm/synchronization/activity/preferences.py +21 -0
  83. wbcrm/synchronization/activity/shortcuts.py +9 -0
  84. wbcrm/synchronization/activity/signals.py +28 -0
  85. wbcrm/synchronization/activity/tasks.py +21 -0
  86. wbcrm/synchronization/activity/urls.py +6 -0
  87. wbcrm/synchronization/activity/utils.py +46 -0
  88. wbcrm/synchronization/activity/views.py +37 -0
  89. wbcrm/synchronization/admin.py +1 -0
  90. wbcrm/synchronization/apps.py +15 -0
  91. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  92. wbcrm/synchronization/management.py +36 -0
  93. wbcrm/synchronization/tasks.py +1 -0
  94. wbcrm/synchronization/urls.py +5 -0
  95. wbcrm/tasks.py +312 -0
  96. wbcrm/tests/__init__.py +0 -0
  97. wbcrm/tests/accounts/__init__.py +0 -0
  98. wbcrm/tests/accounts/test_models.py +380 -0
  99. wbcrm/tests/accounts/test_viewsets.py +87 -0
  100. wbcrm/tests/conftest.py +76 -0
  101. wbcrm/tests/disable_signals.py +52 -0
  102. wbcrm/tests/e2e/__init__.py +1 -0
  103. wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
  104. wbcrm/tests/e2e/test_e2e.py +369 -0
  105. wbcrm/tests/test_assignee_methods.py +39 -0
  106. wbcrm/tests/test_chartviewsets.py +111 -0
  107. wbcrm/tests/test_dto.py +63 -0
  108. wbcrm/tests/test_filters.py +51 -0
  109. wbcrm/tests/test_models.py +216 -0
  110. wbcrm/tests/test_recurrence.py +291 -0
  111. wbcrm/tests/test_report.py +20 -0
  112. wbcrm/tests/test_serializers.py +170 -0
  113. wbcrm/tests/test_tasks.py +94 -0
  114. wbcrm/tests/test_viewsets.py +967 -0
  115. wbcrm/tests/tests.py +120 -0
  116. wbcrm/typings.py +107 -0
  117. wbcrm/urls.py +67 -0
  118. wbcrm/viewsets/__init__.py +22 -0
  119. wbcrm/viewsets/accounts.py +121 -0
  120. wbcrm/viewsets/activities.py +315 -0
  121. wbcrm/viewsets/buttons/__init__.py +7 -0
  122. wbcrm/viewsets/buttons/accounts.py +27 -0
  123. wbcrm/viewsets/buttons/activities.py +68 -0
  124. wbcrm/viewsets/buttons/signals.py +17 -0
  125. wbcrm/viewsets/display/__init__.py +12 -0
  126. wbcrm/viewsets/display/accounts.py +110 -0
  127. wbcrm/viewsets/display/activities.py +443 -0
  128. wbcrm/viewsets/display/groups.py +22 -0
  129. wbcrm/viewsets/display/products.py +105 -0
  130. wbcrm/viewsets/endpoints/__init__.py +8 -0
  131. wbcrm/viewsets/endpoints/accounts.py +32 -0
  132. wbcrm/viewsets/endpoints/activities.py +30 -0
  133. wbcrm/viewsets/endpoints/groups.py +7 -0
  134. wbcrm/viewsets/endpoints/products.py +9 -0
  135. wbcrm/viewsets/groups.py +37 -0
  136. wbcrm/viewsets/menu/__init__.py +8 -0
  137. wbcrm/viewsets/menu/accounts.py +18 -0
  138. wbcrm/viewsets/menu/activities.py +61 -0
  139. wbcrm/viewsets/menu/groups.py +16 -0
  140. wbcrm/viewsets/menu/products.py +20 -0
  141. wbcrm/viewsets/mixins.py +34 -0
  142. wbcrm/viewsets/previews/__init__.py +1 -0
  143. wbcrm/viewsets/previews/activities.py +10 -0
  144. wbcrm/viewsets/products.py +56 -0
  145. wbcrm/viewsets/recurrence.py +26 -0
  146. wbcrm/viewsets/titles/__init__.py +13 -0
  147. wbcrm/viewsets/titles/accounts.py +22 -0
  148. wbcrm/viewsets/titles/activities.py +61 -0
  149. wbcrm/viewsets/titles/products.py +13 -0
  150. wbcrm/viewsets/titles/utils.py +46 -0
  151. wbcrm/workflows/__init__.py +1 -0
  152. wbcrm/workflows/assignee_methods.py +25 -0
  153. wbcrm-2.2.1.dist-info/METADATA +11 -0
  154. wbcrm-2.2.1.dist-info/RECORD +155 -0
  155. wbcrm-2.2.1.dist-info/WHEEL +5 -0
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)
@@ -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
+ )