diff --git a/hypha/apply/activity/adapters/activity_feed.py b/hypha/apply/activity/adapters/activity_feed.py index 0c0a92817a..44e1c2ce25 100644 --- a/hypha/apply/activity/adapters/activity_feed.py +++ b/hypha/apply/activity/adapters/activity_feed.py @@ -50,6 +50,10 @@ class ActivityAdapter(AdapterBase): MESSAGES.CREATED_PROJECT: _( 'Created project with initial status of "{status}"' ), + MESSAGES.CREATED_SOW: _('Created SOW for project "{related.project}"'), + MESSAGES.EDITED_SOW: _('Edited SOW for project "{related.project}"'), + MESSAGES.CREATED_PF: _('Created project form for project "{related.project}"'), + MESSAGES.EDITED_PF: _('Edited project form for project "{related.project}"'), MESSAGES.PROJECT_TRANSITION: "handle_project_transition", MESSAGES.UPDATE_PROJECT_TITLE: _( "updated the project title from {old_title} to {source.title}" @@ -94,6 +98,10 @@ def extra_kwargs(self, message_type, source, sources, **kwargs): MESSAGES.DELETE_REVIEW_OPINION, MESSAGES.BATCH_REVIEWERS_UPDATED, MESSAGES.APPROVE_PROJECT, + MESSAGES.CREATED_SOW, + MESSAGES.EDITED_SOW, + MESSAGES.CREATED_PF, + MESSAGES.EDITED_PF, MESSAGES.REQUEST_PROJECT_CHANGE, MESSAGES.SEND_FOR_APPROVAL, MESSAGES.APPROVE_PAF, diff --git a/hypha/apply/activity/adapters/base.py b/hypha/apply/activity/adapters/base.py index 429428b643..161bf92984 100644 --- a/hypha/apply/activity/adapters/base.py +++ b/hypha/apply/activity/adapters/base.py @@ -21,6 +21,10 @@ MESSAGES.DELETE_REVIEW_OPINION: "review_opinion", MESSAGES.EDIT_REVIEW: "review", MESSAGES.CREATED_PROJECT: "submission", + MESSAGES.CREATED_SOW: "sow", + MESSAGES.EDITED_SOW: "sow", + MESSAGES.EDITED_PF: "pfp", + MESSAGES.CREATED_PF: "pfp", MESSAGES.PROJECT_TRANSITION: "old_stage", MESSAGES.APPROVE_PAF: "paf_approvals", # expect a list MESSAGES.UPDATE_PROJECT_LEAD: "old_lead", diff --git a/hypha/apply/activity/forms.py b/hypha/apply/activity/forms.py index fa8483dbc2..0d0d6c730b 100644 --- a/hypha/apply/activity/forms.py +++ b/hypha/apply/activity/forms.py @@ -1,5 +1,6 @@ from django import forms from django.db import transaction +from django.forms.widgets import Textarea from django.utils.translation import gettext_lazy as _ from django_file_form.forms import FileFormMixin @@ -24,11 +25,16 @@ class CommentForm(FileFormMixin, forms.ModelForm): class Meta: model = Activity - fields = ( - "message", - "visibility", - "assign_to", + + # Fields that should only be included when the mini comment form is used + # as the mini form can be put anywhere and associated to any object. + mini_fields = ( + "related_content_type", + "related_object_id", + "source_content_type", + "source_object_id", ) + fields = ("message", "visibility", "assign_to", *mini_fields) labels = { "visibility": _("Visible to"), "message": _("Message"), @@ -41,9 +47,10 @@ class Meta: widgets = { "visibility": forms.RadioSelect(), "message": PagedownWidget(), + **{field: forms.HiddenInput() for field in mini_fields}, } - def __init__(self, *args, user=None, has_coapplicants=False, **kwargs): + def __init__(self, *args, user=None, has_coapplicants=False, mini=False, **kwargs): super().__init__(*args, **kwargs) self.visibility_choices = self._meta.model.visibility_choices_for( user, has_coapplicants @@ -61,6 +68,15 @@ def __init__(self, *args, user=None, has_coapplicants=False, **kwargs): if not user.is_apply_staff: self.fields["assign_to"].widget = forms.HiddenInput() + if mini: + self.fields["message"].widget = Textarea( + attrs={"rows": 2, "placeholder": _("Write a comment...")} + ) + else: + # If not mini, remove the unneeded fields from the form. + for key in self.Meta.mini_fields: + del self.fields[key] + @transaction.atomic def save(self, commit=True): instance = super().save(commit=True) diff --git a/hypha/apply/activity/migrations/0096_alter_event_type.py b/hypha/apply/activity/migrations/0096_alter_event_type.py new file mode 100644 index 0000000000..9e0f1507d2 --- /dev/null +++ b/hypha/apply/activity/migrations/0096_alter_event_type.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2.17 on 2026-08-05 16:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("activity", "0095_alter_event_type"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="type", + field=models.CharField( + choices=[ + ("UPDATE_LEAD", "updated lead"), + ("BATCH_UPDATE_LEAD", "batch updated lead"), + ("EDIT_SUBMISSION", "edited submission"), + ("APPLICANT_EDIT", "edited applicant"), + ("NEW_SUBMISSION", "submitted new submission"), + ("DRAFT_SUBMISSION", "submitted new draft submission"), + ("SCREENING", "screened"), + ("TRANSITION", "transitioned"), + ("BATCH_TRANSITION", "batch transitioned"), + ("DETERMINATION_OUTCOME", "sent determination outcome"), + ("BATCH_DETERMINATION_OUTCOME", "sent batch determination outcome"), + ("INVITED_TO_PROPOSAL", "invited to proposal"), + ("REVIEWERS_UPDATED", "updated reviewers"), + ("BATCH_REVIEWERS_UPDATED", "batch updated reviewers"), + ("READY_FOR_REVIEW", "marked ready for review"), + ("BATCH_READY_FOR_REVIEW", "marked batch ready for review"), + ("NEW_REVIEW", "added new review"), + ("COMMENT", "added comment"), + ("PROPOSAL_SUBMITTED", "submitted proposal"), + ("OPENED_SEALED", "opened sealed submission"), + ("REVIEW_OPINION", "reviewed opinion"), + ("DELETE_SUBMISSION", "deleted submission"), + ("ANONYMIZE_SUBMISSION", "anonymized submission"), + ("DELETE_REVIEW", "deleted review"), + ("DELETE_REVIEW_OPINION", "deleted review opinion"), + ("CREATED_PROJECT", "created project"), + ("CREATED_SOW", "created a project SOW"), + ("EDITED_SOW", "edited a project SOW"), + ("CREATED_PF", "created a project form"), + ("EDITED_PF", "edited a project form"), + ("UPDATE_PROJECT_LEAD", "updated project lead"), + ("UPDATE_PROJECT_TITLE", "updated project title"), + ( + "UPDATE_PROJECT_CONTRACT_NUMBER", + "updated project contract number", + ), + ("EDIT_REVIEW", "edited review"), + ("SEND_FOR_APPROVAL", "sent for approval"), + ("APPROVE_PROJECT", "approved project"), + ("ASSIGN_PAF_APPROVER", "assign project form approver"), + ("APPROVE_PAF", "approved project form"), + ("PROJECT_TRANSITION", "transitioned project"), + ("REQUEST_PROJECT_CHANGE", "requested project change"), + ("SUBMIT_CONTRACT_DOCUMENTS", "submitted contract documents"), + ("UPLOAD_DOCUMENT", "uploaded document to project"), + ("UPLOAD_CONTRACT", "uploaded contract to project"), + ("APPROVE_CONTRACT", "approved contract"), + ("CREATE_INVOICE", "created invoice for project"), + ("UPDATE_INVOICE_STATUS", "updated invoice status"), + ("APPROVE_INVOICE", "approve invoice"), + ("DELETE_INVOICE", "deleted invoice"), + ("SENT_TO_COMPLIANCE", "sent project to compliance"), + ("UPDATE_INVOICE", "updated invoice"), + ("SUBMIT_REPORT", "submitted report"), + ("DELETE_REPORT", "deleted report"), + ("SKIPPED_REPORT", "skipped report"), + ("REPORT_FREQUENCY_CHANGED", "changed report frequency"), + ("DISABLED_REPORTING", "disabled reporting"), + ("REPORT_NOTIFY", "notified report"), + ("REVIEW_REMINDER", "reminder to review"), + ("BATCH_DELETE_SUBMISSION", "batch deleted submissions"), + ("BATCH_ANONYMIZE_SUBMISSION", "batch anonymized submissions"), + ("BATCH_ARCHIVE_SUBMISSION", "batch archive submissions"), + ("BATCH_INVOICE_STATUS_UPDATE", "batch update invoice status"), + ("STAFF_ACCOUNT_CREATED", "created new account"), + ("STAFF_ACCOUNT_EDITED", "edited account"), + ("ARCHIVE_SUBMISSION", "archived submission"), + ("UNARCHIVE_SUBMISSION", "unarchived submission"), + ("REMOVE_TASK", "remove task"), + ("INVITE_COAPPLICANT", "invite co-applicant"), + ("UPDATE_AUTHOR", "updated author"), + ], + max_length=50, + verbose_name="verb", + ), + ), + ] diff --git a/hypha/apply/activity/options.py b/hypha/apply/activity/options.py index 09e89ad2c2..160d284f0b 100644 --- a/hypha/apply/activity/options.py +++ b/hypha/apply/activity/options.py @@ -36,6 +36,10 @@ class MESSAGES(TextChoices): DELETE_REVIEW = "DELETE_REVIEW", _("deleted review") DELETE_REVIEW_OPINION = "DELETE_REVIEW_OPINION", _("deleted review opinion") CREATED_PROJECT = "CREATED_PROJECT", _("created project") + CREATED_SOW = "CREATED_SOW", _("created a project SOW") + EDITED_SOW = "EDITED_SOW", _("edited a project SOW") + CREATED_PF = "CREATED_PF", _("created a project form") + EDITED_PF = "EDITED_PF", _("edited a project form") UPDATE_PROJECT_LEAD = "UPDATE_PROJECT_LEAD", _("updated project lead") UPDATE_PROJECT_TITLE = "UPDATE_PROJECT_TITLE", _("updated project title") UPDATE_PROJECT_CONTRACT_NUMBER = ( diff --git a/hypha/apply/activity/templates/activity/partials/comment_form.html b/hypha/apply/activity/templates/activity/partials/comment_form.html new file mode 100644 index 0000000000..c491020e44 --- /dev/null +++ b/hypha/apply/activity/templates/activity/partials/comment_form.html @@ -0,0 +1,38 @@ +{% comment %} +Renders the sidebar comments form. + +Params: + form – the CommentForm form +{% endcomment %} + +{% load i18n static heroicons %} + +{# Success is the 204 returned by `post_comment`, which htmx does not swap - a #} +{# 200 means the form came back with validation errors, so keep what was typed. #} +
+

{% trans "Comment" %}

+ {% csrf_token %} + + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} +
+ {% include "forms/includes/field.html" with field=form.message label_classes="sr-only" %} +
+ +
+ {% trans 'Additional options' %}{% heroicon_mini 'chevron-down' class="size-5 group-open:-rotate-180" %} + {# Some small modifications to django-file-form to make it fit better with the smaller modal #} +
+ {% include "forms/includes/field.html" with field=form.visibility %} + {% include "forms/includes/field.html" with field=form.assign_to %} + {% include "forms/includes/field.html" with field=form.attachments %} +
+
+ +
diff --git a/hypha/apply/activity/templates/activity/ui/activity-action-item.html b/hypha/apply/activity/templates/activity/ui/activity-action-item.html index 25643ec769..e72b179853 100644 --- a/hypha/apply/activity/templates/activity/ui/activity-action-item.html +++ b/hypha/apply/activity/templates/activity/ui/activity-action-item.html @@ -1,7 +1,16 @@ +{% comment %} +Renders a line item of activity including the message attached to the activity & an icon based on the message. + +Params: + activity – an Activity object to render into a timeline + no_timeline - bool of should the line item be styled as a piece of a larger timeline + mini - bool that will force including only essential elements of the action item +{% endcomment %} + {% load i18n activity_tags heroicons %} {% with activity|display_for:request.user as activity_text %} -
+
@@ -22,6 +31,12 @@ {% heroicon_micro "lock-closed" class="inline" aria_hidden=true size=14 %} {% elif 'lead' in activity_text.lower or 'author' in activity_text.lower %} {% heroicon_micro "users" class="inline" aria_hidden=true size=14 %} + {% elif 'approved by' in activity_text.lower %} + {% heroicon_micro "check-circle" class="inline" aria_hidden=true size=14 %} + {% elif 'changes requested' in activity_text.lower %} + {% heroicon_micro "exclamation-circle" class="inline" aria_hidden=true size=14 %} + {% elif 'created' in activity_text.lower %} + {% heroicon_micro "folder-plus" class="inline" aria_hidden=true size=14 %} {% else %} {% heroicon_micro "eye" class="inline" aria_hidden=true size=15 %} {% endif %} @@ -39,7 +54,7 @@ {{ activity.timestamp|date:'SHORT_DATETIME_FORMAT' }} - {% if not submission_title and activity|user_can_see_related:request.user %} + {% if not submission_title and activity|user_can_see_related:request.user and not mini %} {% with url=activity.related_object.get_absolute_url %} {% if url %} diff --git a/hypha/apply/activity/templates/activity/ui/activity-comment-item.html b/hypha/apply/activity/templates/activity/ui/activity-comment-item.html index e93b6e3f45..97ee8cca2a 100644 --- a/hypha/apply/activity/templates/activity/ui/activity-comment-item.html +++ b/hypha/apply/activity/templates/activity/ui/activity-comment-item.html @@ -1,23 +1,31 @@ +{% comment %} +Renders a comment activity item. Intended to be used as a part of a timeline + +Params: + activity – a comment Activity object to render into a timeline + mini - bool that will force including only essential elements of the comment item +{% endcomment %} + {% load i18n activity_tags nh3_tags markdown_tags submission_tags apply_tags heroicons users_tags %}
-
- {% with activity|display_activity_author:request.user as author_name %} -
-
- + {% else %} +
+ {% with activity|display_activity_author:request.user as author_name %} +
+
+
+ {{ author_name }} -
- {% if submission_title %} - {% trans "updated" %} {{ activity.source.title }} - {% endif %} + {% if not request.user.is_applicant %} + + {% for role in activity.user.get_role_names %} + + {{ role }} + + {% endfor %} + + {% endif %} -
- {% include 'activity/partial_comment_message.html' with activity=activity %} -
+ + {% trans "commented" %} + {{ activity.timestamp|date:"SHORT_DATETIME_FORMAT" }} + +
+ +
+ {% if not request.user.is_applicant %} + {% if request.user.is_apply_staff and activity.assigned_to %} + + {% heroicon_outline "user-plus" size=14 class="inline" aria_hidden=true %} + {% if activity.assigned_to.id == request.user.id %} + {% trans "Assigned to you" %} + {% else %} + {% blocktrans with activity.assigned_to.full_name as assigned_to %}Assigned to {{ assigned_to }}{% endblocktrans %} + {% endif %} + + {% endif %} - {% if not submission_title and activity|user_can_see_related:request.user %} - {% with url=activity.related_object.get_absolute_url %} - {% if url %} - + {% with activity.visibility|visibility_display:request.user as visibility_text %} + + {% heroicon_outline "eye" size=14 class="inline" aria_hidden=true %} + {{ visibility_text }} + + {% endwith %} {% endif %} - {% endwith %} - {% endif %} + + {% if editable and activity.user == request.user and not activity.deleted %} + + {% heroicon_micro "pencil-square" aria_hidden=true %} + {% trans "Edit" %} + + {% endif %} + + {% if editable and activity.user == request.user and not activity.deleted and request.user.is_apply_staff %} + + {% heroicon_micro "trash" class="opacity-80 size-4" aria_hidden=true %} + {% trans "Delete" %} + + {% endif %} +
+
+ +
+ {% if submission_title %} + {% trans "updated" %} {{ activity.source.title }} + {% endif %} + +
+ {% include 'activity/partial_comment_message.html' with activity=activity %} +
+
-
- {% endwith %} -
+ {% endwith %} +
+ {% endif %}
diff --git a/hypha/apply/activity/tests/test_post_comment.py b/hypha/apply/activity/tests/test_post_comment.py new file mode 100644 index 0000000000..a7c6d5736d --- /dev/null +++ b/hypha/apply/activity/tests/test_post_comment.py @@ -0,0 +1,140 @@ +"""Tests for the "mini" comment form endpoint used by object detail sidebars.""" + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.urls import reverse + +from hypha.apply.activity.models import APPLICANT, COMMENT, Activity +from hypha.apply.funds.models.submissions import ApplicationSubmission +from hypha.apply.projects.models.payment import Invoice +from hypha.apply.projects.models.project import INVOICING_AND_REPORTING +from hypha.apply.projects.tests.factories import ( + InvoiceFactory, + ProjectFactory, +) +from hypha.apply.users.tests.factories import ( + ApplicantFactory, + ContractingApproverFactory, + ContractingFactory, + StaffFactory, +) + + +class BasePostCommentTestCase(TestCase): + def setUp(self): + self.url = reverse("activity:post-comment") + self.vendor = ApplicantFactory() + self.project = ProjectFactory(status=INVOICING_AND_REPORTING, user=self.vendor) + self.submission = self.project.submission + self.invoice = InvoiceFactory(project=self.project) + + self.submission_ct = ContentType.objects.get_for_model(ApplicationSubmission).pk + self.invoice_ct = ContentType.objects.get_for_model(Invoice).pk + + def params(self, **overrides): + params = { + "source_content_type": self.submission_ct, + "source_object_id": self.submission.pk, + "related_content_type": self.invoice_ct, + "related_object_id": self.invoice.pk, + } + params.update(overrides) + return params + + def post(self, message="a comment", visibility=APPLICANT, **overrides): + return self.client.post( + self.url, + {**self.params(**overrides), "message": message, "visibility": visibility}, + secure=True, + ) + + +class TestPostCommentAccess(BasePostCommentTestCase): + def test_staff_can_get_form_and_post(self): + self.client.force_login(StaffFactory()) + + response = self.client.get(self.url, self.params(), secure=True) + self.assertEqual(response.status_code, 200) + + response = self.post() + self.assertEqual(response.status_code, 204) + self.assertIn("commentAdded", response.headers["HX-Trigger"]) + + comment = Activity.comments.get(related_object_id=self.invoice.pk) + self.assertEqual(comment.message, "a comment") + self.assertEqual(comment.source, self.submission) + self.assertEqual(comment.related_object, self.invoice) + + def test_contracting_can_post(self): + """Contracting staff see the PF/SOW sidebar, so they must be able to use it""" + self.client.force_login(ContractingFactory()) + self.assertEqual(self.post().status_code, 204) + + def test_contracting_approver_can_post(self): + self.client.force_login(ContractingApproverFactory()) + self.assertEqual(self.post().status_code, 204) + + def test_project_vendor_can_post(self): + self.client.force_login(self.vendor) + self.assertEqual(self.post().status_code, 204) + + def test_unrelated_applicant_is_denied(self): + self.client.force_login(ApplicantFactory()) + + self.assertEqual( + self.client.get(self.url, self.params(), secure=True).status_code, 403 + ) + self.assertEqual(self.post().status_code, 403) + self.assertFalse(Activity.objects.filter(type=COMMENT).exists()) + + def test_anonymous_is_redirected(self): + self.assertEqual(self.post().status_code, 302) + + +class TestPostCommentValidation(BasePostCommentTestCase): + def setUp(self): + super().setUp() + self.client.force_login(StaffFactory()) + + def test_empty_post_is_a_404_not_a_500(self): + response = self.client.post(self.url, {}, secure=True) + self.assertEqual(response.status_code, 404) + + def test_non_numeric_ids_are_a_404_not_a_500(self): + self.assertEqual(self.post(source_content_type="not-an-int").status_code, 404) + self.assertEqual(self.post(related_object_id="not-an-int").status_code, 404) + + def test_source_must_be_a_submission(self): + self.assertEqual( + self.post(source_content_type=self.invoice_ct).status_code, 404 + ) + + def test_unknown_source_object_is_a_404(self): + self.assertEqual(self.post(source_object_id=0).status_code, 404) + + def test_related_object_type_must_be_allowlisted(self): + self.assertEqual( + self.post( + related_content_type=self.submission_ct, + related_object_id=self.submission.pk, + ).status_code, + 404, + ) + + def test_related_object_must_belong_to_the_submissions_project(self): + other_invoice = InvoiceFactory() + self.assertEqual(self.post(related_object_id=other_invoice.pk).status_code, 404) + self.assertFalse(Activity.objects.filter(type=COMMENT).exists()) + + def test_comment_without_a_related_object_is_accepted(self): + response = self.post(related_content_type="", related_object_id="") + self.assertEqual(response.status_code, 204) + self.assertIsNone( + Activity.comments.get(source_object_id=self.submission.pk).related_object + ) + + def test_blank_message_re_renders_the_form_with_errors(self): + response = self.post(message=" ") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context["form"].errors) + self.assertFalse(Activity.objects.filter(type=COMMENT).exists()) diff --git a/hypha/apply/activity/urls.py b/hypha/apply/activity/urls.py index bd26613df1..f3c45dcd47 100644 --- a/hypha/apply/activity/urls.py +++ b/hypha/apply/activity/urls.py @@ -6,6 +6,7 @@ delete_comment, edit_comment, partial_comments, + post_comment, ) app_name = "activity" @@ -17,6 +18,7 @@ path("comments//", partial_comments, name="partial-comments"), path("/edit-comment/", edit_comment, name="edit-comment"), path("/delete-comment/", delete_comment, name="delete-comment"), + path("post-comment/", post_comment, name="post-comment"), path( "activities/attachment//download/", AttachmentView.as_view(), diff --git a/hypha/apply/activity/views.py b/hypha/apply/activity/views.py index 9933a2af42..511540f460 100644 --- a/hypha/apply/activity/views.py +++ b/hypha/apply/activity/views.py @@ -1,14 +1,25 @@ +import json + +from django.conf import settings from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator +from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, render +from django.utils import timezone from django.utils.decorators import method_decorator +from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from django.views.decorators.http import require_http_methods from django.views.generic import ListView +from django_ratelimit.decorators import ratelimit from rolepermissions.checkers import has_object_permission +from hypha.apply.activity.forms import CommentForm +from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.funds.models.submissions import ApplicationSubmission +from hypha.apply.funds.permissions import user_can_view_post_comment_form from hypha.apply.users.decorators import is_apply_staff, staff_required from hypha.apply.utils.storage import PrivateMediaView @@ -110,6 +121,158 @@ def delete_comment(request, pk): ) +# Models a "mini" comment may be attached to via `related`, mapped to the +# attribute holding the id of the project they belong to (`None` when the object +# *is* the project). Keyed by ContentType `(app_label, model)`. +RELATED_MODEL_PROJECT_ATTRS = { + ("application_projects", "project"): None, + ("application_projects", "invoice"): "project_id", + ("application_projects", "projectsow"): "project_id", + ("application_projects", "projectformpointer"): "project_id", + ("project_reports", "report"): "project_id", +} + + +def _clean_id(value) -> int | None: + """Coerce a content type/object id from request params, `None` if unusable""" + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _get_comment_source(params) -> ApplicationSubmission: + """Resolve the submission a mini comment form is bound to. + + Comments always hang off the submission - every caller of + `generate_post_comment_url` passes one - so anything else is rejected rather + than resolved as an arbitrary content type. + """ + content_type_id = _clean_id(params.get("source_content_type")) + object_id = _clean_id(params.get("source_object_id")) + + if content_type_id is None or object_id is None: + raise Http404 + + if content_type_id != ContentType.objects.get_for_model(ApplicationSubmission).pk: + raise Http404 + + return get_object_or_404(ApplicationSubmission, pk=object_id) + + +def _get_comment_project(submission: ApplicationSubmission): + """The project a submission's comment sidebar hangs off, if there is one. + + Used to keep the comment's related object within the submission's own + project. + """ + return submission.projects.first() + + +def _user_can_comment(user, submission: ApplicationSubmission) -> bool: + """Whether `user` may use the mini comment form on `submission` + + The same gate `comments_view` applies to the full comment form. + """ + return user_can_view_post_comment_form( + user=user, submission=submission + ) and has_object_permission("view_comments", user, submission) + + +def _get_comment_related(params, project): + """Resolve the (optional) object a mini comment is being attached to. + + Restricted to the objects that render a comment sidebar, and to objects + belonging to the comment's own project. + """ + raw_content_type = params.get("related_content_type") or "" + raw_object_id = params.get("related_object_id") or "" + + if not raw_content_type and not raw_object_id: + return None + + content_type_id = _clean_id(raw_content_type) + object_id = _clean_id(raw_object_id) + if content_type_id is None or object_id is None or project is None: + raise Http404 + + content_type = ContentType.objects.filter(pk=content_type_id).first() + if content_type is None: + raise Http404 + + project_attr_key = (content_type.app_label, content_type.model) + if project_attr_key not in RELATED_MODEL_PROJECT_ATTRS: + raise Http404 + + related = get_object_or_404(content_type.model_class(), pk=object_id) + + project_attr = RELATED_MODEL_PROJECT_ATTRS[project_attr_key] + related_project_id = ( + related.pk if project_attr is None else getattr(related, project_attr) + ) + if related_project_id != project.pk: + raise Http404 + + return related + + +@login_required +@require_http_methods(["POST", "GET"]) +@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST") +def post_comment(request: HttpRequest): + """Render & handle the "mini" comment form shown in object detail sidebars.""" + params = request.POST if request.method == "POST" else request.GET + + source = _get_comment_source(params) + project = _get_comment_project(source) + if not _user_can_comment(request.user, source): + raise PermissionDenied + + related = _get_comment_related(params, project) + + if request.method == "GET": + form = CommentForm(user=request.user, mini=True) + form.fields["source_content_type"].initial = ContentType.objects.get_for_model( + source + ).pk + form.fields["source_object_id"].initial = source.pk + if related is not None: + form.fields[ + "related_content_type" + ].initial = ContentType.objects.get_for_model(related).pk + form.fields["related_object_id"].initial = related.pk + return render(request, "activity/partials/comment_form.html", {"form": form}) + + form = CommentForm(user=request.user, data=request.POST, mini=True) + form.instance.user = request.user + form.instance.source = source + form.instance.type = COMMENT + form.instance.timestamp = timezone.now() + + if not form.is_valid(): + return render(request, "activity/partials/comment_form.html", {"form": form}) + + obj = form.save() + messenger( + MESSAGES.COMMENT, + request=request, + user=request.user, + source=source, + related=obj, + ) + return HttpResponse( + status=204, + headers={ + "HX-Trigger": json.dumps( + { + "commentAdded": obj.pk, + "showMessage": mark_safe(_("Comment added!")), + } + ), + }, + ) + + class ActivityContextMixin: """Mixin to add related 'comments' of the current view's 'self.object'""" diff --git a/hypha/apply/funds/permissions.py b/hypha/apply/funds/permissions.py index d468b38a52..5c66f65610 100644 --- a/hypha/apply/funds/permissions.py +++ b/hypha/apply/funds/permissions.py @@ -60,8 +60,9 @@ def view_comments(role, user, submission) -> bool: if submission_view: return True - project = getattr(submission, "project", None) - if project: + # Users such as contracting staff and project form approvers reach a + # submission's comments through its project rather than the submission. + for project in submission.projects.all(): can_access, _ = can_access_project(user, project) if can_access: return True diff --git a/hypha/apply/funds/templates/funds/comments.html b/hypha/apply/funds/templates/funds/comments.html index c6e9f3e3c8..57974df0c9 100644 --- a/hypha/apply/funds/templates/funds/comments.html +++ b/hypha/apply/funds/templates/funds/comments.html @@ -43,7 +43,7 @@ {% if form %} {% if not object.is_archive or object.projects.exists %}

{% trans "Add communication" %}

-
+
{% trans "Add communication" %} {% endfor %}
-
+
{% include "forms/includes/field.html" with field=form.message label_classes="sr-only" %}