Display warning message on course outline in Studio when course contains deprecated features/components
TNL-2303
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Setup the signals on startup.
|
||||
"""
|
||||
import openedx.core.djangoapps.content.course_structures.signals
|
||||
|
||||
127
openedx/core/djangoapps/content/course_structures/api/v0/api.py
Normal file
127
openedx/core/djangoapps/content/course_structures/api/v0/api.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
API implementation of the Course Structure API for Python code.
|
||||
|
||||
Note: The course list and course detail functionality isn't currently supported here because
|
||||
of the tricky interactions between DRF and the code.
|
||||
Most of that information is available by accessing the course objects directly.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
from .serializers import GradingPolicySerializer, CourseStructureSerializer
|
||||
from .errors import CourseNotFoundError, CourseStructureNotAvailableError
|
||||
from openedx.core.djangoapps.content.course_structures import models, tasks
|
||||
from util.cache import cache
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
def _retrieve_course(course_key):
|
||||
"""Retrieves the course for the given course key.
|
||||
|
||||
Args:
|
||||
course_key: The CourseKey for the course we'd like to retrieve.
|
||||
Returns:
|
||||
the course that matches the CourseKey
|
||||
Raises:
|
||||
CourseNotFoundError
|
||||
|
||||
"""
|
||||
course = modulestore().get_course(course_key, depth=0)
|
||||
if course is None:
|
||||
raise CourseNotFoundError
|
||||
|
||||
return course
|
||||
|
||||
|
||||
def course_structure(course_key, block_types=None):
|
||||
"""
|
||||
Retrieves the entire course structure, including information about all the blocks used in the
|
||||
course if `block_types` is None else information about `block_types` will be returned only.
|
||||
Final serialized information will be cached.
|
||||
|
||||
Args:
|
||||
course_key: the CourseKey of the course we'd like to retrieve.
|
||||
block_types: list of required block types. Possible values include sequential,
|
||||
vertical, html, problem, video, and discussion. The type can also be
|
||||
the name of a custom type of block used for the course.
|
||||
Returns:
|
||||
The serialized output of the course structure:
|
||||
* root: The ID of the root node of the course structure.
|
||||
|
||||
* blocks: A dictionary that maps block IDs to a collection of
|
||||
information about each block. Each block contains the following
|
||||
fields.
|
||||
|
||||
* id: The ID of the block.
|
||||
|
||||
* type: The type of block. Possible values include sequential,
|
||||
vertical, html, problem, video, and discussion. The type can also be
|
||||
the name of a custom type of block used for the course.
|
||||
|
||||
* display_name: The display name configured for the block.
|
||||
|
||||
* graded: Whether or not the sequential or problem is graded. The
|
||||
value is true or false.
|
||||
|
||||
* format: The assignment type.
|
||||
|
||||
* children: If the block has child blocks, a list of IDs of the child
|
||||
blocks.
|
||||
Raises:
|
||||
CourseStructureNotAvailableError, CourseNotFoundError
|
||||
|
||||
"""
|
||||
course = _retrieve_course(course_key)
|
||||
|
||||
modified_timestamp = models.CourseStructure.objects.filter(course_id=course_key).values('modified')
|
||||
if modified_timestamp.exists():
|
||||
cache_key = 'openedx.content.course_structures.api.v0.api.course_structure.{}.{}.{}'.format(
|
||||
course_key, modified_timestamp[0]['modified'], '_'.join(block_types or [])
|
||||
)
|
||||
data = cache.get(cache_key) # pylint: disable=maybe-no-member
|
||||
if data is not None:
|
||||
return data
|
||||
|
||||
try:
|
||||
requested_course_structure = models.CourseStructure.objects.get(course_id=course.id)
|
||||
except models.CourseStructure.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
structure = requested_course_structure.structure
|
||||
if block_types is not None:
|
||||
blocks = requested_course_structure.ordered_blocks
|
||||
required_blocks = OrderedDict()
|
||||
for usage_id, block_data in blocks.iteritems():
|
||||
if block_data['block_type'] in block_types:
|
||||
required_blocks[usage_id] = block_data
|
||||
|
||||
structure['blocks'] = required_blocks
|
||||
|
||||
data = CourseStructureSerializer(structure).data
|
||||
cache.set(cache_key, data, None) # pylint: disable=maybe-no-member
|
||||
return data
|
||||
|
||||
# If we don't have data stored, generate it and return an error.
|
||||
tasks.update_course_structure.delay(unicode(course_key))
|
||||
raise CourseStructureNotAvailableError
|
||||
|
||||
|
||||
def course_grading_policy(course_key):
|
||||
"""
|
||||
Retrieves the course grading policy.
|
||||
|
||||
Args:
|
||||
course_key: CourseKey the corresponds to the course we'd like to know grading policy information about.
|
||||
Returns:
|
||||
The serialized version of the course grading policy containing the following information:
|
||||
* assignment_type: The type of the assignment, as configured by course
|
||||
staff. For example, course staff might make the assignment types Homework,
|
||||
Quiz, and Exam.
|
||||
|
||||
* count: The number of assignments of the type.
|
||||
|
||||
* dropped: Number of assignments of the type that are dropped.
|
||||
|
||||
* weight: The weight, or effect, of the assignment type on the learner's
|
||||
final grade.
|
||||
"""
|
||||
course = _retrieve_course(course_key)
|
||||
return GradingPolicySerializer(course.raw_grader).data
|
||||
@@ -0,0 +1,11 @@
|
||||
""" Errors used by the Course Structure API. """
|
||||
|
||||
|
||||
class CourseNotFoundError(Exception):
|
||||
""" The course was not found. """
|
||||
pass
|
||||
|
||||
|
||||
class CourseStructureNotAvailableError(Exception):
|
||||
""" The course structure still needs to be generated. """
|
||||
pass
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
API Serializers
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class GradingPolicySerializer(serializers.Serializer):
|
||||
""" Serializer for course grading policy. """
|
||||
assignment_type = serializers.CharField(source='type')
|
||||
count = serializers.IntegerField(source='min_count')
|
||||
dropped = serializers.IntegerField(source='drop_count')
|
||||
weight = serializers.FloatField()
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
class BlockSerializer(serializers.Serializer):
|
||||
""" Serializer for course structure block. """
|
||||
id = serializers.CharField(source='usage_key')
|
||||
type = serializers.CharField(source='block_type')
|
||||
parent = serializers.CharField(source='parent')
|
||||
display_name = serializers.CharField()
|
||||
graded = serializers.BooleanField(default=False)
|
||||
format = serializers.CharField()
|
||||
children = serializers.CharField()
|
||||
|
||||
|
||||
class CourseStructureSerializer(serializers.Serializer):
|
||||
""" Serializer for course structure. """
|
||||
root = serializers.CharField(source='root')
|
||||
blocks = serializers.SerializerMethodField('get_blocks')
|
||||
|
||||
def get_blocks(self, structure):
|
||||
""" Serialize the individual blocks. """
|
||||
serialized = {}
|
||||
|
||||
for key, block in structure['blocks'].iteritems():
|
||||
serialized[key] = BlockSerializer(block).data
|
||||
|
||||
return serialized
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Course Structure api.py tests
|
||||
"""
|
||||
from .api import course_structure
|
||||
from openedx.core.djangoapps.content.course_structures.signals import listen_for_course_publish
|
||||
from xmodule.modulestore.django import SignalHandler
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
import mock
|
||||
from django.core import cache
|
||||
|
||||
|
||||
class CourseStructureApiTests(ModuleStoreTestCase):
|
||||
"""
|
||||
CourseStructure API Tests
|
||||
"""
|
||||
MOCK_CACHE = "openedx.core.djangoapps.content.course_structures.api.v0.api.cache"
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Test setup
|
||||
"""
|
||||
# For some reason, `listen_for_course_publish` is not called when we run
|
||||
# all (paver test_system -s cms) tests, If we run only run this file then tests run fine.
|
||||
SignalHandler.course_published.connect(listen_for_course_publish)
|
||||
|
||||
super(CourseStructureApiTests, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
self.chapter = ItemFactory.create(
|
||||
parent_location=self.course.location, category='chapter', display_name="Week 1"
|
||||
)
|
||||
self.sequential = ItemFactory.create(
|
||||
parent_location=self.chapter.location, category='sequential', display_name="Lesson 1"
|
||||
)
|
||||
self.vertical = ItemFactory.create(
|
||||
parent_location=self.sequential.location, category='vertical', display_name='Subsection 1'
|
||||
)
|
||||
self.video = ItemFactory.create(
|
||||
parent_location=self.vertical.location, category="video", display_name="My Video"
|
||||
)
|
||||
self.video = ItemFactory.create(
|
||||
parent_location=self.vertical.location, category="html", display_name="My HTML"
|
||||
)
|
||||
|
||||
self.addCleanup(self._disconnect_course_published_event)
|
||||
|
||||
def _disconnect_course_published_event(self):
|
||||
"""
|
||||
Disconnect course_published event.
|
||||
"""
|
||||
# If we don't disconnect then tests are getting failed in test_crud.py
|
||||
SignalHandler.course_published.disconnect(listen_for_course_publish)
|
||||
|
||||
def _expected_blocks(self, block_types=None, get_parent=False):
|
||||
"""
|
||||
Construct expected blocks.
|
||||
Arguments:
|
||||
block_types (list): List of required block types. Possible values include sequential,
|
||||
vertical, html, problem, video, and discussion. The type can also be
|
||||
the name of a custom type of block used for the course.
|
||||
get_parent (bool): If True then add child's parent location else parent is set to None
|
||||
Returns:
|
||||
dict: Information about required block types.
|
||||
"""
|
||||
blocks = {}
|
||||
|
||||
def add_block(xblock):
|
||||
"""
|
||||
Returns expected blocks dict.
|
||||
"""
|
||||
children = xblock.get_children()
|
||||
|
||||
if block_types is None or xblock.category in block_types:
|
||||
|
||||
parent = None
|
||||
if get_parent:
|
||||
item = xblock.get_parent()
|
||||
parent = unicode(item.location) if item is not None else None
|
||||
|
||||
blocks[unicode(xblock.location)] = {
|
||||
u'id': unicode(xblock.location),
|
||||
u'type': xblock.category,
|
||||
u'display_name': xblock.display_name,
|
||||
u'format': xblock.format,
|
||||
u'graded': xblock.graded,
|
||||
u'parent': parent,
|
||||
u'children': [unicode(child.location) for child in children]
|
||||
}
|
||||
|
||||
for child in children:
|
||||
add_block(child)
|
||||
|
||||
course = self.store.get_course(self.course.id, depth=None)
|
||||
add_block(course)
|
||||
|
||||
return blocks
|
||||
|
||||
def test_course_structure_with_no_block_types(self):
|
||||
"""
|
||||
Verify that course_structure returns info for entire course.
|
||||
"""
|
||||
with mock.patch(self.MOCK_CACHE, cache.get_cache(backend='default')):
|
||||
with self.assertNumQueries(3):
|
||||
structure = course_structure(self.course.id)
|
||||
|
||||
expected = {
|
||||
u'root': unicode(self.course.location),
|
||||
u'blocks': self._expected_blocks()
|
||||
}
|
||||
|
||||
self.assertDictEqual(structure, expected)
|
||||
|
||||
with mock.patch(self.MOCK_CACHE, cache.get_cache(backend='default')):
|
||||
with self.assertNumQueries(2):
|
||||
course_structure(self.course.id)
|
||||
|
||||
def test_course_structure_with_block_types(self):
|
||||
"""
|
||||
Verify that course_structure returns info for required block_types only when specific block_types are requested.
|
||||
"""
|
||||
block_types = ['html', 'video']
|
||||
|
||||
with mock.patch(self.MOCK_CACHE, cache.get_cache(backend='default')):
|
||||
with self.assertNumQueries(3):
|
||||
structure = course_structure(self.course.id, block_types=block_types)
|
||||
|
||||
expected = {
|
||||
u'root': unicode(self.course.location),
|
||||
u'blocks': self._expected_blocks(block_types=block_types, get_parent=True)
|
||||
}
|
||||
|
||||
self.assertDictEqual(structure, expected)
|
||||
|
||||
with mock.patch(self.MOCK_CACHE, cache.get_cache(backend='default')):
|
||||
with self.assertNumQueries(2):
|
||||
course_structure(self.course.id, block_types=block_types)
|
||||
|
||||
def test_course_structure_with_non_existed_block_types(self):
|
||||
"""
|
||||
Verify that course_structure returns empty info for non-existed block_types.
|
||||
"""
|
||||
block_types = ['phantom']
|
||||
structure = course_structure(self.course.id, block_types=block_types)
|
||||
expected = {
|
||||
u'root': unicode(self.course.location),
|
||||
u'blocks': {}
|
||||
}
|
||||
|
||||
self.assertDictEqual(structure, expected)
|
||||
@@ -52,7 +52,3 @@ class CourseStructure(TimeStampedModel):
|
||||
|
||||
for child_node in cur_block['children']:
|
||||
self._traverse_tree(child_node, unordered_structure, ordered_blocks, parent=block)
|
||||
|
||||
# Signals must be imported in a file that is automatically loaded at app startup (e.g. models.py). We import them
|
||||
# at the end of this file to avoid circular dependencies.
|
||||
import signals # pylint: disable=unused-import
|
||||
|
||||
Reference in New Issue
Block a user