wbcrm 1.56.8__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. wbcrm/__init__.py +1 -0
  2. wbcrm/admin/__init__.py +5 -0
  3. wbcrm/admin/accounts.py +60 -0
  4. wbcrm/admin/activities.py +104 -0
  5. wbcrm/admin/events.py +43 -0
  6. wbcrm/admin/groups.py +8 -0
  7. wbcrm/admin/products.py +9 -0
  8. wbcrm/apps.py +5 -0
  9. wbcrm/configurations/__init__.py +1 -0
  10. wbcrm/configurations/base.py +16 -0
  11. wbcrm/dynamic_preferences_registry.py +38 -0
  12. wbcrm/factories/__init__.py +14 -0
  13. wbcrm/factories/accounts.py +57 -0
  14. wbcrm/factories/activities.py +124 -0
  15. wbcrm/factories/groups.py +24 -0
  16. wbcrm/factories/products.py +11 -0
  17. wbcrm/filters/__init__.py +10 -0
  18. wbcrm/filters/accounts.py +80 -0
  19. wbcrm/filters/activities.py +204 -0
  20. wbcrm/filters/groups.py +21 -0
  21. wbcrm/filters/products.py +38 -0
  22. wbcrm/filters/signals.py +95 -0
  23. wbcrm/fixtures/wbcrm.json +1215 -0
  24. wbcrm/kpi_handlers/activities.py +171 -0
  25. wbcrm/locale/de/LC_MESSAGES/django.mo +0 -0
  26. wbcrm/locale/de/LC_MESSAGES/django.po +1557 -0
  27. wbcrm/locale/de/LC_MESSAGES/django.po.translated +1630 -0
  28. wbcrm/locale/en/LC_MESSAGES/django.mo +0 -0
  29. wbcrm/locale/en/LC_MESSAGES/django.po +1466 -0
  30. wbcrm/locale/fr/LC_MESSAGES/django.mo +0 -0
  31. wbcrm/locale/fr/LC_MESSAGES/django.po +1467 -0
  32. wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py +3948 -0
  33. wbcrm/migrations/0002_alter_activity_repeat_choice.py +32 -0
  34. wbcrm/migrations/0003_remove_activity_external_id_and_more.py +63 -0
  35. wbcrm/migrations/0004_alter_activity_status.py +28 -0
  36. wbcrm/migrations/0005_account_accountrole_accountroletype_and_more.py +182 -0
  37. wbcrm/migrations/0006_alter_activity_location.py +17 -0
  38. wbcrm/migrations/0007_alter_account_status.py +23 -0
  39. wbcrm/migrations/0008_alter_activity_options.py +16 -0
  40. wbcrm/migrations/0009_alter_account_is_public.py +19 -0
  41. wbcrm/migrations/0010_alter_account_reference_id.py +17 -0
  42. wbcrm/migrations/0011_activity_summary.py +22 -0
  43. wbcrm/migrations/0012_alter_activity_summary.py +17 -0
  44. wbcrm/migrations/0013_account_action_plan_account_relationship_status_and_more.py +34 -0
  45. wbcrm/migrations/0014_alter_account_relationship_status.py +24 -0
  46. wbcrm/migrations/0015_alter_activity_type.py +23 -0
  47. wbcrm/migrations/0016_auto_20241205_1015.py +106 -0
  48. wbcrm/migrations/0017_event.py +40 -0
  49. wbcrm/migrations/0018_activity_search_vector.py +24 -0
  50. wbcrm/migrations/__init__.py +0 -0
  51. wbcrm/models/__init__.py +5 -0
  52. wbcrm/models/accounts.py +648 -0
  53. wbcrm/models/activities.py +1419 -0
  54. wbcrm/models/events.py +15 -0
  55. wbcrm/models/groups.py +119 -0
  56. wbcrm/models/llm/activity_summaries.py +41 -0
  57. wbcrm/models/llm/analyze_relationship.py +50 -0
  58. wbcrm/models/products.py +86 -0
  59. wbcrm/models/recurrence.py +280 -0
  60. wbcrm/preferences.py +13 -0
  61. wbcrm/report/activity_report.py +110 -0
  62. wbcrm/serializers/__init__.py +23 -0
  63. wbcrm/serializers/accounts.py +141 -0
  64. wbcrm/serializers/activities.py +525 -0
  65. wbcrm/serializers/groups.py +30 -0
  66. wbcrm/serializers/products.py +58 -0
  67. wbcrm/serializers/recurrence.py +91 -0
  68. wbcrm/serializers/signals.py +71 -0
  69. wbcrm/static/wbcrm/markdown/documentation/activity.md +86 -0
  70. wbcrm/static/wbcrm/markdown/documentation/activitytype.md +20 -0
  71. wbcrm/static/wbcrm/markdown/documentation/group.md +2 -0
  72. wbcrm/static/wbcrm/markdown/documentation/product.md +11 -0
  73. wbcrm/synchronization/__init__.py +0 -0
  74. wbcrm/synchronization/activity/__init__.py +0 -0
  75. wbcrm/synchronization/activity/admin.py +73 -0
  76. wbcrm/synchronization/activity/backend.py +214 -0
  77. wbcrm/synchronization/activity/backends/__init__.py +0 -0
  78. wbcrm/synchronization/activity/backends/google/__init__.py +2 -0
  79. wbcrm/synchronization/activity/backends/google/google_calendar_backend.py +406 -0
  80. wbcrm/synchronization/activity/backends/google/request_utils/__init__.py +16 -0
  81. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py +75 -0
  82. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py +78 -0
  83. wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py +155 -0
  84. wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py +181 -0
  85. wbcrm/synchronization/activity/backends/google/tasks.py +21 -0
  86. wbcrm/synchronization/activity/backends/google/tests/__init__.py +0 -0
  87. wbcrm/synchronization/activity/backends/google/tests/conftest.py +1 -0
  88. wbcrm/synchronization/activity/backends/google/tests/test_data.py +81 -0
  89. wbcrm/synchronization/activity/backends/google/tests/test_google_backend.py +319 -0
  90. wbcrm/synchronization/activity/backends/google/tests/test_utils.py +274 -0
  91. wbcrm/synchronization/activity/backends/google/typing_informations.py +139 -0
  92. wbcrm/synchronization/activity/backends/google/utils.py +217 -0
  93. wbcrm/synchronization/activity/backends/outlook/__init__.py +0 -0
  94. wbcrm/synchronization/activity/backends/outlook/backend.py +593 -0
  95. wbcrm/synchronization/activity/backends/outlook/msgraph.py +436 -0
  96. wbcrm/synchronization/activity/backends/outlook/parser.py +432 -0
  97. wbcrm/synchronization/activity/backends/outlook/tests/__init__.py +0 -0
  98. wbcrm/synchronization/activity/backends/outlook/tests/conftest.py +1 -0
  99. wbcrm/synchronization/activity/backends/outlook/tests/fixtures.py +606 -0
  100. wbcrm/synchronization/activity/backends/outlook/tests/test_admin.py +118 -0
  101. wbcrm/synchronization/activity/backends/outlook/tests/test_backend.py +274 -0
  102. wbcrm/synchronization/activity/backends/outlook/tests/test_controller.py +249 -0
  103. wbcrm/synchronization/activity/backends/outlook/tests/test_parser.py +174 -0
  104. wbcrm/synchronization/activity/controller.py +627 -0
  105. wbcrm/synchronization/activity/dynamic_preferences_registry.py +119 -0
  106. wbcrm/synchronization/activity/preferences.py +27 -0
  107. wbcrm/synchronization/activity/shortcuts.py +16 -0
  108. wbcrm/synchronization/activity/tasks.py +21 -0
  109. wbcrm/synchronization/activity/urls.py +7 -0
  110. wbcrm/synchronization/activity/utils.py +46 -0
  111. wbcrm/synchronization/activity/views.py +41 -0
  112. wbcrm/synchronization/admin.py +1 -0
  113. wbcrm/synchronization/apps.py +14 -0
  114. wbcrm/synchronization/dynamic_preferences_registry.py +1 -0
  115. wbcrm/synchronization/management.py +36 -0
  116. wbcrm/synchronization/tasks.py +1 -0
  117. wbcrm/synchronization/urls.py +5 -0
  118. wbcrm/tasks.py +264 -0
  119. wbcrm/templates/email/activity.html +98 -0
  120. wbcrm/templates/email/activity_report.html +6 -0
  121. wbcrm/templates/email/daily_summary.html +72 -0
  122. wbcrm/templates/email/global_daily_summary.html +85 -0
  123. wbcrm/tests/__init__.py +0 -0
  124. wbcrm/tests/accounts/__init__.py +0 -0
  125. wbcrm/tests/accounts/test_models.py +393 -0
  126. wbcrm/tests/accounts/test_viewsets.py +88 -0
  127. wbcrm/tests/conftest.py +76 -0
  128. wbcrm/tests/disable_signals.py +62 -0
  129. wbcrm/tests/e2e/__init__.py +1 -0
  130. wbcrm/tests/e2e/e2e_wbcrm_utility.py +83 -0
  131. wbcrm/tests/e2e/test_e2e.py +370 -0
  132. wbcrm/tests/test_assignee_methods.py +40 -0
  133. wbcrm/tests/test_chartviewsets.py +112 -0
  134. wbcrm/tests/test_dto.py +64 -0
  135. wbcrm/tests/test_filters.py +52 -0
  136. wbcrm/tests/test_models.py +217 -0
  137. wbcrm/tests/test_recurrence.py +292 -0
  138. wbcrm/tests/test_report.py +21 -0
  139. wbcrm/tests/test_serializers.py +171 -0
  140. wbcrm/tests/test_tasks.py +95 -0
  141. wbcrm/tests/test_viewsets.py +967 -0
  142. wbcrm/tests/tests.py +121 -0
  143. wbcrm/typings.py +109 -0
  144. wbcrm/urls.py +67 -0
  145. wbcrm/viewsets/__init__.py +22 -0
  146. wbcrm/viewsets/accounts.py +122 -0
  147. wbcrm/viewsets/activities.py +341 -0
  148. wbcrm/viewsets/buttons/__init__.py +7 -0
  149. wbcrm/viewsets/buttons/accounts.py +27 -0
  150. wbcrm/viewsets/buttons/activities.py +89 -0
  151. wbcrm/viewsets/buttons/signals.py +17 -0
  152. wbcrm/viewsets/display/__init__.py +12 -0
  153. wbcrm/viewsets/display/accounts.py +110 -0
  154. wbcrm/viewsets/display/activities.py +444 -0
  155. wbcrm/viewsets/display/groups.py +22 -0
  156. wbcrm/viewsets/display/products.py +105 -0
  157. wbcrm/viewsets/endpoints/__init__.py +8 -0
  158. wbcrm/viewsets/endpoints/accounts.py +25 -0
  159. wbcrm/viewsets/endpoints/activities.py +30 -0
  160. wbcrm/viewsets/endpoints/groups.py +7 -0
  161. wbcrm/viewsets/endpoints/products.py +9 -0
  162. wbcrm/viewsets/groups.py +38 -0
  163. wbcrm/viewsets/menu/__init__.py +8 -0
  164. wbcrm/viewsets/menu/accounts.py +18 -0
  165. wbcrm/viewsets/menu/activities.py +49 -0
  166. wbcrm/viewsets/menu/groups.py +16 -0
  167. wbcrm/viewsets/menu/products.py +20 -0
  168. wbcrm/viewsets/mixins.py +35 -0
  169. wbcrm/viewsets/previews/__init__.py +1 -0
  170. wbcrm/viewsets/previews/activities.py +10 -0
  171. wbcrm/viewsets/products.py +57 -0
  172. wbcrm/viewsets/recurrence.py +27 -0
  173. wbcrm/viewsets/titles/__init__.py +13 -0
  174. wbcrm/viewsets/titles/accounts.py +23 -0
  175. wbcrm/viewsets/titles/activities.py +61 -0
  176. wbcrm/viewsets/titles/products.py +13 -0
  177. wbcrm/viewsets/titles/utils.py +46 -0
  178. wbcrm/workflows/__init__.py +1 -0
  179. wbcrm/workflows/assignee_methods.py +25 -0
  180. wbcrm-1.56.8.dist-info/METADATA +11 -0
  181. wbcrm-1.56.8.dist-info/RECORD +182 -0
  182. wbcrm-1.56.8.dist-info/WHEEL +5 -0
wbcrm/models/events.py ADDED
@@ -0,0 +1,15 @@
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)
13
+
14
+ def __str__(self) -> str:
15
+ return str(self.id)
wbcrm/models/groups.py ADDED
@@ -0,0 +1,119 @@
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
+
11
+ from wbcrm.models import Activity
12
+
13
+
14
+ class Group(WBModel):
15
+ title = models.CharField(max_length=255, unique=True, verbose_name=_("Title"))
16
+ members = models.ManyToManyField("directory.Entry", related_name="groups", verbose_name=_("Members"))
17
+
18
+ class Meta:
19
+ verbose_name = _("Group")
20
+ verbose_name_plural = _("Groups")
21
+
22
+ def __str__(self):
23
+ return self.title
24
+
25
+ @classmethod
26
+ def get_endpoint_basename(cls):
27
+ return "wbcrm:group"
28
+
29
+ @classmethod
30
+ def get_representation_endpoint(cls):
31
+ return "wbcrm:grouprepresentation-list"
32
+
33
+ @classmethod
34
+ def get_representation_value_key(cls):
35
+ return "id"
36
+
37
+ @classmethod
38
+ def get_representation_label_key(cls):
39
+ return "{{title}}"
40
+
41
+
42
+ @receiver(m2m_changed, sender=Group.members.through)
43
+ def m2m_changed_members(sender, instance, action, pk_set, **kwargs):
44
+ """
45
+ M2m changed Group signal: Change participants, companies and entities of future planned activities/calendar items
46
+ if a group's members get updated
47
+ """
48
+ if action == "post_add":
49
+ add_changed_group_members(instance, pk_set)
50
+
51
+ if action == "post_remove":
52
+ remove_changed_group_members(instance, pk_set)
53
+
54
+
55
+ @receiver(pre_delete, sender=Group)
56
+ def pre_delete_group(sender, instance, **kwargs):
57
+ """
58
+ Post delete Group signal: Remove members from future planned activities/calendar items if a group was deleted
59
+ """
60
+ remove_deleted_groups_members(instance)
61
+
62
+
63
+ @shared_task
64
+ def add_changed_group_members(instance, pk_set):
65
+ for activity in Activity.objects.filter(
66
+ status=Activity.Status.PLANNED, start__gte=timezone.now(), groups=instance
67
+ ):
68
+ item = CalendarItem.all_objects.get(id=activity.id)
69
+ for member in pk_set:
70
+ entry = Entry.all_objects.get(id=member)
71
+ if entry not in item.entities.all():
72
+ item.entities.add(entry)
73
+ if entry.is_company:
74
+ company = Company.all_objects.get(id=entry.id)
75
+ if company not in activity.companies.all():
76
+ activity.companies.add(company)
77
+ else:
78
+ person = Person.all_objects.get(id=entry.id)
79
+ if person not in activity.participants.all():
80
+ activity.participants.add(person)
81
+
82
+
83
+ @shared_task
84
+ def remove_changed_group_members(instance, pk_set):
85
+ for activity in Activity.objects.filter(
86
+ status=Activity.Status.PLANNED, start__gte=timezone.now(), groups=instance
87
+ ):
88
+ item = CalendarItem.all_objects.get(id=activity.id)
89
+ for member in pk_set:
90
+ entry = Entry.all_objects.get(id=member)
91
+ if entry in item.entities.all():
92
+ item.entities.remove(entry)
93
+ if entry.is_company:
94
+ company = Company.all_objects.get(id=entry.id)
95
+ if company in activity.companies.all():
96
+ activity.companies.remove(company)
97
+ else:
98
+ person = Person.all_objects.get(id=entry.id)
99
+ if person in activity.participants.all():
100
+ activity.participants.remove(person)
101
+
102
+
103
+ @shared_task
104
+ def remove_deleted_groups_members(instance):
105
+ for activity in Activity.objects.filter(
106
+ status=Activity.Status.PLANNED, start__gte=timezone.now(), groups=instance
107
+ ):
108
+ item = CalendarItem.all_objects.get(id=activity.id)
109
+ for member in instance.members.all():
110
+ if member in item.entities.all():
111
+ item.entities.remove(member)
112
+ if member.is_company:
113
+ company = Company.all_objects.get(id=member.id)
114
+ if company in activity.companies.all():
115
+ activity.companies.remove(company)
116
+ else:
117
+ person = Person.all_objects.get(id=member.id)
118
+ if person in activity.participants.all():
119
+ activity.participants.remove(person)
@@ -0,0 +1,41 @@
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 # noqa
9
+
10
+
11
+ class ActivityLLMResponseModel(BaseModel):
12
+ heat: int = Field(..., ge=1, le=4, description="The sentiment heat.")
13
+ summary: str = Field(..., description="A summary of the activity in English.")
14
+
15
+
16
+ def get_query(instance):
17
+ return {
18
+ "title": instance.title,
19
+ "description": instance.description,
20
+ "period": instance.period,
21
+ "participants": instance.participants.all(),
22
+ "companies": instance.companies.all(),
23
+ "result": instance.result,
24
+ }
25
+
26
+
27
+ analyze_activity = LLMConfig["Activity"](
28
+ key="analyze",
29
+ prompt=[
30
+ SystemMessage(
31
+ 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."
32
+ ),
33
+ HumanMessage(
34
+ content="title={title}, description={description}, period={period}, participants={participants}, companies={companies}, review={result}"
35
+ ),
36
+ ],
37
+ on_save=True,
38
+ on_condition=lambda instance: instance.status == "REVIEWED",
39
+ output_model=ActivityLLMResponseModel,
40
+ query=get_query,
41
+ )
@@ -0,0 +1,50 @@
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
+ class AccountRelationshipResponseModel(BaseModel):
13
+ relationship_status: int = Field(
14
+ ...,
15
+ ge=1,
16
+ le=5,
17
+ description="Rate the customer relationship status from 1 to 5. 1 being the cold and 5 being the hot.",
18
+ )
19
+ relationship_summary: str = Field(
20
+ ...,
21
+ description="Briefly summarize the current state of the relationship and recent interactions. Also include any additional information that might be relevant.",
22
+ )
23
+ action_plan: str = Field(
24
+ ..., description="Provide the next recommended actions or steps to engage with the customer."
25
+ )
26
+
27
+
28
+ def _get_query(account: "Account"):
29
+ details = []
30
+ if account.owner:
31
+ for activity in Activity.objects.filter(companies__id__in=[account.owner.id], status="REVIEWED"):
32
+ details.append((activity.summary, activity.heat, activity.period))
33
+ return {"title": account.title, "details": details}
34
+
35
+
36
+ analyze_relationship = LLMConfig["Account"](
37
+ key="analyze_relationship",
38
+ prompt=[
39
+ SystemMessage(
40
+ 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."
41
+ ),
42
+ HumanMessage(
43
+ content="The title of the account: {title} and the list of activities details (tuple of summary, sentiment heat and period): {details}",
44
+ ),
45
+ ],
46
+ on_save=True,
47
+ on_condition=lambda act: (act.status == act.Status.OPEN) and act.owner is not None and act.is_root_node(),
48
+ output_model=AccountRelationshipResponseModel,
49
+ query=_get_query,
50
+ )
@@ -0,0 +1,86 @@
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
+ def __str__(self) -> str:
30
+ return f"{self.product} - {self.company}"
31
+
32
+
33
+ class Product(ComplexToStringMixin, WBModel):
34
+ title = models.CharField(
35
+ max_length=128,
36
+ verbose_name=_("Title"),
37
+ )
38
+
39
+ slugify_title = models.CharField(
40
+ max_length=128,
41
+ verbose_name="Slugified Title",
42
+ blank=True,
43
+ null=True,
44
+ )
45
+ is_competitor = models.BooleanField(
46
+ verbose_name=_("Is Competitor"),
47
+ default=False,
48
+ help_text=_("Indicates wether this is a competitor's product"),
49
+ )
50
+
51
+ prospects = models.ManyToManyField(
52
+ "directory.Company",
53
+ related_name="interested_products",
54
+ blank=True,
55
+ verbose_name=_("Prospects"),
56
+ help_text=_("The list of prospects"),
57
+ through="wbcrm.ProductCompanyRelationship",
58
+ through_fields=("product", "company"),
59
+ )
60
+
61
+ def __str__(self) -> str:
62
+ return self.title
63
+
64
+ def compute_str(self) -> str:
65
+ return _("{} (Competitor)").format(self.title) if self.is_competitor else self.title
66
+
67
+ @classmethod
68
+ def get_endpoint_basename(cls):
69
+ return "wbcrm:product"
70
+
71
+ @classmethod
72
+ def get_representation_value_key(cls):
73
+ return "id"
74
+
75
+ @classmethod
76
+ def get_representation_endpoint(cls):
77
+ return "wbcrm:productrepresentation-list"
78
+
79
+ class Meta:
80
+ verbose_name = _("Product")
81
+ verbose_name_plural = _("Products")
82
+ unique_together = [["slugify_title", "is_competitor"]]
83
+
84
+ def save(self, *args, **kwargs):
85
+ self.slugify_title = slugify(self.title, separator=" ")
86
+ super().save(*args, **kwargs)
@@ -0,0 +1,280 @@
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
+
13
+ from wbcrm.preferences import (
14
+ get_maximum_allowed_recurrent_date,
15
+ get_recurrence_maximum_count,
16
+ )
17
+
18
+
19
+ class Recurrence(CalendarItem):
20
+ class ReoccuranceChoice(models.TextChoices):
21
+ NEVER = "NEVER", _("Never")
22
+ BUSINESS_DAILY = "RRULE:FREQ=DAILY;INTERVAL=1;WKST=MO;BYDAY=MO,TU,WE,TH,FR", _("Business Daily")
23
+ DAILY = "RRULE:FREQ=DAILY", _("Daily")
24
+ WEEKLY = "RRULE:FREQ=WEEKLY", _("Weekly")
25
+ BIWEEKLY = "RRULE:FREQ=WEEKLY;INTERVAL=2", _("Bi-Weekly")
26
+ MONTHLY = "RRULE:FREQ=MONTHLY", _("Monthly")
27
+ QUARTERLY = "RRULE:FREQ=MONTHLY;INTERVAL=3", _("Quarterly")
28
+ YEARLY = "RRULE:FREQ=YEARLY", _("Annually")
29
+
30
+ parent_occurrence = models.ForeignKey(
31
+ to="self",
32
+ related_name="child_activities",
33
+ null=True,
34
+ blank=True,
35
+ verbose_name=_("Parent Activity"),
36
+ on_delete=models.deletion.DO_NOTHING,
37
+ )
38
+ propagate_for_all_children = models.BooleanField(
39
+ default=False,
40
+ verbose_name=_("Propagate for all following activities?"),
41
+ help_text=_("If this is checked, changes will be propagated to the following activities."),
42
+ )
43
+ exclude_from_propagation = models.BooleanField(
44
+ default=False,
45
+ verbose_name=_("Exclude occurrence from propagation?"),
46
+ help_text=_("If this is checked, changes will not be propagated on this activity."),
47
+ )
48
+ recurrence_end = models.DateField(
49
+ verbose_name=_("Date"),
50
+ null=True,
51
+ blank=True,
52
+ help_text=_(
53
+ "Specifies until when an event is to be repeated. Is mutually exclusive with the Recurrence Count."
54
+ ),
55
+ )
56
+ recurrence_count = models.IntegerField(
57
+ validators=[MinValueValidator(1), MaxValueValidator(365)],
58
+ null=True,
59
+ blank=True,
60
+ verbose_name=_("Count"),
61
+ help_text=_(
62
+ "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."
63
+ ),
64
+ )
65
+ repeat_choice = models.CharField(
66
+ max_length=56,
67
+ default=ReoccuranceChoice.NEVER,
68
+ choices=ReoccuranceChoice.choices,
69
+ verbose_name=_("Recurrence Frequency"),
70
+ help_text=_("Repeat activity at the specified frequency"),
71
+ )
72
+
73
+ class Meta:
74
+ abstract = True
75
+
76
+ @property
77
+ def is_recurrent(self):
78
+ return self.repeat_choice != Recurrence.ReoccuranceChoice.NEVER
79
+
80
+ @property
81
+ def is_root(self):
82
+ return self.is_recurrent and not self.parent_occurrence
83
+
84
+ @property
85
+ def is_leaf(self):
86
+ return self.is_recurrent and not self.next_occurrence
87
+
88
+ @property
89
+ def next_occurrence(self):
90
+ if self.is_recurrent:
91
+ parent_occurrence = self if self.is_root else self.parent_occurrence
92
+ if qs := parent_occurrence.child_activities.filter(
93
+ is_active=True, period__startswith__gt=self.period.lower
94
+ ):
95
+ return qs.earliest("period__startswith")
96
+
97
+ @property
98
+ def previous_occurrence(self):
99
+ if self.is_recurrent and not self.is_root:
100
+ if qs := self.parent_occurrence.child_activities.filter(
101
+ is_active=True, period__startswith__lt=self.period.lower
102
+ ):
103
+ return qs.latest("period__startswith")
104
+ else:
105
+ return self.parent_occurrence
106
+ # Else, it is a pivot and therefore, no previous occurrence should exist
107
+
108
+ def save(self, *args, **kwargs):
109
+ if not self.recurrence_end:
110
+ self.recurrence_end = get_maximum_allowed_recurrent_date()
111
+ if self.does_recurrence_need_cancellation():
112
+ self.cancel_recurrence()
113
+ super().save(*args, **kwargs)
114
+
115
+ def delete(self, **kwargs):
116
+ if self.propagate_for_all_children:
117
+ self.forward_deletion()
118
+ elif self.is_root and (next_occurrence := self.next_occurrence):
119
+ next_occurrence.claim_parent_hood()
120
+ super().delete(**kwargs)
121
+
122
+ def _get_occurrence_start_datetimes(self, include_self: bool = False) -> list:
123
+ """
124
+ Returns a list with datetime values based on the recurrence options of an activity.
125
+
126
+ :return: list with datetime values
127
+ """
128
+ if self.is_recurrent:
129
+ max_allowed_date = get_maximum_allowed_recurrent_date()
130
+ max_allowed_count = get_recurrence_maximum_count()
131
+ occurrence_count = min(
132
+ self.recurrence_count + 1 if self.recurrence_count else max_allowed_count, max_allowed_count
133
+ )
134
+ end_date = min(
135
+ self.recurrence_end + timedelta(days=1) if self.recurrence_end else max_allowed_date, max_allowed_date
136
+ )
137
+ end_date_time = datetime(end_date.year, end_date.month, end_date.day).astimezone(pytz.utc)
138
+ rule_dict = convert_rrulestr_to_dict(
139
+ self.repeat_choice, dtstart=self.period.lower, count=occurrence_count, until=end_date_time
140
+ )
141
+
142
+ start_datetimes = list(rrule.rrule(**rule_dict))
143
+ if not include_self:
144
+ start_date = self.period.lower.replace(microsecond=0)
145
+ if self.period.lower in start_datetimes:
146
+ start_datetimes.remove(self.period.lower)
147
+ elif start_date in start_datetimes:
148
+ start_datetimes.remove(start_date)
149
+ return start_datetimes
150
+
151
+ def _create_recurrence_child(self, *args):
152
+ """
153
+ Return a new child object based on self (parent/root)
154
+ """
155
+ raise NotImplementedError()
156
+
157
+ def get_recurrent_valid_children(self):
158
+ """
159
+ Return a valid queryset representing the recurrent children
160
+ """
161
+ return self.child_activities.filter(exclude_from_propagation=False).order_by("period__startswith")
162
+
163
+ def get_recurrent_invalid_children(self):
164
+ valid = self.get_recurrent_valid_children().values_list("id", flat=True)
165
+ return self.child_activities.all().exclude(id__in=valid).order_by("period__startswith")
166
+
167
+ def _handle_recurrence_m2m_forwarding(self, child):
168
+ """
169
+ Call this when m2m data needs to be forward from the parent (self) to the passed child
170
+
171
+ Args:
172
+ child: Child to get the m2m data from
173
+ """
174
+ pass
175
+
176
+ def does_recurrence_need_cancellation(self) -> bool:
177
+ return False
178
+
179
+ def cancel_recurrence(self):
180
+ """
181
+ 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
182
+
183
+ its a transition method, save needs to be called
184
+ """
185
+ self.exclude_from_propagation = True
186
+
187
+ def claim_parent_hood(self):
188
+ if self.is_recurrent and not self.is_root:
189
+ new_batch = self._meta.model.objects.filter(
190
+ Q(parent_occurrence=self.parent_occurrence) & Q(period__startswith__gt=self.period.lower)
191
+ ).exclude(id=self.id)
192
+ if new_batch.exists():
193
+ new_batch.update(parent_occurrence=self)
194
+ self._meta.model.objects.filter(id=self.id).update(parent_occurrence=None)
195
+
196
+ def generate_occurrences(self, allow_reclaiming_root: bool = True):
197
+ """
198
+ Creation of the occurrences
199
+ Existing occurrences whose start date is part of the list of occurrences dates obtained from the recurrence pattern are excluded
200
+ Those not in the list are deleted
201
+ """
202
+ if self.is_recurrent:
203
+ if not self.is_root and allow_reclaiming_root:
204
+ self.claim_parent_hood()
205
+ self.refresh_from_db()
206
+ if self.is_root:
207
+ if old_occurrences_dict := {occ.period.lower: occ.id for occ in self.child_activities.all()}:
208
+ occurrence_dates = set(self._get_occurrence_start_datetimes())
209
+ old_occurrences_dates = set(old_occurrences_dict.keys())
210
+ new_occurrence_dates = occurrence_dates.difference(old_occurrences_dates)
211
+ if diff_inv := old_occurrences_dates.difference(occurrence_dates):
212
+ self.forward_deletion(child_ids=[old_occurrences_dict[x] for x in diff_inv])
213
+ else:
214
+ new_occurrence_dates = self._get_occurrence_start_datetimes()
215
+ for start_datetime in new_occurrence_dates:
216
+ child = self._create_recurrence_child(start_datetime)
217
+ self._handle_recurrence_m2m_forwarding(child)
218
+ return self.get_recurrent_valid_children()
219
+
220
+ def forward_change(
221
+ self,
222
+ allow_reclaiming_root: bool = True,
223
+ fields_to_forward: tuple[str, ...] = (
224
+ "online_meeting",
225
+ "visibility",
226
+ "conference_room",
227
+ "title",
228
+ "description",
229
+ "type",
230
+ "importance",
231
+ "reminder_choice",
232
+ "location",
233
+ "location_longitude",
234
+ "location_latitude",
235
+ "assigned_to",
236
+ ),
237
+ period_time_changed: bool = False,
238
+ ):
239
+ """
240
+ Forward the changes to the following occurrences
241
+ param: fields_to_forward: allows you to specify the fields to update
242
+ allow_reclaiming_root: if True we split the occurrences and this occurrence becomes the parent of the following occurrences
243
+ period_time_changed: boolean field to know if we need to update the period time or not
244
+ """
245
+ if self.is_recurrent and self.propagate_for_all_children:
246
+ if not self.is_root and allow_reclaiming_root:
247
+ self.claim_parent_hood()
248
+ self.refresh_from_db()
249
+ if self.is_root:
250
+ for child in self.get_recurrent_valid_children().order_by("period__startswith"):
251
+ for field in fields_to_forward:
252
+ setattr(child, field, getattr(self, field))
253
+ if period_time_changed:
254
+ tz = child.period.lower.tzinfo
255
+ start_datetime = datetime.combine(
256
+ child.period.lower.date(), self.period.lower.time()
257
+ ).astimezone(tz)
258
+ child.period = TimestamptzRange(start_datetime, (start_datetime + self.duration))
259
+ child.propagate_for_all_children = False
260
+ child.save()
261
+ self._handle_recurrence_m2m_forwarding(child)
262
+ self._meta.model.objects.filter(id=self.id).update(propagate_for_all_children=False)
263
+
264
+ def forward_deletion(self, child_ids: tuple["str", ...] = ()):
265
+ if self.is_recurrent:
266
+ if self.is_root:
267
+ occurrences = self.get_recurrent_valid_children()
268
+ else:
269
+ occurrences = self.parent_occurrence.get_recurrent_valid_children().filter(
270
+ period__startswith__gt=self.period.lower
271
+ )
272
+ if child_ids:
273
+ occurrences = occurrences.filter(id__in=child_ids)
274
+ if self.propagate_for_all_children:
275
+ # We don't call delete but update to is_active=False in order to silently delete the child activities without trggering the signals
276
+ occurrences.update(is_active=False)
277
+ self._meta.model.objects.filter(id=self.id).update(propagate_for_all_children=False)
278
+ else:
279
+ for occurrence in occurrences:
280
+ occurrence.delete()
wbcrm/preferences.py ADDED
@@ -0,0 +1,13 @@
1
+ from datetime import date, timedelta
2
+
3
+ from dynamic_preferences.registries import global_preferences_registry
4
+
5
+
6
+ def get_maximum_allowed_recurrent_date():
7
+ global_preferences = global_preferences_registry.manager()
8
+ return date.today() + timedelta(days=global_preferences["wbcrm__recurrence_maximum_allowed_days"])
9
+
10
+
11
+ def get_recurrence_maximum_count():
12
+ global_preferences = global_preferences_registry.manager()
13
+ return global_preferences["wbcrm__recurrence_maximum_count"]