diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py
index a3dce74941f6..9473dabbe427 100644
--- a/openedx/core/djangoapps/content/search/api.py
+++ b/openedx/core/djangoapps/content/search/api.py
@@ -322,6 +322,7 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
Fields.tags + "." + Fields.tags_level3,
Fields.type,
Fields.access_id,
+ Fields.last_published,
])
# Mark which attributes are used for keyword search, in order of importance:
client.index(temp_index_name).update_searchable_attributes([
@@ -340,6 +341,24 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
Fields.tags + "." + Fields.tags_level2,
Fields.tags + "." + Fields.tags_level3,
])
+ # Mark which attributes can be used for sorting search results:
+ client.index(temp_index_name).update_sortable_attributes([
+ Fields.display_name,
+ Fields.created,
+ Fields.modified,
+ Fields.last_published,
+ ])
+
+ # Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance.
+ # cf https://www.meilisearch.com/docs/learn/core_concepts/relevancy
+ client.index(temp_index_name).update_ranking_rules([
+ "sort",
+ "words",
+ "typo",
+ "proximity",
+ "attribute",
+ "exactness",
+ ])
############## Libraries ##############
status_cb("Indexing libraries...")
diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py
index dea494f312f0..032023f97c60 100644
--- a/openedx/core/djangoapps/content/search/documents.py
+++ b/openedx/core/djangoapps/content/search/documents.py
@@ -27,6 +27,9 @@ class Fields:
type = "type" # DocType.course_block or DocType.library_block (see below)
block_id = "block_id" # The block_id part of the usage key. Sometimes human-readable, sometimes a random hex ID
display_name = "display_name"
+ modified = "modified"
+ created = "created"
+ last_published = "last_published"
block_type = "block_type"
context_key = "context_key"
org = "org"
@@ -221,6 +224,9 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad
Generate a dictionary document suitable for ingestion into a search engine
like Meilisearch or Elasticsearch, so that the given library block can be
found using faceted search.
+
+ Datetime fields (created, modified, last_published) are serialized to POSIX timestamps so that they can be used to
+ sort the search results.
"""
library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title
block = xblock_api.load_block(xblock_metadata.usage_key, user=None)
@@ -228,7 +234,10 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad
doc = {
Fields.id: meili_id_from_opaque_key(xblock_metadata.usage_key),
Fields.type: DocType.library_block,
- Fields.breadcrumbs: []
+ Fields.breadcrumbs: [],
+ Fields.created: xblock_metadata.created.timestamp(),
+ Fields.modified: xblock_metadata.modified.timestamp(),
+ Fields.last_published: xblock_metadata.last_published.timestamp() if xblock_metadata.last_published else None,
}
doc.update(_fields_from_block(block))
diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py
index 1a80b2215781..ba0e8c1a1680 100644
--- a/openedx/core/djangoapps/content/search/handlers.py
+++ b/openedx/core/djangoapps/content/search/handlers.py
@@ -12,6 +12,7 @@
CONTENT_LIBRARY_UPDATED,
LIBRARY_BLOCK_CREATED,
LIBRARY_BLOCK_DELETED,
+ LIBRARY_BLOCK_UPDATED,
XBLOCK_CREATED,
XBLOCK_DELETED,
XBLOCK_UPDATED,
@@ -96,6 +97,7 @@ def xblock_deleted_handler(**kwargs) -> None:
@receiver(LIBRARY_BLOCK_CREATED)
+@receiver(LIBRARY_BLOCK_UPDATED)
@only_if_meilisearch_enabled
def library_block_updated_handler(**kwargs) -> None:
"""
diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py
index b207d34e963a..9dcdfb76b4a6 100644
--- a/openedx/core/djangoapps/content/search/tests/test_api.py
+++ b/openedx/core/djangoapps/content/search/tests/test_api.py
@@ -5,11 +5,13 @@
import copy
+from datetime import datetime, timezone
from unittest.mock import MagicMock, call, patch
from opaque_keys.edx.keys import UsageKey
import ddt
from django.test import override_settings
+from freezegun import freeze_time
from organizations.tests.factories import OrganizationFactory
from common.djangoapps.student.tests.factories import UserFactory
@@ -118,8 +120,17 @@ def setUp(self):
title="Library",
)
lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key)
- # Populate it with a problem:
- self.problem1 = library_api.create_library_block(self.library.key, "problem", "p1")
+
+ # Populate it with 2 problems, freezing the date so we can verify created date serializes correctly.
+ created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc)
+ with freeze_time(created_date):
+ self.problem1 = library_api.create_library_block(self.library.key, "problem", "p1")
+ self.problem2 = library_api.create_library_block(self.library.key, "problem", "p2")
+ # Update problem1, freezing the date so we can verify modified date serializes correctly.
+ modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc)
+ with freeze_time(modified_date):
+ library_api.set_library_block_olx(self.problem1.usage_key, "")
+
self.doc_problem1 = {
"id": "lborg1libproblemp1-a698218e",
"usage_key": "lb:org1:lib:problem:p1",
@@ -132,8 +143,10 @@ def setUp(self):
"content": {"problem_types": [], "capa_content": " "},
"type": "library_block",
"access_id": lib_access.id,
+ "last_published": None,
+ "created": created_date.timestamp(),
+ "modified": modified_date.timestamp(),
}
- self.problem2 = library_api.create_library_block(self.library.key, "problem", "p2")
self.doc_problem2 = {
"id": "lborg1libproblemp2-b2f65e29",
"usage_key": "lb:org1:lib:problem:p2",
@@ -146,6 +159,9 @@ def setUp(self):
"content": {"problem_types": [], "capa_content": " "},
"type": "library_block",
"access_id": lib_access.id,
+ "last_published": None,
+ "created": created_date.timestamp(),
+ "modified": created_date.timestamp(),
}
# Create a couple of taxonomies with tags
@@ -223,6 +239,22 @@ def mocked_from_component(lib_key, component):
any_order=True,
)
+ # Check that the sorting-related settings were updated to support sorting on the expected fields
+ mock_meilisearch.return_value.index.return_value.update_sortable_attributes.assert_called_with([
+ "display_name",
+ "created",
+ "modified",
+ "last_published",
+ ])
+ mock_meilisearch.return_value.index.return_value.update_ranking_rules.assert_called_with([
+ "sort",
+ "words",
+ "typo",
+ "proximity",
+ "attribute",
+ "exactness",
+ ])
+
@ddt.data(
True,
False
diff --git a/openedx/core/djangoapps/content/search/tests/test_handlers.py b/openedx/core/djangoapps/content/search/tests/test_handlers.py
index 1ce9c57a1ab9..8a6627e3902d 100644
--- a/openedx/core/djangoapps/content/search/tests/test_handlers.py
+++ b/openedx/core/djangoapps/content/search/tests/test_handlers.py
@@ -1,9 +1,11 @@
"""
Tests for the search index update handlers
"""
+from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from django.test import LiveServerTestCase, override_settings
+from freezegun import freeze_time
from organizations.tests.factories import OrganizationFactory
from common.djangoapps.student.tests.factories import UserFactory
@@ -132,7 +134,10 @@ def test_create_delete_library_block(self, meilisearch_client):
)
lib_access, _ = SearchAccess.objects.get_or_create(context_key=library.key)
- problem = library_api.create_library_block(library.key, "problem", "Problem1")
+ # Populate it with a problem, freezing the date so we can verify created date serializes correctly.
+ created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc)
+ with freeze_time(created_date):
+ problem = library_api.create_library_block(library.key, "problem", "Problem1")
doc_problem = {
"id": "lborgalib_aproblemproblem1-ca3186e9",
"type": "library_block",
@@ -145,6 +150,9 @@ def test_create_delete_library_block(self, meilisearch_client):
"breadcrumbs": [{"display_name": "Library Org A"}],
"content": {"problem_types": [], "capa_content": " "},
"access_id": lib_access.id,
+ "last_published": None,
+ "created": created_date.timestamp(),
+ "modified": created_date.timestamp(),
}
meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
@@ -152,10 +160,24 @@ def test_create_delete_library_block(self, meilisearch_client):
# Rename the content library
library_api.update_library(library.key, title="Updated Library Org A")
- # The breadcrumbs should be updated
+ # The breadcrumbs should be updated (but nothing else)
doc_problem["breadcrumbs"][0]["display_name"] = "Updated Library Org A"
meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
+ # Edit the problem block, freezing the date so we can verify modified date serializes correctly
+ modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc)
+ with freeze_time(modified_date):
+ library_api.set_library_block_olx(problem.usage_key, "")
+ doc_problem["modified"] = modified_date.timestamp()
+ meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
+
+ # Publish the content library, freezing the date so we can verify last_published date serializes correctly
+ published_date = datetime(2024, 6, 7, 8, 9, 10, tzinfo=timezone.utc)
+ with freeze_time(published_date):
+ library_api.publish_changes(library.key)
+ doc_problem["last_published"] = published_date.timestamp()
+ meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
+
# Delete the Library Block
library_api.delete_library_block(problem.usage_key)
diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py
index 3800fdb7f4f4..888452c89028 100644
--- a/openedx/core/djangoapps/content_libraries/api.py
+++ b/openedx/core/djangoapps/content_libraries/api.py
@@ -194,7 +194,10 @@ class LibraryXBlockMetadata:
Class that represents the metadata about an XBlock in a content library.
"""
usage_key = attr.ib(type=LibraryUsageLocatorV2)
+ created = attr.ib(type=datetime)
+ modified = attr.ib(type=datetime)
display_name = attr.ib("")
+ last_published = attr.ib(default=None, type=datetime)
has_unpublished_changes = attr.ib(False)
tags_count = attr.ib(0)
@@ -203,6 +206,8 @@ def from_component(cls, library_key, component):
"""
Construct a LibraryXBlockMetadata from a Component object.
"""
+ last_publish_log = authoring_api.get_last_publish(component.pk)
+
return cls(
usage_key=LibraryUsageLocatorV2(
library_key,
@@ -210,6 +215,9 @@ def from_component(cls, library_key, component):
component.local_key,
),
display_name=component.versioning.draft.title,
+ created=component.created,
+ modified=component.versioning.draft.created,
+ last_published=None if last_publish_log is None else last_publish_log.published_at,
has_unpublished_changes=component.versioning.has_unpublished_changes
)
@@ -660,13 +668,11 @@ def get_library_block(usage_key) -> LibraryXBlockMetadata:
if not draft_version:
raise ContentLibraryBlockNotFound(usage_key)
- published_version = component.versioning.published
-
- return LibraryXBlockMetadata(
- usage_key=usage_key,
- display_name=draft_version.title,
- has_unpublished_changes=(draft_version != published_version),
+ xblock_metadata = LibraryXBlockMetadata.from_component(
+ library_key=usage_key.context_key,
+ component=component,
)
+ return xblock_metadata
def set_library_block_olx(usage_key, new_olx_str):
diff --git a/openedx/core/djangoapps/content_libraries/library_context.py b/openedx/core/djangoapps/content_libraries/library_context.py
index 2607c18df7e4..6ff426e73560 100644
--- a/openedx/core/djangoapps/content_libraries/library_context.py
+++ b/openedx/core/djangoapps/content_libraries/library_context.py
@@ -6,6 +6,9 @@
from django.core.exceptions import PermissionDenied
+from openedx_events.content_authoring.data import LibraryBlockData
+from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED
+
from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
from openedx.core.djangoapps.xblock.api import LearningContext
@@ -93,3 +96,16 @@ def block_exists(self, usage_key):
type_name=usage_key.block_type,
local_key=usage_key.block_id,
)
+
+ def send_block_updated_event(self, usage_key):
+ """
+ Send a "block updated" event for the library block with the given usage_key.
+
+ usage_key: the UsageKeyV2 subclass used for this learning context
+ """
+ LIBRARY_BLOCK_UPDATED.send_event(
+ library_block=LibraryBlockData(
+ library_key=usage_key.lib_key,
+ usage_key=usage_key,
+ )
+ )
diff --git a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py
index d3306844ac40..f84ad4e6df72 100644
--- a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py
+++ b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py
@@ -441,7 +441,7 @@ def test_build_library_object_tree(self) -> None:
"""
Test if we can export a library
"""
- with self.assertNumQueries(8):
+ with self.assertNumQueries(11):
tagged_library = build_object_tree_with_objecttags(self.library.key, self.all_library_object_tags)
assert tagged_library == self.expected_library_tagged_xblock
diff --git a/openedx/core/djangoapps/xblock/learning_context/learning_context.py b/openedx/core/djangoapps/xblock/learning_context/learning_context.py
index 1ac621ef244f..2dc5155dc4e2 100644
--- a/openedx/core/djangoapps/xblock/learning_context/learning_context.py
+++ b/openedx/core/djangoapps/xblock/learning_context/learning_context.py
@@ -58,3 +58,10 @@ def definition_for_usage(self, usage_key, **kwargs):
Retuns None if the usage key doesn't exist in this context.
"""
raise NotImplementedError
+
+ def send_block_updated_event(self, usage_key):
+ """
+ Send a "block updated" event for the block with the given usage_key in this context.
+
+ usage_key: the UsageKeyV2 subclass used for this learning context
+ """
diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py
index 3722d9d8ab15..501386efba38 100644
--- a/openedx/core/djangoapps/xblock/rest_api/views.py
+++ b/openedx/core/djangoapps/xblock/rest_api/views.py
@@ -21,6 +21,7 @@
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
+from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
from openedx.core.lib.api.view_utils import view_auth_classes
from ..api import (
get_block_metadata,
@@ -254,6 +255,10 @@ def post(self, request, usage_key_str):
# Save after the callback so any changes made in the callback will get persisted.
block.save()
+ # Signal that we've modified this block
+ context_impl = get_learning_context_impl(usage_key)
+ context_impl.send_updated_event(usage_key)
+
return Response({
"id": str(block.location),
"data": data,