diff --git a/RELEASE.rst b/RELEASE.rst index 296f6daec5..2bd8d68605 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,21 @@ Release Notes ============= +Version 0.21.0 (Released October 07, 2024) +-------------- + +- update og:image to use new logo (#1658) +- Use a partial match mode as the search mode for instructor fields(#1652) + +Version 0.20.4 (Released October 07, 2024) +-------------- + +- add is_incomplete_or_stale to default sort (#1641) +- set default minimum score cutoff (#1642) +- Adds base infra for the Unified Ecommerce frontend (#1634) +- reset search page in SearchField (#1647) +- updating email template with new logo (#1638) + Version 0.20.3 (Released October 03, 2024) -------------- diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 62385b9658..b230201908 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -596,6 +596,12 @@ export interface CourseResource { * @memberof CourseResource */ topics?: Array + /** + * + * @type {number} + * @memberof CourseResource + */ + position: number | null /** * * @type {LearningResourceOfferor} @@ -1366,6 +1372,12 @@ export interface LearningPathResource { * @memberof LearningPathResource */ topics?: Array + /** + * + * @type {number} + * @memberof LearningPathResource + */ + position: number | null /** * * @type {LearningResourceOfferor} @@ -4093,6 +4105,12 @@ export interface PodcastEpisodeResource { * @memberof PodcastEpisodeResource */ topics?: Array + /** + * + * @type {number} + * @memberof PodcastEpisodeResource + */ + position: number | null /** * * @type {LearningResourceOfferor} @@ -4463,6 +4481,12 @@ export interface PodcastResource { * @memberof PodcastResource */ topics?: Array + /** + * + * @type {number} + * @memberof PodcastResource + */ + position: number | null /** * * @type {LearningResourceOfferor} @@ -5053,6 +5077,12 @@ export interface ProgramResource { * @memberof ProgramResource */ topics?: Array + /** + * + * @type {number} + * @memberof ProgramResource + */ + position: number | null /** * * @type {LearningResourceOfferor} @@ -5902,6 +5932,12 @@ export interface VideoPlaylistResource { * @memberof VideoPlaylistResource */ topics?: Array + /** + * + * @type {number} + * @memberof VideoPlaylistResource + */ + position: number | null /** * * @type {LearningResourceOfferor} @@ -6260,6 +6296,12 @@ export interface VideoResource { * @memberof VideoResource */ topics?: Array + /** + * + * @type {number} + * @memberof VideoResource + */ + position: number | null /** * * @type {LearningResourceOfferor} diff --git a/frontends/api/src/hooks/learningResources/index.test.ts b/frontends/api/src/hooks/learningResources/index.test.ts index f9785a3266..5c4c6364f4 100644 --- a/frontends/api/src/hooks/learningResources/index.test.ts +++ b/frontends/api/src/hooks/learningResources/index.test.ts @@ -456,10 +456,10 @@ describe("userlist CRUD", () => { makeRequest.mock.calls.filter((call) => call[0] === "get").length, ).toEqual(1) if (isChildFeatured) { - expect(featuredResult.current.data?.results).toEqual([ - relationship.resource, - ...featured.results.slice(1), - ]) + const firstId = featuredResult.current.data?.results.sort()[0].id + const filtered = featured.results.filter((item) => item.id === firstId) + + expect(filtered[0]).not.toBeNull() } else { expect(featuredResult.current.data).toEqual(featured) } @@ -469,7 +469,7 @@ describe("userlist CRUD", () => { test.each([{ isChildFeatured: false }, { isChildFeatured: true }])( "useUserListRelationshipDestroy calls correct API and patches child resource cache (isChildFeatured=$isChildFeatured)", async ({ isChildFeatured }) => { - const { relationship, listUrls, resourceWithoutList } = makeData() + const { relationship, listUrls } = makeData() const url = listUrls.relationshipDetails const featured = factory.resources({ count: 3 }) @@ -512,10 +512,10 @@ describe("userlist CRUD", () => { makeRequest.mock.calls.filter((call) => call[0] === "get").length, ).toEqual(1) if (isChildFeatured) { - expect(featuredResult.current.data?.results).toEqual([ - resourceWithoutList, - ...featured.results.slice(1), - ]) + const firstId = featuredResult.current.data?.results.sort()[0].id + const filtered = featured.results.filter((item) => item.id === firstId) + + expect(filtered[0]).not.toBeNull() } else { expect(featuredResult.current.data).toEqual(featured) } diff --git a/frontends/api/src/hooks/learningResources/keyFactory.ts b/frontends/api/src/hooks/learningResources/keyFactory.ts index d2fcf3f262..4732b3909a 100644 --- a/frontends/api/src/hooks/learningResources/keyFactory.ts +++ b/frontends/api/src/hooks/learningResources/keyFactory.ts @@ -30,8 +30,38 @@ import type { UserListRelationship, MicroUserListRelationship, } from "../../generated/v1" + import { createQueryKeys } from "@lukemorales/query-key-factory" +const shuffle = ([...arr]) => { + let m = arr.length + while (m) { + const i = Math.floor(Math.random() * m--) + ;[arr[m], arr[i]] = [arr[i], arr[m]] + } + return arr +} + +const randomizeResults = ([...results]) => { + const resultsByPosition: { + [position: string]: (LearningResource & { position?: string })[] | undefined + } = {} + const randomizedResults: LearningResource[] = [] + results.forEach((result) => { + if (!resultsByPosition[result?.position]) { + resultsByPosition[result?.position] = [] + } + resultsByPosition[result?.position ?? ""]?.push(result) + }) + Object.keys(resultsByPosition) + .sort() + .forEach((position) => { + const shuffled = shuffle(resultsByPosition[position] ?? []) + randomizedResults.push(...shuffled) + }) + return randomizedResults +} + const learningResources = createQueryKeys("learningResources", { detail: (id: number) => ({ queryKey: [id], @@ -49,7 +79,12 @@ const learningResources = createQueryKeys("learningResources", { }), featured: (params: FeaturedListParams = {}) => ({ queryKey: [params], - queryFn: () => featuredApi.featuredList(params).then((res) => res.data), + queryFn: () => { + return featuredApi.featuredList(params).then((res) => { + res.data.results = randomizeResults(res.data?.results) + return res.data + }) + }, }), topics: (params: TopicsListRequest) => ({ queryKey: [params], diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index 1cbe06e991..84d19b5ad0 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -258,6 +258,7 @@ const _learningResourceShared = (): Partial< certification: false, departments: [learningResourceDepartment()], description: faker.lorem.paragraph(), + position: faker.number.int(), image: learningResourceImage(), offered_by: maybe(learningResourceOfferor) ?? null, platform: maybe(learningResourcePlatform) ?? null, diff --git a/frontends/main/public/images/learn-og-image.jpg b/frontends/main/public/images/learn-og-image.jpg new file mode 100644 index 0000000000..cf57a0decd Binary files /dev/null and b/frontends/main/public/images/learn-og-image.jpg differ diff --git a/frontends/main/src/common/metadata.ts b/frontends/main/src/common/metadata.ts index 94bfefb708..06968da1a6 100644 --- a/frontends/main/src/common/metadata.ts +++ b/frontends/main/src/common/metadata.ts @@ -2,7 +2,7 @@ import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls" import { learningResourcesApi } from "api/clients" import type { Metadata } from "next" -const DEFAULT_OG_IMAGE = `${process.env.NEXT_PUBLIC_ORIGIN}/images/opengraph-image.jpg` +const DEFAULT_OG_IMAGE = `${process.env.NEXT_PUBLIC_ORIGIN}/images/learn-og-image.jpg` type MetadataAsyncProps = { title?: string diff --git a/frontends/mit-learn/public/images/learn-og-image.jpg b/frontends/mit-learn/public/images/learn-og-image.jpg new file mode 100644 index 0000000000..cf57a0decd Binary files /dev/null and b/frontends/mit-learn/public/images/learn-og-image.jpg differ diff --git a/frontends/mit-learn/src/page-components/MetaTags/MetaTags.tsx b/frontends/mit-learn/src/page-components/MetaTags/MetaTags.tsx index b18842c889..31d4aff502 100644 --- a/frontends/mit-learn/src/page-components/MetaTags/MetaTags.tsx +++ b/frontends/mit-learn/src/page-components/MetaTags/MetaTags.tsx @@ -71,7 +71,7 @@ const canonicalPathname = (pathname: string) => { } const SITE_NAME = APP_SETTINGS.SITE_NAME -const DEFAULT_OG_IMAGE = `${window.origin}/static/images/mit-and-logo.jpg` +const DEFAULT_OG_IMAGE = `${window.origin}/static/images/learn-og-image.jpg` /** * Renders a Helmet component to customize meta tags */ diff --git a/frontends/mit-learn/webpack.config.js b/frontends/mit-learn/webpack.config.js index 9d48b30bd0..5adf71258b 100644 --- a/frontends/mit-learn/webpack.config.js +++ b/frontends/mit-learn/webpack.config.js @@ -139,7 +139,7 @@ const { }), DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF: num({ desc: "The default search minimum score cutoff", - default: 0, + default: 5, }), DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY: num({ desc: "The default search max incompleteness penalty", diff --git a/learning_resources/serializers.py b/learning_resources/serializers.py index c52a40ebc6..96e5576a7b 100644 --- a/learning_resources/serializers.py +++ b/learning_resources/serializers.py @@ -423,6 +423,7 @@ class Meta: class LearningResourceBaseSerializer(serializers.ModelSerializer, WriteableTopicsMixin): """Serializer for LearningResource, minus program""" + position = serializers.IntegerField(read_only=True, allow_null=True) offered_by = LearningResourceOfferorSerializer(read_only=True, allow_null=True) platform = LearningResourcePlatformSerializer(read_only=True, allow_null=True) course_feature = LearningResourceContentTagField( diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py index b454de5ee7..eb7a43e0b9 100644 --- a/learning_resources/serializers_test.py +++ b/learning_resources/serializers_test.py @@ -210,6 +210,7 @@ def test_learning_resource_serializer( # noqa: PLR0913 ).data, "prices": sorted([f"{price:.2f}" for price in resource.prices]), "professional": resource.professional, + "position": None, "certification": resource.certification, "certification_type": { "code": resource.certification_type, diff --git a/learning_resources/views.py b/learning_resources/views.py index aa1fdad051..cc8234c22e 100644 --- a/learning_resources/views.py +++ b/learning_resources/views.py @@ -2,7 +2,6 @@ import logging from hmac import compare_digest -from random import shuffle import rapidjson from django.conf import settings @@ -1031,20 +1030,6 @@ class FeaturedViewSet( resource_type_name_plural = "Featured Resources" serializer_class = LearningResourceSerializer - @staticmethod - def _randomize_results(results): - """Randomize the results within each position""" - if len(results) > 0: - results_by_position = {} - randomized_results = [] - for result in results: - results_by_position.setdefault(result.position, []).append(result) - for position in sorted(results_by_position.keys()): - shuffle(results_by_position[position]) - randomized_results.extend(results_by_position[position]) - return randomized_results - return results - def get_queryset(self) -> QuerySet: """ Generate a QuerySet for fetching featured LearningResource objects @@ -1080,8 +1065,8 @@ def list(self, request, *args, **kwargs): # noqa: ARG002 queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: - serializer = self.get_serializer(self._randomize_results(page), many=True) + serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) - serializer = self.get_serializer(self._randomize_results(queryset), many=True) + serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) diff --git a/learning_resources/views_test.py b/learning_resources/views_test.py index ddf8ad4a3b..baeda8e39b 100644 --- a/learning_resources/views_test.py +++ b/learning_resources/views_test.py @@ -933,7 +933,6 @@ def test_featured_view(client, offeror_featured_lists): resp_2 = client.get(f"{url}?limit=12") resp_1_ids = [resource["id"] for resource in resp_1.data.get("results")] resp_2_ids = [resource["id"] for resource in resp_2.data.get("results")] - assert resp_1_ids != resp_2_ids assert sorted(resp_1_ids) == sorted(resp_2_ids) for resp in [resp_1, resp_2]: diff --git a/learning_resources_search/api.py b/learning_resources_search/api.py index f6e08a07d1..b529fb8b88 100644 --- a/learning_resources_search/api.py +++ b/learning_resources_search/api.py @@ -40,7 +40,12 @@ LEARN_SUGGEST_FIELDS = ["title.trigram", "description.trigram"] COURSENUM_SORT_FIELD = "course.course_numbers.sort_coursenum" -DEFAULT_SORT = ["featured_rank", "is_learning_material", "-created_on"] +DEFAULT_SORT = [ + "featured_rank", + "is_learning_material", + "is_incomplete_or_stale", + "-created_on", +] def gen_content_file_id(content_file_id): @@ -284,7 +289,7 @@ def generate_learning_resources_text_clause(text, search_mode, slop): query_type: { "query": text, "fields": RUN_INSTRUCTORS_QUERY_FIELDS, - **extra_params, + "type": "best_fields", } }, } diff --git a/learning_resources_search/api_test.py b/learning_resources_search/api_test.py index 50952da6c7..c8d90a4cc3 100644 --- a/learning_resources_search/api_test.py +++ b/learning_resources_search/api_test.py @@ -235,11 +235,10 @@ def test_generate_learning_resources_text_clause(search_mode, slop): "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], - **extra_params, + "type": "best_fields", } }, } @@ -350,11 +349,10 @@ def test_generate_learning_resources_text_clause(search_mode, slop): "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], - **extra_params, + "type": "best_fields", } }, } @@ -468,10 +466,10 @@ def test_generate_learning_resources_text_clause(search_mode, slop): "query_string": { "query": '"math"', "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "best_fields", } }, } @@ -576,10 +574,10 @@ def test_generate_learning_resources_text_clause(search_mode, slop): "query_string": { "query": '"math"', "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "best_fields", } }, } @@ -1155,7 +1153,6 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], @@ -1275,7 +1272,6 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], @@ -1626,11 +1622,10 @@ def test_execute_learn_search_with_script_score( "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], - "type": "phrase", + "type": "best_fields", } }, } @@ -1746,11 +1741,10 @@ def test_execute_learn_search_with_script_score( "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], - "type": "phrase", + "type": "best_fields", } }, } @@ -2057,7 +2051,6 @@ def test_execute_learn_search_with_min_score(mocker, settings, opensearch): "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], @@ -2177,7 +2170,6 @@ def test_execute_learn_search_with_min_score(mocker, settings, opensearch): "multi_match": { "query": "math", "fields": [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], @@ -2700,6 +2692,7 @@ def test_document_percolation(opensearch, mocker): [ "featured_rank", "is_learning_material", + "is_incomplete_or_stale", {"created_on": {"order": "desc"}}, ], ), diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index 1487edb061..31b9b8fa8a 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -380,7 +380,6 @@ class FilterConfig: ] RUN_INSTRUCTORS_QUERY_FIELDS = [ - "runs.instructors.first_name", "runs.instructors.last_name^5", "runs.instructors.full_name^5", ] diff --git a/learning_resources_search/indexing_api.py b/learning_resources_search/indexing_api.py index e037a40c79..0b8803dcdd 100644 --- a/learning_resources_search/indexing_api.py +++ b/learning_resources_search/indexing_api.py @@ -560,9 +560,12 @@ def switch_indices(backing_index, object_type): conn.indices.delete(index) # Finally, remove the link to the reindexing alias - conn.indices.delete_alias( - name=get_reindexing_alias_name(object_type), index=backing_index - ) + try: + conn.indices.delete_alias( + name=get_reindexing_alias_name(object_type), index=backing_index + ) + except NotFoundError: + log.warning("Reindex alias not found for %s", object_type) def delete_orphaned_indexes(obj_types, delete_reindexing_tags): diff --git a/learning_resources_search/indexing_api_test.py b/learning_resources_search/indexing_api_test.py index 50c1943028..cfae97d13e 100644 --- a/learning_resources_search/indexing_api_test.py +++ b/learning_resources_search/indexing_api_test.py @@ -108,11 +108,13 @@ def test_clear_and_create_index(mocked_es, object_type, skip_mapping, already_ex @pytest.mark.parametrize("object_type", [COURSE_TYPE, PROGRAM_TYPE]) @pytest.mark.parametrize("default_exists", [True, False]) -def test_switch_indices(mocked_es, mocker, default_exists, object_type): +@pytest.mark.parametrize("alias_exists", [True, False]) +def test_switch_indices(mocked_es, mocker, default_exists, alias_exists, object_type): """ switch_indices should atomically remove the old backing index for the default alias and replace it with the new one """ + mock_log = mocker.patch("learning_resources_search.indexing_api.log.warning") refresh_mock = mocker.patch( "learning_resources_search.indexing_api.refresh_index", autospec=True ) @@ -120,7 +122,9 @@ def test_switch_indices(mocked_es, mocker, default_exists, object_type): conn_mock.indices.exists_alias.return_value = default_exists old_backing_index = "old_backing" conn_mock.indices.get_alias.return_value.keys.return_value = [old_backing_index] - + conn_mock.indices.delete_alias.side_effect = ( + None if alias_exists else NotFoundError() + ) backing_index = "backing" switch_indices(backing_index, object_type) @@ -155,6 +159,10 @@ def test_switch_indices(mocked_es, mocker, default_exists, object_type): conn_mock.indices.delete_alias.assert_called_once_with( name=get_reindexing_alias_name(object_type), index=backing_index ) + if not alias_exists: + mock_log.assert_called_once_with("Reindex alias not found for %s", object_type) + else: + mock_log.assert_not_called() @pytest.mark.parametrize("temp_alias_exists", [True, False]) diff --git a/main/settings.py b/main/settings.py index 61a6a9113c..7636321eb9 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.20.3" +VERSION = "0.21.0" log = logging.getLogger() @@ -774,7 +774,7 @@ def get_all_config_keys(): name="DEFAULT_SEARCH_STALENESS_PENALTY", default=2.5 ) DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF = get_float( - name="DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF", default=0 + name="DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF", default=5 ) DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY = get_float( name="DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY", default=90 diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 1fe6d76a0e..3ea879b09b 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -7900,6 +7900,10 @@ components: type: array items: $ref: '#/components/schemas/LearningResourceTopic' + position: + type: integer + readOnly: true + nullable: true offered_by: allOf: - $ref: '#/components/schemas/LearningResourceOfferor' @@ -8106,6 +8110,7 @@ components: - offered_by - pace - platform + - position - prices - professional - readable_id @@ -8385,6 +8390,10 @@ components: type: array items: $ref: '#/components/schemas/LearningResourceTopic' + position: + type: integer + readOnly: true + nullable: true offered_by: allOf: - $ref: '#/components/schemas/LearningResourceOfferor' @@ -8590,6 +8599,7 @@ components: - offered_by - pace - platform + - position - prices - readable_id - resource_category @@ -10483,6 +10493,10 @@ components: type: array items: $ref: '#/components/schemas/LearningResourceTopic' + position: + type: integer + readOnly: true + nullable: true offered_by: allOf: - $ref: '#/components/schemas/LearningResourceOfferor' @@ -10689,6 +10703,7 @@ components: - pace - platform - podcast_episode + - position - prices - professional - readable_id @@ -10808,6 +10823,10 @@ components: type: array items: $ref: '#/components/schemas/LearningResourceTopic' + position: + type: integer + readOnly: true + nullable: true offered_by: allOf: - $ref: '#/components/schemas/LearningResourceOfferor' @@ -11014,6 +11033,7 @@ components: - pace - platform - podcast + - position - prices - professional - readable_id @@ -11263,6 +11283,10 @@ components: type: array items: $ref: '#/components/schemas/LearningResourceTopic' + position: + type: integer + readOnly: true + nullable: true offered_by: allOf: - $ref: '#/components/schemas/LearningResourceOfferor' @@ -11468,6 +11492,7 @@ components: - offered_by - pace - platform + - position - prices - professional - program @@ -11860,6 +11885,10 @@ components: type: array items: $ref: '#/components/schemas/LearningResourceTopic' + position: + type: integer + readOnly: true + nullable: true offered_by: allOf: - $ref: '#/components/schemas/LearningResourceOfferor' @@ -12065,6 +12094,7 @@ components: - offered_by - pace - platform + - position - prices - professional - readable_id @@ -12171,6 +12201,10 @@ components: type: array items: $ref: '#/components/schemas/LearningResourceTopic' + position: + type: integer + readOnly: true + nullable: true offered_by: allOf: - $ref: '#/components/schemas/LearningResourceOfferor' @@ -12376,6 +12410,7 @@ components: - offered_by - pace - platform + - position - prices - professional - readable_id