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