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',),