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
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import dataclasses
|
|
3
|
+
import operator
|
|
4
|
+
from functools import reduce
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import transaction
|
|
8
|
+
from django.db.models import Q, QuerySet
|
|
9
|
+
from django.http import HttpRequest, HttpResponse
|
|
10
|
+
from django.utils import timezone
|
|
11
|
+
from wbcore.contrib.agenda.models import Building, ConferenceRoom
|
|
12
|
+
from wbcore.contrib.directory.models import EmailContact, Person
|
|
13
|
+
|
|
14
|
+
from wbcrm.models import Activity, ActivityParticipant, ActivityType, Event
|
|
15
|
+
from wbcrm.synchronization.activity.utils import flattened_metadata_lookup
|
|
16
|
+
from wbcrm.typings import Activity as ActivityDTO
|
|
17
|
+
from wbcrm.typings import ConferenceRoom as ConferenceRoomDTO
|
|
18
|
+
from wbcrm.typings import ParticipantStatus as ParticipantStatusDTO
|
|
19
|
+
from wbcrm.typings import Person as PersonDTO
|
|
20
|
+
from wbcrm.typings import User as UserDTO
|
|
21
|
+
|
|
22
|
+
from .backend import SyncBackend
|
|
23
|
+
from .preferences import (
|
|
24
|
+
can_sync_cancelled_activity,
|
|
25
|
+
can_sync_cancelled_external_activity,
|
|
26
|
+
can_sync_create_new_activity_on_replanned_reviewed_activity,
|
|
27
|
+
can_sync_past_activity,
|
|
28
|
+
can_synchronize_activity_description,
|
|
29
|
+
can_synchronize_external_participants,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ActivityController:
|
|
34
|
+
update_fields = [
|
|
35
|
+
"title",
|
|
36
|
+
"period",
|
|
37
|
+
"visibility",
|
|
38
|
+
"creator",
|
|
39
|
+
"conference_room",
|
|
40
|
+
"reminder_choice",
|
|
41
|
+
"is_cancelled",
|
|
42
|
+
"all_day",
|
|
43
|
+
"online_meeting",
|
|
44
|
+
"location",
|
|
45
|
+
"description",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def __init__(self, backend: SyncBackend):
|
|
49
|
+
self.backend = backend()
|
|
50
|
+
|
|
51
|
+
def _is_valid(self, activity_dto: ActivityDTO) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Check if the activity can be synchronized or not.
|
|
54
|
+
- Past activity is not synchronized if global preference is False
|
|
55
|
+
- Activity is synchronized if at least one of the participants has an active webhook
|
|
56
|
+
"""
|
|
57
|
+
if period := activity_dto.period:
|
|
58
|
+
if period.upper < timezone.now() and not can_sync_past_activity():
|
|
59
|
+
return False
|
|
60
|
+
else:
|
|
61
|
+
return self.backend.is_valid(activity_dto)
|
|
62
|
+
else:
|
|
63
|
+
qs_activities = self.get_activities(activity_dto)
|
|
64
|
+
with transaction.atomic():
|
|
65
|
+
return qs_activities.exists() and any(
|
|
66
|
+
[self._is_valid(act._build_dto()) for act in qs_activities if act.period]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def handle_inbound_validation_response(self, request: HttpRequest) -> HttpResponse:
|
|
70
|
+
"""
|
|
71
|
+
allows to send a response to the external calendar if it is required before receiving the events in the webhook
|
|
72
|
+
"""
|
|
73
|
+
return self.backend._validation_response(request)
|
|
74
|
+
|
|
75
|
+
def get_events_from_inbound_request(self, request: HttpRequest) -> list[dict[str, Any]]:
|
|
76
|
+
"""
|
|
77
|
+
allows to get list of event following the notification
|
|
78
|
+
"""
|
|
79
|
+
events = []
|
|
80
|
+
self.backend.open()
|
|
81
|
+
if self.backend._is_inbound_request_valid(request):
|
|
82
|
+
for event in self.backend._get_events_from_request(request):
|
|
83
|
+
events.append(event)
|
|
84
|
+
self.backend.close()
|
|
85
|
+
return events
|
|
86
|
+
|
|
87
|
+
def handle_inbound(self, event: dict, event_object_id: int) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Each event received in the webhook is processed here, it allows to delete or create/update the corresponding activity
|
|
90
|
+
"""
|
|
91
|
+
self.backend.open()
|
|
92
|
+
activity_dto, is_deleted, user_dto = self.backend._deserialize(event)
|
|
93
|
+
if activity_dto.is_recurrent or self._is_valid(activity_dto):
|
|
94
|
+
if is_deleted:
|
|
95
|
+
self.delete_activity(activity_dto, user_dto, event_object_id)
|
|
96
|
+
else:
|
|
97
|
+
self.update_or_create_activity(activity_dto, event_object_id)
|
|
98
|
+
self.backend.close()
|
|
99
|
+
|
|
100
|
+
def _handle_outbound_data_preferences(self, activity_dto: ActivityDTO) -> tuple[ActivityDTO, list]:
|
|
101
|
+
"""
|
|
102
|
+
Activity data is parsed to take into account the company's preferences
|
|
103
|
+
this allows you to exclude the description or exclude external participants from the data if global preference is True
|
|
104
|
+
"""
|
|
105
|
+
if not can_synchronize_activity_description():
|
|
106
|
+
activity_dto.description = ""
|
|
107
|
+
valid_participants = []
|
|
108
|
+
internal_participants = []
|
|
109
|
+
for participant_dto in activity_dto.participants:
|
|
110
|
+
# External person are removed of the list if global preference is True
|
|
111
|
+
if (
|
|
112
|
+
Person.all_objects.get(id=participant_dto.person.id).is_internal
|
|
113
|
+
or can_synchronize_external_participants()
|
|
114
|
+
):
|
|
115
|
+
internal_participants.append(participant_dto)
|
|
116
|
+
if participant_dto.status != ActivityParticipant.ParticipationStatus.CANCELLED:
|
|
117
|
+
valid_participants.append(participant_dto)
|
|
118
|
+
|
|
119
|
+
activity_dto.participants = valid_participants
|
|
120
|
+
|
|
121
|
+
# list of external participants present in the external event, they will be added to the participants before updating the event to avoid deleting them
|
|
122
|
+
external_participants = (
|
|
123
|
+
self.backend.get_external_participants(activity_dto, internal_participants)
|
|
124
|
+
if not can_synchronize_external_participants()
|
|
125
|
+
else []
|
|
126
|
+
)
|
|
127
|
+
return activity_dto, external_participants
|
|
128
|
+
|
|
129
|
+
def handle_outbound(
|
|
130
|
+
self, activity_dto: ActivityDTO, old_activity_dto: ActivityDTO = None, is_deleted: bool = False
|
|
131
|
+
):
|
|
132
|
+
"""
|
|
133
|
+
Requests sent to the external calendar are processed here
|
|
134
|
+
It allows to send requests to delete, modify or create the event in the external calendar corresponding to the activity
|
|
135
|
+
"""
|
|
136
|
+
if not self._is_valid(activity_dto):
|
|
137
|
+
return
|
|
138
|
+
self.backend.open()
|
|
139
|
+
if is_deleted or activity_dto.is_cancelled:
|
|
140
|
+
self.backend._stream_deletion(activity_dto)
|
|
141
|
+
else:
|
|
142
|
+
activities_metadata = []
|
|
143
|
+
created = True if not old_activity_dto else False
|
|
144
|
+
# dataclasses.replace returns a new copy of the object without passing in any changes, return a copy with no modification
|
|
145
|
+
activity_dto_preference, external_participants = self._handle_outbound_data_preferences(
|
|
146
|
+
dataclasses.replace(activity_dto)
|
|
147
|
+
)
|
|
148
|
+
activity_dict = self.backend._serialize(activity_dto_preference, created=created)
|
|
149
|
+
if created: # then it's a creation
|
|
150
|
+
activities_metadata = self.backend._stream_creation(activity_dto, activity_dict)
|
|
151
|
+
elif self._has_changed(activity_dto, old_activity_dto):
|
|
152
|
+
keep_external_description = not can_synchronize_activity_description()
|
|
153
|
+
only_participants_changed = self._has_changed(
|
|
154
|
+
activity_dto, old_activity_dto, update_fields=["participants"]
|
|
155
|
+
) and not self._has_changed(activity_dto, old_activity_dto, exclude_fields=["participants"])
|
|
156
|
+
activities_metadata = self.backend._stream_update(
|
|
157
|
+
activity_dto,
|
|
158
|
+
activity_dict,
|
|
159
|
+
only_participants_changed,
|
|
160
|
+
external_participants,
|
|
161
|
+
keep_external_description,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if activities_metadata:
|
|
165
|
+
for act_dto, act_metadata in activities_metadata:
|
|
166
|
+
self.update_activity_metadata(act_dto, act_metadata)
|
|
167
|
+
self.backend.close()
|
|
168
|
+
|
|
169
|
+
def handle_outbound_participant(
|
|
170
|
+
self,
|
|
171
|
+
participant_dto: ParticipantStatusDTO,
|
|
172
|
+
old_participant_dto: ParticipantStatusDTO = None,
|
|
173
|
+
is_deleted: bool = False,
|
|
174
|
+
):
|
|
175
|
+
"""
|
|
176
|
+
allows to update the status of the event in the external calendar to match the one updated in the internal activity
|
|
177
|
+
"""
|
|
178
|
+
# check if activity creator is internal or activity is not passed according to global preference
|
|
179
|
+
if not self._is_valid(participant_dto.activity):
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# check if participant is internal or can sync external participant is allowed according to global preference
|
|
183
|
+
if not (
|
|
184
|
+
Person.all_objects.get(id=participant_dto.person.id).is_internal or can_synchronize_external_participants()
|
|
185
|
+
):
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
self.backend.open()
|
|
189
|
+
was_cancelled = False
|
|
190
|
+
status_changed = False
|
|
191
|
+
if old_participant_dto and old_participant_dto.status != participant_dto.status:
|
|
192
|
+
was_cancelled = old_participant_dto.status == ActivityParticipant.ParticipationStatus.CANCELLED
|
|
193
|
+
status_changed = participant_dto.status not in [
|
|
194
|
+
ActivityParticipant.ParticipationStatus.NOTRESPONDED,
|
|
195
|
+
ActivityParticipant.ParticipationStatus.PENDING_INVITATION,
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
wait_before_changing_status = False
|
|
199
|
+
if (not is_deleted and not old_participant_dto) or was_cancelled:
|
|
200
|
+
# self.backend._stream_forward(participant_dto.activity, [participant_dto])
|
|
201
|
+
# forward event doesn't work when user is an external, following the documentation it's recommended to only include the attendees property in the request body
|
|
202
|
+
# It will only send notifications to newly added attendees.
|
|
203
|
+
self.backend._stream_update_only_attendees(
|
|
204
|
+
activity_dto=participant_dto.activity, participants_dto=[participant_dto]
|
|
205
|
+
)
|
|
206
|
+
wait_before_changing_status = status_changed
|
|
207
|
+
if is_deleted or status_changed:
|
|
208
|
+
self.backend._stream_participant_change(
|
|
209
|
+
participant_dto, is_deleted, wait_before_changing=wait_before_changing_status
|
|
210
|
+
)
|
|
211
|
+
self.backend.close()
|
|
212
|
+
|
|
213
|
+
def handle_outbound_external_participants(self, activity_dto, participants_dto: list[ParticipantStatusDTO]):
|
|
214
|
+
"""
|
|
215
|
+
allows to update the status of the event in the external calendar to match the one updated in the internal activity
|
|
216
|
+
"""
|
|
217
|
+
# check if activity creator is internal or activity is not passed according to global preference
|
|
218
|
+
if not self._is_valid(activity_dto):
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
self.backend.open()
|
|
222
|
+
self.backend._stream_update_only_attendees(activity_dto=activity_dto, participants_dto=participants_dto)
|
|
223
|
+
self.backend.close()
|
|
224
|
+
|
|
225
|
+
def _changed_participants(
|
|
226
|
+
self, participants: list[ParticipantStatusDTO], old_participants: list[ParticipantStatusDTO]
|
|
227
|
+
) -> bool:
|
|
228
|
+
"""
|
|
229
|
+
Comparison of 2 lists of participants, returns false if they are identical
|
|
230
|
+
"""
|
|
231
|
+
d1 = {elt.person.email: elt.status for elt in participants}
|
|
232
|
+
d2 = {elt.person.email: elt.status for elt in old_participants}
|
|
233
|
+
if set(d1.keys()) == set(d2.keys()):
|
|
234
|
+
return any([d1[key] != d2[key] for key in d1.keys()])
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
def _has_changed(
|
|
238
|
+
self,
|
|
239
|
+
activity_dto: ActivityDTO,
|
|
240
|
+
old_activity_dto: ActivityDTO,
|
|
241
|
+
update_fields: list | None = None,
|
|
242
|
+
exclude_fields: list | None = None,
|
|
243
|
+
) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Comparison of 2 activities, returns false if they are identical
|
|
246
|
+
|
|
247
|
+
:param update_fields: allows to specify the list of fields taken into account in the comparison,
|
|
248
|
+
if not specified we use the list of the controller
|
|
249
|
+
|
|
250
|
+
:param exclude_fields: allows you to exclude fields from the comparison list
|
|
251
|
+
"""
|
|
252
|
+
if exclude_fields is None:
|
|
253
|
+
exclude_fields = []
|
|
254
|
+
if not can_synchronize_activity_description():
|
|
255
|
+
exclude_fields.append("description")
|
|
256
|
+
update_fields = (
|
|
257
|
+
update_fields
|
|
258
|
+
if update_fields
|
|
259
|
+
else (
|
|
260
|
+
self.update_fields + ["propagate_for_all_children", "exclude_from_propagation"]
|
|
261
|
+
if self.update_fields
|
|
262
|
+
else activity_dto.__dataclass_fields__
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
fields = list(set(update_fields) - set(exclude_fields))
|
|
266
|
+
if "participants" in fields:
|
|
267
|
+
fields.remove("participants")
|
|
268
|
+
participants_changed = self._changed_participants(activity_dto.participants, old_activity_dto.participants)
|
|
269
|
+
else:
|
|
270
|
+
participants_changed = False
|
|
271
|
+
is_new_activity = True if activity_dto and not old_activity_dto else False
|
|
272
|
+
return (
|
|
273
|
+
participants_changed
|
|
274
|
+
or is_new_activity
|
|
275
|
+
or (
|
|
276
|
+
activity_dto
|
|
277
|
+
and old_activity_dto
|
|
278
|
+
and any(
|
|
279
|
+
[getattr(activity_dto, field, None) != getattr(old_activity_dto, field, None) for field in fields]
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def get_activities(self, activity_dto: ActivityDTO, _operator: operator = operator.or_) -> QuerySet["Activity"]:
|
|
285
|
+
"""
|
|
286
|
+
Received events are deserialized into data transfer object of activity,
|
|
287
|
+
we use the metadata construct to identify the activity
|
|
288
|
+
the operator allows to know which operation combination to perform during the filter
|
|
289
|
+
"""
|
|
290
|
+
if conditions := [
|
|
291
|
+
Q(**{key: value}) for key, value in flattened_metadata_lookup(activity_dto.metadata, key_string="metadata")
|
|
292
|
+
]:
|
|
293
|
+
return Activity.all_objects.select_for_update().filter(reduce(_operator, conditions))
|
|
294
|
+
return Activity.objects.none()
|
|
295
|
+
|
|
296
|
+
def get_activity_participant(self, user_dto: UserDTO) -> Person:
|
|
297
|
+
"""
|
|
298
|
+
Attendees of the external event are deserialized into person data transfer objects
|
|
299
|
+
we use the metadata construct to identify the person
|
|
300
|
+
"""
|
|
301
|
+
if conditions := [
|
|
302
|
+
Q(**{key: value})
|
|
303
|
+
for key, value in flattened_metadata_lookup(user_dto.metadata, key_string="user_account__metadata")
|
|
304
|
+
]:
|
|
305
|
+
try:
|
|
306
|
+
return Person.objects.get(reduce(operator.and_, conditions))
|
|
307
|
+
except Exception:
|
|
308
|
+
return None
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
def _get_data_from_activity_dto(self, activity_dto: ActivityDTO, parent_occurrence: Activity = None) -> dict:
|
|
312
|
+
"""
|
|
313
|
+
Data transfer object of activity obtained from the external event is parsed into a dict to allow the creation or update of the object in the database
|
|
314
|
+
"""
|
|
315
|
+
activity_data = {}
|
|
316
|
+
fields = self.update_fields if self.update_fields else activity_dto.__dataclass_fields__
|
|
317
|
+
if not can_synchronize_activity_description() and "description" in fields:
|
|
318
|
+
fields.remove("description")
|
|
319
|
+
|
|
320
|
+
for field in fields:
|
|
321
|
+
activity_data[field] = getattr(activity_dto, field)
|
|
322
|
+
if activity_data.get("creator"):
|
|
323
|
+
activity_data["creator"] = self.get_or_create_person(activity_dto.creator)
|
|
324
|
+
if activity_data.get("conference_room"):
|
|
325
|
+
activity_data["conference_room"] = self.get_or_create_conference_room(activity_dto.conference_room)
|
|
326
|
+
if activity_dto.is_recurrent:
|
|
327
|
+
activity_data.update(
|
|
328
|
+
{
|
|
329
|
+
"recurrence_end": activity_dto.recurrence_end,
|
|
330
|
+
"recurrence_count": activity_dto.recurrence_count,
|
|
331
|
+
"repeat_choice": activity_dto.repeat_choice,
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
if parent_occurrence:
|
|
335
|
+
activity_data["parent_occurrence"] = parent_occurrence
|
|
336
|
+
return activity_data
|
|
337
|
+
|
|
338
|
+
def _create_activity(self, activity_dto: ActivityDTO, parent_occurrence: Activity = None) -> Activity:
|
|
339
|
+
"""
|
|
340
|
+
Uses the Data transfer object obtained from the external event to create the activity in the database
|
|
341
|
+
"""
|
|
342
|
+
activity_data = self._get_data_from_activity_dto(activity_dto, parent_occurrence)
|
|
343
|
+
activity_type, created = ActivityType.objects.get_or_create(
|
|
344
|
+
slugify_title="meeting", defaults={"title": "Meeting"}
|
|
345
|
+
)
|
|
346
|
+
activity = Activity(**activity_data, type=activity_type)
|
|
347
|
+
activity.save(synchronize=False)
|
|
348
|
+
if activity.period.lower < activity.created:
|
|
349
|
+
# A created past event should not appear at the top of the list.
|
|
350
|
+
Activity.objects.filter(id=activity.id).update(created=activity.period.lower, edited=activity.period.lower)
|
|
351
|
+
self.update_or_create_participants(activity, activity_dto.participants)
|
|
352
|
+
self.update_activity_metadata(activity._build_dto(), activity_dto.metadata)
|
|
353
|
+
return activity
|
|
354
|
+
|
|
355
|
+
def _update_activity(
|
|
356
|
+
self, activity: Activity, activity_dto: ActivityDTO, parent_occurrence: Activity = None
|
|
357
|
+
) -> tuple[Activity, str]:
|
|
358
|
+
"""
|
|
359
|
+
Convert the data transfer object obtained from the external event into a dict to update the activity in the database.
|
|
360
|
+
"""
|
|
361
|
+
activity_data = self._get_data_from_activity_dto(activity_dto, parent_occurrence)
|
|
362
|
+
if has_changed := self._has_changed(activity_dto, activity._build_dto(), update_fields=activity_data.keys()):
|
|
363
|
+
# When previously canceled by the only internal participant is finally accepted by the latter
|
|
364
|
+
if (
|
|
365
|
+
(creator := activity.creator)
|
|
366
|
+
and not creator.is_internal
|
|
367
|
+
and activity.status == Activity.Status.CANCELLED
|
|
368
|
+
):
|
|
369
|
+
activity_data["status"] = Activity.Status.PLANNED
|
|
370
|
+
Activity.objects.filter(id=activity.id).update(**activity_data)
|
|
371
|
+
activity.refresh_from_db()
|
|
372
|
+
activity.save(synchronize=False)
|
|
373
|
+
_, participant_changed = self.update_or_create_participants(activity, activity_dto.participants)
|
|
374
|
+
self.update_activity_metadata(activity._build_dto(), activity_dto.metadata)
|
|
375
|
+
return activity, f"activity_changes: {has_changed}, participants_changes: {participant_changed or False}; "
|
|
376
|
+
|
|
377
|
+
@transaction.atomic
|
|
378
|
+
def update_or_create_activity(self, activity_dto: ActivityDTO, event_object_id: int | None = None) -> None: # noqa: C901
|
|
379
|
+
"""
|
|
380
|
+
allows you to create or update a single or recurring activity from a data transfer object obtained from an external event
|
|
381
|
+
"""
|
|
382
|
+
qs_activities = self.get_activities(activity_dto)
|
|
383
|
+
if activity_dto.is_recurrent:
|
|
384
|
+
event_result = {"action_type": "update or create recurring activities", "action": ""}
|
|
385
|
+
ids_dto_dict = {}
|
|
386
|
+
dates_dto_dict = {}
|
|
387
|
+
for _dto in activity_dto.recurring_activities:
|
|
388
|
+
if _dto.id:
|
|
389
|
+
ids_dto_dict[_dto.id] = _dto
|
|
390
|
+
dates_dto_dict[_dto.period.lower.date()] = _dto
|
|
391
|
+
ids_dto_list = set(ids_dto_dict.keys())
|
|
392
|
+
dates_dto_list = set(dates_dto_dict.keys())
|
|
393
|
+
|
|
394
|
+
for act in qs_activities.exclude(Q(id__in=ids_dto_list) | Q(period__startswith__date__in=dates_dto_list)):
|
|
395
|
+
act.delete(synchronize=False)
|
|
396
|
+
for act in qs_activities.filter(Q(id__in=ids_dto_list) | Q(period__startswith__date__in=dates_dto_list)):
|
|
397
|
+
act_dto = (
|
|
398
|
+
_act_dto if (_act_dto := ids_dto_dict.get(act.id)) else dates_dto_dict[act.period.lower.date()]
|
|
399
|
+
)
|
|
400
|
+
activity_updated, _ = self._update_activity(act, act_dto)
|
|
401
|
+
if not act_dto.id:
|
|
402
|
+
self.backend._stream_extension_event(activity_updated._build_dto())
|
|
403
|
+
|
|
404
|
+
dates_act_dict = {act.period.lower.date(): act for act in qs_activities}
|
|
405
|
+
if dates_to_create := dates_dto_list.difference(set(dates_act_dict.keys())):
|
|
406
|
+
parent_start = sorted(dates_dto_list)[0]
|
|
407
|
+
if dates_act_dict.get(parent_start):
|
|
408
|
+
parent_act = dates_act_dict[parent_start]
|
|
409
|
+
else:
|
|
410
|
+
parent_dto = dates_dto_dict[parent_start]
|
|
411
|
+
if parent_dto.id and (instance := Activity.all_objects.filter(id=parent_dto.id).first()):
|
|
412
|
+
Activity.all_objects.filter(id=parent_dto.id).update(parent_occurrence=None, is_active=True)
|
|
413
|
+
parent_act, _ = self._update_activity(instance, parent_dto)
|
|
414
|
+
else:
|
|
415
|
+
if parent_act := self._create_activity(parent_dto):
|
|
416
|
+
qs_activities.exclude(id=parent_act.id).update(parent_occurrence=parent_act)
|
|
417
|
+
self.backend._stream_extension_event(parent_act._build_dto())
|
|
418
|
+
if parent_start in dates_to_create:
|
|
419
|
+
dates_to_create.remove(parent_start)
|
|
420
|
+
for _start_date in sorted(dates_to_create):
|
|
421
|
+
instance_dto = dates_dto_dict[_start_date]
|
|
422
|
+
if (instance_dto.id and (instance := Activity.all_objects.filter(id=instance_dto.id).first())) or (
|
|
423
|
+
instance := parent_act.child_activities.filter(period__startswith__date=_start_date).first()
|
|
424
|
+
):
|
|
425
|
+
new_activity, _ = self._update_activity(instance, instance_dto, parent_occurrence=parent_act)
|
|
426
|
+
else:
|
|
427
|
+
new_activity = self._create_activity(instance_dto, parent_occurrence=parent_act)
|
|
428
|
+
self.backend._stream_extension_event(new_activity._build_dto())
|
|
429
|
+
else:
|
|
430
|
+
if qs_activities.exists():
|
|
431
|
+
event_result = {"action_type": "update", "action": ""}
|
|
432
|
+
for activity in qs_activities:
|
|
433
|
+
previous_status = activity.status
|
|
434
|
+
if not (
|
|
435
|
+
previous_status == Activity.Status.REVIEWED
|
|
436
|
+
and can_sync_create_new_activity_on_replanned_reviewed_activity()
|
|
437
|
+
and activity._build_dto().period != activity_dto.period
|
|
438
|
+
):
|
|
439
|
+
activity_updated, participant_updates_info = self._update_activity(activity, activity_dto)
|
|
440
|
+
msg = f"{activity.id} => {previous_status} activity, {participant_updates_info} -> {activity_updated.status} activity; "
|
|
441
|
+
event_result["action"] += msg
|
|
442
|
+
else:
|
|
443
|
+
new_activity = self._create_activity(activity_dto)
|
|
444
|
+
event_result["action"] += (
|
|
445
|
+
f"reviewed activity {activity.id} replanned to {new_activity.id} -> new activity {new_activity.status} created"
|
|
446
|
+
)
|
|
447
|
+
# remove metadata since the old activity is not sync anymore
|
|
448
|
+
Activity.objects.filter(id=activity.id).update(
|
|
449
|
+
metadata={
|
|
450
|
+
self.backend.METADATA_KEY: {
|
|
451
|
+
"info": f"reviewed activity has been replanned to {new_activity.id}"
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
new_activity = self._create_activity(activity_dto)
|
|
457
|
+
event_result = {
|
|
458
|
+
"action_type": "create",
|
|
459
|
+
"action": f"{new_activity.id} -> new activity {new_activity.status} created",
|
|
460
|
+
}
|
|
461
|
+
Event.objects.filter(id=event_object_id).update(result=event_result)
|
|
462
|
+
|
|
463
|
+
def update_activity_metadata(self, activity_dto: ActivityDTO, new_metadata):
|
|
464
|
+
"""
|
|
465
|
+
allows to update the metadata used to save the external event id which is used to retrieve the event/activity
|
|
466
|
+
"""
|
|
467
|
+
old_metadata = activity_dto.metadata.get(self.backend.METADATA_KEY, {})
|
|
468
|
+
metadata = copy.deepcopy(old_metadata)
|
|
469
|
+
new_metadata = new_metadata.get(self.backend.METADATA_KEY, {})
|
|
470
|
+
for key, new_value in new_metadata.items():
|
|
471
|
+
if old_value := metadata.get(key):
|
|
472
|
+
if isinstance(old_value, list):
|
|
473
|
+
values = set(new_value) if isinstance(new_value, list) else {new_value}
|
|
474
|
+
if new_values := [_value for _value in values if _value not in old_value]:
|
|
475
|
+
metadata[key] = sorted(old_value + new_values)
|
|
476
|
+
else:
|
|
477
|
+
metadata[key] = new_value
|
|
478
|
+
else:
|
|
479
|
+
metadata[key] = new_value
|
|
480
|
+
if old_metadata != metadata:
|
|
481
|
+
activity_dto.metadata[self.backend.METADATA_KEY] = metadata
|
|
482
|
+
Activity.objects.filter(id=activity_dto.id).update(metadata=activity_dto.metadata)
|
|
483
|
+
|
|
484
|
+
def _cancel_or_delete_activity(self, activity: "Activity") -> None:
|
|
485
|
+
"""
|
|
486
|
+
when an event is deleted in the external calendar, we cancel or delete the activity according to global preferences
|
|
487
|
+
"""
|
|
488
|
+
if can_sync_cancelled_activity():
|
|
489
|
+
if activity.status == Activity.Status.PLANNED:
|
|
490
|
+
activity.cancel()
|
|
491
|
+
activity.save(synchronize=False)
|
|
492
|
+
else:
|
|
493
|
+
activity.delete(synchronize=False)
|
|
494
|
+
|
|
495
|
+
def _delete_activity(self, activity: Activity, participant: Person, exact_match: bool):
|
|
496
|
+
previous_status = activity.status
|
|
497
|
+
internal_activity = activity.creator.is_internal if activity.creator else False
|
|
498
|
+
is_internal_creator = internal_activity and activity.creator == participant
|
|
499
|
+
if exact_match and is_internal_creator:
|
|
500
|
+
if activity.parent_occurrence:
|
|
501
|
+
# delete instance activity if it's not the parent of recurring activities
|
|
502
|
+
activity.delete(synchronize=False)
|
|
503
|
+
else:
|
|
504
|
+
# single activity or first parent of recurring activity
|
|
505
|
+
self._cancel_or_delete_activity(activity)
|
|
506
|
+
msg = f"{activity.id} => {previous_status} activity {activity.status}; "
|
|
507
|
+
else:
|
|
508
|
+
activity.activity_participants.filter(participant=participant).update(
|
|
509
|
+
participation_status=ActivityParticipant.ParticipationStatus.CANCELLED
|
|
510
|
+
)
|
|
511
|
+
msg = f"{activity.id} => participant status cancelled; "
|
|
512
|
+
|
|
513
|
+
# Handle external creator activity cancellation if no internal participants exist
|
|
514
|
+
internal_participants_exist = (
|
|
515
|
+
activity.activity_participants.exclude(
|
|
516
|
+
participation_status=ActivityParticipant.ParticipationStatus.CANCELLED
|
|
517
|
+
)
|
|
518
|
+
.exclude(participant=participant)
|
|
519
|
+
.filter(participant__in=Person.objects.filter_only_internal())
|
|
520
|
+
.exists()
|
|
521
|
+
)
|
|
522
|
+
if not internal_activity and can_sync_cancelled_external_activity() and not internal_participants_exist:
|
|
523
|
+
self._cancel_or_delete_activity(activity)
|
|
524
|
+
msg += f"external {previous_status} activity {activity.status}; "
|
|
525
|
+
|
|
526
|
+
return msg
|
|
527
|
+
|
|
528
|
+
@transaction.atomic
|
|
529
|
+
def delete_activity(
|
|
530
|
+
self, activity_dto: ActivityDTO, user_dto: UserDTO, event_object_id: int | None = None
|
|
531
|
+
) -> None:
|
|
532
|
+
event_result = {"action_type": "delete", "action": ""}
|
|
533
|
+
|
|
534
|
+
# Try to get the participant, return if not found
|
|
535
|
+
participant = self.get_activity_participant(user_dto)
|
|
536
|
+
if not participant:
|
|
537
|
+
Event.objects.filter(id=event_object_id).update(result=event_result)
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
# Prepare query sets for activities deletion
|
|
541
|
+
qs_activities = self.get_activities(activity_dto, operator.and_) # activities deleted by the organizer
|
|
542
|
+
qs_invitation_activities = self.get_activities(activity_dto) # activities deleted by a participant
|
|
543
|
+
|
|
544
|
+
# skip deletion when notification received is not a deletion
|
|
545
|
+
if not activity_dto.delete_notification:
|
|
546
|
+
activities_ids = list(qs_invitation_activities.values_list("id", "status"))
|
|
547
|
+
event_result["action"] += f"{activities_ids} => skip deletion; wait for delete notification"
|
|
548
|
+
Event.objects.filter(id=event_object_id).update(result=event_result)
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
# Handle activities deleted by organizer
|
|
552
|
+
if qs_activities.exists():
|
|
553
|
+
for activity in qs_activities:
|
|
554
|
+
event_result["action"] += self._delete_activity(activity, participant, exact_match=True)
|
|
555
|
+
|
|
556
|
+
# Handle activities with participant invitation
|
|
557
|
+
elif qs_invitation_activities.exists():
|
|
558
|
+
for activity in qs_invitation_activities:
|
|
559
|
+
event_result["action"] += self._delete_activity(activity, participant, exact_match=False)
|
|
560
|
+
|
|
561
|
+
event_result["participant"] = participant.id
|
|
562
|
+
Event.objects.filter(id=event_object_id).update(result=event_result)
|
|
563
|
+
|
|
564
|
+
def get_or_create_person(self, person_dto: PersonDTO) -> Person:
|
|
565
|
+
"""
|
|
566
|
+
A method to get or create the internal person of a "External" event. Returns a Person objects of the internal database.
|
|
567
|
+
|
|
568
|
+
:param mail: person mail.
|
|
569
|
+
"""
|
|
570
|
+
potential_persons = Person.objects.filter(emails__address=person_dto.email.lower()).order_by(
|
|
571
|
+
"-emails__primary"
|
|
572
|
+
)
|
|
573
|
+
if potential_persons.exists():
|
|
574
|
+
person = potential_persons.first() # TODO change with owner feature
|
|
575
|
+
else:
|
|
576
|
+
person = Person.objects.create(
|
|
577
|
+
last_name=person_dto.last_name, first_name=person_dto.first_name, is_draft_entry=True
|
|
578
|
+
)
|
|
579
|
+
if (
|
|
580
|
+
potential_contact := EmailContact.objects.filter(entry__isnull=True, address=person_dto.email.lower())
|
|
581
|
+
.order_by("-primary")
|
|
582
|
+
.first()
|
|
583
|
+
):
|
|
584
|
+
EmailContact.objects.filter(id=potential_contact.id).update(entry=person)
|
|
585
|
+
else:
|
|
586
|
+
EmailContact.objects.create(entry=person, address=person_dto.email, primary=True)
|
|
587
|
+
return person
|
|
588
|
+
|
|
589
|
+
def get_or_create_conference_room(self, conference_room: ConferenceRoomDTO) -> ConferenceRoom:
|
|
590
|
+
if ConferenceRoom.objects.filter(email=conference_room.email).exists():
|
|
591
|
+
conference_room = ConferenceRoom.objects.get(email=conference_room.email)
|
|
592
|
+
else:
|
|
593
|
+
name_building = conference_room.name_building if conference_room.name_building else conference_room.email
|
|
594
|
+
name_conference_room = conference_room.name if conference_room.name else conference_room.email
|
|
595
|
+
building, _ = Building.objects.get_or_create(name=name_building)
|
|
596
|
+
conference_room = ConferenceRoom.objects.create(
|
|
597
|
+
name=name_conference_room, email=conference_room.email, building=building
|
|
598
|
+
)
|
|
599
|
+
return conference_room
|
|
600
|
+
|
|
601
|
+
def update_or_create_participants(
|
|
602
|
+
self, activity: Activity, participants_dto: list[ParticipantStatusDTO]
|
|
603
|
+
) -> tuple[list[ActivityParticipant], str]:
|
|
604
|
+
"""
|
|
605
|
+
allows to create or update the status of the participants of an activity
|
|
606
|
+
"""
|
|
607
|
+
activity_participants = []
|
|
608
|
+
participants_changed = ""
|
|
609
|
+
for participant_dto in participants_dto:
|
|
610
|
+
person = self.get_or_create_person(participant_dto.person)
|
|
611
|
+
kwargs = {"participation_status": participant_dto.status} if participant_dto.status else {}
|
|
612
|
+
|
|
613
|
+
if activity_participant := ActivityParticipant.objects.filter(
|
|
614
|
+
activity=activity, participant=person
|
|
615
|
+
).first():
|
|
616
|
+
if activity_participant.participation_status != participant_dto.status:
|
|
617
|
+
ActivityParticipant.objects.filter(activity=activity, participant=person).update(**kwargs)
|
|
618
|
+
participants_changed += (
|
|
619
|
+
f"- {person}: {activity_participant.participation_status} -> {participant_dto.status}"
|
|
620
|
+
)
|
|
621
|
+
else:
|
|
622
|
+
activity_participant = ActivityParticipant.objects.create(
|
|
623
|
+
activity=activity, participant=person, **kwargs
|
|
624
|
+
)
|
|
625
|
+
participants_changed += f"- new participant {person}: {participant_dto.status}"
|
|
626
|
+
activity_participants.append(activity_participant)
|
|
627
|
+
return activity_participants, participants_changed
|