course_structures: remove app code, except migrations and models
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
"""
|
||||
Django Admin model registry for Course Structures sub-application
|
||||
"""
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import CourseStructure
|
||||
|
||||
|
||||
class CourseStructureAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Django Admin class for managing Course Structures model data
|
||||
"""
|
||||
search_fields = ('course_id',)
|
||||
list_display = ('course_id', 'modified')
|
||||
ordering = ('course_id', '-modified')
|
||||
|
||||
|
||||
admin.site.register(CourseStructure, CourseStructureAdmin)
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
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 openedx.core.lib.exceptions import CourseNotFoundError
|
||||
from .serializers import GradingPolicySerializer, CourseStructureSerializer
|
||||
from .errors import 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, many=True).data
|
||||
@@ -1,6 +0,0 @@
|
||||
""" Errors used by the Course Structure API. """
|
||||
|
||||
|
||||
class CourseStructureNotAvailableError(Exception):
|
||||
""" The course structure still needs to be generated. """
|
||||
pass
|
||||
@@ -1,76 +0,0 @@
|
||||
"""
|
||||
API Serializers
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
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()
|
||||
|
||||
def to_representation(self, obj):
|
||||
"""
|
||||
Return a representation of the grading policy.
|
||||
"""
|
||||
# Backwards compatibility with the behavior of DRF v2.
|
||||
# When the grader dictionary was missing keys, DRF v2 would default to None;
|
||||
# DRF v3 unhelpfully raises an exception.
|
||||
return dict(
|
||||
super(GradingPolicySerializer, self).to_representation(
|
||||
defaultdict(lambda: None, obj)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# 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(required=False)
|
||||
display_name = serializers.CharField()
|
||||
graded = serializers.BooleanField(default=False)
|
||||
format = serializers.CharField()
|
||||
children = serializers.CharField()
|
||||
|
||||
def to_representation(self, obj):
|
||||
"""
|
||||
Return a representation of the block.
|
||||
|
||||
NOTE: this method maintains backwards compatibility with the behavior
|
||||
of Django Rest Framework v2.
|
||||
"""
|
||||
data = super(BlockSerializer, self).to_representation(obj)
|
||||
|
||||
# Backwards compatibility with the behavior of DRF v2
|
||||
# Include a NULL value for "parent" in the representation
|
||||
# (instead of excluding the key entirely)
|
||||
if obj.get("parent") is None:
|
||||
data["parent"] = None
|
||||
|
||||
# Backwards compatibility with the behavior of DRF v2
|
||||
# Leave the children list as a list instead of serializing
|
||||
# it to a string.
|
||||
data["children"] = obj["children"]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CourseStructureSerializer(serializers.Serializer):
|
||||
""" Serializer for course structure. """
|
||||
root = serializers.CharField()
|
||||
blocks = serializers.SerializerMethodField()
|
||||
|
||||
def get_blocks(self, structure):
|
||||
""" Serialize the individual blocks. """
|
||||
serialized = {}
|
||||
|
||||
for key, block in structure['blocks'].iteritems():
|
||||
serialized[key] = BlockSerializer(block).data
|
||||
|
||||
return serialized
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
|
||||
ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache']
|
||||
|
||||
ENABLED_SIGNALS = ['course_published']
|
||||
|
||||
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.caches['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.caches['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.caches['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.caches['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)
|
||||
@@ -9,11 +9,3 @@ class CourseStructuresConfig(AppConfig):
|
||||
Custom AppConfig for openedx.core.djangoapps.content.course_structures
|
||||
"""
|
||||
name = u'openedx.core.djangoapps.content.course_structures'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Define tasks to perform at app loading time:
|
||||
|
||||
* Connect signal handlers
|
||||
"""
|
||||
from . import signals # pylint: disable=unused-variable
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""
|
||||
Django Management Command: Generate Course Structure
|
||||
Generates and stores course structure information for one or more courses.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from six import text_type
|
||||
|
||||
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Generates and stores course structure information for one or more courses.
|
||||
"""
|
||||
help = 'Generates and stores course structure for one or more courses.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_id', nargs='*')
|
||||
parser.add_argument('--all',
|
||||
action='store_true',
|
||||
help='Generate structures for all courses.')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Perform the course structure generation workflow
|
||||
"""
|
||||
if options['all']:
|
||||
course_keys = [course.id for course in modulestore().get_courses()]
|
||||
else:
|
||||
course_keys = [CourseKey.from_string(arg) for arg in options['course_id']]
|
||||
|
||||
if not course_keys:
|
||||
log.fatal('No courses specified.')
|
||||
return
|
||||
|
||||
log.info('Generating course structures for %d courses.', len(course_keys))
|
||||
log.debug('Generating course structure(s) for the following courses: %s', course_keys)
|
||||
|
||||
for course_key in course_keys:
|
||||
try:
|
||||
# Run the update task synchronously so that we know when all course structures have been updated.
|
||||
# TODO Future improvement: Use .delay(), add return value to ResultSet, and wait for execution of
|
||||
# all tasks using ResultSet.join(). I (clintonb) am opting not to make this improvement right now
|
||||
# as I do not have time to test it fully.
|
||||
update_course_structure.apply(args=[text_type(course_key)])
|
||||
except Exception as ex:
|
||||
log.exception('An error occurred while generating course structure for %s: %s',
|
||||
text_type(course_key), text_type(ex))
|
||||
|
||||
log.info('Finished generating course structures.')
|
||||
@@ -1,29 +0,0 @@
|
||||
"""
|
||||
Django Signals classes and functions for the Course Structure application
|
||||
"""
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from xmodule.modulestore.django import SignalHandler
|
||||
|
||||
from .models import CourseStructure
|
||||
|
||||
|
||||
@receiver(SignalHandler.course_published)
|
||||
def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Course Structure application receiver for the course_published signal
|
||||
"""
|
||||
# Import tasks here to avoid a circular import.
|
||||
from .tasks import update_course_structure
|
||||
|
||||
# Delete the existing discussion id map cache to avoid inconsistencies
|
||||
try:
|
||||
structure = CourseStructure.objects.get(course_id=course_key)
|
||||
structure.discussion_id_map_json = None
|
||||
structure.save()
|
||||
except CourseStructure.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Note: The countdown=0 kwarg is set to to ensure the method below does not attempt to access the course
|
||||
# before the signal emitter has finished all operations. This is also necessary to ensure all tests pass.
|
||||
update_course_structure.apply_async([unicode(course_key)], countdown=0)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
Asynchronous tasks related to the Course Structure sub-application
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from celery.task import task
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from six import text_type
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
log = logging.getLogger('edx.celery.task')
|
||||
|
||||
|
||||
def _generate_course_structure(course_key):
|
||||
"""
|
||||
Generates a course structure dictionary for the specified course.
|
||||
"""
|
||||
with modulestore().bulk_operations(course_key):
|
||||
course = modulestore().get_course(course_key, depth=None)
|
||||
blocks_stack = [course]
|
||||
blocks_dict = {}
|
||||
discussions = {}
|
||||
while blocks_stack:
|
||||
curr_block = blocks_stack.pop()
|
||||
children = curr_block.get_children() if curr_block.has_children else []
|
||||
key = unicode(curr_block.scope_ids.usage_id)
|
||||
block = {
|
||||
"usage_key": key,
|
||||
"block_type": curr_block.category,
|
||||
"display_name": curr_block.display_name,
|
||||
"children": [unicode(child.scope_ids.usage_id) for child in children]
|
||||
}
|
||||
|
||||
if (curr_block.category == 'discussion' and
|
||||
hasattr(curr_block, 'discussion_id') and
|
||||
curr_block.discussion_id):
|
||||
discussions[curr_block.discussion_id] = unicode(curr_block.scope_ids.usage_id)
|
||||
|
||||
# Retrieve these attributes separately so that we can fail gracefully
|
||||
# if the block doesn't have the attribute.
|
||||
attrs = (('graded', False), ('format', None))
|
||||
for attr, default in attrs:
|
||||
if hasattr(curr_block, attr):
|
||||
block[attr] = getattr(curr_block, attr, default)
|
||||
else:
|
||||
log.warning('Failed to retrieve %s attribute of block %s. Defaulting to %s.', attr, key, default)
|
||||
block[attr] = default
|
||||
|
||||
blocks_dict[key] = block
|
||||
|
||||
# Add this blocks children to the stack so that we can traverse them as well.
|
||||
blocks_stack.extend(children)
|
||||
return {
|
||||
'structure': {
|
||||
"root": unicode(course.scope_ids.usage_id),
|
||||
"blocks": blocks_dict
|
||||
},
|
||||
'discussion_id_map': discussions
|
||||
}
|
||||
|
||||
|
||||
@task(name=u'openedx.core.djangoapps.content.course_structures.tasks.update_course_structure')
|
||||
def update_course_structure(course_key):
|
||||
"""
|
||||
Regenerates and updates the course structure (in the database) for the specified course.
|
||||
"""
|
||||
# Import here to avoid circular import.
|
||||
from .models import CourseStructure
|
||||
|
||||
# Ideally we'd like to accept a CourseLocator; however, CourseLocator is not JSON-serializable (by default) so
|
||||
# Celery's delayed tasks fail to start. For this reason, callers should pass the course key as a Unicode string.
|
||||
if not isinstance(course_key, basestring):
|
||||
raise ValueError('course_key must be a string. {} is not acceptable.'.format(type(course_key)))
|
||||
|
||||
course_key = CourseKey.from_string(course_key)
|
||||
|
||||
try:
|
||||
structure = _generate_course_structure(course_key)
|
||||
except Exception as ex:
|
||||
log.exception('An error occurred while generating course structure: %s', text_type(ex))
|
||||
raise
|
||||
|
||||
structure_json = json.dumps(structure['structure'])
|
||||
discussion_id_map_json = json.dumps(structure['discussion_id_map'])
|
||||
|
||||
structure_model, created = CourseStructure.objects.get_or_create(
|
||||
course_id=course_key,
|
||||
defaults={
|
||||
'structure_json': structure_json,
|
||||
'discussion_id_map_json': discussion_id_map_json
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
structure_model.structure_json = structure_json
|
||||
structure_model.discussion_id_map_json = discussion_id_map_json
|
||||
structure_model.save()
|
||||
@@ -1,243 +0,0 @@
|
||||
"""
|
||||
Course Structure Content sub-application test cases
|
||||
"""
|
||||
import json
|
||||
from nose.plugins.attrib import attr
|
||||
from opaque_keys.edx.django.models import UsageKey
|
||||
|
||||
from xmodule.modulestore.django import SignalHandler
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
|
||||
from openedx.core.djangoapps.content.course_structures.signals import listen_for_course_publish
|
||||
from openedx.core.djangoapps.content.course_structures.tasks import _generate_course_structure, update_course_structure
|
||||
|
||||
|
||||
class SignalDisconnectTestMixin(object):
|
||||
"""
|
||||
Mixin for tests to disable calls to signals.listen_for_course_publish when the course_published signal is fired.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(SignalDisconnectTestMixin, self).setUp()
|
||||
SignalHandler.course_published.disconnect(listen_for_course_publish)
|
||||
|
||||
def tearDown(self):
|
||||
SignalHandler.course_published.connect(listen_for_course_publish)
|
||||
super(SignalDisconnectTestMixin, self).tearDown()
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
class CourseStructureTaskTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Test cases covering Course Structure task-related workflows
|
||||
"""
|
||||
def setUp(self, **kwargs):
|
||||
super(CourseStructureTaskTests, self).setUp()
|
||||
self.course = CourseFactory.create(org='TestX', course='TS101', run='T1')
|
||||
self.section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
|
||||
self.discussion_xblock_1 = ItemFactory.create(
|
||||
parent=self.course,
|
||||
category='discussion',
|
||||
discussion_id='test_discussion_id_1'
|
||||
)
|
||||
self.discussion_xblock_2 = ItemFactory.create(
|
||||
parent=self.course,
|
||||
category='discussion',
|
||||
discussion_id='test_discussion_id_2'
|
||||
)
|
||||
CourseStructure.objects.all().delete()
|
||||
|
||||
def test_generate_course_structure(self):
|
||||
blocks = {}
|
||||
|
||||
def add_block(block):
|
||||
"""
|
||||
Inserts new child XBlocks into the existing course tree
|
||||
"""
|
||||
children = block.get_children() if block.has_children else []
|
||||
|
||||
blocks[unicode(block.location)] = {
|
||||
"usage_key": unicode(block.location),
|
||||
"block_type": block.category,
|
||||
"display_name": block.display_name,
|
||||
"graded": block.graded,
|
||||
"format": block.format,
|
||||
"children": [unicode(child.location) for child in children]
|
||||
}
|
||||
|
||||
for child in children:
|
||||
add_block(child)
|
||||
|
||||
add_block(self.course)
|
||||
|
||||
expected = {
|
||||
'root': unicode(self.course.location),
|
||||
'blocks': blocks
|
||||
}
|
||||
|
||||
self.maxDiff = None
|
||||
actual = _generate_course_structure(self.course.id)
|
||||
self.assertDictEqual(actual['structure'], expected)
|
||||
|
||||
def test_structure_json(self):
|
||||
"""
|
||||
Although stored as compressed data, CourseStructure.structure_json should always return the uncompressed string.
|
||||
"""
|
||||
course_id = 'a/b/c'
|
||||
structure = {
|
||||
'root': course_id,
|
||||
'blocks': {
|
||||
course_id: {
|
||||
'id': course_id
|
||||
}
|
||||
}
|
||||
}
|
||||
structure_json = json.dumps(structure)
|
||||
structure = CourseStructure.objects.create(course_id=self.course.id, structure_json=structure_json)
|
||||
self.assertEqual(structure.structure_json, structure_json)
|
||||
|
||||
# Reload the data to ensure the init signal is fired to decompress the data.
|
||||
cs = CourseStructure.objects.get(course_id=self.course.id)
|
||||
self.assertEqual(cs.structure_json, structure_json)
|
||||
|
||||
def test_structure(self):
|
||||
"""
|
||||
CourseStructure.structure should return the uncompressed, JSON-parsed course structure.
|
||||
"""
|
||||
structure = {
|
||||
'root': 'a/b/c',
|
||||
'blocks': {
|
||||
'a/b/c': {
|
||||
'id': 'a/b/c'
|
||||
}
|
||||
}
|
||||
}
|
||||
structure_json = json.dumps(structure)
|
||||
cs = CourseStructure.objects.create(course_id=self.course.id, structure_json=structure_json)
|
||||
self.assertDictEqual(cs.structure, structure)
|
||||
|
||||
def test_ordered_blocks(self):
|
||||
structure = {
|
||||
'root': 'a/b/c',
|
||||
'blocks': {
|
||||
'a/b/c': {
|
||||
'id': 'a/b/c',
|
||||
'children': [
|
||||
'g/h/i'
|
||||
]
|
||||
},
|
||||
'd/e/f': {
|
||||
'id': 'd/e/f',
|
||||
'children': []
|
||||
},
|
||||
'g/h/i': {
|
||||
'id': 'h/j/k',
|
||||
'children': [
|
||||
'j/k/l',
|
||||
'd/e/f'
|
||||
]
|
||||
},
|
||||
'j/k/l': {
|
||||
'id': 'j/k/l',
|
||||
'children': []
|
||||
}
|
||||
}
|
||||
}
|
||||
in_order_blocks = ['a/b/c', 'g/h/i', 'j/k/l', 'd/e/f']
|
||||
structure_json = json.dumps(structure)
|
||||
retrieved_course_structure = CourseStructure.objects.create(
|
||||
course_id=self.course.id, structure_json=structure_json
|
||||
)
|
||||
|
||||
self.assertEqual(retrieved_course_structure.ordered_blocks.keys(), in_order_blocks)
|
||||
|
||||
def test_block_with_missing_fields(self):
|
||||
"""
|
||||
The generator should continue to operate on blocks/XModule that do not have graded or format fields.
|
||||
"""
|
||||
# TODO In the future, test logging using testfixtures.LogCapture
|
||||
# (https://pythonhosted.org/testfixtures/logging.html). Talk to TestEng before adding that library.
|
||||
category = 'peergrading'
|
||||
display_name = 'Testing Module'
|
||||
module = ItemFactory.create(parent=self.section, category=category, display_name=display_name)
|
||||
structure = _generate_course_structure(self.course.id)
|
||||
|
||||
usage_key = unicode(module.location)
|
||||
actual = structure['structure']['blocks'][usage_key]
|
||||
expected = {
|
||||
"usage_key": usage_key,
|
||||
"block_type": category,
|
||||
"display_name": display_name,
|
||||
"graded": False,
|
||||
"format": None,
|
||||
"children": []
|
||||
}
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_generate_discussion_id_map(self):
|
||||
id_map = {}
|
||||
|
||||
def add_block(block):
|
||||
"""Adds the given block and all of its children to the expected discussion id map"""
|
||||
children = block.get_children() if block.has_children else []
|
||||
|
||||
if block.category == 'discussion':
|
||||
id_map[block.discussion_id] = unicode(block.location)
|
||||
|
||||
for child in children:
|
||||
add_block(child)
|
||||
|
||||
add_block(self.course)
|
||||
|
||||
actual = _generate_course_structure(self.course.id)
|
||||
self.assertEqual(actual['discussion_id_map'], id_map)
|
||||
|
||||
def test_discussion_id_map_json(self):
|
||||
id_map = {
|
||||
'discussion_id_1': 'module_location_1',
|
||||
'discussion_id_2': 'module_location_2'
|
||||
}
|
||||
id_map_json = json.dumps(id_map)
|
||||
structure = CourseStructure.objects.create(course_id=self.course.id, discussion_id_map_json=id_map_json)
|
||||
self.assertEqual(structure.discussion_id_map_json, id_map_json)
|
||||
|
||||
structure = CourseStructure.objects.get(course_id=self.course.id)
|
||||
self.assertEqual(structure.discussion_id_map_json, id_map_json)
|
||||
|
||||
def test_discussion_id_map(self):
|
||||
id_map = {
|
||||
'discussion_id_1': 'block-v1:TestX+TS101+T1+type@discussion+block@b141953dff414921a715da37eb14ecdc',
|
||||
'discussion_id_2': 'i4x://TestX/TS101/discussion/466f474fa4d045a8b7bde1b911e095ca'
|
||||
}
|
||||
id_map_json = json.dumps(id_map)
|
||||
structure = CourseStructure.objects.create(course_id=self.course.id, discussion_id_map_json=id_map_json)
|
||||
expected_id_map = {
|
||||
key: UsageKey.from_string(value).map_into_course(self.course.id)
|
||||
for key, value in id_map.iteritems()
|
||||
}
|
||||
self.assertEqual(structure.discussion_id_map, expected_id_map)
|
||||
|
||||
def test_discussion_id_map_missing(self):
|
||||
structure = CourseStructure.objects.create(course_id=self.course.id)
|
||||
self.assertIsNone(structure.discussion_id_map)
|
||||
|
||||
def test_update_course_structure(self):
|
||||
"""
|
||||
Test the actual task that orchestrates data generation and updating the database.
|
||||
"""
|
||||
# Method requires string input
|
||||
course_id = self.course.id
|
||||
self.assertRaises(ValueError, update_course_structure, course_id)
|
||||
|
||||
# Ensure a CourseStructure object is created
|
||||
expected_structure = _generate_course_structure(course_id)
|
||||
update_course_structure(unicode(course_id))
|
||||
structure = CourseStructure.objects.get(course_id=course_id)
|
||||
self.assertEqual(structure.course_id, course_id)
|
||||
self.assertEqual(structure.structure, expected_structure['structure'])
|
||||
self.assertEqual(structure.discussion_id_map.keys(), expected_structure['discussion_id_map'].keys())
|
||||
self.assertEqual(
|
||||
[unicode(value) for value in structure.discussion_id_map.values()],
|
||||
expected_structure['discussion_id_map'].values()
|
||||
)
|
||||
Reference in New Issue
Block a user