wbcrm 2.2.4__py2.py3-none-any.whl → 2.2.5__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.

@@ -0,0 +1,171 @@
1
+ from typing import Type
2
+
3
+ from django.db.models import Q
4
+ from django.db.models.expressions import F
5
+ from django.db.models.query import QuerySet
6
+ from django.utils.translation import gettext_lazy as _
7
+ from wbcore import serializers
8
+ from wbcore.contrib.directory.models import Person
9
+ from wbcore.serializers.serializers import Serializer
10
+ from wbcrm.models import Activity, ActivityParticipant, ActivityType
11
+ from wbcrm.serializers import ActivityTypeRepresentationSerializer
12
+ from wbhuman_resources.models.kpi import KPI, KPIHandler
13
+ from wbhuman_resources.serializers import KPIModelSerializer
14
+
15
+
16
+ class NumberOfActivityKPISerializer(KPIModelSerializer):
17
+ activity_type = serializers.PrimaryKeyRelatedField(
18
+ required=True, many=True, queryset=ActivityType.objects.all(), label=_("Activity Type")
19
+ )
20
+ _activity_type = ActivityTypeRepresentationSerializer(source="activity_type", many=True)
21
+
22
+ activity_area = serializers.ChoiceField(
23
+ default="all",
24
+ choices=[("only_internal", _("Only Internal")), ("only_external", _("Only External")), ("all", _("All"))],
25
+ )
26
+ person_participates = serializers.BooleanField(
27
+ default=True,
28
+ label=_("Participants of Activity"),
29
+ help_text=_("Activities considered are related to the participants"),
30
+ )
31
+ person_created = serializers.BooleanField(
32
+ default=True, label=_("Creator of activity"), help_text=_("Activities considered are related to the creator")
33
+ )
34
+ person_assigned = serializers.BooleanField(
35
+ default=True,
36
+ label=_("Person Assigned"),
37
+ help_text=_("Activities considered are related to the persons assigned"),
38
+ )
39
+
40
+ def update(self, instance, validated_data):
41
+ activity_type = validated_data.get(
42
+ "activity_type",
43
+ instance.additional_data["serializer_data"].get(
44
+ "activity_type", list(ActivityType.objects.values_list("id", flat=True))
45
+ ),
46
+ )
47
+ activity_area = validated_data.get(
48
+ "activity_area",
49
+ instance.additional_data["serializer_data"].get("activity_area", "all"),
50
+ )
51
+
52
+ person_participates = validated_data.get(
53
+ "person_participates",
54
+ instance.additional_data["serializer_data"].get("person_participates", True),
55
+ )
56
+ person_created = validated_data.get(
57
+ "person_created",
58
+ instance.additional_data["serializer_data"].get("person_created", True),
59
+ )
60
+ person_assigned = validated_data.get(
61
+ "person_assigned",
62
+ instance.additional_data["serializer_data"].get("person_assigned", True),
63
+ )
64
+
65
+ additional_data = instance.additional_data
66
+ additional_data["serializer_data"]["activity_type"] = (
67
+ [_type.id for _type in validated_data.get("activity_type")]
68
+ if validated_data.get("activity_type")
69
+ else activity_type
70
+ )
71
+ additional_data["serializer_data"]["activity_area"] = activity_area
72
+ additional_data["serializer_data"]["person_participates"] = person_participates
73
+ additional_data["serializer_data"]["person_created"] = person_created
74
+ additional_data["serializer_data"]["person_assigned"] = person_assigned
75
+
76
+ additional_data["list_data"] = instance.get_handler().get_list_data(additional_data["serializer_data"])
77
+ validated_data["additional_data"] = additional_data
78
+
79
+ return super().update(instance, validated_data)
80
+
81
+ class Meta(KPIModelSerializer.Meta):
82
+ fields = (
83
+ *KPIModelSerializer.Meta.fields,
84
+ "activity_type",
85
+ "_activity_type",
86
+ "activity_area",
87
+ "person_participates",
88
+ "person_created",
89
+ "person_assigned",
90
+ )
91
+
92
+
93
+ class NumberOfActivityKPI(KPIHandler):
94
+ def get_name(self) -> str:
95
+ return _("Number of Activities")
96
+
97
+ def get_serializer(self) -> Type[Serializer]:
98
+ return NumberOfActivityKPISerializer
99
+
100
+ def annotate_parameters(self, queryset: QuerySet[KPI]) -> QuerySet[KPI]:
101
+ return queryset.annotate(
102
+ activity_type=F("additional_data__serializer_data__activity_type"),
103
+ activity_area=F("additional_data__serializer_data__activity_area"),
104
+ person_participates=F("additional_data__serializer_data__person_participates"),
105
+ person_created=F("additional_data__serializer_data__person_created"),
106
+ person_assigned=F("additional_data__serializer_data__person_assigned"),
107
+ )
108
+
109
+ def get_list_data(self, serializer_data: dict) -> list[str]:
110
+ activity_types = list(
111
+ ActivityType.objects.filter(pk__in=serializer_data["activity_type"]).values_list("title", flat=True)
112
+ )
113
+ return [
114
+ _("Activity Type: {types}").format(types=activity_types),
115
+ _("Activity Area: {area}").format(area=serializer_data["activity_area"]),
116
+ _("Person Participates: {participating}").format(participating={serializer_data["person_participates"]}),
117
+ _("Person Created: {created}").format(created=serializer_data["person_created"]),
118
+ _("Person Assigned: {assigned}").format(assigned=serializer_data["person_assigned"]),
119
+ ]
120
+
121
+ def get_display_grid(self) -> list[list[str]]:
122
+ return [
123
+ ["activity_type"] * 3,
124
+ ["activity_area"] * 3,
125
+ ["person_created", "person_assigned", "person_participates"],
126
+ ]
127
+
128
+ def evaluate(self, kpi: "KPI", evaluated_person=None, evaluation_date=None) -> int:
129
+ persons = (
130
+ [evaluated_person.id] if evaluated_person else kpi.evaluated_persons.all().values_list("id", flat=True)
131
+ )
132
+ serializer_data = kpi.additional_data.get("serializer_data")
133
+ qs = Activity.objects.filter(
134
+ period__startswith__date__gte=kpi.period.lower,
135
+ period__endswith__date__lte=evaluation_date if evaluation_date else kpi.period.upper,
136
+ ).exclude(status=Activity.Status.CANCELLED)
137
+ qs = (
138
+ qs.filter(type__id__in=serializer_data.get("activity_type"))
139
+ if serializer_data.get("activity_type")
140
+ else qs
141
+ )
142
+
143
+ condition = None
144
+ if serializer_data.get("person_created") or serializer_data.get("person_created") is None:
145
+ condition = Q(creator__in=persons)
146
+ if serializer_data.get("person_assigned") or serializer_data.get("person_assigned") is None:
147
+ condition = (condition | Q(assigned_to__in=persons)) if condition else Q(assigned_to__in=persons)
148
+ if serializer_data.get("person_participates") or serializer_data.get("person_participates") is None:
149
+ condition = (condition | Q(participants__in=persons)) if condition else Q(participants__in=persons)
150
+ if condition:
151
+ qs = qs.filter(condition)
152
+
153
+ if (activity_area := serializer_data.get("activity_area")) and (activity_area != "all"):
154
+ participant_ids = set(
155
+ ActivityParticipant.objects.filter(activity__id__in=qs.values_list("id", flat=True)).values_list(
156
+ "participant", flat=True
157
+ )
158
+ )
159
+ employee_ids = Person.objects.filter_only_internal().values_list("id", flat=True)
160
+ externals = set(
161
+ Person.objects.filter(Q(id__in=participant_ids) & ~Q(id__in=employee_ids)).values_list("id", flat=True)
162
+ )
163
+ if activity_area == "only_internal":
164
+ qs = qs.exclude(
165
+ Q(creator__in=externals) | Q(assigned_to__in=externals) | Q(participants__id__in=externals)
166
+ )
167
+ elif activity_area == "only_external":
168
+ qs = qs.filter(
169
+ Q(creator__in=externals) | Q(assigned_to__in=externals) | Q(participants__id__in=externals)
170
+ )
171
+ return qs.distinct("id").count()
@@ -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,110 @@
1
+ from datetime import datetime
2
+ from io import BytesIO
3
+
4
+ import xlsxwriter
5
+ from celery import shared_task
6
+ from django.conf import settings
7
+ from django.core.mail import EmailMultiAlternatives
8
+ from django.db.models import Q
9
+ from django.template.loader import get_template
10
+ from django.utils import timezone
11
+ from django.utils.html import strip_tags
12
+ from django.utils.translation import gettext as _
13
+ from wbcore.contrib.directory.models import Person
14
+ from wbcrm.models import Activity, ActivityType
15
+ from xlsxwriter.utility import xl_rowcol_to_cell
16
+
17
+
18
+ def set_cell(worksheet, row, col, value, width_cols, base_format):
19
+ cell = xl_rowcol_to_cell(row, col)
20
+ if value:
21
+ worksheet.write_string(cell, value, base_format)
22
+ if len(value) > width_cols[col]:
23
+ width_cols[col] = len(value)
24
+
25
+
26
+ @shared_task
27
+ def create_report_and_send(profile_id, employee_id, start_date=None, end_date=None):
28
+ employee = Person.objects.get(id=employee_id)
29
+ profile = Person.objects.get(id=profile_id)
30
+ if end_date is None:
31
+ end_date = timezone.now()
32
+ if start_date is None:
33
+ start_date = datetime(year=end_date.year, month=1, day=1, hour=0, minute=0, second=0)
34
+
35
+ report = create_report(employee_id, start_date, end_date)
36
+
37
+ html = get_template("email/activity_report.html")
38
+
39
+ context = {"profile": profile, "employee": employee}
40
+ html_content = html.render(context)
41
+
42
+ msg = EmailMultiAlternatives(
43
+ _("Report"), strip_tags(html_content), settings.DEFAULT_FROM_EMAIL, [profile.user_account.email]
44
+ )
45
+ msg.attach_alternative(html_content, "text/html")
46
+ title = _("all")
47
+ if start_date and end_date:
48
+ title = "{}_{}".format(start_date.strftime("%m/%d/%Y-%H:%M:%S"), end_date.strftime("%m/%d/%Y-%H:%M:%S"))
49
+ msg.attach(
50
+ "report_{}_{}.xlsx".format(employee.computed_str, title),
51
+ report.read(),
52
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
53
+ )
54
+ msg.send()
55
+
56
+
57
+ def create_report(employee, start_date, end_date):
58
+ output = BytesIO()
59
+ workbook = xlsxwriter.Workbook(output, {"in_memory": True})
60
+
61
+ base_format = workbook.add_format({"font_name": "Liberation Sans", "font_size": 10})
62
+ bold_format = workbook.add_format({"font_name": "Liberation Sans", "font_size": 10, "bold": True})
63
+ related_activities = Activity.objects.filter(
64
+ Q(participants__id=employee) | Q(creator__id=employee) | Q(assigned_to__id=employee)
65
+ )
66
+ activities = Activity.get_inrange_activities(related_activities, start_date, end_date)
67
+ # HERE STARTS THE FIRST WORKSHEET
68
+ for type in ActivityType.objects.all():
69
+ width_cols = [5, 5, 16, 8, 7, 11, 12]
70
+ activities_type = activities.filter(type=type.id)
71
+ worksheet = workbook.add_worksheet(type.title)
72
+ worksheet.write_string(0, 0, _("Title"), bold_format)
73
+ worksheet.write_string(0, 1, _("Start"), bold_format)
74
+ worksheet.write_string(0, 2, _("Duration (hours)"), bold_format)
75
+ worksheet.write_string(0, 3, _("Location"), bold_format)
76
+ worksheet.write_string(0, 4, _("Creator"), bold_format)
77
+ worksheet.write_string(0, 5, _("Assigned to"), bold_format)
78
+ worksheet.write_string(0, 6, _("Participants"), bold_format)
79
+
80
+ worksheet.write_string(2, 0, _("Total"), bold_format)
81
+ worksheet.write_string(2, 1, str(len(activities_type.all())), bold_format)
82
+
83
+ for row, activity in enumerate(activities_type.all(), start=4):
84
+ hours = (activity.period.upper - activity.period.lower).total_seconds() / 3600
85
+ duration = format(hours, ".2f")
86
+ creator = activity.creator
87
+ creator_name = ""
88
+ if creator:
89
+ creator_name = creator.computed_str
90
+ assigned_to = activity.assigned_to
91
+ assigned_to_name = ""
92
+ if assigned_to:
93
+ assigned_to_name = assigned_to.computed_str
94
+ participants = ", ".join([participant.computed_str for participant in activity.participants.all()])
95
+
96
+ set_cell(worksheet, row, 0, activity.title, width_cols, base_format)
97
+ set_cell(worksheet, row, 1, f"{activity.period.lower:%d.%m.%Y}", width_cols, base_format)
98
+ set_cell(worksheet, row, 2, duration, width_cols, base_format)
99
+ set_cell(worksheet, row, 2, activity.location, width_cols, base_format)
100
+ set_cell(worksheet, row, 4, creator_name, width_cols, base_format)
101
+ set_cell(worksheet, row, 5, assigned_to_name, width_cols, base_format)
102
+ set_cell(worksheet, row, 6, participants, width_cols, base_format)
103
+
104
+ for col, max_width in enumerate(width_cols):
105
+ if max_width > 300:
106
+ max_width = 300
107
+ worksheet.set_column(col, col, max_width)
108
+ workbook.close()
109
+ output.seek(0)
110
+ return output
@@ -0,0 +1,75 @@
1
+ from datetime import timedelta
2
+
3
+ from celery import shared_task
4
+ from dynamic_preferences.registries import global_preferences_registry
5
+ from googleapiclient.discovery import Resource
6
+ from psycopg.types.range import TimestamptzRange
7
+ from wbcore.contrib.authentication.models import User
8
+ from wbcrm.models import Activity
9
+
10
+ from ...typing_informations import GoogleEventType
11
+ from ...utils import GoogleSyncUtils
12
+ from .update import update_activity_participant
13
+
14
+
15
+ @shared_task(queue="synchronization")
16
+ def create_internal_activity_based_on_google_event(
17
+ event: GoogleEventType, user: User, service: Resource, parent_occurrence: Activity | None = None, is_instance=False
18
+ ):
19
+ """
20
+ A method for creating a "Workbench" activity based on a "Google" event. If the google event is a recurring event, this method will also create the corresponding workbench activities.
21
+
22
+ :param event: A google event body.
23
+ :param user: The current workbench user.
24
+ :param service: Thee google Resource.
25
+ :param parent_occurrence: The parent activity. This is only used when the activity is part of a recurring chain. Per default it is None.
26
+ :param is_instance: True, when the event is a google instance (A instance is an event that is part of a recurring chain)
27
+ """
28
+
29
+ external_id = event["id"]
30
+ if event.get("status") == "cancelled" or Activity.objects.filter(external_id=event["id"]).exists():
31
+ return
32
+
33
+ event_creator = event.get("organizer", {})
34
+ event_creator_mail = event_creator.get("email", "")
35
+ event_creator_displayed_name = event_creator.get("displayName", "")
36
+ creator = GoogleSyncUtils.get_or_create_person(event_creator_mail, event_creator_displayed_name)
37
+ event_start, event_end = GoogleSyncUtils.get_start_and_end(event)
38
+ if event_start == event_end:
39
+ event_end = event_end + timedelta(seconds=1)
40
+ period = TimestamptzRange(event_start, event_end) # type: ignore
41
+ all_day: bool = True if event["start"].get("date") else False
42
+ metadata = {"google_backend": {"instance": event}} if is_instance else {"google_backend": {"event": event}}
43
+
44
+ act = Activity.objects.create(
45
+ external_id=external_id,
46
+ title=event.get("summary", "(No Subject)"),
47
+ assigned_to=creator,
48
+ creator=creator,
49
+ description=event.get("description", ""),
50
+ start=event_start,
51
+ end=event_end,
52
+ period=period,
53
+ all_day=all_day,
54
+ location=event.get("location"),
55
+ visibility=GoogleSyncUtils.convert_event_visibility_to_activity_visibility(event.get("visibility", "")),
56
+ metadata=metadata,
57
+ parent_occurrence=parent_occurrence,
58
+ )
59
+
60
+ update_activity_participant(event, act)
61
+
62
+ if event.get("recurrence") and not is_instance:
63
+ instances: dict = service.events().instances(calendarId=user.email, eventId=event["id"]).execute()
64
+ instance_items: list[GoogleEventType] = instances["items"]
65
+ global_preferences = global_preferences_registry.manager()
66
+ max_list_length: int = global_preferences["wbcrm__recurrence_maximum_count"]
67
+ instance_items = instance_items[:max_list_length]
68
+
69
+ for item in instance_items:
70
+ if item["start"] == act.metadata["google_backend"].get("event", {}).get("start"):
71
+ updated_metadata: dict = act.metadata
72
+ updated_metadata["google_backend"] |= {"instance": item}
73
+ Activity.objects.filter(id=act.id).update(metadata=updated_metadata)
74
+ else:
75
+ create_internal_activity_based_on_google_event(item, user, service, act, True)
@@ -0,0 +1,78 @@
1
+ from django.db.models import QuerySet
2
+ from dynamic_preferences.registries import global_preferences_registry
3
+ from googleapiclient.discovery import Resource
4
+ from wbcrm.models import Activity
5
+
6
+
7
+ def cancel_or_delete_activity(activity: "Activity") -> None:
8
+ # Activity is cancelled rather than disable if global preference is True
9
+ if global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_activity"]:
10
+ Activity.objects.filter(id=activity.id).update(status=Activity.Status.CANCELLED)
11
+ else:
12
+ activity.delete()
13
+
14
+
15
+ def cancel_or_delete_activity_queryset(activity_qs: QuerySet["Activity"]) -> None:
16
+ # Activities are cancelled rather than delete if global preference is True
17
+ if global_preferences_registry.manager()["wbactivity_sync__sync_cancelled_activity"]:
18
+ activity_qs.update(status=Activity.Status.CANCELLED)
19
+ else:
20
+ activity_qs.delete()
21
+
22
+
23
+ def delete_single_activity(activity: Activity):
24
+ """
25
+ Deletes the activity that has the corresponding external ID
26
+ """
27
+ Activity.objects.filter(id=activity.id).update(external_id=None)
28
+ cancel_or_delete_activity(activity)
29
+
30
+
31
+ def delete_recurring_activity(activity: Activity, event: dict, user_mail: str, service: Resource):
32
+ """
33
+ Handles the deletion of recurring activities (either a single activity, a certain number of activities or all activities),
34
+ based on the changes done to the google event.
35
+
36
+ :param event: The google event dict that was deleted.
37
+ :param activity: The corresponding workbench activity.
38
+ :param user_mail: The e-mail address of the current user.
39
+ :param service: The google service to interact with googles resources.
40
+ """
41
+
42
+ parent_occurrence = activity.parent_occurrence if activity.parent_occurrence else activity
43
+ metadata_event = parent_occurrence.metadata["google_backend"].get(
44
+ "event", parent_occurrence.metadata["google_backend"].get("instance", {})
45
+ )
46
+ if event.get("recurringEventId"):
47
+ # Delete single event in event chain
48
+ if activity.status not in [Activity.Status.REVIEWED, Activity.Status.FINISHED]:
49
+ cancel_or_delete_activity(activity)
50
+
51
+ elif event.get("recurrence") != metadata_event.get("recurrence"):
52
+ # Delete all events after a certain event in the event chain
53
+
54
+ event_instances: dict = service.events().instances(calendarId=user_mail, eventId=event["id"]).execute()
55
+ external_id_list: list = [instance["id"] for instance in event_instances["items"]]
56
+ activities_to_remove = Activity.objects.filter(
57
+ metadata__google_backend__instance__recurringEventId__startswith=event["id"],
58
+ status=Activity.Status.PLANNED,
59
+ ).exclude(metadata__google_backend__instance__id__in=external_id_list)
60
+ first_in_list = activities_to_remove.order_by("start").first()
61
+ metadata = parent_occurrence.metadata
62
+ metadata["google_backend"] |= {"event": event}
63
+ Activity.objects.filter(id=parent_occurrence.id).update(metadata=metadata)
64
+ if (
65
+ first_in_list
66
+ and first_in_list.id == parent_occurrence.id
67
+ and parent_occurrence.status not in [Activity.Status.REVIEWED, Activity.Status.FINISHED]
68
+ ):
69
+ cancel_or_delete_activity(parent_occurrence)
70
+ cancel_or_delete_activity_queryset(activities_to_remove)
71
+
72
+ else:
73
+ # Delete all events in event chain
74
+ activities_to_cancel = Activity.objects.filter(
75
+ parent_occurrence=parent_occurrence, status=Activity.Status.PLANNED
76
+ )
77
+ cancel_or_delete_activity_queryset(activities_to_cancel)
78
+ cancel_or_delete_activity(activity)
@@ -0,0 +1,155 @@
1
+ from datetime import timedelta
2
+
3
+ from django.utils import timezone
4
+ from dynamic_preferences.registries import global_preferences_registry
5
+ from googleapiclient.discovery import Resource
6
+ from psycopg.types.range import TimestamptzRange
7
+ from wbcore.contrib.directory.models import Person
8
+ from wbcrm.models import Activity, ActivityParticipant
9
+
10
+ from ...typing_informations import GoogleEventType
11
+ from ...utils import GoogleSyncUtils
12
+
13
+
14
+ def update_activity_participant(event: GoogleEventType, activity: Activity):
15
+ """
16
+ Used to update the participants in a workbench activity.
17
+
18
+ :param event: The Google event dict with the participant informations.
19
+ :param activity:The corresponding activity.
20
+ """
21
+
22
+ can_sync_external_participants: bool = global_preferences_registry.manager()[
23
+ "wbactivity_sync__sync_external_participants"
24
+ ]
25
+ event_participants = GoogleSyncUtils.get_or_create_participants(event, activity.creator)
26
+
27
+ def update_or_add_participants():
28
+ for event_participant in event_participants:
29
+ if person := Person.objects.filter(id=event_participant["person_id"]).first():
30
+ activity.participants.add(person.id)
31
+ ActivityParticipant.objects.filter(activity=activity, participant=person).update(
32
+ participation_status=event_participant["status"]
33
+ )
34
+
35
+ if can_sync_external_participants or not activity.creator.is_internal: # type: ignore
36
+ update_or_add_participants()
37
+ else:
38
+ internal_activity_participants_set = set(
39
+ activity.participants.filter(id__in=Person.objects.filter_only_internal())
40
+ )
41
+ event_participants_set = set(Person.objects.filter(id__in=[x["person_id"] for x in event_participants]))
42
+ missing_activity_participants = internal_activity_participants_set - event_participants_set
43
+ update_or_add_participants()
44
+ if missing_activity_participants:
45
+ ActivityParticipant.objects.filter(
46
+ participant__in=missing_activity_participants, activity=activity
47
+ ).update(participation_status=ActivityParticipant.ParticipationStatus.CANCELLED)
48
+
49
+
50
+ def update_single_activity(event: GoogleEventType, activity: Activity, change_status=False):
51
+ """
52
+ Updates a single workbench activity based on changes done in a google event.
53
+
54
+ :param event: The google event dict that was updated.
55
+ :param activity: The corresponding workbench activity.
56
+ :param change_status: Information wheter the status of the event was updated or not.
57
+ """
58
+ if activity.external_id is None:
59
+ return
60
+ if activity.status == Activity.Status.REVIEWED or activity.status == Activity.Status.FINISHED:
61
+ metadata = activity.metadata
62
+ metadata["google_backend"] |= {"event": event} if not event.get("recurringEventId") else {"instance": event}
63
+ Activity.objects.filter(id=activity.id).update(
64
+ external_id=event["id"] if "_" in activity.external_id else activity.external_id,
65
+ metadata=metadata,
66
+ )
67
+ return
68
+ event_organizer = event.get("organizer", {})
69
+ event_organizer_mail = event_organizer.get("email", "")
70
+ event_organizer_displayed_name = event_organizer.get("displayName", "")
71
+ organizer = GoogleSyncUtils.get_or_create_person(event_organizer_mail, event_organizer_displayed_name)
72
+ event_start, event_end = GoogleSyncUtils.get_start_and_end(event)
73
+ if event_start == event_end:
74
+ event_end = event_end + timedelta(seconds=1)
75
+ period = TimestamptzRange(event_start, event_end) # type: ignore
76
+ all_day: bool = True if event["start"].get("date") else False
77
+
78
+ metadata = activity.metadata
79
+ metadata["google_backend"] |= {"event": event} if not event.get("recurringEventId") else {"instance": event}
80
+ Activity.objects.filter(id=activity.id).update(
81
+ title=event.get("summary", "(No Subject)"),
82
+ assigned_to=organizer,
83
+ description=event.get("description", ""),
84
+ start=event_start,
85
+ status=Activity.Status.PLANNED if change_status else activity.status,
86
+ end=event_end,
87
+ period=period,
88
+ all_day=all_day,
89
+ location=event.get("location"),
90
+ visibility=GoogleSyncUtils.convert_event_visibility_to_activity_visibility(event.get("visibility")),
91
+ metadata=metadata,
92
+ external_id=event["id"] if "_" in activity.external_id else activity.external_id,
93
+ )
94
+ update_activity_participant(event, activity)
95
+
96
+
97
+ def update_all_activities(activity: Activity, event: GoogleEventType, user_mail: str, service: Resource):
98
+ """
99
+ Updates all workbench activities in a recurrence chain based on changes done in a google event.
100
+
101
+ :param event: The google event dict that was updated.
102
+ :param activity: The corresponding workbench activity.
103
+ :param user_mail: The e-mail address of the current user.
104
+ :param service: The google service to interact with googles resources.
105
+ """
106
+
107
+ activity_instance = activity.metadata["google_backend"].get(
108
+ "instance", activity.metadata["google_backend"].get("event", {})
109
+ )
110
+ event_start, event_end = event["start"], event["end"]
111
+ activity_start, activity_end = activity_instance.get("start"), activity_instance.get("end")
112
+ event_instances: dict = service.events().instances(calendarId=user_mail, eventId=event["id"]).execute()
113
+ instance_items = event_instances["items"]
114
+ connected_activities = Activity.objects.filter(parent_occurrence=activity)
115
+ connected_activities |= Activity.objects.filter(metadata__google_backend__instance__recurringEventId=event["id"])
116
+ connected_activities |= Activity.objects.filter(id=activity.id)
117
+ if event_start != activity_start or event_end != activity_end:
118
+ # If the start/end time of the google event chain changes, google will also change the id of the event instances. That is why we need to handle this case seperatly
119
+ connected_activities = connected_activities.order_by("external_id")
120
+ activity_list = list(connected_activities)
121
+ instance_items.sort(key=lambda x: x["start"].get("date", x["start"]["dateTime"]))
122
+ for instance in instance_items:
123
+ if len(activity_list) == 0:
124
+ break
125
+ activity_instance = activity_list.pop(0)
126
+ update_single_activity(instance, activity_instance)
127
+ activity_instance.refresh_from_db()
128
+ else:
129
+ for instance in instance_items:
130
+ if activity_child := connected_activities.filter(external_id=instance["id"]).first() or (
131
+ activity_child := Activity.objects.filter(
132
+ metadata__google_backend__instance__id=instance["id"]
133
+ ).first()
134
+ ):
135
+ update_single_activity(instance, activity_child)
136
+
137
+
138
+ def update_activities_from_new_parent(event: dict, parent_occurrence: Activity, user_mail: str, service: Resource):
139
+ """
140
+ This methods updates child activities whose parent activity was altered.
141
+
142
+ :param event: The google event dict that was updated.
143
+ :param parent_occurrence: The corresponding workbench parent activity.
144
+ :param user_mail: The e-mail address of the current user.
145
+ :param service: The google service to interact with googles resources.
146
+
147
+ """
148
+ now = timezone.now()
149
+ canceled_child_activities = Activity.objects.filter(
150
+ parent_occurrence=parent_occurrence, period__startswith__gt=now
151
+ )
152
+ event_instances: dict = service.events().instances(calendarId=user_mail, eventId=event["id"]).execute()
153
+ for instance in event_instances["items"]:
154
+ if activity := canceled_child_activities.filter(external_id=instance["id"]).first():
155
+ update_single_activity(instance, activity, True)
@@ -0,0 +1,180 @@
1
+ import re
2
+ import warnings
3
+ from uuid import uuid4
4
+
5
+ from dateutil.rrule import rrule, rrulestr
6
+ from googleapiclient.discovery import Resource
7
+ from wbcrm.models import Activity
8
+
9
+ from ...utils import GoogleSyncUtils
10
+
11
+
12
+ def update_single_event(creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict):
13
+ """
14
+ Updates a single Google-Event.
15
+
16
+ After the external event has been updated, the metadata field of the internal activity is also updated with the information of the external event.
17
+
18
+ Note: Do not use for recurring events.
19
+
20
+ :param creator_mail: The e-mail address of the activities creator.
21
+ :param google_service: The google service to interact with googles resources.
22
+ :param internal_activity: The internal workbench activity that corresponds to the external google event.
23
+ :param updates: The update information.
24
+ :returns: None
25
+ """
26
+
27
+ updated_external_event = (
28
+ google_service.events()
29
+ .update(calendarId=creator_mail, eventId=internal_activity.external_id, body=updates)
30
+ .execute()
31
+ )
32
+ metadata = internal_activity.metadata | {"google_backend": {"event": updated_external_event}}
33
+ Activity.objects.filter(id=internal_activity.id).update(metadata=metadata)
34
+
35
+
36
+ def update_single_recurring_event(
37
+ creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict
38
+ ):
39
+ """
40
+ Updates a single recurring Google-Event.
41
+
42
+ After the external event has been updated, the metadata field of the internal activity is also updated with the information of the external event.
43
+
44
+ Note: Do not use for not recurring events.
45
+
46
+ :param creator_mail: The e-mail address of the activities creator.
47
+ :param google_service: The google service to interact with googles resources.
48
+ :param internal_activity: The internal workbench activity that corresponds to the external google event.
49
+ :param updates: The update information.
50
+ :returns: None
51
+ """
52
+ is_parent = Activity.objects.filter(parent_occurrence=internal_activity).exists()
53
+ external_id = (
54
+ internal_activity.metadata["google_backend"]["instance"].get("id")
55
+ if is_parent
56
+ else internal_activity.external_id
57
+ )
58
+ updated_event = google_service.events().patch(calendarId=creator_mail, eventId=external_id, body=updates).execute()
59
+ metadata = internal_activity.metadata
60
+ metadata["google_backend"] |= {"instance": updated_event}
61
+ Activity.objects.filter(id=internal_activity.id).update(metadata=metadata)
62
+
63
+
64
+ def update_all_recurring_events_from_parent(
65
+ creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict
66
+ ):
67
+ """
68
+ Updates all Google-Event-Instances belonging to the same recurring event chain.
69
+
70
+ After the external event instances have been updated, the metadata fields of the internal activities is also updated with the information of the corresponding external event instance.
71
+
72
+ Note: Do not use when creating a new parent activity from an existing child.
73
+
74
+ :param creator_mail: The e-mail address of the activities creator.
75
+ :param google_service: The google service to interact with googles resources.
76
+ :param internal_activity: The internal workbench parent activity that corresponds to the external google event.
77
+ :param updates: The update information.
78
+ :returns: None
79
+ """
80
+ updated_event = (
81
+ google_service.events()
82
+ .patch(calendarId=creator_mail, eventId=internal_activity.external_id, body=updates)
83
+ .execute()
84
+ )
85
+ metadata = internal_activity.metadata | {"google_backend": {"event": updated_event}}
86
+ instances = google_service.events().instances(calendarId=creator_mail, eventId=updated_event["id"]).execute()
87
+ google_event_items = instances["items"]
88
+ GoogleSyncUtils.add_instance_metadata(internal_activity, google_event_items, metadata)
89
+
90
+
91
+ def update_all_recurring_events_from_new_parent(
92
+ creator_mail: str, google_service: Resource, internal_activity: Activity, updates: dict
93
+ ):
94
+ """
95
+ Updates all Google-Event-Instances belonging to the same parent event.
96
+
97
+ After the external event instances have been updated, the metadata fields of the internal activities is also updated with the information of the corresponding external event instance.
98
+
99
+ Note: Do not use when updating from the original event chain parent.
100
+
101
+ :param creator_mail: The e-mail address of the activities creator.
102
+ :param google_service: The google service to interact with googles resources.
103
+ :param internal_activity: The internal workbench parent activity that corresponds to the external google event.
104
+ :param updates: The update information.
105
+ :returns: None
106
+ """
107
+
108
+ # If the old parent does not exist anymore, we cannot update the child.
109
+ if not (
110
+ current_parent_occurrence := Activity.objects.filter(
111
+ id=internal_activity.metadata.get("old_parent_id")
112
+ ).first()
113
+ ):
114
+ return warnings.warn(
115
+ "Could not update the recurring events on google, because the old parent activity was already deleted."
116
+ )
117
+
118
+ # Get the current parent event from google
119
+ current_google_parent_event: dict = (
120
+ google_service.events().get(calendarId=creator_mail, eventId=current_parent_occurrence.external_id).execute()
121
+ )
122
+
123
+ # Get the current recurrence rules. We need to modify them for both the current parent event and the new parent event.
124
+ current_parent_rrule_str: str = "\n".join(current_google_parent_event["recurrence"])
125
+
126
+ # We need to adjust the rrules to mimic the current status on the workbench. So we replace any until or count value with the current number of child activities.
127
+ current_parent_child_count = Activity.objects.filter(parent_occurrence=current_parent_occurrence).count()
128
+ new_parent_child_count = Activity.objects.filter(parent_occurrence=internal_activity).count()
129
+ current_parent_new_rrule: rrule = rrulestr(current_parent_rrule_str).replace( # type: ignore
130
+ count=current_parent_child_count + 1, until=None
131
+ )
132
+ new_parent_rrule: rrule = rrulestr(current_parent_rrule_str).replace(count=new_parent_child_count + 1, until=None) # type: ignore
133
+
134
+ # Converting the rrule back to str. Since the .__str__() method adds a DTSTART value, we need to remove this by using regex.
135
+ current_parent_new_rrule_str: str = re.sub("[DTSTART].*[\n]", "", current_parent_new_rrule.__str__()).split("T0")[
136
+ 0
137
+ ]
138
+ new_parent_rrule_str: str = re.sub("[DTSTART].*[\n]", "", new_parent_rrule.__str__()).split("T0")[0]
139
+
140
+ # Updating the current parent with the new rrules. This will remove all the child events on google that are not in the scope of the changed rrules anymore.
141
+ current_google_parent_event |= {"recurrence": [current_parent_new_rrule_str]}
142
+ updated_current_parent_event: dict = (
143
+ google_service.events()
144
+ .update(calendarId=creator_mail, eventId=current_google_parent_event["id"], body=current_google_parent_event)
145
+ .execute()
146
+ )
147
+
148
+ # Updating the corresponding metadata
149
+ current_parent_metadata = current_parent_occurrence.metadata | {
150
+ "google_backend": {"event": updated_current_parent_event}
151
+ }
152
+ Activity.objects.filter(id=current_parent_occurrence.id).update(metadata=current_parent_metadata)
153
+ current_instances = (
154
+ google_service.events()
155
+ .instances(calendarId=creator_mail, eventId=updated_current_parent_event["id"])
156
+ .execute()
157
+ )
158
+ current_instances_google_event_items = current_instances["items"]
159
+ current_parent_occurrence.refresh_from_db()
160
+ GoogleSyncUtils.add_instance_metadata(
161
+ current_parent_occurrence, current_instances_google_event_items, current_parent_metadata
162
+ )
163
+
164
+ # Updating the new parent with the created rrules. This will create the child events in google which are in the scope of the newly created rrules.
165
+
166
+ internal_activity_query = Activity.objects.filter(id=internal_activity.id)
167
+ external_id = uuid4().hex
168
+ updates |= {"recurrence": [new_parent_rrule_str], "id": external_id}
169
+ internal_activity_query.update(external_id=external_id)
170
+ new_parent_event: dict = google_service.events().insert(calendarId=creator_mail, body=updates).execute()
171
+
172
+ # Updating the corresponding metadata
173
+ metadata = internal_activity.metadata | {"google_backend": {"event": new_parent_event}, "old_parent_id": None}
174
+ internal_activity_query.update(metadata=metadata)
175
+ new_instances = (
176
+ google_service.events().instances(calendarId=creator_mail, eventId=new_parent_event["id"]).execute()
177
+ )
178
+ new_google_event_items = new_instances["items"]
179
+ internal_activity.refresh_from_db()
180
+ GoogleSyncUtils.add_instance_metadata(internal_activity, new_google_event_items, metadata, True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: wbcrm
3
- Version: 2.2.4
3
+ Version: 2.2.5
4
4
  Summary: A workbench module that contains all the functionality related to a customer relationship management.
5
5
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
6
6
  Requires-Dist: django-eventtools==1.*
@@ -23,6 +23,7 @@ wbcrm/filters/activities.py,sha256=aQ5pGGgeAzaskWnO25KeUiEk97BKfe8OZ543VU2_TTc,6
23
23
  wbcrm/filters/groups.py,sha256=KcjyimEoJ7LB_KIRyTIsiNneVWf5ud9CvSqJasffud0,623
24
24
  wbcrm/filters/products.py,sha256=gCiL4MVsaLZgLhyLeclWsBdKLRoYS-C7tL52_VWtfKE,1203
25
25
  wbcrm/filters/signals.py,sha256=0BSomHJQjlGV4O_JI6kPDFcghH-u6l7i0cEizhM_wOA,3819
26
+ wbcrm/kpi_handlers/activities.py,sha256=xjq6dQUToDmYZFqaZm3Gj3deQnPeUZcH4VS2qvVtIXY,7900
26
27
  wbcrm/migrations/0001_initial_squashed_squashed_0032_productcompanyrelationship_alter_product_prospects_and_more.py,sha256=tI2lU8Z8oW_-FexgquJOC41HwqFO6OyLuRtVY9Es2Ho,166228
27
28
  wbcrm/migrations/0002_alter_activity_repeat_choice.py,sha256=OBl6j8p4l3f5k1T3TzOgQtunSXJx0M3Uk-gAuPjPxkM,1187
28
29
  wbcrm/migrations/0003_remove_activity_external_id_and_more.py,sha256=Lz4RTsGJPnR2nKZ_FH_WSD22IRVqZ6_aZcleTJT4pSM,2097
@@ -46,6 +47,9 @@ wbcrm/models/activities.py,sha256=PxrNbP03jxciQPDieHrigj6l7faiNtzwVlC6KpTC9sY,53
46
47
  wbcrm/models/groups.py,sha256=T4ikoRw6BNWL5oeHDITsGt0KJWibm3OMYppwnkBliMM,4300
47
48
  wbcrm/models/products.py,sha256=q7BOU3hPGZwolVfOPkkAFhGYYBRDGHU8ldKui9FAMPQ,2472
48
49
  wbcrm/models/recurrence.py,sha256=onftWStKx1nvaxqxOgVRO7yrHqBpke8mDQ4Erojxbtw,11998
50
+ wbcrm/models/llm/activity_summaries.py,sha256=8dJP_aDVoakanbOnj_jYnSpeqX8b7JYtU1aXj7pj1-Y,1359
51
+ wbcrm/models/llm/analyze_relationship.py,sha256=Po6nE9GrlwKZtL471fsWySJYDqRZGW20cLAQTdiqFGk,2346
52
+ wbcrm/report/activity_report.py,sha256=AzSfiWne--CG4C4jpcODJtqegxcALCqxvOs9jeD4EB8,4835
49
53
  wbcrm/serializers/__init__.py,sha256=qKwo5e-Ix-Iow1RRdKFC0uZQmuSHzc8DIypIhi_E8HI,726
50
54
  wbcrm/serializers/accounts.py,sha256=pt9tu8a7P0TO9h4zMBoS1jEfdlqcQ0IaiM1S9sOT13A,4915
51
55
  wbcrm/serializers/activities.py,sha256=dVj6ta3EKh0uaXsbiZZmp9ycmW5etEvRvQFhfX4Hz3U,21756
@@ -79,6 +83,10 @@ wbcrm/synchronization/activity/backends/google/tasks.py,sha256=vQ-_z6koyAQd6kjp3
79
83
  wbcrm/synchronization/activity/backends/google/typing_informations.py,sha256=7fDaQEok0kzm_5_vrRrMiakFAh2O3j4G09XWPwYSK1I,2655
80
84
  wbcrm/synchronization/activity/backends/google/utils.py,sha256=glDYCtbQDnTrge2mpVM-GICebneaa_I9KZwCgFkwRMA,11572
81
85
  wbcrm/synchronization/activity/backends/google/request_utils/__init__.py,sha256=FQ8XwEEPtUR-DKHBgiNJQXHPduUNcJM9YiTSEH5LXmQ,530
86
+ wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/create.py,sha256=VyAg2iftSO_hyQthX6dtL299cogIFWsmrhE_xOwZQbs,3622
87
+ wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/delete.py,sha256=AvhoJx-vzf2RlHtDPwXy4LXQtxSGaTWQXLiHlmlFCtE,3700
88
+ wbcrm/synchronization/activity/backends/google/request_utils/external_to_internal/update.py,sha256=4AWhTV-Rdtp4RVijULw7JlIr7ewYVLWv4s6SBb4OKuI,7810
89
+ wbcrm/synchronization/activity/backends/google/request_utils/internal_to_external/update.py,sha256=XqEfqrLzELrxhCCovP_qvL1FECeiqxhV6caVZHfwRvk,8990
82
90
  wbcrm/synchronization/activity/backends/google/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
83
91
  wbcrm/synchronization/activity/backends/google/tests/conftest.py,sha256=yLKwA_DjolyFXkU83ePpwxsVbtl5lrXJxYcUFLg-4fs,35
84
92
  wbcrm/synchronization/activity/backends/google/tests/test_data.py,sha256=X7uwRyRkfM1PWtZ5ycLD_ZY2sxm4V6vZ9L4DV4OAzbI,3156
@@ -150,6 +158,6 @@ wbcrm/viewsets/titles/products.py,sha256=cFAK5zljjybabk2U0KzPT2PVfV5vmO_UlJd6QlI
150
158
  wbcrm/viewsets/titles/utils.py,sha256=IaHQTmEG2OwIHS1bRv7sjuT950wefUJNi3yvPdrpNEs,1144
151
159
  wbcrm/workflows/__init__.py,sha256=biwXXPkVJugT9Vc1cwbInAUY8EnVmOauxdPz7e_2w_A,32
152
160
  wbcrm/workflows/assignee_methods.py,sha256=L7ymErtcpFgXdTMTj_lDOVJqsLAGLNT6qMlrkHGuXWM,999
153
- wbcrm-2.2.4.dist-info/METADATA,sha256=mcLL2JVodlT3hfV_RLfbG79cTD6gidbquJDqvH29WVQ,449
154
- wbcrm-2.2.4.dist-info/WHEEL,sha256=aO3RJuuiFXItVSnAUEmQ0yRBvv9e1sbJh68PtuQkyAE,105
155
- wbcrm-2.2.4.dist-info/RECORD,,
161
+ wbcrm-2.2.5.dist-info/METADATA,sha256=dWfaN2T-FAkxX0XUa7ZAjWX8RXTzsv6vAJNajqbKPhk,449
162
+ wbcrm-2.2.5.dist-info/WHEEL,sha256=aO3RJuuiFXItVSnAUEmQ0yRBvv9e1sbJh68PtuQkyAE,105
163
+ wbcrm-2.2.5.dist-info/RECORD,,
File without changes