From 6460e276b5493edd8ea18d4fa83f0278cb10450b Mon Sep 17 00:00:00 2001 From: Arslan Ashraf Date: Mon, 27 Jan 2025 18:07:33 +0500 Subject: [PATCH] feat: Add course language filter --- cms/api.py | 6 +- cms/constants.py | 1 + cms/models.py | 27 +- cms/models_test.py | 75 +++ cms/templates/catalog_page.html | 436 ++++++++++-------- cms/views_test.py | 67 +++ .../migrations/0044_add_language_is_active.py | 17 + courses/models.py | 1 + mitxpro/features.py | 1 + static/js/entry/django.js | 4 + static/js/languageFilter.js | 13 + static/js/topicFilter.js | 13 + static/scss/catalog/tabs.scss | 39 ++ 13 files changed, 518 insertions(+), 182 deletions(-) create mode 100644 courses/migrations/0044_add_language_is_active.py create mode 100644 static/js/languageFilter.js create mode 100644 static/js/topicFilter.js diff --git a/cms/api.py b/cms/api.py index 3b32fedca..093e23605 100644 --- a/cms/api.py +++ b/cms/api.py @@ -9,7 +9,11 @@ from wagtail.models import Page, Site from cms import models as cms_models -from cms.constants import CERTIFICATE_INDEX_SLUG, ENTERPRISE_PAGE_SLUG, CatalogSorting +from cms.constants import ( + CERTIFICATE_INDEX_SLUG, + ENTERPRISE_PAGE_SLUG, + CatalogSorting, +) log = logging.getLogger(__name__) DEFAULT_HOMEPAGE_PROPS = dict(title="Home Page", subhead="This is the home page") # noqa: C408 diff --git a/cms/constants.py b/cms/constants.py index ec99d74d6..1a571ca30 100644 --- a/cms/constants.py +++ b/cms/constants.py @@ -12,6 +12,7 @@ COMMON_COURSEWARE_COMPONENT_INDEX_SLUG = "common-courseware-component-pages" ALL_TOPICS = "All Topics" +ALL_LANGUAGES = "All Languages" ALL_TAB = "all-tab" # ************** CONSTANTS FOR WEBINARS ************** diff --git a/cms/models.py b/cms/models.py index cffaac70a..df7704a4a 100644 --- a/cms/models.py +++ b/cms/models.py @@ -61,6 +61,7 @@ from cms.constants import ( ALL_TAB, ALL_TOPICS, + ALL_LANGUAGES, BLOG_INDEX_SLUG, CERTIFICATE_INDEX_SLUG, COMMON_COURSEWARE_COMPONENT_INDEX_SLUG, @@ -90,8 +91,11 @@ Program, ProgramCertificate, ProgramRun, + CourseLanguage, ) from ecommerce.models import Product +from mitol.olposthog.features import is_enabled +from mitxpro.features import CATALOG_LANGUAGE_FILTER from mitxpro.utils import now_in_utc from mitxpro.views import get_base_context @@ -516,6 +520,9 @@ def get_context(self, request, *args, **kwargs): # noqa: ARG002 Populate the context with live programs, courses and programs + courses """ topic_filter = request.GET.get("topic", ALL_TOPICS) + language_filter = request.GET.get("language", ALL_LANGUAGES) + + is_language_filter_enabled = is_enabled(CATALOG_LANGUAGE_FILTER, default=False) # Best Match is the default sorting. sort_by = request.GET.get("sort-by", CatalogSorting.BEST_MATCH.sorting_value) @@ -556,10 +563,19 @@ def get_context(self, request, *args, **kwargs): # noqa: ARG002 .order_by("title") ) + if language_filter != ALL_LANGUAGES: + program_page_qset = program_page_qset.filter(language__name=language_filter) + external_program_qset = external_program_qset.filter( + language__name=language_filter + ) + course_page_qset = course_page_qset.filter(language__name=language_filter) + external_course_qset = external_course_qset.filter( + language__name=language_filter + ) + if topic_filter != ALL_TOPICS: program_page_qset = program_page_qset.related_pages(topic_filter) external_program_qset = external_program_qset.related_pages(topic_filter) - course_page_qset = course_page_qset.related_pages(topic_filter) external_course_qset = external_course_qset.related_pages(topic_filter) @@ -647,6 +663,15 @@ def get_context(self, request, *args, **kwargs): # noqa: ARG002 } for sorting_option in CatalogSorting ], + show_language_filter=is_language_filter_enabled, + selected_language=language_filter, + language_options=[ALL_LANGUAGES] + + [ + course_language.name + for course_language in CourseLanguage.objects.filter(is_active=True) + ] + if is_language_filter_enabled + else [], ) diff --git a/cms/models_test.py b/cms/models_test.py index a9bff4715..e240abff2 100644 --- a/cms/models_test.py +++ b/cms/models_test.py @@ -24,8 +24,10 @@ UPCOMING_WEBINAR, UPCOMING_WEBINAR_BUTTON_TITLE, WEBINAR_HEADER_BANNER, + ALL_LANGUAGES, ) from cms.factories import ( + CatalogPageFactory, CertificatePageFactory, CommonComponentIndexPageFactory, CompaniesLogoCarouselPageFactory, @@ -88,6 +90,7 @@ CourseRunFactory, ProgramCertificateFactory, ) +from courses.models import CourseLanguage from ecommerce.factories import ProductFactory, ProductVersionFactory pytestmark = [pytest.mark.django_db] @@ -240,6 +243,78 @@ def test_webinar_detail_page_button_title(): assert upcoming_webinar.detail_page_button_title == UPCOMING_WEBINAR_BUTTON_TITLE +@pytest.mark.parametrize( + "languages, selected_language", + [ + (None, ALL_LANGUAGES), + ( + [ + "Languag1", + "Language2", + ], + "Language2", + ), + (["Languag1", "Language2", "Language3"], "Languag1"), + ], +) +def test_catalog_page_language_context( + mocker, staff_user, languages, selected_language +): + """ + Verify the language context is properly passed to the catalog_page.html + """ + mocker.patch("cms.models.is_enabled", return_value=True) + CourseLanguage.objects.all().delete() + catalog_page = CatalogPageFactory.create() + if languages: + CourseLanguageFactory.create_batch( + len(languages), name=factory.Iterator(languages) + ) + + rf = RequestFactory() + request = rf.get(f"/?language={selected_language}") + request.user = staff_user + context = catalog_page.get_context(request=request) + + assert context.get("self") == catalog_page + assert context.get("page") == catalog_page + assert context.get("request") == request + assert context.get("selected_language") == selected_language + assert ( + context.get("language_options") == [ALL_LANGUAGES] + languages + if languages + else ALL_LANGUAGES + ) + + +@pytest.mark.parametrize( + "is_enabled", + [True, False], +) +def test_catalog_page_language_feature_flag(mocker, staff_user, is_enabled): + """ + Verify the language context is properly passed to the catalog_page.html + """ + mocker.patch("cms.models.is_enabled", return_value=is_enabled) + CourseLanguage.objects.all().delete() + catalog_page = CatalogPageFactory.create() + languages = CourseLanguageFactory.create_batch(2) + + rf = RequestFactory() + request = rf.get("/") + request.user = staff_user + context = catalog_page.get_context(request=request) + + assert context.get("selected_language") == ALL_LANGUAGES + assert context.get("show_language_filter") == is_enabled + expected_languages = ( + [ALL_LANGUAGES] + [language.name for language in languages] + if is_enabled + else [] + ) + assert context.get("language_options") == expected_languages + + def test_course_page_program_page(): """ Verify `program_page` property from the course page returns expected value diff --git a/cms/templates/catalog_page.html b/cms/templates/catalog_page.html index 1d18d0293..e97ecdd1a 100644 --- a/cms/templates/catalog_page.html +++ b/cms/templates/catalog_page.html @@ -21,234 +21,310 @@

{{ site_name }}—Professional Development, the MIT Way

{% endblock %}
- {% for topic in topics %} - {% if selected_topic == topic %} -
- {% else %} -
- {% endif %} - {{ topic }} + Topic + + + - {% endfor %} -
-
-
-
-
- + +
diff --git a/cms/views_test.py b/cms/views_test.py index 50ff87b79..646bc3884 100644 --- a/cms/views_test.py +++ b/cms/views_test.py @@ -16,6 +16,7 @@ from cms.constants import ( ALL_TOPICS, + ALL_LANGUAGES, ON_DEMAND_WEBINAR, UPCOMING_WEBINAR, WEBINAR_DEFAULT_IMAGES, @@ -48,6 +49,7 @@ TextVideoSection, ) from courses.factories import ( + CourseLanguageFactory, CourseRunCertificateFactory, CourseRunFactory, CourseTopicFactory, @@ -55,6 +57,7 @@ ProgramFactory, ProgramRunFactory, ) +from courses.models import CourseLanguage from ecommerce.factories import ProductVersionFactory from mitxpro.utils import now_in_utc @@ -441,6 +444,70 @@ def test_catalog_page_topics( # noqa: PLR0913 assert len(resp.context_data["program_pages"]) == expected_program_count +@pytest.mark.parametrize( # noqa: PT007 + "language_options, selected_language, assign_language, expected_courses_count, expected_program_count", # noqa: PT006 + [ + [["Language1", "Language2"], ALL_LANGUAGES, "Language1", 2, 2], + [["Language1", "Language2"], "Language1", "Language1", 2, 2], + [["Language1", "Language2"], "Language2", "Language2", 2, 2], + [["Language1", "Language2"], "Language2", "Language1", 0, 0], + [["Language1", "Language2"], "Language1", "Language2", 0, 0], + ], +) +def test_catalog_page_languages( # noqa: PLR0913 + mocker, + client, + wagtail_basics, + language_options, + selected_language, + assign_language, + expected_courses_count, + expected_program_count, +): + """ + Test that language filters are working fine. + """ + mocker.patch("cms.models.is_enabled", return_value=True) + CourseLanguage.objects.all().delete() + homepage = wagtail_basics.root + catalog_page = CatalogPageFactory.create(parent=homepage) + catalog_page.save_revision().publish() + + now = now_in_utc() + start_date = now + timedelta(days=2) + end_date = now + timedelta(days=10) + + courseware_languages = CourseLanguageFactory.create_batch( + 2, name=factory.Iterator(language_options) + ) + assign_language = next( + language + for language in courseware_languages + if language.name == assign_language + ) + program_pages = ProgramPageFactory.create_batch(2, language=assign_language) + + CourseRunFactory.create_batch( + 2, + course__program=factory.Iterator( + [program_page.program for program_page in program_pages] + ), + course__live=True, + course__page__language=assign_language, + start_date=start_date, + end_date=end_date, + live=True, + ) + + resp = client.get(f"{catalog_page.get_url()}?language={selected_language}") + + assert resp.status_code == status.HTTP_200_OK + assert resp.context_data["language_options"] == [ALL_LANGUAGES] + language_options + assert resp.context_data["selected_language"] == selected_language + assert len(resp.context_data["course_pages"]) == expected_courses_count + assert len(resp.context_data["program_pages"]) == expected_program_count + + def test_catalog_page_topics_ordering(client, wagtail_basics): """ Test that topics are ordered alphabetically on Catalog Page diff --git a/courses/migrations/0044_add_language_is_active.py b/courses/migrations/0044_add_language_is_active.py new file mode 100644 index 000000000..d7cd6e0e0 --- /dev/null +++ b/courses/migrations/0044_add_language_is_active.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.18 on 2025-01-22 08:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0043_rename_sync_daily_platform_enable_sync"), + ] + + operations = [ + migrations.AddField( + model_name="courselanguage", + name="is_active", + field=models.BooleanField(default=True), + ), + ] diff --git a/courses/models.py b/courses/models.py index e5fb3ff5b..1a90a1408 100644 --- a/courses/models.py +++ b/courses/models.py @@ -256,6 +256,7 @@ class CourseLanguage(TimestampedModel, ValidateOnSaveMixin): validators=[MinValueValidator(1)], help_text="The priority of this language in the course/program sorting.", ) + is_active = models.BooleanField(default=True) class Meta: constraints = [ diff --git a/mitxpro/features.py b/mitxpro/features.py index 28a77a6d5..ebf937c79 100644 --- a/mitxpro/features.py +++ b/mitxpro/features.py @@ -3,3 +3,4 @@ DIGITAL_CREDENTIALS = "digital_credentials" ENABLE_ENTERPRISE = "enable_enterprise" ENROLLMENT_WELCOME_EMAIL = "enrollment_welcome_email" +CATALOG_LANGUAGE_FILTER = "catalog_language_filter" diff --git a/static/js/entry/django.js b/static/js/entry/django.js index 3caea352a..2d4cd9100 100644 --- a/static/js/entry/django.js +++ b/static/js/entry/django.js @@ -21,6 +21,8 @@ import blogPostsCarousel from "../blog_posts_carousel"; import companiesLogoCarousel from "../companies_logo_carousel.js"; import successStoriesCarousel from "../success_stories_carousel.js"; import applySorting from "../catalogSorting"; +import applyTopicFilter from "../topicFilter.js"; +import applyLanguageFilter from "../languageFilter.js"; document.addEventListener("DOMContentLoaded", function () { notifications(); @@ -38,4 +40,6 @@ document.addEventListener("DOMContentLoaded", function () { companiesLogoCarousel(); successStoriesCarousel(); applySorting(); + applyTopicFilter(); + applyLanguageFilter(); }); diff --git a/static/js/languageFilter.js b/static/js/languageFilter.js new file mode 100644 index 000000000..98fb31468 --- /dev/null +++ b/static/js/languageFilter.js @@ -0,0 +1,13 @@ +/*eslint-env jquery*/ +/*eslint semi: ["error", "always"]*/ +/* eslint-disable no-unused-vars */ +export default function applyLanguageFilter() { + $(".language-filter-option").on("click", function (event) { + event.preventDefault(); + const url = new URL(window.location.href); + const searchParams = url.searchParams; + searchParams.set("language", $(event.target).attr("data-filter-value")); + url.search = searchParams.toString(); + window.location.href = url.toString(); + }); +} diff --git a/static/js/topicFilter.js b/static/js/topicFilter.js new file mode 100644 index 000000000..e0d7e135e --- /dev/null +++ b/static/js/topicFilter.js @@ -0,0 +1,13 @@ +/*eslint-env jquery*/ +/*eslint semi: ["error", "always"]*/ +/* eslint-disable no-unused-vars */ +export default function applyTopicFilter() { + $(".topic-filter-option").on("click", function (event) { + event.preventDefault(); + const url = new URL(window.location.href); + const searchParams = url.searchParams; + searchParams.set("topic", $(event.target).attr("data-filter-value")); + url.search = searchParams.toString(); + window.location.href = url.toString(); + }); +} diff --git a/static/scss/catalog/tabs.scss b/static/scss/catalog/tabs.scss index 773bb747f..a89d4c18b 100644 --- a/static/scss/catalog/tabs.scss +++ b/static/scss/catalog/tabs.scss @@ -201,3 +201,42 @@ } } } + +.catalog-filter-dropdown { + @extend .catalog-sort-by-dropdown; + + display: block; + margin-left: 0; + border: none; + margin-bottom: 32px; + padding: 0; + + &.show, + &:hover { + border: none; + } + + .dropdown-toggle { + border: 1px solid #dde1ec; + padding: 16px; + width: 220px; + border-radius: 4px; + text-wrap-mode: wrap; + + &::after { + float: right; + } + } + + .dropdown-menu { + .dropdown-item { + text-wrap-mode: wrap; + line-height: 25px; + margin-top: 0; + } + } + + .col-2 { + max-width: 100%; + } +}