Skip to content

Commit f231acc

Browse files
authored
Rebuild communication and activity feed + assign a comment as task to user (#3932)
Fixes #3048 Fixes #4029 - [x] Introduce machine generated avatars based on the user's name (in-future allow for gravatar or similar service) - [x] Color Code the user avatar based on their roles 🆕 - [x] Render the staff roles as label (#3916) - [x] Reduce the size of the rendered comment box. - [x] Merge activities with communication in a single timeline. 🆕 - [x] Use htmx for comment edit function, the js version seems to break some functionality. - [x] Use different icons for different activities where possible. - [x] Render paginated comment and activities 🆕 - [x] Cleanup/remove JSON APIs related to comments - [x] Use the same logic for rendering of comments on the projects module - [x] ~Async comment add (won't be done in this PR)~ - [x] Deep-link for individual comments - [x] fix testcases
1 parent 8beb874 commit f231acc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1030
-731
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"pytestmark",
1010
"ratelimit",
1111
"SIGNUP",
12+
"svgwrite",
1213
"WAGTAILADMIN",
1314
"wagtailcore"
1415
]

hypha/apply/activity/adapters/activity_feed.py

+28-30
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from hypha.apply.activity.models import ALL, APPLICANT, TEAM
88
from hypha.apply.activity.options import MESSAGES
9+
from hypha.apply.funds.workflow import PHASE_BG_COLORS
910
from hypha.apply.projects.utils import (
1011
get_invoice_public_status,
1112
get_invoice_status_display_value,
@@ -26,12 +27,12 @@ class ActivityAdapter(AdapterBase):
2627
MESSAGES.NEW_SUBMISSION: _(
2728
"Submitted {source.title_text_display} for {source.page.title}"
2829
),
29-
MESSAGES.EDIT_SUBMISSION: _("Edited"),
30-
MESSAGES.APPLICANT_EDIT: _("Edited"),
31-
MESSAGES.UPDATE_LEAD: _("Lead changed from {old_lead} to {source.lead}"),
32-
MESSAGES.BATCH_UPDATE_LEAD: _("Batch Lead changed to {new_lead}"),
30+
MESSAGES.EDIT_SUBMISSION: _("edited the submission"),
31+
MESSAGES.APPLICANT_EDIT: _("edited the submission"),
32+
MESSAGES.UPDATE_LEAD: _("updated Lead from {old_lead} to {source.lead}"),
33+
MESSAGES.BATCH_UPDATE_LEAD: _("batch updated Lead to {new_lead}"),
3334
MESSAGES.DETERMINATION_OUTCOME: _(
34-
"Sent a determination. Outcome: {determination.clean_outcome}"
35+
"sent a determination. Outcome: {determination.clean_outcome}"
3536
),
3637
MESSAGES.BATCH_DETERMINATION_OUTCOME: "batch_determination",
3738
MESSAGES.INVITED_TO_PROPOSAL: _("Invited to submit a proposal"),
@@ -42,24 +43,22 @@ class ActivityAdapter(AdapterBase):
4243
MESSAGES.OPENED_SEALED: _("Opened the submission while still sealed"),
4344
MESSAGES.SCREENING: "handle_screening_statuses",
4445
MESSAGES.REVIEW_OPINION: _(
45-
"{user} {opinion.opinion_display}s with {opinion.review.author}s review of {source}"
46+
"{opinion.opinion_display}s with {opinion.review.author}s review of {source}"
4647
),
4748
MESSAGES.DELETE_REVIEW_OPINION: _(
48-
"{user} deleted the opinion for review: {review_opinion.review}"
49+
"deleted the opinion for review: {review_opinion.review}"
4950
),
5051
MESSAGES.CREATED_PROJECT: _("Created project"),
5152
MESSAGES.PROJECT_TRANSITION: "handle_project_transition",
5253
MESSAGES.UPDATE_PROJECT_TITLE: _(
53-
"{user} has updated the project title from {old_title} to {source.title}"
54-
),
55-
MESSAGES.UPDATE_PROJECT_LEAD: _(
56-
"Lead changed from {old_lead} to {source.lead}"
54+
"updated the project title from {old_title} to {source.title}"
5755
),
56+
MESSAGES.UPDATE_PROJECT_LEAD: _("update Lead from {old_lead} to {source.lead}"),
5857
MESSAGES.SEND_FOR_APPROVAL: _("Requested approval"),
5958
MESSAGES.APPROVE_PAF: "handle_paf_assignment",
6059
MESSAGES.APPROVE_PROJECT: _("Approved"),
6160
MESSAGES.REQUEST_PROJECT_CHANGE: _(
62-
'Requested changes for acceptance: "{comment}"'
61+
'requested changes for acceptance: "{comment}"'
6362
),
6463
MESSAGES.SUBMIT_CONTRACT_DOCUMENTS: _("Submitted Contract Documents"),
6564
MESSAGES.UPLOAD_CONTRACT: _("Uploaded a {contract.state} contract"),
@@ -69,17 +68,13 @@ class ActivityAdapter(AdapterBase):
6968
MESSAGES.SUBMIT_REPORT: _("Submitted a report"),
7069
MESSAGES.SKIPPED_REPORT: "handle_skipped_report",
7170
MESSAGES.REPORT_FREQUENCY_CHANGED: "handle_report_frequency",
72-
MESSAGES.DISABLED_REPORTING: _("Reporting disabled"),
71+
MESSAGES.DISABLED_REPORTING: _("disabled reporting"),
7372
MESSAGES.BATCH_DELETE_SUBMISSION: "handle_batch_delete_submission",
7473
MESSAGES.BATCH_ARCHIVE_SUBMISSION: "handle_batch_archive_submission",
7574
MESSAGES.BATCH_UPDATE_INVOICE_STATUS: "handle_batch_update_invoice_status",
76-
MESSAGES.ARCHIVE_SUBMISSION: _(
77-
"{user} has archived the submission: {source.title_text_display}"
78-
),
79-
MESSAGES.UNARCHIVE_SUBMISSION: _(
80-
"{user} has unarchived the submission: {source.title_text_display}"
81-
),
82-
MESSAGES.DELETE_INVOICE: _("Deleted an invoice"),
75+
MESSAGES.ARCHIVE_SUBMISSION: _("archived this submission"),
76+
MESSAGES.UNARCHIVE_SUBMISSION: _("un-archived this submission"),
77+
MESSAGES.DELETE_INVOICE: _("deleted an invoice"),
8378
MESSAGES.REMOVE_TASK: "handle_task_removal",
8479
}
8580

@@ -214,28 +209,31 @@ def handle_paf_assignment(self, source, paf_approvals, **kwargs):
214209
def handle_task_removal(self, source, task, **kwargs):
215210
if task.user:
216211
return _(
217-
"{user} has removed the task {task.code} for {source} from the task list".format(
218-
user=kwargs.get("user"), task=task, source=source
212+
"removed the task {task.code} for {source} from the task list".format(
213+
task=task, source=source
219214
)
220215
)
221216
return _(
222-
"{user} has removed the task {task.code} for {source} from whole team's{user_groups} task list.".format(
223-
user=kwargs.get("user"),
217+
"removed the task {task.code} for {source} from whole team's{user_groups} task list.".format(
224218
task=task,
225219
source=source,
226220
user_groups=list(task.user_group.all().values_list("name", flat=True)),
227221
)
228222
)
229223

230224
def handle_transition(self, old_phase, source, **kwargs):
225+
def wrap_in_color_class(text):
226+
color_class = PHASE_BG_COLORS.get(text, "")
227+
return f'<span class="rounded-full inline-block px-2 py-0.5 font-medium text-gray-800 {color_class}">{text}</span>'
228+
231229
submission = source
232230
base_message = _("Progressed from {old_display} to {new_display}")
233231

234232
new_phase = submission.phase
235233

236234
staff_message = base_message.format(
237-
old_display=old_phase.display_name,
238-
new_display=new_phase.display_name,
235+
old_display=wrap_in_color_class(old_phase.display_name),
236+
new_display=wrap_in_color_class(new_phase.display_name),
239237
)
240238

241239
if new_phase.permissions.can_view(submission.user):
@@ -246,8 +244,8 @@ def handle_transition(self, old_phase, source, **kwargs):
246244
)
247245

248246
applicant_message = base_message.format(
249-
old_display=old_phase.public_name,
250-
new_display=new_phase.public_name,
247+
old_display=wrap_in_color_class(old_phase.public_name),
248+
new_display=wrap_in_color_class(new_phase.public_name),
251249
)
252250

253251
return json.dumps(
@@ -363,10 +361,10 @@ def handle_screening_statuses(self, source, old_status, **kwargs):
363361

364362
if new_status and old_status != "-":
365363
return _(
366-
'Updated screening decision from "{old_status}" to "{new_status}".'
364+
'Updated screening decision from "{old_status}" to "{new_status}"'
367365
).format(old_status=old_status, new_status=new_status)
368366
elif new_status:
369-
return _('Added screening decision to "{new_status}".').format(
367+
return _('Added screening decision to "{new_status}"').format(
370368
new_status=new_status
371369
)
372370
elif old_status != "-":

hypha/apply/activity/forms.py

+24-2
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,29 @@
55
from pagedown.widgets import PagedownWidget
66

77
from hypha.apply.stream_forms.fields import MultiFileField
8+
from hypha.apply.todo.options import COMMENT_TASK
9+
from hypha.apply.todo.views import add_task_to_user
10+
from hypha.apply.users.models import STAFF_GROUP_NAME, User
811

912
from .models import Activity, ActivityAttachment
1013

1114

1215
class CommentForm(FileFormMixin, forms.ModelForm):
1316
attachments = MultiFileField(label=_("Attachments"), required=False)
17+
assign_to = forms.ModelChoiceField(
18+
queryset=User.objects.filter(groups__name=STAFF_GROUP_NAME),
19+
required=False,
20+
empty_label=_("Select..."),
21+
label=_("Assign to"),
22+
)
1423

1524
class Meta:
1625
model = Activity
17-
fields = ("message", "visibility")
26+
fields = (
27+
"message",
28+
"visibility",
29+
"assign_to",
30+
)
1831
labels = {
1932
"visibility": "Visible to",
2033
"message": "Message",
@@ -47,14 +60,23 @@ def __init__(self, *args, user=None, **kwargs):
4760
visibility.choices = self.visibility_choices
4861
visibility.initial = visibility.initial[0]
4962
visibility.widget = forms.HiddenInput()
63+
if not user.is_apply_staff:
64+
self.fields["assign_to"].widget = forms.HiddenInput()
5065

5166
@transaction.atomic
5267
def save(self, commit=True):
5368
instance = super().save(commit=True)
5469
added_files = self.cleaned_data["attachments"]
70+
assigned_user = self.cleaned_data["assign_to"]
71+
if assigned_user:
72+
# add task to assigned user
73+
add_task_to_user(
74+
code=COMMENT_TASK,
75+
user=assigned_user,
76+
related_obj=instance,
77+
)
5578
if added_files:
5679
ActivityAttachment.objects.bulk_create(
5780
ActivityAttachment(activity=instance, file=file) for file in added_files
5881
)
59-
6082
return instance

hypha/apply/activity/models.py

+3
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ class Meta:
240240
ordering = ["-timestamp"]
241241
base_manager_name = "objects"
242242

243+
def get_absolute_url(self):
244+
return f"{self.source.get_absolute_url()}#communications--{self.id}"
245+
243246
@property
244247
def priviledged(self):
245248
# Not visible to applicant

hypha/apply/activity/services.py

+67-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
1+
from django.contrib.contenttypes.models import ContentType
2+
from django.db.models import OuterRef, Subquery
3+
from django.db.models.functions import JSONObject
4+
from django.utils import timezone
5+
6+
from hypha.apply.todo.models import Task
7+
18
from .models import Activity
29

310

11+
def edit_comment(activity: Activity, message: str) -> Activity:
12+
"""
13+
Edit a comment by creating a clone of the original comment with the updated message.
14+
15+
Args:
16+
activity (Activity): The original comment activity to be edited.
17+
message (str): The new message to replace the original comment's message.
18+
19+
Returns:
20+
Activity: The edited comment activity with the updated message.
21+
"""
22+
if message == activity.message:
23+
return activity
24+
25+
# Create a clone of the comment to edit
26+
previous = Activity.objects.get(pk=activity.pk)
27+
previous.pk = None
28+
previous.current = False
29+
previous.save()
30+
31+
activity.previous = previous
32+
activity.edited = timezone.now()
33+
activity.message = message
34+
activity.current = True
35+
activity.save()
36+
37+
return activity
38+
39+
440
def get_related_actions_for_user(obj, user):
541
"""Return Activity objects related to an object, esp. useful with
642
ApplicationSubmission and Project.
@@ -37,11 +73,40 @@ def get_related_comments_for_user(obj, user):
3773
"""
3874
related_query = type(obj).activities.rel.related_query_name
3975

40-
return (
41-
Activity.comments.filter(**{related_query: obj})
76+
queryset = (
77+
Activity.objects.filter(**{related_query: obj})
78+
.exclude(current=False)
4279
.select_related("user")
4380
.prefetch_related(
4481
"related_object",
4582
)
4683
.visible_to(user)
4784
)
85+
86+
if user.is_apply_staff:
87+
assigned_to_subquery = (
88+
Task.objects.filter(
89+
related_content_type=ContentType.objects.get_for_model(Activity),
90+
related_object_id=OuterRef("id"),
91+
)
92+
.select_related("user")
93+
.values(
94+
json=JSONObject(
95+
full_name="user__full_name", email="user__email", id="user__id"
96+
)
97+
)
98+
)
99+
100+
queryset = queryset.annotate(assigned_to=Subquery(assigned_to_subquery))
101+
102+
return queryset
103+
104+
105+
def get_comment_count(obj, user):
106+
related_query = type(obj).activities.rel.related_query_name
107+
108+
return (
109+
Activity.comments.filter(**{related_query: obj})
110+
.exclude(current=False)
111+
.visible_to(user)
112+
).count()
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{% load i18n %}
2+
23
{% for action in actions %}
3-
{% include "activity/include/listing_base.html" with activity=action %}
4+
{% include "activity/ui/activity-action-item.html" with activity=action %}
45
{% empty %}
56
{% trans "There are no actions." %}
67
{% endfor %}

hypha/apply/activity/templates/activity/include/all_activity_list.html

-3
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,56 @@
1-
{% load i18n %}
1+
{% load i18n static %}
22

3-
<div class="wrapper wrapper--comments">
4-
{% trans "Submit" as submit %}
5-
{% include "funds/includes/delegated_form_base.html" with form=comment_form value=submit extra_classes="form__comments" %}
3+
<div class="wrapper wrapper--comments pb-4">
4+
<form
5+
class="form form__comments"
6+
method="post"
7+
id="{% if form_id %}{{ form_id }}{% else %}{{ comment_form.name }}{% endif %}"
8+
enctype="multipart/form-data"
9+
{% if action %}action="{{ action }}"{% endif %}
10+
>
11+
{% csrf_token %}
12+
13+
{{ comment_form.media }}
14+
{% for hidden in comment_form.hidden_fields %}
15+
{{ hidden }}
16+
{% endfor %}
17+
18+
<div class="flex flex-wrap gap-4 lg:flex-nowrap lg:gap-8">
19+
<div class="w-full lg:flex-1 -mt-4 max-w-[53rem]">
20+
{% include "forms/includes/field.html" with field=comment_form.message label_classes="sr-only" %}
21+
22+
<div class="text-right">
23+
<button
24+
class="button button--primary w-full lg:w-auto"
25+
id="{{ comment_form.name }}-submit"
26+
name="{{ form_prefix }}{{ comment_form.name }}"
27+
type="submit"
28+
form="{% if form_id %}{{ form_id }}{% else %}{{ comment_form.name }}{% endif %}"
29+
>
30+
{% trans "Add Comment" %}
31+
</button>
32+
</div>
33+
</div>
34+
35+
<div class="w-full lg:w-auto">
36+
{% include "forms/includes/field.html" with field=comment_form.visibility %}
37+
{% include "forms/includes/field.html" with field=comment_form.assign_to %}
38+
{% include "forms/includes/field.html" with field=comment_form.attachments %}
39+
</div>
40+
</div>
41+
</form>
42+
<script type="module">
43+
{% comment %} Do this here as the select elements for partners are dynamically generated. {% endcomment %}
44+
import Choices from "{% static 'js/esm/choices.js-10-2-0.js' %}";
45+
46+
const selectElements = document.querySelectorAll('.id_assign_to select');
47+
48+
// add choices to all select elements
49+
selectElements.forEach((selectElement) => {
50+
new Choices(selectElement, {
51+
removeItemButton: true,
52+
allowHTML: true,
53+
});
54+
});
55+
</script>
656
</div>

0 commit comments

Comments
 (0)