Commit 06a14869 by Muhammad Ammar

Merge pull request #8450 from edx/ammar/tnl2303-course-outline-ora1-warning-message

Display message on course outline in Studio when course contains ORA 1
parents 1a9a6982 b0eda4ca
...@@ -27,6 +27,7 @@ from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException ...@@ -27,6 +27,7 @@ from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException
from openedx.core.lib.course_tabs import CourseTabPluginManager from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
from xmodule.modulestore import EdxJSONEncoder from xmodule.modulestore import EdxJSONEncoder
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
...@@ -48,6 +49,7 @@ from contentstore.utils import ( ...@@ -48,6 +49,7 @@ from contentstore.utils import (
get_lms_link_for_item, get_lms_link_for_item,
reverse_course_url, reverse_course_url,
reverse_library_url, reverse_library_url,
reverse_usage_url,
reverse_url, reverse_url,
remove_all_instructors, remove_all_instructors,
) )
...@@ -473,6 +475,44 @@ def _get_rerun_link_for_item(course_key): ...@@ -473,6 +475,44 @@ def _get_rerun_link_for_item(course_key):
return reverse_course_url('course_rerun_handler', course_key) return reverse_course_url('course_rerun_handler', course_key)
def _deprecated_blocks_info(course_module, deprecated_block_types):
"""
Returns deprecation information about `deprecated_block_types`
Arguments:
course_module (CourseDescriptor): course object
deprecated_block_types (list): list of deprecated blocks types
Returns:
Dict with following keys:
block_types (list): list containing types of all deprecated blocks
block_types_enabled (bool): True if any or all `deprecated_blocks` present in Advanced Module List else False
blocks (list): List of `deprecated_block_types` component names and their parent's url
advance_settings_url (str): URL to advance settings page
"""
data = {
'block_types': deprecated_block_types,
'block_types_enabled': any(
block_type in course_module.advanced_modules for block_type in deprecated_block_types
),
'blocks': [],
'advance_settings_url': reverse_course_url('advanced_settings_handler', course_module.id)
}
try:
structure_data = api.course_structure(course_module.id, block_types=deprecated_block_types)
except errors.CourseStructureNotAvailableError:
return data
blocks = []
for block in structure_data['blocks'].values():
blocks.append([reverse_usage_url('container_handler', block['parent']), block['display_name']])
data['blocks'].extend(blocks)
return data
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def course_index(request, course_key): def course_index(request, course_key):
...@@ -500,6 +540,8 @@ def course_index(request, course_key): ...@@ -500,6 +540,8 @@ def course_index(request, course_key):
except (ItemNotFoundError, CourseActionStateItemNotFoundError): except (ItemNotFoundError, CourseActionStateItemNotFoundError):
current_action = None current_action = None
deprecated_blocks_info = _deprecated_blocks_info(course_module, settings.DEPRECATED_BLOCK_TYPES)
return render_to_response('course_outline.html', { return render_to_response('course_outline.html', {
'context_course': course_module, 'context_course': course_module,
'lms_link': lms_link, 'lms_link': lms_link,
...@@ -513,6 +555,7 @@ def course_index(request, course_key): ...@@ -513,6 +555,7 @@ def course_index(request, course_key):
'course_release_date': course_release_date, 'course_release_date': course_release_date,
'settings_url': settings_url, 'settings_url': settings_url,
'reindex_link': reindex_link, 'reindex_link': reindex_link,
'deprecated_blocks_info': deprecated_blocks_info,
'notification_dismiss_url': reverse_course_url( 'notification_dismiss_url': reverse_course_url(
'course_notifications_handler', 'course_notifications_handler',
current_action.course_key, current_action.course_key,
......
""" """
Unit tests for getting the list of courses and the course outline. Unit tests for getting the list of courses and the course outline.
""" """
import ddt
import json import json
import lxml import lxml
import datetime import datetime
import os
import mock import mock
import pytz import pytz
...@@ -14,8 +14,10 @@ from django.utils.translation import ugettext as _ ...@@ -14,8 +14,10 @@ from django.utils.translation import ugettext as _
from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor, reverse_usage_url
from contentstore.views.course import course_outline_initial_state, reindex_course_and_check_access from contentstore.views.course import (
course_outline_initial_state, reindex_course_and_check_access, _deprecated_blocks_info
)
from contentstore.views.item import create_xblock_info, VisibilityState from contentstore.views.item import create_xblock_info, VisibilityState
from course_action_state.managers import CourseRerunUIStateManager from course_action_state.managers import CourseRerunUIStateManager
from course_action_state.models import CourseRerunState from course_action_state.models import CourseRerunState
...@@ -225,6 +227,7 @@ class TestCourseIndex(CourseTestCase): ...@@ -225,6 +227,7 @@ class TestCourseIndex(CourseTestCase):
self.assert_correct_json_response(child_response) self.assert_correct_json_response(child_response)
@ddt.ddt
class TestCourseOutline(CourseTestCase): class TestCourseOutline(CourseTestCase):
""" """
Unit tests for the course outline. Unit tests for the course outline.
...@@ -340,6 +343,146 @@ class TestCourseOutline(CourseTestCase): ...@@ -340,6 +343,146 @@ class TestCourseOutline(CourseTestCase):
self.assertEqual(_get_release_date(response), get_default_time_display(self.course.start)) self.assertEqual(_get_release_date(response), get_default_time_display(self.course.start))
_assert_settings_link_present(response) _assert_settings_link_present(response)
def _create_test_data(self, course_module, create_blocks=False, publish=True, block_types=None):
"""
Create data for test.
"""
if create_blocks:
for block_type in block_types:
ItemFactory.create(
parent_location=self.vertical.location,
category=block_type,
display_name='{} Problem'.format(block_type)
)
if not publish:
self.store.unpublish(self.vertical.location, self.user.id)
course_module.advanced_modules.extend(block_types)
def _verify_deprecated_info(self, course_id, advanced_modules, info, deprecated_block_types):
"""
Verify deprecated info.
"""
expected_blocks = []
for block_type in deprecated_block_types:
expected_blocks.append(
[
reverse_usage_url('container_handler', self.vertical.location),
'{} Problem'.format(block_type)
]
)
self.assertEqual(info['block_types'], deprecated_block_types)
self.assertEqual(
info['block_types_enabled'],
any(component in advanced_modules for component in deprecated_block_types)
)
self.assertItemsEqual(info['blocks'], expected_blocks)
self.assertEqual(
info['advance_settings_url'],
reverse_course_url('advanced_settings_handler', course_id)
)
@ddt.data(
{'publish': True},
{'publish': False},
)
@ddt.unpack
def test_verify_deprecated_warning_message_with_single_feature(self, publish):
"""
Verify deprecated warning info for single deprecated feature.
"""
block_types = settings.DEPRECATED_BLOCK_TYPES
course_module = modulestore().get_item(self.course.location)
self._create_test_data(course_module, create_blocks=True, block_types=block_types, publish=publish)
info = _deprecated_blocks_info(course_module, block_types)
self._verify_deprecated_info(
course_module.id,
course_module.advanced_modules,
info,
block_types
)
def test_verify_deprecated_warning_message_with_multiple_features(self):
"""
Verify deprecated warning info for multiple deprecated features.
"""
block_types = ['peergrading', 'combinedopenended', 'openassessment']
course_module = modulestore().get_item(self.course.location)
self._create_test_data(course_module, create_blocks=True, block_types=block_types)
info = _deprecated_blocks_info(course_module, block_types)
self._verify_deprecated_info(course_module.id, course_module.advanced_modules, info, block_types)
@ddt.data(
{'delete_vertical': True},
{'delete_vertical': False},
)
@ddt.unpack
def test_deprecated_blocks_list_updated_correctly(self, delete_vertical):
"""
Verify that deprecated blocks list shown on banner is updated correctly.
Here is the scenario:
This list of deprecated blocks shown on banner contains published
and un-published blocks. That list should be updated when we delete
un-published block(s). This behavior should be same if we delete
unpublished vertical or problem.
"""
block_types = ['peergrading']
course_module = modulestore().get_item(self.course.location)
vertical1 = ItemFactory.create(
parent_location=self.sequential.location, category='vertical', display_name='Vert1 Subsection1'
)
problem1 = ItemFactory.create(
parent_location=vertical1.location,
category='peergrading',
display_name='peergrading problem in vert1',
publish_item=False
)
info = _deprecated_blocks_info(course_module, block_types)
# info['blocks'] should be empty here because there is nothing
# published or un-published present
self.assertEqual(info['blocks'], [])
vertical2 = ItemFactory.create(
parent_location=self.sequential.location, category='vertical', display_name='Vert2 Subsection1'
)
ItemFactory.create(
parent_location=vertical2.location,
category='peergrading',
display_name='peergrading problem in vert2',
pubish_item=True
)
# At this point CourseStructure will contain both the above
# published and un-published verticals
info = _deprecated_blocks_info(course_module, block_types)
self.assertItemsEqual(
info['blocks'],
[
[reverse_usage_url('container_handler', vertical1.location), 'peergrading problem in vert1'],
[reverse_usage_url('container_handler', vertical2.location), 'peergrading problem in vert2']
]
)
# Delete the un-published vertical or problem so that CourseStructure updates its data
if delete_vertical:
self.store.delete_item(vertical1.location, self.user.id)
else:
self.store.delete_item(problem1.location, self.user.id)
info = _deprecated_blocks_info(course_module, block_types)
# info['blocks'] should only contain the info about vertical2 which is published.
# There shouldn't be any info present about un-published vertical1
self.assertEqual(
info['blocks'],
[[reverse_usage_url('container_handler', vertical2.location), 'peergrading problem in vert2']]
)
class TestCourseReIndex(CourseTestCase): class TestCourseReIndex(CourseTestCase):
""" """
......
...@@ -1007,3 +1007,8 @@ CREDIT_TASK_MAX_RETRIES = 5 ...@@ -1007,3 +1007,8 @@ CREDIT_TASK_MAX_RETRIES = 5
# when a credit provider notifies us that a student has been approved # when a credit provider notifies us that a student has been approved
# or denied for credit. # or denied for credit.
CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60 CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60
################################ Deprecated Blocks Info ################################
DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended']
...@@ -937,3 +937,7 @@ body.error { ...@@ -937,3 +937,7 @@ body.error {
padding: 14px 40px 18px; padding: 14px 40px 18px;
} }
} }
.advance-modules-remove-text {
margin-top: ($baseline/2);
}
...@@ -6,6 +6,7 @@ import logging ...@@ -6,6 +6,7 @@ import logging
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from contentstore.utils import reverse_usage_url from contentstore.utils import reverse_usage_url
from microsite_configuration import microsite
%> %>
<%block name="title">${_("Course Outline")}</%block> <%block name="title">${_("Course Outline")}</%block>
<%block name="bodyclass">is-signedin course view-outline</%block> <%block name="bodyclass">is-signedin course view-outline</%block>
...@@ -50,6 +51,53 @@ from contentstore.utils import reverse_usage_url ...@@ -50,6 +51,53 @@ from contentstore.utils import reverse_usage_url
</div> </div>
</div> </div>
%endif %endif
%if deprecated_blocks_info.get('blocks') or deprecated_blocks_info.get('block_types_enabled'):
<%
platform_name = microsite.get_value('platform_name', settings.PLATFORM_NAME)
%>
<div class="wrapper wrapper-alert wrapper-alert-error is-shown">
<div class="alert announcement">
<i class="feedback-symbol fa fa-warning" aria-hidden="true"></i><span class="sr">${_("Warning")}</span>
<div class="copy">
<h2 class="title title-3 warning-heading-text">${_("This course uses features that are no longer supported.")}</h2>
%if deprecated_blocks_info.get('blocks'):
<div class="components-list">
<p class="components-list-heading-text">${_("You must delete or replace the following components.")}</p>
<nav class="nav-related" aria-label="${_('Unsupported Components')}">
<ul>
% for component_parent_url, component_display_name in deprecated_blocks_info['blocks']:
<li class="nav-item"><a href="${component_parent_url}">${_(component_display_name)}</a></li>
% endfor
</ul>
</nav>
</div>
%endif
% if deprecated_blocks_info.get('block_types_enabled'):
<div class="advance-modules-list">
<p class="advance-modules-remove-text">
${_("To avoid errors, {platform_name} strongly recommends that you remove unsupported features from the course advanced settings. To do this, go to the {link_start}Advanced Settings page{link_end}, locate the \"Advanced Module List\" setting, and then delete the following modules from the list.").format(
platform_name=platform_name,
link_start='<a href="{advance_settings_url}">'.format(advance_settings_url=deprecated_blocks_info['advance_settings_url']), link_end="</a>"
)}
</p>
<nav class="nav-related" aria-label="${_('Unsupported Advance Modules')}">
<ul>
% for block_type in deprecated_blocks_info['block_types']:
<li class="nav-item">${block_type}</li>
% endfor
</ul>
</nav>
</div>
% endif
</div>
</div>
</div>
%endif
</%block> </%block>
<%block name="content"> <%block name="content">
......
...@@ -586,6 +586,62 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -586,6 +586,62 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
""" """
return self.q(css=".license-value").first.text[0] return self.q(css=".license-value").first.text[0]
@property
def deprecated_warning_visible(self):
"""
Returns true if the deprecated warning is visible.
"""
return self.q(css='.wrapper-alert-error.is-shown').is_present()
@property
def warning_heading_text(self):
"""
Returns deprecated warning heading text.
"""
return self.q(css='.warning-heading-text').text[0]
@property
def components_list_heading(self):
"""
Returns deprecated warning component list heading text.
"""
return self.q(css='.components-list-heading-text').text[0]
@property
def modules_remove_text_shown(self):
"""
Returns True if deprecated warning advance modules remove text is visible.
"""
return self.q(css='.advance-modules-remove-text').visible
@property
def modules_remove_text(self):
"""
Returns deprecated warning advance modules remove text.
"""
return self.q(css='.advance-modules-remove-text').text[0]
@property
def components_visible(self):
"""
Returns True if components list visible.
"""
return self.q(css='.components-list').visible
@property
def components_display_names(self):
"""
Returns deprecated warning components display name list.
"""
return self.q(css='.components-list li>a').text
@property
def deprecated_advance_modules(self):
"""
Returns deprecated advance modules list.
"""
return self.q(css='.advance-modules-list li').text
class CourseOutlineModal(object): class CourseOutlineModal(object):
MODAL_SELECTOR = ".wrapper-modal-window" MODAL_SELECTOR = ".wrapper-modal-window"
......
""" """
Acceptance tests for studio related to the outline page. Acceptance tests for studio related to the outline page.
""" """
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
import itertools import itertools
from pytz import UTC from pytz import UTC
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from ...pages.studio.settings_advanced import AdvancedSettingsPage
from ...pages.studio.overview import CourseOutlinePage, ContainerPage, ExpandCollapseLinkState from ...pages.studio.overview import CourseOutlinePage, ContainerPage, ExpandCollapseLinkState
from ...pages.studio.utils import add_discussion, drag, verify_ordering from ...pages.studio.utils import add_discussion, drag, verify_ordering
from ...pages.lms.courseware import CoursewarePage from ...pages.lms.courseware import CoursewarePage
...@@ -37,6 +39,9 @@ class CourseOutlineTest(StudioCourseTest): ...@@ -37,6 +39,9 @@ class CourseOutlineTest(StudioCourseTest):
self.course_outline_page = CourseOutlinePage( self.course_outline_page = CourseOutlinePage(
self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'] self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']
) )
self.advanced_settings = AdvancedSettingsPage(
self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']
)
def populate_course_fixture(self, course_fixture): def populate_course_fixture(self, course_fixture):
""" Install a course with sections/problems, tabs, updates, and handouts """ """ Install a course with sections/problems, tabs, updates, and handouts """
...@@ -1578,3 +1583,142 @@ class PublishSectionTest(CourseOutlineTest): ...@@ -1578,3 +1583,142 @@ class PublishSectionTest(CourseOutlineTest):
unit = subsection.expand_subsection().unit(UNIT_NAME) unit = subsection.expand_subsection().unit(UNIT_NAME)
return (section, subsection, unit) return (section, subsection, unit)
@attr('shard_3')
class DeprecationWarningMessageTest(CourseOutlineTest):
"""
Feature: Verify deprecation warning message.
"""
HEADING_TEXT = 'This course uses features that are no longer supported.'
COMPONENT_LIST_HEADING = 'You must delete or replace the following components.'
ADVANCE_MODULES_REMOVE_TEXT = ('To avoid errors, edX strongly recommends that you remove unsupported features '
'from the course advanced settings. To do this, go to the Advanced Settings '
'page, locate the "Advanced Module List" setting, and then delete the following '
'modules from the list.')
def _add_deprecated_advance_modules(self, block_types):
"""
Add `block_types` into `Advanced Module List`
Arguments:
block_types (list): list of block types
"""
self.advanced_settings.visit()
self.advanced_settings.set_values({"Advanced Module List": json.dumps(block_types)})
def _create_deprecated_components(self):
"""
Create deprecated components.
"""
parent_vertical = self.course_fixture.get_nested_xblocks(category="vertical")[0]
self.course_fixture.create_xblock(
parent_vertical.locator,
XBlockFixtureDesc('combinedopenended', "Open", data=load_data_str('ora_peer_problem.xml'))
)
self.course_fixture.create_xblock(parent_vertical.locator, XBlockFixtureDesc('peergrading', 'Peer'))
def _verify_deprecation_warning_info(
self,
deprecated_blocks_present,
components_present,
components_display_name_list=None,
deprecated_modules_list=None
):
"""
Verify deprecation warning
Arguments:
deprecated_blocks_present (bool): deprecated blocks remove text and
is list is visible if True else False
components_present (bool): components list shown if True else False
components_display_name_list (list): list of components display name
deprecated_modules_list (list): list of deprecated advance modules
"""
self.assertTrue(self.course_outline_page.deprecated_warning_visible)
self.assertEqual(self.course_outline_page.warning_heading_text, self.HEADING_TEXT)
self.assertEqual(self.course_outline_page.modules_remove_text_shown, deprecated_blocks_present)
if deprecated_blocks_present:
self.assertEqual(self.course_outline_page.modules_remove_text, self.ADVANCE_MODULES_REMOVE_TEXT)
self.assertEqual(self.course_outline_page.deprecated_advance_modules, deprecated_modules_list)
self.assertEqual(self.course_outline_page.components_visible, components_present)
if components_present:
self.assertEqual(self.course_outline_page.components_list_heading, self.COMPONENT_LIST_HEADING)
self.assertItemsEqual(self.course_outline_page.components_display_names, components_display_name_list)
def test_no_deprecation_warning_message_present(self):
"""
Scenario: Verify that deprecation warning message is not shown if ORA1
advance modules are not present and also no ORA1 component exist in
course outline.
When I goto course outline
Then I don't see ORA1 deprecated warning
"""
self.course_outline_page.visit()
self.assertFalse(self.course_outline_page.deprecated_warning_visible)
def test_deprecation_warning_message_present(self):
"""
Scenario: Verify deprecation warning message if ORA1 advance modules
and ORA1 components are present.
Given I have ORA1 advance modules present in `Advanced Module List`
And I have created 2 ORA1 components
When I go to course outline
Then I see ORA1 deprecated warning
And I see correct ORA1 deprecated warning heading text
And I see correct ORA1 deprecated warning advance modules remove text
And I see list of ORA1 components with correct display names
"""
self._add_deprecated_advance_modules(block_types=['peergrading', 'combinedopenended'])
self._create_deprecated_components()
self.course_outline_page.visit()
self._verify_deprecation_warning_info(
deprecated_blocks_present=True,
components_present=True,
components_display_name_list=['Open', 'Peer'],
deprecated_modules_list=['peergrading', 'combinedopenended']
)
def test_warning_with_ora1_advance_modules_only(self):
"""
Scenario: Verify that deprecation warning message is shown if only
ORA1 advance modules are present and no ORA1 component exist.
Given I have ORA1 advance modules present in `Advanced Module List`
When I go to course outline
Then I see ORA1 deprecated warning
And I see correct ORA1 deprecated warning heading text
And I see correct ORA1 deprecated warning advance modules remove text
And I don't see list of ORA1 components
"""
self._add_deprecated_advance_modules(block_types=['peergrading', 'combinedopenended'])
self.course_outline_page.visit()
self._verify_deprecation_warning_info(
deprecated_blocks_present=True,
components_present=False,
deprecated_modules_list=['peergrading', 'combinedopenended']
)
def test_warning_with_ora1_components_only(self):
"""
Scenario: Verify that deprecation warning message is shown if only
ORA1 component exist and no ORA1 advance modules are present.
Given I have created two ORA1 components
When I go to course outline
Then I see ORA1 deprecated warning
And I see correct ORA1 deprecated warning heading text
And I don't see ORA1 deprecated warning advance modules remove text
And I see list of ORA1 components with correct display names
"""
self._create_deprecated_components()
self.course_outline_page.visit()
self._verify_deprecation_warning_info(
deprecated_blocks_present=False,
components_present=True,
components_display_name_list=['Open', 'Peer']
)
...@@ -40,37 +40,3 @@ class CourseSerializer(serializers.Serializer): ...@@ -40,37 +40,3 @@ class CourseSerializer(serializers.Serializer):
def get_image_url(self, course): def get_image_url(self, course):
""" Get the course image URL """ """ Get the course image URL """
return course_image_url(course) return course_image_url(course)
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')
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
...@@ -9,7 +9,6 @@ from mock import patch, Mock ...@@ -9,7 +9,6 @@ from mock import patch, Mock
from itertools import product from itertools import product
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory from oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
...@@ -336,6 +335,7 @@ class CourseStructureTests(CourseDetailTestMixin, CourseViewTestsMixin, ModuleSt ...@@ -336,6 +335,7 @@ class CourseStructureTests(CourseDetailTestMixin, CourseViewTestsMixin, ModuleSt
blocks[unicode(xblock.location)] = { blocks[unicode(xblock.location)] = {
u'id': unicode(xblock.location), u'id': unicode(xblock.location),
u'type': xblock.category, u'type': xblock.category,
u'parent': None,
u'display_name': xblock.display_name, u'display_name': xblock.display_name,
u'format': xblock.format, u'format': xblock.format,
u'graded': xblock.graded, u'graded': xblock.graded,
......
...@@ -15,14 +15,14 @@ from rest_framework.reverse import reverse ...@@ -15,14 +15,14 @@ from rest_framework.reverse import reverse
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from course_structure_api.v0 import api, serializers from course_structure_api.v0 import serializers
from course_structure_api.v0.errors import CourseNotFoundError, CourseStructureNotAvailableError
from courseware import courses from courseware import courses
from courseware.access import has_access from courseware.access import has_access
from courseware.model_data import FieldDataCache from courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor from courseware.module_render import get_module_for_descriptor
from openedx.core.lib.api.view_utils import view_course_access, view_auth_classes from openedx.core.lib.api.view_utils import view_course_access, view_auth_classes
from openedx.core.lib.api.serializers import PaginationSerializer from openedx.core.lib.api.serializers import PaginationSerializer
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
from student.roles import CourseInstructorRole, CourseStaffRole from student.roles import CourseInstructorRole, CourseStaffRole
from util.module_utils import get_dynamic_descriptor_children from util.module_utils import get_dynamic_descriptor_children
...@@ -73,7 +73,7 @@ class CourseViewMixin(object): ...@@ -73,7 +73,7 @@ class CourseViewMixin(object):
self.course_key = CourseKey.from_string(course_id) self.course_key = CourseKey.from_string(course_id)
self.check_course_permissions(self.request.user, self.course_key) self.check_course_permissions(self.request.user, self.course_key)
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
except CourseNotFoundError: except errors.CourseNotFoundError:
raise Http404 raise Http404
return func_wrapper return func_wrapper
...@@ -262,7 +262,7 @@ class CourseStructure(CourseViewMixin, RetrieveAPIView): ...@@ -262,7 +262,7 @@ class CourseStructure(CourseViewMixin, RetrieveAPIView):
def get(self, request, **kwargs): def get(self, request, **kwargs):
try: try:
return Response(api.course_structure(self.course_key)) return Response(api.course_structure(self.course_key))
except CourseStructureNotAvailableError: except errors.CourseStructureNotAvailableError:
# If we don't have data stored, we will try to regenerate it, so # If we don't have data stored, we will try to regenerate it, so
# return a 503 and as them to retry in 2 minutes. # return a 503 and as them to retry in 2 minutes.
return Response(status=503, headers={'Retry-After': '120'}) return Response(status=503, headers={'Retry-After': '120'})
......
"""
Setup the signals on startup.
"""
import openedx.core.djangoapps.content.course_structures.signals
...@@ -5,11 +5,12 @@ Note: The course list and course detail functionality isn't currently supported ...@@ -5,11 +5,12 @@ Note: The course list and course detail functionality isn't currently supported
of the tricky interactions between DRF and the code. of the tricky interactions between DRF and the code.
Most of that information is available by accessing the course objects directly. Most of that information is available by accessing the course objects directly.
""" """
from collections import OrderedDict
from course_structure_api.v0 import serializers from .serializers import GradingPolicySerializer, CourseStructureSerializer
from course_structure_api.v0.errors import CourseNotFoundError, CourseStructureNotAvailableError from .errors import CourseNotFoundError, CourseStructureNotAvailableError
from openedx.core.djangoapps.content.course_structures import models, tasks from openedx.core.djangoapps.content.course_structures import models, tasks
from courseware import courses from util.cache import cache
from xmodule.modulestore.django import modulestore
def _retrieve_course(course_key): def _retrieve_course(course_key):
...@@ -23,18 +24,24 @@ def _retrieve_course(course_key): ...@@ -23,18 +24,24 @@ def _retrieve_course(course_key):
CourseNotFoundError CourseNotFoundError
""" """
try: course = modulestore().get_course(course_key, depth=0)
return courses.get_course(course_key) if course is None:
except ValueError:
raise CourseNotFoundError raise CourseNotFoundError
return course
def course_structure(course_key): def course_structure(course_key, block_types=None):
""" """
Retrieves the entire course structure, including information about all the blocks used in the course. 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: Args:
course_key: the CourseKey of the course we'd like to retrieve. 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: Returns:
The serialized output of the course structure: The serialized output of the course structure:
* root: The ID of the root node of the course structure. * root: The ID of the root node of the course structure.
...@@ -60,15 +67,41 @@ def course_structure(course_key): ...@@ -60,15 +67,41 @@ def course_structure(course_key):
blocks. blocks.
Raises: Raises:
CourseStructureNotAvailableError, CourseNotFoundError CourseStructureNotAvailableError, CourseNotFoundError
""" """
course = _retrieve_course(course_key) course = _retrieve_course(course_key)
try:
requested_course_structure = models.CourseStructure.objects.get(course_id=course.id) modified_timestamp = models.CourseStructure.objects.filter(course_id=course_key).values('modified')
return serializers.CourseStructureSerializer(requested_course_structure.structure).data if modified_timestamp.exists():
except models.CourseStructure.DoesNotExist: cache_key = 'openedx.content.course_structures.api.v0.api.course_structure.{}.{}.{}'.format(
# If we don't have data stored, generate it and return an error. course_key, modified_timestamp[0]['modified'], '_'.join(block_types or [])
tasks.update_course_structure.delay(unicode(course_key)) )
raise CourseStructureNotAvailableError 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): def course_grading_policy(course_key):
...@@ -91,4 +124,4 @@ def course_grading_policy(course_key): ...@@ -91,4 +124,4 @@ def course_grading_policy(course_key):
final grade. final grade.
""" """
course = _retrieve_course(course_key) course = _retrieve_course(course_key)
return serializers.GradingPolicySerializer(course.raw_grader).data return GradingPolicySerializer(course.raw_grader).data
"""
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
"""
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): ...@@ -52,7 +52,3 @@ class CourseStructure(TimeStampedModel):
for child_node in cur_block['children']: for child_node in cur_block['children']:
self._traverse_tree(child_node, unordered_structure, ordered_blocks, parent=block) 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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment