From b91f995366e22c544ee24c56f14175f7c547c5b7 Mon Sep 17 00:00:00 2001
From: Saurabh Kumar <theskumar@users.noreply.github.com>
Date: Tue, 11 Apr 2023 15:11:27 +0530
Subject: [PATCH] Load the activities tab of submission and project on demand
 (#3330)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The activities tab can have a lot of activities as it’s unpaginated
and also has an N+1 query to fetch the related objects.

This PR loads the data only after the tab is clicked.

I makes use of the htmx and alpine to observe the window-hash change.

Notes:
- The communication tab with it's markdown editor, requires some custom
initialization logic, which breaks with the htmx loaded content. So it'
not included in this PR.
- On a submission with 5 activities the number of SQL queries reduced
from 168 to 148.

Related #3328
---
 hypha/apply/activity/services.py              | 47 +++++++++++++++++++
 hypha/apply/activity/views.py                 | 16 ++-----
 .../funds/applicationsubmission_detail.html   | 16 +++++--
 hypha/apply/funds/urls.py                     |  2 +
 hypha/apply/funds/views_partials.py           | 21 +++++++++
 .../application_projects/project_detail.html  | 16 ++++++-
 hypha/apply/projects/urls.py                  |  2 +
 hypha/apply/projects/views/__init__.py        |  2 +
 .../apply/projects/views/project_partials.py  | 19 ++++++++
 9 files changed, 124 insertions(+), 17 deletions(-)
 create mode 100644 hypha/apply/activity/services.py
 create mode 100644 hypha/apply/funds/views_partials.py
 create mode 100644 hypha/apply/projects/views/project_partials.py

diff --git a/hypha/apply/activity/services.py b/hypha/apply/activity/services.py
new file mode 100644
index 000000000..e91c51145
--- /dev/null
+++ b/hypha/apply/activity/services.py
@@ -0,0 +1,47 @@
+from .models import Activity
+
+
+def get_related_actions_for_user(obj, user):
+    """Return Activity objects related to an object, esp. useful with
+    ApplicationSubmission and Project.
+
+    Args:
+        obj: instance of a model class
+        user: user who these actions are visible to.
+
+    Returns:
+        `Activity` queryset
+    """
+    related_query = type(obj).activities.rel.related_query_name
+
+    return (
+        Activity.actions.filter(**{related_query: obj})
+        .select_related('user')
+        .prefetch_related(
+            'related_object',
+        )
+        .visible_to(user)
+    )
+
+
+def get_related_comments_for_user(obj, user):
+    """Return comments/communications related to an object, esp. useful with
+    ApplicationSubmission and Project.
+
+    Args:
+        obj: instance of a model class
+        user: user who these actions are visible to.
+
+    Returns:
+        `Activity` queryset
+    """
+    related_query = type(obj).activities.rel.related_query_name
+
+    return (
+        Activity.comments.filter(**{related_query: obj})
+        .select_related('user')
+        .prefetch_related(
+            'related_object',
+        )
+        .visible_to(user)
+    )
diff --git a/hypha/apply/activity/views.py b/hypha/apply/activity/views.py
index 1a1be200f..caadfa494 100644
--- a/hypha/apply/activity/views.py
+++ b/hypha/apply/activity/views.py
@@ -9,25 +9,17 @@ from .filters import NotificationFilter
 from .forms import CommentForm
 from .messaging import MESSAGES, messenger
 from .models import COMMENT, Activity
+from .services import get_related_comments_for_user
 
 
 class ActivityContextMixin:
+    """Mixin to add related 'comments' of the current view's 'self.object'
+    """
     def get_context_data(self, **kwargs):
-        related_query = self.model.activities.rel.related_query_name
-        query = {related_query: self.object}
         extra = {
             # Do not prefetch on the related_object__author as the models
             # are not homogeneous and this will fail
-            'actions': Activity.actions.filter(**query).select_related(
-                'user',
-            ).prefetch_related(
-                'related_object',
-            ).visible_to(self.request.user),
-            'comments': Activity.comments.filter(**query).select_related(
-                'user',
-            ).prefetch_related(
-                'related_object',
-            ).visible_to(self.request.user),
+            'comments': get_related_comments_for_user(self.object, self.request.user)
         }
         return super().get_context_data(**extra, **kwargs)
 
diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html
index cba7ed595..08aba5edc 100644
--- a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html
+++ b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html
@@ -44,7 +44,16 @@
                     {% trans "Communications" %}
                 </a>
 
-                <a class="tab__item" href="#activity-feed" data-tab="tab-3">
+                <a class="tab__item"
+                    href="#activity-feed"
+                    hx-get="{% url 'funds:submissions:partial-activities' object.id %}"
+                    hx-target="#tab-3 .feed"
+                    hx-trigger="open-tab-3 once"
+                    data-tab="tab-3"
+                    x-data
+                    @hashchange.window="location.hash === '#activity-feed' ? $dispatch('open-tab-3') : ''"
+                    x-init="location.hash === '#activity-feed' ? $dispatch('open-tab-3') : ''"
+                >
                     {% trans "Activity feed" %}
                 </a>
                 {% if request.user.is_apply_staff_admin %}
@@ -58,7 +67,7 @@
 </div>
 
 <div class="wrapper wrapper--large wrapper--tabs js-tabs-content">
-{# Tab 1 #}
+    {# Tab 1 #}
     <div class="tabs__content" id="tab-1">
         {% block mobile_actions %}
         {% endblock %}
@@ -169,7 +178,8 @@
     {# Tab 3 #}
     <div class="tabs__content" id="tab-3">
         <div class="feed">
-            {% include "activity/include/action_list.html" %}
+            {% comment %} Loaded using the htmx via alpine's custom event "open-tab-3"{% endcomment %}
+            <p>Loading...</p>
         </div>
     </div>
 </div>
diff --git a/hypha/apply/funds/urls.py b/hypha/apply/funds/urls.py
index 0f4e7a03e..02962ba71 100644
--- a/hypha/apply/funds/urls.py
+++ b/hypha/apply/funds/urls.py
@@ -27,6 +27,7 @@ from .views import (
     SubmissionStaffFlaggedView,
     SubmissionUserFlaggedView,
 )
+from .views_partials import partial_submission_activities
 
 revision_urls = ([
     path('', RevisionListView.as_view(), name='list'),
@@ -59,6 +60,7 @@ submission_urls = ([
     ])),
     path('<int:pk>/', include([
         path('', SubmissionDetailView.as_view(), name="detail"),
+        path('partial/activities/', partial_submission_activities, name="partial-activities"),
         path('edit/', SubmissionEditView.as_view(), name="edit"),
         path('sealed/', SubmissionSealedView.as_view(), name="sealed"),
         path('simplified/', SubmissionDetailSimplifiedView.as_view(), name="simplified"),
diff --git a/hypha/apply/funds/views_partials.py b/hypha/apply/funds/views_partials.py
new file mode 100644
index 000000000..a7442eaeb
--- /dev/null
+++ b/hypha/apply/funds/views_partials.py
@@ -0,0 +1,21 @@
+from django.contrib.auth.decorators import login_required
+from django.shortcuts import get_object_or_404, render
+from django.views.decorators.http import require_GET
+
+from hypha.apply.activity.services import (
+    get_related_actions_for_user,
+)
+from hypha.apply.funds.permissions import has_permission
+
+from .models import ApplicationSubmission
+
+
+@login_required
+@require_GET
+def partial_submission_activities(request, pk):
+    submission = get_object_or_404(ApplicationSubmission, pk=pk)
+    has_permission(
+        'submission_view', request.user, object=submission, raise_exception=True
+    )
+    ctx = {'actions': get_related_actions_for_user(submission, request.user)}
+    return render(request, 'activity/include/action_list.html', ctx)
diff --git a/hypha/apply/projects/templates/application_projects/project_detail.html b/hypha/apply/projects/templates/application_projects/project_detail.html
index e41078735..15d81d43c 100644
--- a/hypha/apply/projects/templates/application_projects/project_detail.html
+++ b/hypha/apply/projects/templates/application_projects/project_detail.html
@@ -86,7 +86,18 @@
                     {% trans "Communications" %}
                 </a>
 
-                <a class="tab__item" href="#activity-feed" data-tab="tab-3">
+                <a
+                    class="tab__item"
+                    href="#activity-feed"
+                    data-tab="tab-3"
+                    hx-get="{% url 'apply:projects:partial-activities' object.id %}"
+                    hx-target="#tab-3 .feed"
+                    hx-trigger="open-tab-3 once"
+                    data-tab="tab-3"
+                    x-data
+                    @hashchange.window="location.hash === '#activity-feed' ? $dispatch('open-tab-3') : ''"
+                    x-init="location.hash === '#activity-feed' ? $dispatch('open-tab-3') : ''"
+                >
                     {% trans "Activity Feed" %}
                 </a>
             </div>
@@ -204,7 +215,8 @@
     {# Tab 3 #}
     <div class="tabs__content" id="tab-3">
         <div class="feed">
-            {% include "activity/include/action_list.html" with editable=False %}
+            {% comment %} Loaded using the htmx via alpine's custom event "open-tab-3"{% endcomment %}
+            <p>Loading...</p>
         </div>
     </div>
 </div>
diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py
index 6c3b8f7a0..909926715 100644
--- a/hypha/apply/projects/urls.py
+++ b/hypha/apply/projects/urls.py
@@ -26,6 +26,7 @@ from .views import (
     ReportUpdateView,
     VendorDetailView,
     VendorPrivateMediaView,
+    partial_project_activities,
 )
 
 app_name = 'projects'
@@ -37,6 +38,7 @@ urlpatterns = [
 
     path('<int:pk>/', include([
         path('', ProjectDetailView.as_view(), name='detail'),
+        path('partial/activities/', partial_project_activities, name="partial-activities"),
         path('edit/', ProjectApprovalFormEditView.as_view(), name="edit"),
         path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"),
         path('contract/<int:file_pk>/', ContractPrivateMediaView.as_view(), name="contract"),
diff --git a/hypha/apply/projects/views/__init__.py b/hypha/apply/projects/views/__init__.py
index fb7ca2a0a..7520c2342 100644
--- a/hypha/apply/projects/views/__init__.py
+++ b/hypha/apply/projects/views/__init__.py
@@ -30,6 +30,7 @@ from .project import (
     UploadContractView,
     UploadDocumentView,
 )
+from .project_partials import partial_project_activities
 from .report import (
     ReportDetailView,
     ReportFrequencyUpdate,
@@ -41,6 +42,7 @@ from .report import (
 from .vendor import CreateVendorView, VendorDetailView, VendorPrivateMediaView
 
 __all__ = [
+    'partial_project_activities',
     'ChangeInvoiceStatusView',
     'SendForApprovalView',
     'UploadDocumentView',
diff --git a/hypha/apply/projects/views/project_partials.py b/hypha/apply/projects/views/project_partials.py
new file mode 100644
index 000000000..72e24dc8c
--- /dev/null
+++ b/hypha/apply/projects/views/project_partials.py
@@ -0,0 +1,19 @@
+from django.contrib.auth.decorators import login_required
+from django.shortcuts import get_object_or_404, render
+from django.views.decorators.http import require_GET
+
+from hypha.apply.activity.services import (
+    get_related_actions_for_user,
+)
+
+from ..models.project import Project
+
+
+@login_required
+@require_GET
+def partial_project_activities(request, pk):
+    project = get_object_or_404(Project, pk=pk)
+    ctx = {
+        'actions': get_related_actions_for_user(project, request.user)
+    }
+    return render(request, 'activity/include/action_list.html', ctx)
-- 
GitLab