From 727e828a136cd1bc1c6a63583046170ba2e170e5 Mon Sep 17 00:00:00 2001
From: zawan-ila <87228907+zawan-ila@users.noreply.github.com>
Date: Tue, 14 Jan 2025 19:55:23 +0500
Subject: [PATCH] perf: optimize django admin pages (#4543)

---
 .../apps/course_metadata/admin.py             | 21 ++++-
 .../apps/course_metadata/forms.py             | 65 ++++++++++++++++
 .../apps/course_metadata/lookups.py           | 77 ++++++++++++++++++-
 .../apps/course_metadata/tests/test_admin.py  |  3 +-
 .../course_metadata/tests/test_lookups.py     | 38 ++++++++-
 course_discovery/apps/course_metadata/urls.py | 18 ++++-
 6 files changed, 212 insertions(+), 10 deletions(-)

diff --git a/course_discovery/apps/course_metadata/admin.py b/course_discovery/apps/course_metadata/admin.py
index 8c7da6330f..ef1827cdd7 100644
--- a/course_discovery/apps/course_metadata/admin.py
+++ b/course_discovery/apps/course_metadata/admin.py
@@ -126,6 +126,7 @@ class AdditionalMetadataInline(admin.TabularInline):
 @admin.register(GeoLocation)
 class GeoLocationAdmin(admin.ModelAdmin):
     """Admin for GeoLocation model."""
+    search_fields = ('location_name', )
 
 
 @admin.register(ProductValue)
@@ -145,7 +146,10 @@ class CourseAdmin(DjangoObjectActions, SimpleHistoryAdmin):
     readonly_fields = ['enrollment_count', 'recent_enrollment_count', 'active_url_slug', 'key', 'number']
     search_fields = ('uuid', 'key', 'key_for_reruns', 'title',)
     raw_id_fields = ('canonical_course_run', 'draft_version', 'location_restriction')
-    autocomplete_fields = ['canonical_course_run']
+    autocomplete_fields = [
+        'canonical_course_run', 'geolocation', 'in_year_value', 'video', 'extra_description',
+        'additional_metadata'
+    ]
     change_actions = ('course_skills', 'refresh_course_skills')
 
     def get_queryset(self, request):
@@ -229,6 +233,13 @@ def get_urls(self):
 
     course_skills.label = "view course skills"
 
+    class Media:
+        js = (
+            'bower_components/jquery-ui/ui/minified/jquery-ui.min.js',
+            'bower_components/jquery/dist/jquery.min.js',
+            SortableSelectJSPath()
+        )
+
 
 @admin.register(CourseEditor)
 class CourseEditorAdmin(admin.ModelAdmin):
@@ -307,6 +318,9 @@ class CourseRunAdmin(SimpleHistoryAdmin):
     search_fields = ('uuid', 'key', 'title_override', 'course__title', 'slug', 'external_key', 'variant_id')
     save_error = False
     form = CourseRunAdminForm
+    autocomplete_fields = (
+        'video',
+    )
 
     def get_queryset(self, request):
         qs = super().get_queryset(request)
@@ -398,8 +412,7 @@ class ProgramAdmin(DjangoObjectActions, SimpleHistoryAdmin):
     )
     raw_id_fields = ('video',)
     autocomplete_fields = (
-        'corporate_endorsements', 'faq', 'individual_endorsements', 'job_outlook_items',
-        'expected_learning_items', 'in_year_value'
+        'in_year_value',
     )
     search_fields = ('uuid', 'title', 'marketing_slug')
     exclude = ('card_image_url',)
@@ -603,7 +616,7 @@ class RankingAdmin(admin.ModelAdmin):
 @admin.register(AdditionalPromoArea)
 class AdditionalPromoAreaAdmin(admin.ModelAdmin):
     list_display = ('title', 'description', 'courses')
-    search_fields = ('description',)
+    search_fields = ('description', 'title')
 
     def get_queryset(self, request):
         queryset = super().get_queryset(request)
diff --git a/course_discovery/apps/course_metadata/forms.py b/course_discovery/apps/course_metadata/forms.py
index 2d4f671228..4f017db15e 100644
--- a/course_discovery/apps/course_metadata/forms.py
+++ b/course_discovery/apps/course_metadata/forms.py
@@ -30,6 +30,13 @@ class Meta:
                 },
                 forward=['product_source'],
             ),
+            'corporate_endorsements': SortedModelSelect2Multiple(
+                url='admin_metadata:corporate-endorsement-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                }
+            ),
             'credit_backing_organizations': SortedModelSelect2Multiple(
                 url='admin_metadata:organisation-autocomplete',
                 attrs={
@@ -38,6 +45,27 @@ class Meta:
                 },
                 forward=['product_source'],
             ),
+            'expected_learning_items': SortedModelSelect2Multiple(
+                url='admin_metadata:expected-learning-item-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                }
+            ),
+            'faq': SortedModelSelect2Multiple(
+                url='admin_metadata:faq-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                }
+            ),
+            'individual_endorsements': SortedModelSelect2Multiple(
+                url='admin_metadata:endorsement-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                }
+            ),
             'instructor_ordering': SortedModelSelect2Multiple(
                 url='admin_metadata:person-autocomplete',
                 attrs={
@@ -45,6 +73,13 @@ class Meta:
                     'class': 'sortable-select',
                 }
             ),
+            'job_outlook_items': SortedModelSelect2Multiple(
+                url='admin_metadata:job-outlook-item-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                }
+            ),
         }
 
     def __init__(self, *args, **kwargs):
@@ -117,6 +152,36 @@ class Meta:
         model = Course
         fields = '__all__'
         exclude = ('slug', 'url_slug', )
+        widgets = {
+            'authoring_organizations': SortedModelSelect2Multiple(
+                url='admin_metadata:organisation-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                },
+            ),
+            'collaborators': SortedModelSelect2Multiple(
+                url='admin_metadata:collaborator-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                },
+            ),
+            'expected_learning_items': SortedModelSelect2Multiple(
+                url='admin_metadata:expected-learning-item-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                },
+            ),
+            'sponsoring_organizations': SortedModelSelect2Multiple(
+                url='admin_metadata:organisation-autocomplete',
+                attrs={
+                    'data-minimum-input-length': 2,
+                    'class': 'sortable-select',
+                },
+            ),
+        }
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
diff --git a/course_discovery/apps/course_metadata/lookups.py b/course_discovery/apps/course_metadata/lookups.py
index 65cddab6c9..3628d9d508 100644
--- a/course_discovery/apps/course_metadata/lookups.py
+++ b/course_discovery/apps/course_metadata/lookups.py
@@ -4,7 +4,10 @@
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.db.models import Q
 
-from .models import Course, CourseRun, Organization, Person, Program
+from course_discovery.apps.course_metadata.models import (
+    FAQ, Collaborator, CorporateEndorsement, Course, CourseRun, Endorsement, ExpectedLearningItem, JobOutlookItem,
+    Organization, Person, Program
+)
 
 
 class CourseAutocomplete(autocomplete.Select2QuerySetView):
@@ -19,6 +22,30 @@ def get_queryset(self):
         return []
 
 
+class CollaboratorAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if self.request.user.is_authenticated and self.request.user.is_staff:
+            qs = Collaborator.objects.all()
+            if self.q:
+                qs = qs.filter(Q(name__icontains=self.q) | Q(uuid__icontains=self.q.strip()))
+
+            return qs
+
+        return []
+
+
+class CorporateEndorsementAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if self.request.user.is_authenticated and self.request.user.is_staff:
+            qs = CorporateEndorsement.objects.all()
+            if self.q:
+                qs = qs.filter(Q(corporation_name__icontains=self.q) | Q(statement__icontains=self.q))
+
+            return qs
+
+        return []
+
+
 class CourseRunAutocomplete(autocomplete.Select2QuerySetView):
     def get_queryset(self):
         if self.request.user.is_authenticated and self.request.user.is_staff:
@@ -36,6 +63,54 @@ def get_queryset(self):
         return []
 
 
+class EndorsementAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if self.request.user.is_authenticated and self.request.user.is_staff:
+            qs = Endorsement.objects.all()
+            if self.q:
+                qs = qs.filter(quote__icontains=self.q)
+
+            return qs
+
+        return []
+
+
+class ExpectedLearningItemAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if self.request.user.is_authenticated and self.request.user.is_staff:
+            qs = ExpectedLearningItem.objects.all()
+            if self.q:
+                qs = qs.filter(value__icontains=self.q)
+
+            return qs
+
+        return []
+
+
+class FAQAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if self.request.user.is_authenticated and self.request.user.is_staff:
+            qs = FAQ.objects.all()
+            if self.q:
+                qs = qs.filter(Q(question__icontains=self.q) | Q(answer__icontains=self.q))
+
+            return qs
+
+        return []
+
+
+class JobOutlookItemAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if self.request.user.is_authenticated and self.request.user.is_staff:
+            qs = JobOutlookItem.objects.all()
+            if self.q:
+                qs = qs.filter(value__icontains=self.q)
+
+            return qs
+
+        return []
+
+
 class OrganizationAutocomplete(autocomplete.Select2QuerySetView):
     def get_queryset(self):
         if self.request.user.is_authenticated and self.request.user.is_staff:
diff --git a/course_discovery/apps/course_metadata/tests/test_admin.py b/course_discovery/apps/course_metadata/tests/test_admin.py
index 1a106ff8dd..a2acdea6d3 100644
--- a/course_discovery/apps/course_metadata/tests/test_admin.py
+++ b/course_discovery/apps/course_metadata/tests/test_admin.py
@@ -130,8 +130,7 @@ def test_updating_order_of_authoring_orgs(self):
 
         course = factories.CourseFactory(authoring_organizations=[org1, org2, org3])
 
-        new_ordering = (',').join(map(lambda org: str(org.id), [org2, org3, org1]))
-        params = {'authoring_organizations': new_ordering}
+        params = {'authoring_organizations': [org2.id, org3.id, org1.id]}
 
         post_url = reverse('admin:course_metadata_course_change', args=(course.id,))
         response = self.client.post(post_url, params)
diff --git a/course_discovery/apps/course_metadata/tests/test_lookups.py b/course_discovery/apps/course_metadata/tests/test_lookups.py
index 85e0138dda..dcbcd8d0d2 100644
--- a/course_discovery/apps/course_metadata/tests/test_lookups.py
+++ b/course_discovery/apps/course_metadata/tests/test_lookups.py
@@ -1,4 +1,5 @@
 import json
+from itertools import cycle
 from urllib.parse import quote, urlencode
 
 import pytest
@@ -8,7 +9,9 @@
 from course_discovery.apps.api.tests.mixins import SiteMixin
 from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory
 from course_discovery.apps.course_metadata.tests.factories import (
-    CourseFactory, CourseRunFactory, OrganizationFactory, PersonFactory, PositionFactory, ProgramFactory
+    CollaboratorFactory, CorporateEndorsementFactory, CourseFactory, CourseRunFactory, EndorsementFactory,
+    ExpectedLearningItemFactory, FAQFactory, JobOutlookItemFactory, OrganizationFactory, PersonFactory, PositionFactory,
+    ProgramFactory
 )
 from course_discovery.apps.publisher.tests.factories import OrganizationExtensionFactory
 
@@ -91,7 +94,13 @@ def test_organization_autocomplete(self, admin_client):
         self.assert_valid_query_result(admin_client, path, organization.key[:3], organization)
         self.assert_valid_query_result(admin_client, path, organization.name[:5], organization)
 
-    @pytest.mark.parametrize('view_prefix', ['organisation', 'course', 'course-run'])
+    @pytest.mark.parametrize(
+        'view_prefix',
+        [
+            'collaborator', 'corporate-endorsement', 'course', 'course-run', 'endorsement',
+            'expected-learning-item', 'faq', 'job-outlook-item', 'organisation'
+        ]
+    )
     def test_autocomplete_requires_staff_permission(self, view_prefix, client):
         """ Verify autocomplete returns empty list for non-staff users. """
 
@@ -102,6 +111,31 @@ def test_autocomplete_requires_staff_permission(self, view_prefix, client):
         assert response.status_code == 200
         assert data['results'] == []
 
+    @pytest.mark.parametrize(
+        'model_factory, autocomplete_path, lookup_attrs',
+        [
+            (CollaboratorFactory, 'collaborator-autocomplete', ['name', 'uuid']),
+            (CorporateEndorsementFactory, 'corporate-endorsement-autocomplete', ['corporation_name', 'statement']),
+            (EndorsementFactory, 'endorsement-autocomplete', ['quote']),
+            (ExpectedLearningItemFactory, 'expected-learning-item-autocomplete', ['value']),
+            (FAQFactory, 'faq-autocomplete', ['question', 'answer']),
+            (JobOutlookItemFactory, 'job-outlook-item-autocomplete', ['value']),
+        ]
+    )
+    def test_models_autocomplete(self, admin_client, model_factory, autocomplete_path, lookup_attrs):
+        objects = model_factory.create_batch(3)
+        path = reverse(f'admin_metadata:{autocomplete_path}')
+        response = admin_client.get(path)
+        data = json.loads(response.content.decode('utf-8'))
+        assert response.status_code == 200
+        assert len(data['results']) == 3
+
+        # Search based on attributes
+        cycle_objects = cycle(objects)
+        for attr in lookup_attrs:
+            obj = next(cycle_objects)
+            self.assert_valid_query_result(admin_client, path, str(getattr(obj, attr))[:4], obj)
+
 
 class AutoCompletePersonTests(SiteMixin, TestCase):
     """
diff --git a/course_discovery/apps/course_metadata/urls.py b/course_discovery/apps/course_metadata/urls.py
index 444a5f0021..033fc67fb3 100644
--- a/course_discovery/apps/course_metadata/urls.py
+++ b/course_discovery/apps/course_metadata/urls.py
@@ -5,7 +5,9 @@
 from django.urls import path
 
 from course_discovery.apps.course_metadata.lookups import (
-    CourseAutocomplete, CourseRunAutocomplete, OrganizationAutocomplete, PersonAutocomplete, ProgramAutocomplete
+    CollaboratorAutocomplete, CorporateEndorsementAutocomplete, CourseAutocomplete, CourseRunAutocomplete,
+    EndorsementAutocomplete, ExpectedLearningItemAutocomplete, FAQAutocomplete, JobOutlookItemAutocomplete,
+    OrganizationAutocomplete, PersonAutocomplete, ProgramAutocomplete
 )
 from course_discovery.apps.course_metadata.views import CourseRunSelectionAdmin
 
@@ -13,8 +15,22 @@
 
 urlpatterns = [
     path('update_course_runs/<int:pk>/', CourseRunSelectionAdmin.as_view(), name='update_course_runs',),
+    path('collaborator-autocomplete/', CollaboratorAutocomplete.as_view(), name='collaborator-autocomplete',),
+    path(
+        'corporate-endorsement-autocomplete/',
+        CorporateEndorsementAutocomplete.as_view(),
+        name='corporate-endorsement-autocomplete',
+    ),
     path('course-autocomplete/', CourseAutocomplete.as_view(), name='course-autocomplete',),
     path('course-run-autocomplete/', CourseRunAutocomplete.as_view(), name='course-run-autocomplete',),
+    path('endorsement-autocomplete/', EndorsementAutocomplete.as_view(), name='endorsement-autocomplete',),
+    path(
+        'expected-learning-item-autocomplete/',
+        ExpectedLearningItemAutocomplete.as_view(),
+        name='expected-learning-item-autocomplete',
+    ),
+    path('faq-autocomplete/', FAQAutocomplete.as_view(), name='faq-autocomplete',),
+    path('job-outlook-item-autocomplete/', JobOutlookItemAutocomplete.as_view(), name='job-outlook-item-autocomplete',),
     path('organisation-autocomplete/', OrganizationAutocomplete.as_view(), name='organisation-autocomplete',),
     path('person-autocomplete/', PersonAutocomplete.as_view(), name='person-autocomplete',),
     path('program-autocomplete/', ProgramAutocomplete.as_view(), name='program-autocomplete',),