From 882047c35e75f709d3f97951c69f2cb12c031760 Mon Sep 17 00:00:00 2001 From: Wes Appler Date: Wed, 29 Jul 2026 14:27:54 -0400 Subject: [PATCH 1/5] Initial implementation for a comments sidebar on reports, invoices, PAF & SOW --- .../apply/activity/adapters/activity_feed.py | 8 + hypha/apply/activity/adapters/base.py | 4 + hypha/apply/activity/forms.py | 17 ++ .../migrations/0095_alter_event_type.py | 89 ++++++ hypha/apply/activity/options.py | 4 + .../activity/partials/comment_form.html | 37 +++ .../activity/ui/activity-action-item.html | 19 +- .../activity/ui/activity-comment-item.html | 268 ++++++++++++------ hypha/apply/activity/urls.py | 2 + hypha/apply/activity/views.py | 92 ++++++ .../apply/funds/templates/funds/comments.html | 2 +- hypha/apply/funds/views/comments.py | 1 - .../migrations/0104_projectformpointer.py | 39 +++ .../0105_create_project_form_pointers.py | 21 ++ hypha/apply/projects/models/payment.py | 6 +- hypha/apply/projects/models/project.py | 26 ++ hypha/apply/projects/reports/models.py | 5 + .../templates/reports/report_detail.html | 120 ++++---- .../includes/fetch_object_activity.html | 21 ++ .../application_projects/invoice_detail.html | 34 ++- .../partials/invoice_status.html | 74 ----- .../partials/object_status.html | 52 ++++ .../project_approval_detail.html | 159 ++++++----- .../project_sow_detail.html | 84 +++--- hypha/apply/projects/urls.py | 39 +++ hypha/apply/projects/views/__init__.py | 6 + hypha/apply/projects/views/payment.py | 18 +- hypha/apply/projects/views/project.py | 34 +++ .../apply/projects/views/project_partials.py | 116 ++++++-- .../apply/templates/forms/includes/field.html | 2 +- hypha/apply/utils/templatetags/apply_tags.py | 81 +++++- .../static_src/sass/components/_sidebar.scss | 2 +- .../includes/_toast-placeholder.html | 2 +- 33 files changed, 1110 insertions(+), 374 deletions(-) create mode 100644 hypha/apply/activity/migrations/0095_alter_event_type.py create mode 100644 hypha/apply/activity/templates/activity/partials/comment_form.html create mode 100644 hypha/apply/projects/migrations/0104_projectformpointer.py create mode 100644 hypha/apply/projects/migrations/0105_create_project_form_pointers.py create mode 100644 hypha/apply/projects/templates/application_projects/includes/fetch_object_activity.html delete mode 100644 hypha/apply/projects/templates/application_projects/partials/invoice_status.html create mode 100644 hypha/apply/projects/templates/application_projects/partials/object_status.html diff --git a/hypha/apply/activity/adapters/activity_feed.py b/hypha/apply/activity/adapters/activity_feed.py index bc1abce63e..72392ab4b6 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}" @@ -91,6 +95,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 e3359f75fe..86289e11d3 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 @@ -28,6 +29,10 @@ class Meta: "message", "visibility", "assign_to", + "related_content_type", + "related_object_id", + "source_content_type", + "source_object_id", ) labels = { "visibility": _("Visible to"), @@ -41,6 +46,10 @@ class Meta: widgets = { "visibility": forms.RadioSelect(), "message": PagedownWidget(), + "related_content_type": forms.HiddenInput(), + "related_object_id": forms.HiddenInput(), + "source_content_type": forms.HiddenInput(), + "source_object_id": forms.HiddenInput(), } def __init__(self, *args, user=None, **kwargs): @@ -76,3 +85,11 @@ def save(self, commit=True): ActivityAttachment(activity=instance, file=file) for file in added_files ) return instance + + +class CommentFormMini(CommentForm): + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, user=user, **kwargs) + self.fields["message"].widget = Textarea( + attrs={"rows": 2, "placeholder": "Write a comment"} + ) diff --git a/hypha/apply/activity/migrations/0095_alter_event_type.py b/hypha/apply/activity/migrations/0095_alter_event_type.py new file mode 100644 index 0000000000..5d0463bbca --- /dev/null +++ b/hypha/apply/activity/migrations/0095_alter_event_type.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2.15 on 2026-07-21 15:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("activity", "0094_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"), + ("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 b14da216f6..5af752aa69 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") EDIT_REVIEW = "EDIT_REVIEW", _("edited review") 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..28894a172d --- /dev/null +++ b/hypha/apply/activity/templates/activity/partials/comment_form.html @@ -0,0 +1,37 @@ +{% comment %} +Renders the sidebar comments form. + +Params: + form – the CommentFormMini form +{% endcomment %} + +{% load i18n static heroicons %} + +
+

Comment

+ {% csrf_token %} + + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} +
+ {% include "forms/includes/field.html" with field=form.message label_classes="sr-only" %} +
+ +
+ 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 %} + {{ form.related_content_type }} +
+
+ +
\ No newline at end of file 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..50f29aeebc 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/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..b3387f4cbf 100644 --- a/hypha/apply/activity/views.py +++ b/hypha/apply/activity/views.py @@ -1,13 +1,21 @@ +import json + 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 rolepermissions.checkers import has_object_permission +from hypha.apply.activity.forms import CommentFormMini +from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.funds.models.submissions import ApplicationSubmission from hypha.apply.users.decorators import is_apply_staff, staff_required from hypha.apply.utils.storage import PrivateMediaView @@ -110,6 +118,90 @@ def delete_comment(request, pk): ) +@login_required +@require_http_methods(["POST", "GET"]) +@user_passes_test(is_apply_staff) +def post_comment(request: HttpRequest): + if request.method == "POST": + form = CommentFormMini(user=request.user, data=request.POST or None) + if (source_content_type := form.data["source_content_type"]) and ( + source_object_id := form.data["source_object_id"] + ): + if ( + not ContentType.objects.filter(id=source_content_type).exists() + or not ContentType.objects.get_for_id(source_content_type) + .model_class() + .objects.filter(id=source_object_id) + .exists() + ): + raise Http404 + + source = ContentType.objects.get_for_id( + source_content_type + ).get_object_for_this_type(id=source_object_id) + form.instance.user = request.user + form.instance.source = source + form.instance.type = COMMENT + form.instance.timestamp = timezone.now() + if form.is_valid(): + 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!")), + } + ), + }, + ) + return render( + request, "activity/partials/comment_form.html", {"form": form} + ) + else: + form = CommentFormMini(user=request.user) + + params = request.GET.dict() + + # Ensure the provided source content type & object actually exist + source_content_type = params.get("source_content_type") + source_object_id = params.get("source_object_id") + if not (source_content_type and source_object_id) or ( + not ContentType.objects.filter(id=source_content_type).exists() + or not ContentType.objects.get_for_id(source_content_type) + .model_class() + .objects.filter(id=source_object_id) + .exists() + ): + raise Http404 + + form.fields["source_content_type"].initial = source_content_type + form.fields["source_object_id"].initial = source_object_id + + if (related_content_type := params.get("related_content_type")) and ( + related_object_id := params.get("related_object_id") + ): + if ( + ContentType.objects.filter(id=related_content_type).exists() + and ContentType.objects.get_for_id(related_content_type) + .model_class() + .objects.filter(id=related_object_id) + .exists() + ): + form.fields["related_content_type"].initial = related_content_type + form.fields["related_object_id"].initial = related_object_id + + return render(request, "activity/partials/comment_form.html", {"form": form}) + + class ActivityContextMixin: """Mixin to add related 'comments' of the current view's 'self.object'""" diff --git a/hypha/apply/funds/templates/funds/comments.html b/hypha/apply/funds/templates/funds/comments.html index ce8b1634e9..2d0e2e7bef 100644 --- a/hypha/apply/funds/templates/funds/comments.html +++ b/hypha/apply/funds/templates/funds/comments.html @@ -69,7 +69,7 @@

{% trans "Add communication" %}

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