diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index 4479086ac3787fc7bacbcde068f32347efab5aaf..b8b0f47aa3d399c3c89a4322f746df0f6743d5af 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -260,7 +260,7 @@ class CertificatesListView(GenericAPIView): passing_certificates[course_key] = course_certificate viewable_certificates = [] - for course_key, course_overview in CourseOverview.get_from_ids_if_exists( + for course_key, course_overview in CourseOverview.get_from_ids( list(passing_certificates.keys()) ).items(): if certificates_viewable_for_course(course_overview): diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/views.py b/lms/djangoapps/program_enrollments/rest_api/v1/views.py index 59d48f5a647e44cd08f5046c5915ea70b5deb969..277cac8d4cb540c64369a0e3bf8d80c70315fd2d 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/views.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/views.py @@ -900,7 +900,7 @@ class ProgramCourseEnrollmentOverviewView( is_active=True, ) - overviews = CourseOverview.get_from_ids_if_exists(course_run_keys) + overviews = CourseOverview.get_from_ids(course_run_keys) course_run_resume_urls = get_resume_urls_for_enrollments(user, course_enrollments) diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index e32f8068e1605939fc291be1a3aa05d884202a37..fd417dbfb28b61186f06d21c091e58eb61abc111 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -335,48 +335,34 @@ class CourseOverview(TimeStampedModel): return course_overview or cls.load_from_module_store(course_id) @classmethod - def get_from_ids_if_exists(cls, course_ids): + def get_from_ids(cls, course_ids): """ - Return a dict mapping course_ids to CourseOverviews, if they exist. + Return a dict mapping course_ids to CourseOverviews. - This method will *not* generate new CourseOverviews or delete outdated - ones. It exists only as a small optimization used when CourseOverviews - are known to exist, for common situations like the student dashboard. + Tries to select all CourseOverviews in one query, + then fetches remaining (uncached) overviews from the modulestore. - Callers should assume that this list is incomplete and fall back to - get_from_id if they need to guarantee CourseOverview generation. + Course IDs for non-existant courses will map to None. + + Arguments: + course_ids (iterable[CourseKey]) + + Returns: dict[CourseKey, CourseOverview|None] """ - return { + overviews = { overview.id: overview - for overview - in cls.objects.select_related('image_set').filter( + for overview in cls.objects.select_related('image_set').filter( id__in=course_ids, version__gte=cls.VERSION ) } - - @classmethod - def get_from_id_if_exists(cls, course_id): - """ - Return a CourseOverview for the provided course_id if it exists. - Returns None if no CourseOverview exists with the provided course_id - - This method will *not* generate new CourseOverviews or delete outdated - ones. It exists only as a small optimization used when CourseOverviews - are known to exist, for common situations like the student dashboard. - - Callers should assume that this list is incomplete and fall back to - get_from_id if they need to guarantee CourseOverview generation. - """ - try: - course_overview = cls.objects.select_related('image_set').get( - id=course_id, - version__gte=cls.VERSION - ) - except cls.DoesNotExist: - course_overview = None - - return course_overview + for course_id in course_ids: + if course_id not in overviews: + try: + overviews[course_id] = cls.load_from_module_store(course_id) + except CourseOverview.DoesNotExist: + overviews[course_id] = None + return overviews def clean_id(self, padding_char='='): """ diff --git a/openedx/core/djangoapps/content/course_overviews/signals.py b/openedx/core/djangoapps/content/course_overviews/signals.py index a683bf7ca3d79d2789da6777f658026c2606e742..1ce66e3b0d2b2dcbac2892ec0c1fda8f5845b153 100644 --- a/openedx/core/djangoapps/content/course_overviews/signals.py +++ b/openedx/core/djangoapps/content/course_overviews/signals.py @@ -25,7 +25,10 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable Catches the signal that a course has been published in Studio and updates the corresponding CourseOverview cache entry. """ - previous_course_overview = CourseOverview.get_from_ids_if_exists([course_key]).get(course_key) + try: + previous_course_overview = CourseOverview.objects.get(id=course_key) + except CourseOverview.DoesNotExist: + previous_course_overview = None updated_course_overview = CourseOverview.load_from_module_store(course_key) _check_for_course_changes(previous_course_overview, updated_course_overview) diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py index a7cd1fa65cd38e35ea68df234d4faf124db763f2..3b11a36be7f905d3332a5405648940a7ae93ff34 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py @@ -16,6 +16,7 @@ from django.conf import settings from django.db.utils import IntegrityError from django.test.utils import override_settings from django.utils import timezone +from opaque_keys.edx.keys import CourseKey from PIL import Image from six.moves import range # pylint: disable=ungrouped-imports @@ -550,46 +551,48 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase, Cache .format(filter_), ) - def test_get_from_ids_if_exists(self): + def test_get_from_ids(self): + """ + Assert that CourseOverviews.get_from_ids works as expected. + + We expect that if we have four courses, of which: + * two have cached course overviews, + * one does *not* have a cache course overview, and + * one has an *out-of-date* course overview, that + all four course overviews will appear in teh resulting dictionary, + with the former two coming from the CourseOverviews SQL cache + and the latter two coming from the modulestore. + """ course_with_overview_1 = CourseFactory.create(emit_signals=True) course_with_overview_2 = CourseFactory.create(emit_signals=True) course_without_overview = CourseFactory.create(emit_signals=False) - - courses = [course_with_overview_1, course_with_overview_2, course_without_overview] - course_ids_to_overviews = CourseOverview.get_from_ids_if_exists( - course.id for course in courses - ) - - # We should see the ones that have published CourseOverviews - # (when the signals were emitted), but not the one that didn't issue - # a publish signal. - self.assertEqual(len(course_ids_to_overviews), 2) - self.assertIn(course_with_overview_1.id, course_ids_to_overviews) - self.assertIn(course_with_overview_2.id, course_ids_to_overviews) - self.assertNotIn(course_without_overview.id, course_ids_to_overviews) - - # But if we set a CourseOverview to be an old version, it shouldn't be - # returned by get_from_ids_if_exists() - overview_2 = course_ids_to_overviews[course_with_overview_2.id] - overview_2.version = CourseOverview.VERSION - 1 - overview_2.save() - course_ids_to_overviews = CourseOverview.get_from_ids_if_exists( - course.id for course in courses - ) - self.assertEqual(len(course_ids_to_overviews), 1) - self.assertIn(course_with_overview_1.id, course_ids_to_overviews) - - def test_get_from_id_if_exists(self): - course_with_overview = CourseFactory.create(emit_signals=True) - course_id_to_overview = CourseOverview.get_from_id_if_exists(course_with_overview.id) - self.assertEqual(course_with_overview.id, course_id_to_overview.id) - - overview_prev_version = CourseOverview.get_from_id_if_exists(course_with_overview.id) - overview_prev_version.version = CourseOverview.VERSION - 1 - overview_prev_version.save() - - course_id_to_overview = CourseOverview.get_from_id_if_exists(course_with_overview.id) - self.assertEqual(course_id_to_overview, None) + course_with_old_overview = CourseFactory.create(emit_signals=True) + old_overview = CourseOverview.objects.get(id=course_with_old_overview.id) + old_overview.version = CourseOverview.VERSION - 1 + old_overview.save() + + courses = [ + course_with_overview_1, + course_with_overview_2, + course_without_overview, + course_with_old_overview, + ] + non_existent_course_key = CourseKey.from_string('course-v1:This+Course+IsFake') + course_ids = [course.id for course in courses] + [non_existent_course_key] + + with mock.patch.object( + CourseOverview, + 'load_from_module_store', + wraps=CourseOverview.load_from_module_store + ) as mock_load_from_modulestore: + overviews_by_id = CourseOverview.get_from_ids(course_ids) + assert len(overviews_by_id) == 5 + assert overviews_by_id[course_with_overview_1.id].id == course_with_overview_1.id + assert overviews_by_id[course_with_overview_2.id].id == course_with_overview_2.id + assert overviews_by_id[course_with_old_overview.id].id == course_with_old_overview.id + assert overviews_by_id[old_overview.id].id == old_overview.id + assert overviews_by_id[non_existent_course_key] is None + assert mock_load_from_modulestore.call_count == 3 @ddt.ddt diff --git a/openedx/core/lib/api/tests/test_view_utils.py b/openedx/core/lib/api/tests/test_view_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..ac3c5779375b7a7f9056c4cbec608f0d4f282fcf --- /dev/null +++ b/openedx/core/lib/api/tests/test_view_utils.py @@ -0,0 +1,66 @@ +""" +Tests for (some of) the view utilities. +""" +from django.conf.urls import url +from django.test.utils import override_settings +from rest_framework.response import Response +from rest_framework.test import APITestCase +from rest_framework.views import APIView + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from ..view_utils import DeveloperErrorViewMixin, verify_course_exists + + +class MockAPIView(DeveloperErrorViewMixin, APIView): + """ + Mock API view for testing. + """ + + @verify_course_exists + def get(self, request, course_id): + """ + Mock GET handler for testing. + """ + return Response("Success {}".format(course_id)) + +urlpatterns = [ + url(r'^mock/(?P<course_id>.*)/$', MockAPIView.as_view()), # Only works with new-style course keys +] + + +@override_settings(ROOT_URLCONF=__name__) +class VerifyCourseExistsTestCase(SharedModuleStoreTestCase, APITestCase): + """ + Tests for behavior of @verify_course_exists. + """ + def test_bad_key_404(self): + response = self.client.get('/mock/this-is-a-bad-key/') + assert response.status_code == 404 + + def test_nonexistent_course_404(self): + CourseFactory() + response = self.client.get('/mock/course-v1:Non+Existent+Course/') + assert response.status_code == 404 + + def test_course_exists_200(self): + course = CourseFactory( + org="This", course="IsA", run="Course", + default_store=ModuleStoreEnum.Type.split, + ) + response = self.client.get('/mock/{}/'.format(course.id)) + assert response.status_code == 200 + + def test_course_with_outdated_overview_200(self): + course = CourseFactory( + org="This", course="IsAnother", run="Course", + default_store=ModuleStoreEnum.Type.split, + ) + course_overview = CourseOverview.get_from_id(course.id) + course_overview.version = CourseOverview.VERSION - 1 + course_overview.save() + response = self.client.get('/mock/{}/'.format(course.id)) + assert response.status_code == 200 diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 47cc8e3a7a5f7337f41444827fe54c100991ea66..b25c072fc186edbb6d1b067792583fa9ac76cdc9 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -417,7 +417,7 @@ def verify_course_exists(view_func): error_code='invalid_course_key' ) - if not CourseOverview.get_from_id_if_exists(course_key): + if not CourseOverview.course_exists(course_key): raise self.api_error( status_code=status.HTTP_404_NOT_FOUND, developer_message=u"Requested grade for unknown course {course}".format(course=text_type(course_key)),