wbcrm 1.43.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 (175) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +5 -0
  3. wbcrm/admin/accounts.py +59 -0
  4. wbcrm/admin/activities.py +102 -0
  5. wbcrm/admin/events.py +42 -0
  6. wbcrm/admin/groups.py +7 -0
  7. wbcrm/admin/products.py +8 -0
  8. wbcrm/apps.py +5 -0
  9. wbcrm/configurations/__init__.py +1 -0
  10. wbcrm/configurations/base.py +16 -0
  11. wbcrm/dynamic_preferences_registry.py +38 -0
  12. wbcrm/factories/__init__.py +14 -0
  13. wbcrm/factories/accounts.py +56 -0
  14. wbcrm/factories/activities.py +125 -0
  15. wbcrm/factories/groups.py +23 -0
  16. wbcrm/factories/products.py +10 -0
  17. wbcrm/filters/__init__.py +10 -0
  18. wbcrm/filters/accounts.py +67 -0
  19. wbcrm/filters/activities.py +199 -0
  20. wbcrm/filters/groups.py +20 -0
  21. wbcrm/filters/products.py +37 -0
  22. wbcrm/filters/signals.py +94 -0
  23. wbcrm/fixtures/wbcrm.json +1215 -0
  24. wbcrm/kpi_handlers/activities.py +171 -0
  25. wbcrm/locale/de/LC_MESSAGES/django.po +1538 -0
  26. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  27. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  28. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  29. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  30. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  31. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  32. wbcrm/migrations/0007_alter_account_status.py +23 -0
  33. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  34. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  35. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  36. wbcrm/migrations/0011_activity_summary.py +22 -0
  37. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  38. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  39. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  40. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  41. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  42. wbcrm/migrations/0017_event.py +40 -0
  43. wbcrm/migrations/__init__.py +0 -0
  44. wbcrm/models/__init__.py +5 -0
  45. wbcrm/models/accounts.py +637 -0
  46. wbcrm/models/activities.py +1341 -0
  47. wbcrm/models/events.py +12 -0
  48. wbcrm/models/groups.py +118 -0
  49. wbcrm/models/llm/activity_summaries.py +33 -0
  50. wbcrm/models/llm/analyze_relationship.py +54 -0
  51. wbcrm/models/products.py +83 -0
  52. wbcrm/models/recurrence.py +279 -0
  53. wbcrm/preferences.py +14 -0
  54. wbcrm/report/activity_report.py +110 -0
  55. wbcrm/serializers/__init__.py +23 -0
  56. wbcrm/serializers/accounts.py +126 -0
  57. wbcrm/serializers/activities.py +526 -0
  58. wbcrm/serializers/groups.py +30 -0
  59. wbcrm/serializers/products.py +57 -0
  60. wbcrm/serializers/recurrence.py +90 -0
  61. wbcrm/serializers/signals.py +70 -0
  62. wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
  63. wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
  64. wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
  65. wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
  66. wbcrm/synchronization/__init__.py +0 -0
  67. wbcrm/synchronization/activity/__init__.py +0 -0
  68. wbcrm/synchronization/activity/admin.py +72 -0
  69. wbcrm/synchronization/activity/backend.py +213 -0
  70. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  71. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  72. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +399 -0
  73. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  74. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
  75. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
  76. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
  77. wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +180 -0
  78. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  79. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  80. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  81. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  82. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  83. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  84. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  85. wbcrm/synchronization/activity/backends/google/utils.py +216 -0
  86. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  87. wbcrm/synchronization/activity/backends/outlook/backend.py +585 -0
  88. wbcrm/synchronization/activity/backends/outlook/msgraph.py +438 -0
  89. wbcrm/synchronization/activity/backends/outlook/parser.py +431 -0
  90. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  91. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  92. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  93. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +117 -0
  94. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +273 -0
  95. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +248 -0
  96. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +173 -0
  97. wbcrm/synchronization/activity/controller.py +624 -0
  98. wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
  99. wbcrm/synchronization/activity/preferences.py +27 -0
  100. wbcrm/synchronization/activity/shortcuts.py +10 -0
  101. wbcrm/synchronization/activity/tasks.py +21 -0
  102. wbcrm/synchronization/activity/urls.py +6 -0
  103. wbcrm/synchronization/activity/utils.py +46 -0
  104. wbcrm/synchronization/activity/views.py +40 -0
  105. wbcrm/synchronization/admin.py +1 -0
  106. wbcrm/synchronization/apps.py +14 -0
  107. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  108. wbcrm/synchronization/management.py +36 -0
  109. wbcrm/synchronization/tasks.py +1 -0
  110. wbcrm/synchronization/urls.py +5 -0
  111. wbcrm/tasks.py +312 -0
  112. wbcrm/templates/email/activity.html +98 -0
  113. wbcrm/templates/email/activity_report.html +6 -0
  114. wbcrm/templates/email/daily_summary.html +72 -0
  115. wbcrm/templates/email/global_daily_summary.html +85 -0
  116. wbcrm/tests/__init__.py +0 -0
  117. wbcrm/tests/accounts/__init__.py +0 -0
  118. wbcrm/tests/accounts/test_models.py +380 -0
  119. wbcrm/tests/accounts/test_viewsets.py +87 -0
  120. wbcrm/tests/conftest.py +76 -0
  121. wbcrm/tests/disable_signals.py +52 -0
  122. wbcrm/tests/e2e/__init__.py +1 -0
  123. wbcrm/tests/e2e/e2e_wbcrm_utility.py +82 -0
  124. wbcrm/tests/e2e/test_e2e.py +369 -0
  125. wbcrm/tests/test_assignee_methods.py +39 -0
  126. wbcrm/tests/test_chartviewsets.py +111 -0
  127. wbcrm/tests/test_dto.py +63 -0
  128. wbcrm/tests/test_filters.py +51 -0
  129. wbcrm/tests/test_models.py +216 -0
  130. wbcrm/tests/test_recurrence.py +291 -0
  131. wbcrm/tests/test_report.py +20 -0
  132. wbcrm/tests/test_serializers.py +170 -0
  133. wbcrm/tests/test_tasks.py +94 -0
  134. wbcrm/tests/test_viewsets.py +967 -0
  135. wbcrm/tests/tests.py +120 -0
  136. wbcrm/typings.py +109 -0
  137. wbcrm/urls.py +67 -0
  138. wbcrm/viewsets/__init__.py +22 -0
  139. wbcrm/viewsets/accounts.py +121 -0
  140. wbcrm/viewsets/activities.py +344 -0
  141. wbcrm/viewsets/buttons/__init__.py +7 -0
  142. wbcrm/viewsets/buttons/accounts.py +27 -0
  143. wbcrm/viewsets/buttons/activities.py +88 -0
  144. wbcrm/viewsets/buttons/signals.py +17 -0
  145. wbcrm/viewsets/display/__init__.py +12 -0
  146. wbcrm/viewsets/display/accounts.py +110 -0
  147. wbcrm/viewsets/display/activities.py +443 -0
  148. wbcrm/viewsets/display/groups.py +22 -0
  149. wbcrm/viewsets/display/products.py +105 -0
  150. wbcrm/viewsets/endpoints/__init__.py +8 -0
  151. wbcrm/viewsets/endpoints/accounts.py +32 -0
  152. wbcrm/viewsets/endpoints/activities.py +30 -0
  153. wbcrm/viewsets/endpoints/groups.py +7 -0
  154. wbcrm/viewsets/endpoints/products.py +9 -0
  155. wbcrm/viewsets/groups.py +37 -0
  156. wbcrm/viewsets/menu/__init__.py +8 -0
  157. wbcrm/viewsets/menu/accounts.py +18 -0
  158. wbcrm/viewsets/menu/activities.py +61 -0
  159. wbcrm/viewsets/menu/groups.py +16 -0
  160. wbcrm/viewsets/menu/products.py +20 -0
  161. wbcrm/viewsets/mixins.py +34 -0
  162. wbcrm/viewsets/previews/__init__.py +1 -0
  163. wbcrm/viewsets/previews/activities.py +10 -0
  164. wbcrm/viewsets/products.py +56 -0
  165. wbcrm/viewsets/recurrence.py +26 -0
  166. wbcrm/viewsets/titles/__init__.py +13 -0
  167. wbcrm/viewsets/titles/accounts.py +22 -0
  168. wbcrm/viewsets/titles/activities.py +61 -0
  169. wbcrm/viewsets/titles/products.py +13 -0
  170. wbcrm/viewsets/titles/utils.py +46 -0
  171. wbcrm/workflows/__init__.py +1 -0
  172. wbcrm/workflows/assignee_methods.py +25 -0
  173. wbcrm-1.43.1.dist-info/METADATA +11 -0
  174. wbcrm-1.43.1.dist-info/RECORD +175 -0
  175. wbcrm-1.43.1.dist-info/WHEEL +5 -0
wbcrm/models/events.py ADDED
@@ -0,0 +1,12 @@
1
+ from django.db import models
2
+
3
+
4
+ class Event(models.Model):
5
+ """
6
+ we store the event notification we received from the webhook in this model to easily debug the sync
7
+ """
8
+
9
+ data = models.JSONField(default=dict, blank=True)
10
+ result = models.JSONField(default=dict, blank=True)
11
+ created = models.DateTimeField(auto_now_add=True)
12
+ updated = models.DateTimeField(auto_now=True)
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,33 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from langchain_core.messages import HumanMessage, SystemMessage
4
+ from pydantic import BaseModel, Field
5
+ from wbcore.contrib.ai.llm.config import LLMConfig
6
+
7
+ if TYPE_CHECKING:
8
+ from wbcrm.models import Activity
9
+
10
+
11
+ def analyze_activity_prompt(activity: "Activity"):
12
+ return [
13
+ SystemMessage(
14
+ content="You are a manager in a company and there are activities, both internal as well as external, meaning between service providers, clients and ourselves. We want to analyze these activities in regards of their sentiment and summarize them in English. The summary should be a very short bottom-line focussed text."
15
+ ),
16
+ HumanMessage(
17
+ content=f"title={activity.title}, description={activity.description}, period={activity.period}, participants={activity.participants.all()}, companies={activity.companies.all()}, review={activity.result}"
18
+ ),
19
+ ]
20
+
21
+
22
+ class ActivityLLMResponseModel(BaseModel):
23
+ heat: int = Field(..., ge=1, le=4, description="The sentiment heat.")
24
+ summary: str = Field(..., description="A summary of the activity in English.")
25
+
26
+
27
+ analyze_activity = LLMConfig["Activity"](
28
+ key="analyze",
29
+ prompt=analyze_activity_prompt,
30
+ on_save=True,
31
+ on_condition=lambda instance: instance.status == "REVIEWED",
32
+ output_model=ActivityLLMResponseModel,
33
+ )
@@ -0,0 +1,54 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from langchain_core.messages import HumanMessage, SystemMessage
4
+ from pydantic import BaseModel, Field
5
+ from wbcore.contrib.ai.llm.config import LLMConfig
6
+ from wbcrm.models.activities import Activity
7
+
8
+ if TYPE_CHECKING:
9
+ from wbcrm.models import Account
10
+
11
+
12
+ def analyze_relationship_prompt(account: "Account"):
13
+ messages = [
14
+ SystemMessage(
15
+ content="Based on the recent interactions/activities, current relationship health, and planned actions with the customer, provide a status score (1 to 5, where 1 is cold and 5 is hot), a summary of the relationship, and recommended next steps for maintaining or improving the relationship. Keep in mind that more information might become available later to refine these insights. Please include all information that is relevant to the relationship in the summary. Regarding the activities/interactions, older interactions are less relevant than recent ones."
16
+ ),
17
+ HumanMessage(
18
+ content=f"The title of the account: {account.title}",
19
+ ),
20
+ ]
21
+ if account.owner:
22
+ for activity in Activity.objects.filter(companies__id__in=[account.owner.id], status="REVIEWED"):
23
+ messages.append(
24
+ HumanMessage(
25
+ content=f"Activity: {activity.summary}, with sentiment: {activity.heat}, period: {activity.period}",
26
+ )
27
+ )
28
+
29
+ return messages
30
+
31
+
32
+ class AccountRelationshipResponseModel(BaseModel):
33
+ relationship_status: int = Field(
34
+ ...,
35
+ ge=1,
36
+ le=5,
37
+ description="Rate the customer relationship status from 1 to 5. 1 being the cold and 5 being the hot.",
38
+ )
39
+ relationship_summary: str = Field(
40
+ ...,
41
+ description="Briefly summarize the current state of the relationship and recent interactions. Also include any additional information that might be relevant.",
42
+ )
43
+ action_plan: str = Field(
44
+ ..., description="Provide the next recommended actions or steps to engage with the customer."
45
+ )
46
+
47
+
48
+ analyze_relationship = LLMConfig["Account"](
49
+ key="analyze_relationship",
50
+ prompt=analyze_relationship_prompt,
51
+ on_save=True,
52
+ on_condition=lambda act: (act.status == act.Status.OPEN) and act.owner is not None and act.is_root_node(),
53
+ output_model=AccountRelationshipResponseModel,
54
+ )
@@ -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"]