Merge pull request #9488 from edx/dsjen/courses-via-overview
Optimize OpenID Course Claims for users.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Command to load course overviews.
|
||||
"""
|
||||
import logging
|
||||
from optparse import make_option
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Example usage:
|
||||
$ ./manage.py lms generate_course_overview --all --settings=devstack
|
||||
$ ./manage.py lms generate_course_overview 'edX/DemoX/Demo_Course' --settings=devstack
|
||||
"""
|
||||
args = '<course_id course_id ...>'
|
||||
help = 'Generates and stores course overview for one or more courses.'
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--all',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='Generate course overview for all courses.'),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
course_keys = []
|
||||
|
||||
if options['all']:
|
||||
course_keys = [course.id for course in modulestore().get_courses()]
|
||||
else:
|
||||
if len(args) < 1:
|
||||
raise CommandError('At least one course or --all must be specified.')
|
||||
try:
|
||||
course_keys = [CourseKey.from_string(arg) for arg in args]
|
||||
except InvalidKeyError:
|
||||
log.fatal('Invalid key specified.')
|
||||
|
||||
if not course_keys:
|
||||
log.fatal('No courses specified.')
|
||||
|
||||
log.info('Generating course overview for %d courses.', len(course_keys))
|
||||
log.debug('Generating course overview(s) for the following courses: %s', course_keys)
|
||||
|
||||
for course_key in course_keys:
|
||||
try:
|
||||
CourseOverview.get_from_id(course_key)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
log.exception('An error occurred while generating course overview for %s: %s', unicode(
|
||||
course_key), ex.message)
|
||||
|
||||
log.info('Finished generating course overviews.')
|
||||
@@ -0,0 +1,80 @@
|
||||
# pylint: disable=missing-docstring
|
||||
from django.core.management.base import CommandError
|
||||
from mock import patch
|
||||
from openedx.core.djangoapps.content.course_overviews.management.commands import generate_course_overview
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class TestGenerateCourseOverview(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests course overview management command.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Create courses in modulestore.
|
||||
"""
|
||||
super(TestGenerateCourseOverview, self).setUp()
|
||||
self.course_key_1 = CourseFactory.create().id
|
||||
self.course_key_2 = CourseFactory.create().id
|
||||
self.command = generate_course_overview.Command()
|
||||
|
||||
def _assert_courses_not_in_overview(self, *courses):
|
||||
"""
|
||||
Assert that courses doesn't exist in the course overviews.
|
||||
"""
|
||||
course_keys = CourseOverview.get_all_course_keys()
|
||||
for expected_course_key in courses:
|
||||
self.assertNotIn(expected_course_key, course_keys)
|
||||
|
||||
def _assert_courses_in_overview(self, *courses):
|
||||
"""
|
||||
Assert courses exists in course overviews.
|
||||
"""
|
||||
course_keys = CourseOverview.get_all_course_keys()
|
||||
for expected_course_key in courses:
|
||||
self.assertIn(expected_course_key, course_keys)
|
||||
|
||||
def test_generate_all(self):
|
||||
"""
|
||||
Test that all courses in the modulestore are loaded into course overviews.
|
||||
"""
|
||||
# ensure that the newly created courses aren't in course overviews
|
||||
self._assert_courses_not_in_overview(self.course_key_1, self.course_key_2)
|
||||
self.command.handle(all=True)
|
||||
|
||||
# CourseOverview will be populated with all courses in the modulestore
|
||||
self._assert_courses_in_overview(self.course_key_1, self.course_key_2)
|
||||
|
||||
def test_generate_one(self):
|
||||
"""
|
||||
Test that a specified course is loaded into course overviews.
|
||||
"""
|
||||
self._assert_courses_not_in_overview(self.course_key_1, self.course_key_2)
|
||||
self.command.handle(unicode(self.course_key_1), all=False)
|
||||
self._assert_courses_in_overview(self.course_key_1)
|
||||
self._assert_courses_not_in_overview(self.course_key_2)
|
||||
|
||||
@patch('openedx.core.djangoapps.content.course_overviews.management.commands.generate_course_overview.log')
|
||||
def test_invalid_key(self, mock_log):
|
||||
"""
|
||||
Test that invalid key errors are logged.
|
||||
"""
|
||||
self.command.handle('not/found', all=False)
|
||||
self.assertTrue(mock_log.fatal.called)
|
||||
|
||||
@patch('openedx.core.djangoapps.content.course_overviews.management.commands.generate_course_overview.log')
|
||||
def test_not_found_key(self, mock_log):
|
||||
"""
|
||||
Test keys not found are logged.
|
||||
"""
|
||||
self.command.handle('fake/course/id', all=False)
|
||||
self.assertTrue(mock_log.exception.called)
|
||||
|
||||
def test_no_params(self):
|
||||
"""
|
||||
Test exception raised when no parameters are specified.
|
||||
"""
|
||||
with self.assertRaises(CommandError):
|
||||
self.command.handle(all=False)
|
||||
@@ -9,6 +9,7 @@ from django.db.utils import IntegrityError
|
||||
from django.utils.translation import ugettext
|
||||
from model_utils.models import TimeStampedModel
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from util.date_utils import strftime_localized
|
||||
from xmodule import course_metadata_utils
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
@@ -151,7 +152,7 @@ class CourseOverview(TimeStampedModel):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _load_from_module_store(cls, course_id):
|
||||
def load_from_module_store(cls, course_id):
|
||||
"""
|
||||
Load a CourseDescriptor, create a new CourseOverview from it, cache the
|
||||
overview, and return it.
|
||||
@@ -225,7 +226,7 @@ class CourseOverview(TimeStampedModel):
|
||||
course_overview = None
|
||||
except cls.DoesNotExist:
|
||||
course_overview = None
|
||||
return course_overview or cls._load_from_module_store(course_id)
|
||||
return course_overview or cls.load_from_module_store(course_id)
|
||||
|
||||
def clean_id(self, padding_char='='):
|
||||
"""
|
||||
@@ -340,3 +341,13 @@ class CourseOverview(TimeStampedModel):
|
||||
Returns a list of ID strings for this course's prerequisite courses.
|
||||
"""
|
||||
return json.loads(self._pre_requisite_courses_json)
|
||||
|
||||
@classmethod
|
||||
def get_all_course_keys(cls):
|
||||
"""
|
||||
Returns all course keys from course overviews.
|
||||
"""
|
||||
return [
|
||||
CourseKey.from_string(course_overview['id'])
|
||||
for course_overview in CourseOverview.objects.values('id')
|
||||
]
|
||||
|
||||
@@ -11,9 +11,10 @@ from xmodule.modulestore.django import SignalHandler
|
||||
def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Catches the signal that a course has been published in Studio and
|
||||
invalidates the corresponding CourseOverview cache entry if one exists.
|
||||
updates the corresponding CourseOverview cache entry.
|
||||
"""
|
||||
CourseOverview.objects.filter(id=course_key).delete()
|
||||
CourseOverview.load_from_module_store(course_key)
|
||||
|
||||
|
||||
@receiver(SignalHandler.course_deleted)
|
||||
|
||||
@@ -258,31 +258,22 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
|
||||
self.store.delete_course(course.id, ModuleStoreEnum.UserID.test)
|
||||
CourseOverview.get_from_id(course.id)
|
||||
|
||||
@ddt.data((ModuleStoreEnum.Type.mongo, 1, 1), (ModuleStoreEnum.Type.split, 3, 4))
|
||||
@ddt.unpack
|
||||
def test_course_overview_caching(self, modulestore_type, min_mongo_calls, max_mongo_calls):
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_course_overview_caching(self, modulestore_type):
|
||||
"""
|
||||
Tests that CourseOverview structures are actually getting cached.
|
||||
|
||||
Arguments:
|
||||
modulestore_type (ModuleStoreEnum.Type): type of store to create the
|
||||
course in.
|
||||
min_mongo_calls (int): minimum number of MongoDB queries we expect
|
||||
to be made.
|
||||
max_mongo_calls (int): maximum number of MongoDB queries we expect
|
||||
to be made.
|
||||
"""
|
||||
course = CourseFactory.create(default_store=modulestore_type)
|
||||
|
||||
# The first time we load a CourseOverview, it will be a cache miss, so
|
||||
# we expect the modulestore to be queried.
|
||||
with check_mongo_calls_range(max_finds=max_mongo_calls, min_finds=min_mongo_calls):
|
||||
_course_overview_1 = CourseOverview.get_from_id(course.id)
|
||||
# Creating a new course will trigger a publish event and the course will be cached
|
||||
course = CourseFactory.create(default_store=modulestore_type, emit_signals=True)
|
||||
|
||||
# The second time we load a CourseOverview, it will be a cache hit, so
|
||||
# we expect no modulestore queries to be made.
|
||||
# The cache will be hit and mongo will not be queried
|
||||
with check_mongo_calls(0):
|
||||
_course_overview_2 = CourseOverview.get_from_id(course.id)
|
||||
CourseOverview.get_from_id(course.id)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
|
||||
def test_get_non_existent_course(self, modulestore_type):
|
||||
@@ -298,24 +289,18 @@ class CourseOverviewTestCase(ModuleStoreTestCase):
|
||||
with self.assertRaises(CourseOverview.DoesNotExist):
|
||||
CourseOverview.get_from_id(store.make_course_key('Non', 'Existent', 'Course'))
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
|
||||
def test_get_errored_course(self, modulestore_type):
|
||||
def test_get_errored_course(self):
|
||||
"""
|
||||
Test that getting an ErrorDescriptor back from the module store causes
|
||||
get_from_id to raise an IOError.
|
||||
|
||||
Arguments:
|
||||
modulestore_type (ModuleStoreEnum.Type): type of store to create the
|
||||
course in.
|
||||
load_from_module_store to raise an IOError.
|
||||
"""
|
||||
course = CourseFactory.create(default_store=modulestore_type)
|
||||
mock_get_course = mock.Mock(return_value=ErrorDescriptor)
|
||||
with mock.patch('xmodule.modulestore.mixed.MixedModuleStore.get_course', mock_get_course):
|
||||
# This mock makes it so when the module store tries to load course data,
|
||||
# an exception is thrown, which causes get_course to return an ErrorDescriptor,
|
||||
# which causes get_from_id to raise an IOError.
|
||||
with self.assertRaises(IOError):
|
||||
CourseOverview.get_from_id(course.id)
|
||||
CourseOverview.load_from_module_store(self.store.make_course_key('Non', 'Existent', 'Course'))
|
||||
|
||||
def test_malformed_grading_policy(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user