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
|
Custom AppConfig for openedx.core.djangoapps.content.course_structures
|
||||||
"""
|
"""
|
||||||
name = u'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