Commit cddd387b by Diana Huang

Merge pull request #8367 from edx/diana/merge-course-view-and-tab

Refactor and merge CourseViewType and CourseTab.
parents 6809d5e7 7461a2fd
...@@ -23,7 +23,7 @@ from xmodule.error_module import ErrorDescriptor ...@@ -23,7 +23,7 @@ from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.tabs import CourseTab from xmodule.tabs import CourseTab
from openedx.core.djangoapps.course_views.course_views import CourseViewTypeManager from openedx.core.lib.course_tabs import CourseTabPluginManager
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
...@@ -998,7 +998,7 @@ def _refresh_course_tabs(request, course_module): ...@@ -998,7 +998,7 @@ def _refresh_course_tabs(request, course_module):
Adds or removes a course tab based upon whether it is enabled. Adds or removes a course tab based upon whether it is enabled.
""" """
tab_panel = { tab_panel = {
"type": tab_type.name, "type": tab_type.type,
"name": tab_type.title, "name": tab_type.title,
} }
has_tab = tab_panel in tabs has_tab = tab_panel in tabs
...@@ -1010,7 +1010,7 @@ def _refresh_course_tabs(request, course_module): ...@@ -1010,7 +1010,7 @@ def _refresh_course_tabs(request, course_module):
course_tabs = copy.copy(course_module.tabs) course_tabs = copy.copy(course_module.tabs)
# Additionally update any tabs that are provided by non-dynamic course views # Additionally update any tabs that are provided by non-dynamic course views
for tab_type in CourseViewTypeManager.get_course_view_types(): for tab_type in CourseTabPluginManager.get_tab_types():
if not tab_type.is_dynamic and tab_type.is_default: if not tab_type.is_dynamic and tab_type.is_default:
tab_enabled = tab_type.is_enabled(course_module, user=request.user) tab_enabled = tab_type.is_enabled(course_module, user=request.user)
update_tab(course_tabs, tab_type, tab_enabled) update_tab(course_tabs, tab_type, tab_enabled)
......
...@@ -12,13 +12,13 @@ from django.http import HttpResponse ...@@ -12,13 +12,13 @@ from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangoapps.course_views.course_views import StaticTab
from edxmako.shortcuts import render_to_string, render_to_response from edxmako.shortcuts import render_to_string, render_to_response
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from xblock.core import XBlock from xblock.core import XBlock
import dogstats_wrapper as dog_stats_api import dogstats_wrapper as dog_stats_api
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.x_module import DEPRECATION_VSCOMPAT_EVENT from xmodule.x_module import DEPRECATION_VSCOMPAT_EVENT
from xmodule.tabs import StaticTab
from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
......
...@@ -14,9 +14,8 @@ from django.views.decorators.http import require_http_methods ...@@ -14,9 +14,8 @@ from django.views.decorators.http import require_http_methods
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.tabs import CourseTabList, CourseTab, InvalidTabsException from xmodule.tabs import CourseTabList, CourseTab, InvalidTabsException, StaticTab
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.course_views.course_views import StaticTab
from ..utils import get_lms_link_for_item from ..utils import get_lms_link_for_item
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangoapps.course_views.course_views import StaticTab from xmodule.tabs import StaticTab
from django.template.defaultfilters import escapejs from django.template.defaultfilters import escapejs
%> %>
<%block name="title">${_("Pages")}</%block> <%block name="title">${_("Pages")}</%block>
......
...@@ -21,6 +21,7 @@ from tempfile import mkdtemp ...@@ -21,6 +21,7 @@ from tempfile import mkdtemp
import ddt import ddt
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from mock import patch
from xmodule.tests import CourseComparisonTest from xmodule.tests import CourseComparisonTest
from xmodule.modulestore.mongo.base import ModuleStoreEnum from xmodule.modulestore.mongo.base import ModuleStoreEnum
...@@ -31,6 +32,7 @@ from xmodule.modulestore.xml_importer import import_course_from_xml ...@@ -31,6 +32,7 @@ from xmodule.modulestore.xml_importer import import_course_from_xml
from xmodule.modulestore.xml_exporter import export_course_to_xml from xmodule.modulestore.xml_exporter import export_course_to_xml
from xmodule.modulestore.split_mongo.split_draft import DraftVersioningModuleStore from xmodule.modulestore.split_mongo.split_draft import DraftVersioningModuleStore
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.tests.utils import mock_tab_from_json
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.partitions.tests.test_partitions import PartitionTestCase from xmodule.partitions.tests.test_partitions import PartitionTestCase
from xmodule.x_module import XModuleMixin from xmodule.x_module import XModuleMixin
...@@ -365,6 +367,7 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase): ...@@ -365,6 +367,7 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase):
self.export_dir = mkdtemp() self.export_dir = mkdtemp()
self.addCleanup(rmtree, self.export_dir, ignore_errors=True) self.addCleanup(rmtree, self.export_dir, ignore_errors=True)
@patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
@ddt.data(*itertools.product( @ddt.data(*itertools.product(
MODULESTORE_SETUPS, MODULESTORE_SETUPS,
MODULESTORE_SETUPS, MODULESTORE_SETUPS,
...@@ -373,7 +376,10 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase): ...@@ -373,7 +376,10 @@ class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase):
COURSE_DATA_NAMES, COURSE_DATA_NAMES,
)) ))
@ddt.unpack @ddt.unpack
def test_round_trip(self, source_builder, dest_builder, source_content_builder, dest_content_builder, course_data_name): def test_round_trip(
self, source_builder, dest_builder, source_content_builder,
dest_content_builder, course_data_name, _mock_tab_from_json
):
# Construct the contentstore for storing the first import # Construct the contentstore for storing the first import
with source_content_builder.build() as source_content: with source_content_builder.build() as source_content:
# Construct the modulestore for storing the first import (using the previously created contentstore) # Construct the modulestore for storing the first import (using the previously created contentstore)
......
...@@ -11,6 +11,7 @@ import mimetypes ...@@ -11,6 +11,7 @@ import mimetypes
from unittest import skip from unittest import skip
from uuid import uuid4 from uuid import uuid4
from contextlib import contextmanager from contextlib import contextmanager
from mock import patch
# Mixed modulestore depends on django, so we'll manually configure some django settings # Mixed modulestore depends on django, so we'll manually configure some django settings
# before importing the module # before importing the module
...@@ -47,7 +48,7 @@ from xmodule.modulestore.mixed import MixedModuleStore ...@@ -47,7 +48,7 @@ from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.modulestore.search import path_to_location, navigation_index from xmodule.modulestore.search import path_to_location, navigation_index
from xmodule.modulestore.tests.factories import check_mongo_calls, check_exact_number_of_calls, \ from xmodule.modulestore.tests.factories import check_mongo_calls, check_exact_number_of_calls, \
mongo_uses_error_check mongo_uses_error_check
from xmodule.modulestore.tests.utils import create_modulestore_instance, LocationMixin from xmodule.modulestore.tests.utils import create_modulestore_instance, LocationMixin, mock_tab_from_json
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.tests import DATA_DIR, CourseComparisonTest from xmodule.tests import DATA_DIR, CourseComparisonTest
...@@ -2057,8 +2058,9 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): ...@@ -2057,8 +2058,9 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
self.store.clone_course(course_key, dest_course_id, self.user_id) self.store.clone_course(course_key, dest_course_id, self.user_id)
self.assertEqual(receiver.call_count, 1) self.assertEqual(receiver.call_count, 1)
@patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_course_publish_signal_import_firing(self, default): def test_course_publish_signal_import_firing(self, default, _from_json):
with MongoContentstoreBuilder().build() as contentstore: with MongoContentstoreBuilder().build() as contentstore:
self.store = MixedModuleStore( self.store = MixedModuleStore(
contentstore=contentstore, contentstore=contentstore,
......
...@@ -17,6 +17,7 @@ from uuid import uuid4 ...@@ -17,6 +17,7 @@ from uuid import uuid4
from datetime import datetime from datetime import datetime
from pytz import UTC from pytz import UTC
import unittest import unittest
from mock import patch
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict
...@@ -41,7 +42,7 @@ from git.test.lib.asserts import assert_not_none ...@@ -41,7 +42,7 @@ from git.test.lib.asserts import assert_not_none
from xmodule.x_module import XModuleMixin from xmodule.x_module import XModuleMixin
from xmodule.modulestore.mongo.base import as_draft from xmodule.modulestore.mongo.base import as_draft
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.tests.utils import LocationMixin from xmodule.modulestore.tests.utils import LocationMixin, mock_tab_from_json
from xmodule.modulestore.edit_info import EditInfoMixin from xmodule.modulestore.edit_info import EditInfoMixin
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
...@@ -129,36 +130,38 @@ class TestMongoModuleStoreBase(unittest.TestCase): ...@@ -129,36 +130,38 @@ class TestMongoModuleStoreBase(unittest.TestCase):
xblock_mixins=(EditInfoMixin, InheritanceMixin, LocationMixin, XModuleMixin) xblock_mixins=(EditInfoMixin, InheritanceMixin, LocationMixin, XModuleMixin)
) )
import_course_from_xml(
draft_store,
999,
DATA_DIR,
cls.courses,
static_content_store=content_store
)
# also test a course with no importing of static content with patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json):
import_course_from_xml( import_course_from_xml(
draft_store, draft_store,
999, 999,
DATA_DIR, DATA_DIR,
['test_import_course'], cls.courses,
static_content_store=content_store, static_content_store=content_store
do_import_static=False, )
verbose=True
)
# also import a course under a different course_id (especially ORG) # also test a course with no importing of static content
import_course_from_xml( import_course_from_xml(
draft_store, draft_store,
999, 999,
DATA_DIR, DATA_DIR,
['test_import_course'], ['test_import_course'],
static_content_store=content_store, static_content_store=content_store,
do_import_static=False, do_import_static=False,
verbose=True, verbose=True
target_id=SlashSeparatedCourseKey('guestx', 'foo', 'bar') )
)
# also import a course under a different course_id (especially ORG)
import_course_from_xml(
draft_store,
999,
DATA_DIR,
['test_import_course'],
static_content_store=content_store,
do_import_static=False,
verbose=True,
target_id=SlashSeparatedCourseKey('guestx', 'foo', 'bar')
)
return content_store, draft_store return content_store, draft_store
...@@ -203,7 +206,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -203,7 +206,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
) )
assert_equals(store.get_modulestore_type(''), ModuleStoreEnum.Type.mongo) assert_equals(store.get_modulestore_type(''), ModuleStoreEnum.Type.mongo)
def test_get_courses(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_get_courses(self, _from_json):
'''Make sure the course objects loaded properly''' '''Make sure the course objects loaded properly'''
courses = self.draft_store.get_courses() courses = self.draft_store.get_courses()
...@@ -241,7 +245,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -241,7 +245,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
assert_false(self.draft_store.has_course(mix_cased)) assert_false(self.draft_store.has_course(mix_cased))
assert_true(self.draft_store.has_course(mix_cased, ignore_case=True)) assert_true(self.draft_store.has_course(mix_cased, ignore_case=True))
def test_get_org_courses(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_get_org_courses(self, _from_json):
""" """
Make sure that we can query for a filtered list of courses for a given ORG Make sure that we can query for a filtered list of courses for a given ORG
""" """
...@@ -437,7 +442,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -437,7 +442,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
{'displayname': 'hello'} {'displayname': 'hello'}
) )
def test_get_courses_for_wiki(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_get_courses_for_wiki(self, _from_json):
""" """
Test the get_courses_for_wiki method Test the get_courses_for_wiki method
""" """
...@@ -552,7 +558,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -552,7 +558,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
check_xblock_fields() check_xblock_fields()
check_mongo_fields() check_mongo_fields()
def test_export_course_image(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_export_course_image(self, _from_json):
""" """
Test to make sure that we have a course image in the contentstore, Test to make sure that we have a course image in the contentstore,
then export it to ensure it gets copied to both file locations. then export it to ensure it gets copied to both file locations.
...@@ -571,7 +578,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase): ...@@ -571,7 +578,8 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
finally: finally:
shutil.rmtree(root_dir) shutil.rmtree(root_dir)
def test_export_course_image_nondefault(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_export_course_image_nondefault(self, _from_json):
""" """
Make sure that if a non-default image path is specified that we Make sure that if a non-default image path is specified that we
don't export it to the static default location don't export it to the static default location
......
...@@ -30,6 +30,7 @@ from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore ...@@ -30,6 +30,7 @@ from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from xmodule.modulestore.tests.test_modulestore import check_has_course_method from xmodule.modulestore.tests.test_modulestore import check_has_course_method
from xmodule.modulestore.split_mongo import BlockKey from xmodule.modulestore.split_mongo import BlockKey
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.tests.utils import mock_tab_from_json
from xmodule.modulestore.edit_info import EditInfoMixin from xmodule.modulestore.edit_info import EditInfoMixin
...@@ -37,14 +38,6 @@ BRANCH_NAME_DRAFT = ModuleStoreEnum.BranchName.draft ...@@ -37,14 +38,6 @@ BRANCH_NAME_DRAFT = ModuleStoreEnum.BranchName.draft
BRANCH_NAME_PUBLISHED = ModuleStoreEnum.BranchName.published BRANCH_NAME_PUBLISHED = ModuleStoreEnum.BranchName.published
def mock_tab_from_json(tab_dict):
"""
Mocks out the CourseTab.from_json to just return the tab_dict itself so that we don't have to deal
with plugin errors.
"""
return tab_dict
@attr('mongo') @attr('mongo')
class SplitModuleTest(unittest.TestCase): class SplitModuleTest(unittest.TestCase):
''' '''
...@@ -567,7 +560,8 @@ class SplitModuleTest(unittest.TestCase): ...@@ -567,7 +560,8 @@ class SplitModuleTest(unittest.TestCase):
class TestHasChildrenAtDepth(SplitModuleTest): class TestHasChildrenAtDepth(SplitModuleTest):
"""Test the has_children_at_depth method of XModuleMixin. """ """Test the has_children_at_depth method of XModuleMixin. """
def test_has_children_at_depth(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_has_children_at_depth(self, _from_json):
course_locator = CourseLocator( course_locator = CourseLocator(
org='testx', course='GreekHero', run="run", branch=BRANCH_NAME_DRAFT org='testx', course='GreekHero', run="run", branch=BRANCH_NAME_DRAFT
) )
...@@ -628,7 +622,8 @@ class SplitModuleCourseTests(SplitModuleTest): ...@@ -628,7 +622,8 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertEqual(course.edited_by, "testassist@edx.org") self.assertEqual(course.edited_by, "testassist@edx.org")
self.assertDictEqual(course.grade_cutoffs, {"Pass": 0.45}) self.assertDictEqual(course.grade_cutoffs, {"Pass": 0.45})
def test_get_org_courses(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_get_org_courses(self, _from_json):
courses = modulestore().get_courses(branch=BRANCH_NAME_DRAFT, org='guestx') courses = modulestore().get_courses(branch=BRANCH_NAME_DRAFT, org='guestx')
# should have gotten 1 draft courses # should have gotten 1 draft courses
...@@ -730,7 +725,8 @@ class SplitModuleCourseTests(SplitModuleTest): ...@@ -730,7 +725,8 @@ class SplitModuleCourseTests(SplitModuleTest):
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
modulestore().get_course(CourseLocator(org='testx', course='GreekHero', run="run", branch=BRANCH_NAME_PUBLISHED)) modulestore().get_course(CourseLocator(org='testx', course='GreekHero', run="run", branch=BRANCH_NAME_PUBLISHED))
def test_cache(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_cache(self, _from_json):
""" """
Test that the mechanics of caching work. Test that the mechanics of caching work.
""" """
...@@ -742,7 +738,8 @@ class SplitModuleCourseTests(SplitModuleTest): ...@@ -742,7 +738,8 @@ class SplitModuleCourseTests(SplitModuleTest):
self.assertIn(BlockKey('chapter', 'chapter1'), block_map) self.assertIn(BlockKey('chapter', 'chapter1'), block_map)
self.assertIn(BlockKey('problem', 'problem3_2'), block_map) self.assertIn(BlockKey('problem', 'problem3_2'), block_map)
def test_course_successors(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_course_successors(self, _from_json):
""" """
get_course_successors(course_locator, version_history_depth=1) get_course_successors(course_locator, version_history_depth=1)
""" """
...@@ -779,7 +776,8 @@ class SplitModuleItemTests(SplitModuleTest): ...@@ -779,7 +776,8 @@ class SplitModuleItemTests(SplitModuleTest):
Item read tests including inheritance Item read tests including inheritance
''' '''
def test_has_item(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_has_item(self, _from_json):
''' '''
has_item(BlockUsageLocator) has_item(BlockUsageLocator)
''' '''
...@@ -843,7 +841,8 @@ class SplitModuleItemTests(SplitModuleTest): ...@@ -843,7 +841,8 @@ class SplitModuleItemTests(SplitModuleTest):
) )
self.assertFalse(modulestore().has_item(locator)) self.assertFalse(modulestore().has_item(locator))
def test_get_item(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_get_item(self, _from_json):
''' '''
get_item(blocklocator) get_item(blocklocator)
''' '''
...@@ -1001,7 +1000,8 @@ class SplitModuleItemTests(SplitModuleTest): ...@@ -1001,7 +1000,8 @@ class SplitModuleItemTests(SplitModuleTest):
parent = modulestore().get_parent_location(locator) parent = modulestore().get_parent_location(locator)
self.assertIsNone(parent) self.assertIsNone(parent)
def test_get_children(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_get_children(self, _from_json):
""" """
Test the existing get_children method on xdescriptors Test the existing get_children method on xdescriptors
""" """
...@@ -1354,7 +1354,8 @@ class TestItemCrud(SplitModuleTest): ...@@ -1354,7 +1354,8 @@ class TestItemCrud(SplitModuleTest):
other_updated = modulestore().update_item(other_block, self.user_id) other_updated = modulestore().update_item(other_block, self.user_id)
self.assertIn(moved_child.version_agnostic(), version_agnostic(other_updated.children)) self.assertIn(moved_child.version_agnostic(), version_agnostic(other_updated.children))
def test_update_definition(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_update_definition(self, _from_json):
""" """
test updating an item's definition: ensure it gets versioned as well as the course getting versioned test updating an item's definition: ensure it gets versioned as well as the course getting versioned
""" """
...@@ -1625,7 +1626,8 @@ class TestCourseCreation(SplitModuleTest): ...@@ -1625,7 +1626,8 @@ class TestCourseCreation(SplitModuleTest):
fields['grading_policy']['GRADE_CUTOFFS'] fields['grading_policy']['GRADE_CUTOFFS']
) )
def test_update_course_index(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_update_course_index(self, _from_json):
""" """
Test the versions pointers. NOTE: you can change the org, course, or other things, but Test the versions pointers. NOTE: you can change the org, course, or other things, but
it's not clear how you'd find them again or associate them w/ existing student history since it's not clear how you'd find them again or associate them w/ existing student history since
...@@ -1680,7 +1682,8 @@ class TestCourseCreation(SplitModuleTest): ...@@ -1680,7 +1682,8 @@ class TestCourseCreation(SplitModuleTest):
dupe_course_key.org, dupe_course_key.course, dupe_course_key.run, user, BRANCH_NAME_DRAFT dupe_course_key.org, dupe_course_key.course, dupe_course_key.run, user, BRANCH_NAME_DRAFT
) )
def test_bulk_ops_get_courses(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_bulk_ops_get_courses(self, _from_json):
""" """
Test get_courses when some are created, updated, and deleted w/in a bulk operation Test get_courses when some are created, updated, and deleted w/in a bulk operation
""" """
...@@ -1719,7 +1722,8 @@ class TestInheritance(SplitModuleTest): ...@@ -1719,7 +1722,8 @@ class TestInheritance(SplitModuleTest):
""" """
Test the metadata inheritance mechanism. Test the metadata inheritance mechanism.
""" """
def test_inheritance(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_inheritance(self, _from_json):
""" """
The actual test The actual test
""" """
...@@ -1799,7 +1803,8 @@ class TestPublish(SplitModuleTest): ...@@ -1799,7 +1803,8 @@ class TestPublish(SplitModuleTest):
def tearDown(self): def tearDown(self):
SplitModuleTest.tearDown(self) SplitModuleTest.tearDown(self)
def test_publish_safe(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_publish_safe(self, _from_json):
""" """
Test the standard patterns: publish to new branch, revise and publish Test the standard patterns: publish to new branch, revise and publish
""" """
...@@ -1868,7 +1873,8 @@ class TestPublish(SplitModuleTest): ...@@ -1868,7 +1873,8 @@ class TestPublish(SplitModuleTest):
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
modulestore().copy(self.user_id, source_course, destination_course, [problem1], []) modulestore().copy(self.user_id, source_course, destination_course, [problem1], [])
def test_move_delete(self): @patch('xmodule.tabs.CourseTab.from_json', side_effect=mock_tab_from_json)
def test_move_delete(self, _from_json):
""" """
Test publishing moves and deletes. Test publishing moves and deletes.
""" """
......
...@@ -54,6 +54,14 @@ def create_modulestore_instance( ...@@ -54,6 +54,14 @@ def create_modulestore_instance(
) )
def mock_tab_from_json(tab_dict):
"""
Mocks out the CourseTab.from_json to just return the tab_dict itself so that we don't have to deal
with plugin errors.
"""
return tab_dict
class LocationMixin(XBlockMixin): class LocationMixin(XBlockMixin):
""" """
Adds a `location` property to an :class:`XBlock` so it is more compatible Adds a `location` property to an :class:`XBlock` so it is more compatible
......
...@@ -28,54 +28,59 @@ class CourseTab(object): ...@@ -28,54 +28,59 @@ class CourseTab(object):
# subclass, shared by all instances of the subclass. # subclass, shared by all instances of the subclass.
type = '' type = ''
# The title of the tab, which should be internationalized
title = None
# Class property that specifies whether the tab can be hidden for a particular course # Class property that specifies whether the tab can be hidden for a particular course
is_hideable = False is_hideable = False
# Class property that specifies whether the tab is hidden for a particular course # Class property that specifies whether the tab is hidden for a particular course
is_hidden = False is_hidden = False
# The relative priority of this view that affects the ordering (lower numbers shown first)
priority = None
# Class property that specifies whether the tab can be moved within a course's list of tabs # Class property that specifies whether the tab can be moved within a course's list of tabs
is_movable = True is_movable = True
# Class property that specifies whether the tab is a collection of other tabs # Class property that specifies whether the tab is a collection of other tabs
is_collection = False is_collection = False
def __init__(self, name, tab_id, link_func): # True if this tab is dynamically added to the list of tabs
""" is_dynamic = False
Initializes class members with values passed in by subclasses.
Args: # True if this tab is a default for the course (when enabled)
name: The name of the tab is_default = True
# True if this tab can be included more than once for a course.
allow_multiple = False
tab_id: Intended to be a unique id for this tab, although it is currently not enforced # If there is a single view associated with this tab, this is the name of it
within this module. It is used by the UI to determine which page is active. view_name = None
link_func: A function that computes the link for the tab, def __init__(self, tab_dict):
given the course and a reverse-url function as input parameters
""" """
Initializes class members with values passed in by subclasses.
self.name = name Args:
tab_dict (dict) - a dictionary of parameters used to build the tab.
"""
self.tab_id = tab_id self.name = tab_dict.get('name', self.title)
self.tab_id = tab_dict.get('tab_id', getattr(self, 'tab_id', self.type))
self.link_func = tab_dict.get('link_func', link_reverse_func(self.view_name))
self.link_func = link_func self.is_hidden = tab_dict.get('is_hidden', False)
def is_enabled(self, course, user=None): # pylint: disable=unused-argument @classmethod
""" def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
Determines whether the tab is enabled for the given course and a particular user. """Returns true if this course tab is enabled in the course.
This method is to be overridden by subclasses when applicable. The base class
implementation always returns True.
Args: Args:
course: An xModule CourseDescriptor course (CourseDescriptor): the course using the feature
user (User): an optional user interacting with the course (defaults to None)
user: An optional user for whom the tab will be displayed. If none,
then the code should assume a staff user or an author.
Returns:
A boolean value to indicate whether this instance of the tab is enabled.
""" """
return True raise NotImplementedError()
def get(self, key, default=None): def get(self, key, default=None):
""" """
...@@ -98,6 +103,8 @@ class CourseTab(object): ...@@ -98,6 +103,8 @@ class CourseTab(object):
return self.type return self.type
elif key == 'tab_id': elif key == 'tab_id':
return self.tab_id return self.tab_id
elif key == 'is_hidden':
return self.is_hidden
else: else:
raise KeyError('Key {0} not present in tab {1}'.format(key, self.to_json())) raise KeyError('Key {0} not present in tab {1}'.format(key, self.to_json()))
...@@ -112,6 +119,8 @@ class CourseTab(object): ...@@ -112,6 +119,8 @@ class CourseTab(object):
self.name = value self.name = value
elif key == 'tab_id': elif key == 'tab_id':
self.tab_id = value self.tab_id = value
elif key == 'is_hidden':
self.is_hidden = value
else: else:
raise KeyError('Key {0} cannot be set in tab {1}'.format(key, self.to_json())) raise KeyError('Key {0} cannot be set in tab {1}'.format(key, self.to_json()))
...@@ -129,8 +138,10 @@ class CourseTab(object): ...@@ -129,8 +138,10 @@ class CourseTab(object):
# allow tabs without names; if a name is required, its presence was checked in the validator. # allow tabs without names; if a name is required, its presence was checked in the validator.
name_is_eq = (other.get('name') is None or self.name == other['name']) name_is_eq = (other.get('name') is None or self.name == other['name'])
is_hidden_eq = self.is_hidden == other.get('is_hidden', False)
# only compare the persisted/serialized members: 'type' and 'name' # only compare the persisted/serialized members: 'type' and 'name'
return self.type == other.get('type') and name_is_eq return self.type == other.get('type') and name_is_eq and is_hidden_eq
def __ne__(self, other): def __ne__(self, other):
""" """
...@@ -170,7 +181,10 @@ class CourseTab(object): ...@@ -170,7 +181,10 @@ class CourseTab(object):
Returns: Returns:
a dictionary with keys for the properties of the CourseTab object. a dictionary with keys for the properties of the CourseTab object.
""" """
return {'type': self.type, 'name': self.name} to_json_val = {'type': self.type, 'name': self.name}
if self.is_hidden:
to_json_val.update({'is_hidden': True})
return to_json_val
@staticmethod @staticmethod
def from_json(tab_dict): def from_json(tab_dict):
...@@ -191,22 +205,88 @@ class CourseTab(object): ...@@ -191,22 +205,88 @@ class CourseTab(object):
InvalidTabsException if the given tab doesn't have the right keys. InvalidTabsException if the given tab doesn't have the right keys.
""" """
# TODO: don't import openedx capabilities from common # TODO: don't import openedx capabilities from common
from openedx.core.djangoapps.course_views.course_views import CourseViewTypeManager from openedx.core.lib.course_tabs import CourseTabPluginManager
tab_type_name = tab_dict.get('type') tab_type_name = tab_dict.get('type')
if tab_type_name is None: if tab_type_name is None:
log.error('No type included in tab_dict: %r', tab_dict) log.error('No type included in tab_dict: %r', tab_dict)
return None return None
try: try:
tab_type = CourseViewTypeManager.get_plugin(tab_type_name) tab_type = CourseTabPluginManager.get_plugin(tab_type_name)
except PluginError: except PluginError:
log.exception( log.exception(
"Unknown tab type %r Known types: %r.", "Unknown tab type %r Known types: %r.",
tab_type_name, tab_type_name,
CourseViewTypeManager.get_course_view_types() CourseTabPluginManager.get_tab_types()
) )
return None return None
tab_type.validate(tab_dict) tab_type.validate(tab_dict)
return tab_type.create_tab(tab_dict=tab_dict) return tab_type(tab_dict=tab_dict)
class StaticTab(CourseTab):
"""
A custom tab.
"""
type = 'static_tab'
is_default = False # A static tab is never added to a course by default
allow_multiple = True
def __init__(self, tab_dict=None, name=None, url_slug=None):
def link_func(course, reverse_func):
""" Returns a url for a given course and reverse function. """
return reverse_func(self.type, args=[course.id.to_deprecated_string(), self.url_slug])
self.url_slug = tab_dict.get('url_slug') if tab_dict else url_slug
if tab_dict is None:
tab_dict = dict()
if name is not None:
tab_dict['name'] = name
tab_dict['link_func'] = link_func
tab_dict['tab_id'] = 'static_tab_{0}'.format(self.url_slug)
super(StaticTab, self).__init__(tab_dict)
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
"""
Static tabs are viewable to everyone, even anonymous users.
"""
return True
@classmethod
def validate(cls, tab_dict, raise_error=True):
"""
Ensures that the specified tab_dict is valid.
"""
return (super(StaticTab, cls).validate(tab_dict, raise_error)
and key_checker(['name', 'url_slug'])(tab_dict, raise_error))
def __getitem__(self, key):
if key == 'url_slug':
return self.url_slug
else:
return super(StaticTab, self).__getitem__(key)
def __setitem__(self, key, value):
if key == 'url_slug':
self.url_slug = value
else:
super(StaticTab, self).__setitem__(key, value)
def to_json(self):
""" Return a dictionary representation of this tab. """
to_json_val = super(StaticTab, self).to_json()
to_json_val.update({'url_slug': self.url_slug})
return to_json_val
def __eq__(self, other):
if not super(StaticTab, self).__eq__(other):
return False
return self.url_slug == other.get('url_slug')
class CourseTabList(List): class CourseTabList(List):
...@@ -338,10 +418,10 @@ class CourseTabList(List): ...@@ -338,10 +418,10 @@ class CourseTabList(List):
# the following tabs should appear only once # the following tabs should appear only once
# TODO: don't import openedx capabilities from common # TODO: don't import openedx capabilities from common
from openedx.core.djangoapps.course_views.course_views import CourseViewTypeManager from openedx.core.lib.course_tabs import CourseTabPluginManager
for course_view_type in CourseViewTypeManager.get_course_view_types(): for tab_type in CourseTabPluginManager.get_tab_types():
if not course_view_type.allow_multiple: if not tab_type.allow_multiple:
cls._validate_num_tabs_of_type(tabs, course_view_type.name, 1) cls._validate_num_tabs_of_type(tabs, tab_type.type, 1)
@staticmethod @staticmethod
def _validate_num_tabs_of_type(tabs, tab_type, max_num): def _validate_num_tabs_of_type(tabs, tab_type, max_num):
...@@ -411,6 +491,16 @@ def key_checker(expected_keys): ...@@ -411,6 +491,16 @@ def key_checker(expected_keys):
return check return check
def link_reverse_func(reverse_name):
"""
Returns a function that takes in a course and reverse_url_func,
and calls the reverse_url_func with the given reverse_name and course's ID.
This is used to generate the url for a CourseTab without having access to Django's reverse function.
"""
return lambda course, reverse_url_func: reverse_url_func(reverse_name, args=[course.id.to_deprecated_string()])
def need_name(dictionary, raise_error=True): def need_name(dictionary, raise_error=True):
""" """
Returns whether the 'name' key exists in the given dictionary. Returns whether the 'name' key exists in the given dictionary.
......
...@@ -5,16 +5,16 @@ Registers the CCX feature for the edX platform. ...@@ -5,16 +5,16 @@ Registers the CCX feature for the edX platform.
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangoapps.course_views.course_views import CourseViewType from xmodule.tabs import CourseTab
from student.roles import CourseCcxCoachRole from student.roles import CourseCcxCoachRole
class CcxCourseViewType(CourseViewType): class CcxCourseTab(CourseTab):
""" """
The representation of the CCX course view type. The representation of the CCX course tab
""" """
name = "ccx_coach" type = "ccx_coach"
title = _("CCX Coach") title = _("CCX Coach")
view_name = "ccx_coach_dashboard" view_name = "ccx_coach_dashboard"
is_dynamic = True # The CCX view is dynamically added to the set of tabs when it is enabled is_dynamic = True # The CCX view is dynamically added to the set of tabs when it is enabled
......
...@@ -6,15 +6,15 @@ a user has on an article. ...@@ -6,15 +6,15 @@ a user has on an article.
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from courseware.tabs import EnrolledCourseViewType from courseware.tabs import EnrolledTab
class WikiCourseViewType(EnrolledCourseViewType): class WikiTab(EnrolledTab):
""" """
Defines the Wiki view type that is shown as a course tab. Defines the Wiki view type that is shown as a course tab.
""" """
name = "wiki" type = "wiki"
title = _('Wiki') title = _('Wiki')
view_name = "course_wiki" view_name = "course_wiki"
is_hideable = True is_hideable = True
...@@ -28,4 +28,4 @@ class WikiCourseViewType(EnrolledCourseViewType): ...@@ -28,4 +28,4 @@ class WikiCourseViewType(EnrolledCourseViewType):
return False return False
if course.allow_public_wiki_access: if course.allow_public_wiki_access:
return True return True
return super(WikiCourseViewType, cls).is_enabled(course, user=user) return super(WikiTab, cls).is_enabled(course, user=user)
...@@ -11,8 +11,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -11,8 +11,8 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id
from courseware.tabs import ( from courseware.tabs import (
get_course_tab_list, CoursewareViewType, CourseInfoViewType, ProgressCourseViewType, get_course_tab_list, CoursewareTab, CourseInfoTab, ProgressTab,
StaticCourseViewType, ExternalDiscussionCourseViewType, ExternalLinkCourseViewType ExternalDiscussionCourseTab, ExternalLinkCourseTab
) )
from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase
from courseware.tests.factories import InstructorFactory, StaffFactory from courseware.tests.factories import InstructorFactory, StaffFactory
...@@ -85,7 +85,7 @@ class TabTestCase(ModuleStoreTestCase): ...@@ -85,7 +85,7 @@ class TabTestCase(ModuleStoreTestCase):
Can be 'None' if the given tab class does not have any keys to validate. Can be 'None' if the given tab class does not have any keys to validate.
""" """
# create tab # create tab
tab = tab_class.create_tab(tab_dict=dict_tab) tab = tab_class(tab_dict=dict_tab)
# name is as expected # name is as expected
self.assertEqual(tab.name, expected_name) self.assertEqual(tab.name, expected_name)
...@@ -475,17 +475,17 @@ class TabListTestCase(TabTestCase): ...@@ -475,17 +475,17 @@ class TabListTestCase(TabTestCase):
# invalid tabs # invalid tabs
self.invalid_tabs = [ self.invalid_tabs = [
# less than 2 tabs # less than 2 tabs
[{'type': CoursewareViewType.name}], [{'type': CoursewareTab.type}],
# missing course_info # missing course_info
[{'type': CoursewareViewType.name}, {'type': 'discussion', 'name': 'fake_name'}], [{'type': CoursewareTab.type}, {'type': 'discussion', 'name': 'fake_name'}],
# incorrect order # incorrect order
[{'type': CourseInfoViewType.name, 'name': 'fake_name'}, {'type': CoursewareViewType.name}], [{'type': CourseInfoTab.type, 'name': 'fake_name'}, {'type': CoursewareTab.type}],
] ]
# tab types that should appear only once # tab types that should appear only once
unique_tab_types = [ unique_tab_types = [
CoursewareViewType.name, CoursewareTab.type,
CourseInfoViewType.name, CourseInfoTab.type,
'textbooks', 'textbooks',
'pdf_textbooks', 'pdf_textbooks',
'html_textbooks', 'html_textbooks',
...@@ -493,8 +493,8 @@ class TabListTestCase(TabTestCase): ...@@ -493,8 +493,8 @@ class TabListTestCase(TabTestCase):
for unique_tab_type in unique_tab_types: for unique_tab_type in unique_tab_types:
self.invalid_tabs.append([ self.invalid_tabs.append([
{'type': CoursewareViewType.name}, {'type': CoursewareTab.type},
{'type': CourseInfoViewType.name, 'name': 'fake_name'}, {'type': CourseInfoTab.type, 'name': 'fake_name'},
# add the unique tab multiple times # add the unique tab multiple times
{'type': unique_tab_type}, {'type': unique_tab_type},
{'type': unique_tab_type}, {'type': unique_tab_type},
...@@ -502,26 +502,27 @@ class TabListTestCase(TabTestCase): ...@@ -502,26 +502,27 @@ class TabListTestCase(TabTestCase):
# valid tabs # valid tabs
self.valid_tabs = [ self.valid_tabs = [
# empty list # any empty list is valid because a default list of tabs will be
# generated to replace the empty list.
[], [],
# all valid tabs # all valid tabs
[ [
{'type': CoursewareViewType.name}, {'type': CoursewareTab.type},
{'type': CourseInfoViewType.name, 'name': 'fake_name'}, {'type': CourseInfoTab.type, 'name': 'fake_name'},
{'type': 'discussion', 'name': 'fake_name'}, {'type': 'discussion', 'name': 'fake_name'},
{'type': ExternalLinkCourseViewType.name, 'name': 'fake_name', 'link': 'fake_link'}, {'type': ExternalLinkCourseTab.type, 'name': 'fake_name', 'link': 'fake_link'},
{'type': 'textbooks'}, {'type': 'textbooks'},
{'type': 'pdf_textbooks'}, {'type': 'pdf_textbooks'},
{'type': 'html_textbooks'}, {'type': 'html_textbooks'},
{'type': ProgressCourseViewType.name, 'name': 'fake_name'}, {'type': ProgressTab.type, 'name': 'fake_name'},
{'type': StaticCourseViewType.name, 'name': 'fake_name', 'url_slug': 'schlug'}, {'type': xmodule_tabs.StaticTab.type, 'name': 'fake_name', 'url_slug': 'schlug'},
{'type': 'syllabus'}, {'type': 'syllabus'},
], ],
# with external discussion # with external discussion
[ [
{'type': CoursewareViewType.name}, {'type': CoursewareTab.type},
{'type': CourseInfoViewType.name, 'name': 'fake_name'}, {'type': CourseInfoTab.type, 'name': 'fake_name'},
{'type': ExternalDiscussionCourseViewType.name, 'name': 'fake_name', 'link': 'fake_link'} {'type': ExternalDiscussionCourseTab.type, 'name': 'fake_name', 'link': 'fake_link'}
], ],
] ]
...@@ -550,8 +551,8 @@ class ValidateTabsTestCase(TabListTestCase): ...@@ -550,8 +551,8 @@ class ValidateTabsTestCase(TabListTestCase):
tab_list = xmodule_tabs.CourseTabList() tab_list = xmodule_tabs.CourseTabList()
self.assertEquals( self.assertEquals(
len(tab_list.from_json([ len(tab_list.from_json([
{'type': CoursewareViewType.name}, {'type': CoursewareTab.type},
{'type': CourseInfoViewType.name, 'name': 'fake_name'}, {'type': CourseInfoTab.type, 'name': 'fake_name'},
{'type': 'no_such_type'} {'type': 'no_such_type'}
])), ])),
2 2
...@@ -660,10 +661,10 @@ class ProgressTestCase(TabTestCase): ...@@ -660,10 +661,10 @@ class ProgressTestCase(TabTestCase):
def check_progress_tab(self): def check_progress_tab(self):
"""Helper function for verifying the progress tab.""" """Helper function for verifying the progress tab."""
return self.check_tab( return self.check_tab(
tab_class=ProgressCourseViewType, tab_class=ProgressTab,
dict_tab={'type': ProgressCourseViewType.name, 'name': 'same'}, dict_tab={'type': ProgressTab.type, 'name': 'same'},
expected_link=self.reverse('progress', args=[self.course.id.to_deprecated_string()]), expected_link=self.reverse('progress', args=[self.course.id.to_deprecated_string()]),
expected_tab_id=ProgressCourseViewType.name, expected_tab_id=ProgressTab.type,
invalid_dict_tab=None, invalid_dict_tab=None,
) )
...@@ -692,8 +693,8 @@ class StaticTabTestCase(TabTestCase): ...@@ -692,8 +693,8 @@ class StaticTabTestCase(TabTestCase):
url_slug = 'schmug' url_slug = 'schmug'
tab = self.check_tab( tab = self.check_tab(
tab_class=StaticCourseViewType, tab_class=xmodule_tabs.StaticTab,
dict_tab={'type': StaticCourseViewType.name, 'name': 'same', 'url_slug': url_slug}, dict_tab={'type': xmodule_tabs.StaticTab.type, 'name': 'same', 'url_slug': url_slug},
expected_link=self.reverse('static_tab', args=[self.course.id.to_deprecated_string(), url_slug]), expected_link=self.reverse('static_tab', args=[self.course.id.to_deprecated_string(), url_slug]),
expected_tab_id='static_tab_schmug', expected_tab_id='static_tab_schmug',
invalid_dict_tab=self.fake_dict_tab, invalid_dict_tab=self.fake_dict_tab,
......
...@@ -1150,9 +1150,9 @@ def notification_image_for_tab(course_tab, user, course): ...@@ -1150,9 +1150,9 @@ def notification_image_for_tab(course_tab, user, course):
""" """
tab_notification_handlers = { tab_notification_handlers = {
StaffGradingTab.name: open_ended_notifications.staff_grading_notifications, StaffGradingTab.type: open_ended_notifications.staff_grading_notifications,
PeerGradingTab.name: open_ended_notifications.peer_grading_notifications, PeerGradingTab.type: open_ended_notifications.peer_grading_notifications,
OpenEndedGradingTab.name: open_ended_notifications.combined_notifications OpenEndedGradingTab.type: open_ended_notifications.combined_notifications
} }
if course_tab.name in tab_notification_handlers: if course_tab.name in tab_notification_handlers:
......
...@@ -25,7 +25,7 @@ from openedx.core.djangoapps.course_groups.cohorts import ( ...@@ -25,7 +25,7 @@ from openedx.core.djangoapps.course_groups.cohorts import (
get_course_cohorts, get_course_cohorts,
is_commentable_cohorted is_commentable_cohorted
) )
from courseware.tabs import EnrolledCourseViewType from courseware.tabs import EnrolledTab
from courseware.access import has_access from courseware.access import has_access
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from ccx.overrides import get_current_ccx from ccx.overrides import get_current_ccx
...@@ -49,19 +49,19 @@ PAGES_NEARBY_DELTA = 2 ...@@ -49,19 +49,19 @@ PAGES_NEARBY_DELTA = 2
log = logging.getLogger("edx.discussions") log = logging.getLogger("edx.discussions")
class DiscussionCourseViewType(EnrolledCourseViewType): class DiscussionTab(EnrolledTab):
""" """
A tab for the cs_comments_service forums. A tab for the cs_comments_service forums.
""" """
name = 'discussion' type = 'discussion'
title = _('Discussion') title = _('Discussion')
priority = None priority = None
view_name = 'django_comment_client.forum.views.forum_form_discussion' view_name = 'django_comment_client.forum.views.forum_form_discussion'
@classmethod @classmethod
def is_enabled(cls, course, user=None): def is_enabled(cls, course, user=None):
if not super(DiscussionCourseViewType, cls).is_enabled(course, user): if not super(DiscussionTab, cls).is_enabled(course, user):
return False return False
if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
......
...@@ -4,15 +4,15 @@ Registers the "edX Notes" feature for the edX platform. ...@@ -4,15 +4,15 @@ Registers the "edX Notes" feature for the edX platform.
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from courseware.tabs import EnrolledCourseViewType from courseware.tabs import EnrolledTab
class EdxNotesCourseViewType(EnrolledCourseViewType): class EdxNotesTab(EnrolledTab):
""" """
The representation of the edX Notes course view type. The representation of the edX Notes course tab type.
""" """
name = "edxnotes" type = "edxnotes"
title = _("Notes") title = _("Notes")
view_name = "edxnotes" view_name = "edxnotes"
...@@ -25,6 +25,6 @@ class EdxNotesCourseViewType(EnrolledCourseViewType): ...@@ -25,6 +25,6 @@ class EdxNotesCourseViewType(EnrolledCourseViewType):
settings (dict): a dict of configuration settings settings (dict): a dict of configuration settings
user (User): the user interacting with the course user (User): the user interacting with the course
""" """
if not super(EdxNotesCourseViewType, cls).is_enabled(course, user=user): if not super(EdxNotesTab, cls).is_enabled(course, user=user):
return False return False
return course.edxnotes return course.edxnotes
...@@ -26,6 +26,7 @@ from lms.djangoapps.lms_xblock.runtime import quote_slashes ...@@ -26,6 +26,7 @@ from lms.djangoapps.lms_xblock.runtime import quote_slashes
from openedx.core.lib.xblock_utils import wrap_xblock from openedx.core.lib.xblock_utils import wrap_xblock
from xmodule.html_module import HtmlDescriptor from xmodule.html_module import HtmlDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.tabs import CourseTab
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from courseware.access import has_access from courseware.access import has_access
...@@ -38,7 +39,6 @@ from course_modes.models import CourseMode, CourseModesArchive ...@@ -38,7 +39,6 @@ from course_modes.models import CourseMode, CourseModesArchive
from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole
from certificates.models import CertificateGenerationConfiguration from certificates.models import CertificateGenerationConfiguration
from certificates import api as certs_api from certificates import api as certs_api
from openedx.core.djangoapps.course_views.course_views import CourseViewType
from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem
from .tools import get_units_with_due_date, title_or_url, bulk_email_is_enabled_for_course from .tools import get_units_with_due_date, title_or_url, bulk_email_is_enabled_for_course
...@@ -47,12 +47,12 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -47,12 +47,12 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class InstructorDashboardViewType(CourseViewType): class InstructorDashboardTab(CourseTab):
""" """
Defines the Instructor Dashboard view type that is shown as a course tab. Defines the Instructor Dashboard view type that is shown as a course tab.
""" """
name = "instructor" type = "instructor"
title = _('Instructor') title = _('Instructor')
view_name = "instructor_dashboard" view_name = "instructor_dashboard"
is_dynamic = True # The "Instructor" tab is instead dynamically added when it is enabled is_dynamic = True # The "Instructor" tab is instead dynamically added when it is enabled
......
...@@ -9,7 +9,7 @@ from django.http import Http404 ...@@ -9,7 +9,7 @@ from django.http import Http404
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from courseware.tabs import EnrolledCourseViewType from courseware.tabs import EnrolledTab
from notes.models import Note from notes.models import Note
from notes.utils import notes_enabled_for_course from notes.utils import notes_enabled_for_course
from xmodule.annotator_token import retrieve_token from xmodule.annotator_token import retrieve_token
...@@ -40,16 +40,16 @@ def notes(request, course_id): ...@@ -40,16 +40,16 @@ def notes(request, course_id):
return render_to_response('notes.html', context) return render_to_response('notes.html', context)
class NotesCourseViewType(EnrolledCourseViewType): class NotesTab(EnrolledTab):
""" """
A tab for the course notes. A tab for the course notes.
""" """
name = 'notes' type = 'notes'
title = _("My Notes") title = _("My Notes")
view_name = "notes" view_name = "notes"
@classmethod @classmethod
def is_enabled(cls, course, user=None): def is_enabled(cls, course, user=None):
if not super(NotesCourseViewType, cls).is_enabled(course, user): if not super(NotesTab, cls).is_enabled(course, user):
return False return False
return settings.FEATURES.get('ENABLE_STUDENT_NOTES') and "notes" in course.advanced_modules return settings.FEATURES.get('ENABLE_STUDENT_NOTES') and "notes" in course.advanced_modules
...@@ -4,11 +4,10 @@ from django.views.decorators.cache import cache_control ...@@ -4,11 +4,10 @@ from django.views.decorators.cache import cache_control
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangoapps.course_views.course_views import CourseViewType
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from courseware.access import has_access from courseware.access import has_access
from courseware.tabs import EnrolledCourseViewType from courseware.tabs import EnrolledTab
from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
import json import json
...@@ -66,11 +65,11 @@ ALERT_DICT = { ...@@ -66,11 +65,11 @@ ALERT_DICT = {
} }
class StaffGradingTab(CourseViewType): class StaffGradingTab(EnrolledTab):
""" """
A tab for staff grading. A tab for staff grading.
""" """
name = 'staff_grading' type = 'staff_grading'
title = _("Staff grading") title = _("Staff grading")
view_name = "staff_grading" view_name = "staff_grading"
...@@ -81,11 +80,11 @@ class StaffGradingTab(CourseViewType): ...@@ -81,11 +80,11 @@ class StaffGradingTab(CourseViewType):
return "combinedopenended" in course.advanced_modules return "combinedopenended" in course.advanced_modules
class PeerGradingTab(EnrolledCourseViewType): class PeerGradingTab(EnrolledTab):
""" """
A tab for peer grading. A tab for peer grading.
""" """
name = 'peer_grading' type = 'peer_grading'
# Translators: "Peer grading" appears on a tab that allows # Translators: "Peer grading" appears on a tab that allows
# students to view open-ended problems that require grading # students to view open-ended problems that require grading
title = _("Peer grading") title = _("Peer grading")
...@@ -98,11 +97,11 @@ class PeerGradingTab(EnrolledCourseViewType): ...@@ -98,11 +97,11 @@ class PeerGradingTab(EnrolledCourseViewType):
return "combinedopenended" in course.advanced_modules return "combinedopenended" in course.advanced_modules
class OpenEndedGradingTab(EnrolledCourseViewType): class OpenEndedGradingTab(EnrolledTab):
""" """
A tab for open ended grading. A tab for open ended grading.
""" """
name = 'open_ended' type = 'open_ended'
# Translators: "Open Ended Panel" appears on a tab that, when clicked, opens up a panel that # Translators: "Open Ended Panel" appears on a tab that, when clicked, opens up a panel that
# displays information about open-ended problems that a user has submitted or needs to grade # displays information about open-ended problems that a user has submitted or needs to grade
title = _("Open Ended Panel") title = _("Open Ended Panel")
......
...@@ -3,16 +3,16 @@ Definition of the course team feature. ...@@ -3,16 +3,16 @@ Definition of the course team feature.
""" """
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from courseware.tabs import EnrolledCourseViewType from courseware.tabs import EnrolledTab
from .views import is_feature_enabled from .views import is_feature_enabled
class TeamsCourseViewType(EnrolledCourseViewType): class TeamsTab(EnrolledTab):
""" """
The representation of the course teams view type. The representation of the course teams view type.
""" """
name = "teams" type = "teams"
title = _("Teams") title = _("Teams")
view_name = "teams_dashboard" view_name = "teams_dashboard"
...@@ -24,7 +24,7 @@ class TeamsCourseViewType(EnrolledCourseViewType): ...@@ -24,7 +24,7 @@ class TeamsCourseViewType(EnrolledCourseViewType):
course (CourseDescriptor): the course using the feature course (CourseDescriptor): the course using the feature
user (User): the user interacting with the course user (User): the user interacting with the course
""" """
if not super(TeamsCourseViewType, cls).is_enabled(course, user=user): if not super(TeamsTab, cls).is_enabled(course, user=user):
return False return False
return is_feature_enabled(course) return is_feature_enabled(course)
"""
Tabs for courseware.
"""
from openedx.core.lib.api.plugins import PluginManager
from xmodule.tabs import CourseTab
_ = lambda text: text
# Stevedore extension point namespaces
COURSE_VIEW_TYPE_NAMESPACE = 'openedx.course_view_type'
def link_reverse_func(reverse_name):
"""
Returns a function that takes in a course and reverse_url_func,
and calls the reverse_url_func with the given reverse_name and course' ID.
"""
return lambda course, reverse_url_func: reverse_url_func(reverse_name, args=[course.id.to_deprecated_string()])
class CourseViewType(object):
"""
Base class of all course view type plugins.
These are responsible for defining tabs that can be displayed in the courseware. In order to create
and register a new CourseViewType. Create a class (either in edx-platform or in a pip installable library)
that inherits from CourseViewType and create a new entry in setup.py.
For example:
entry_points={
"openedx.course_view_type": [
"new_view = my_feature.NewCourseViewType",
],
}
"""
name = None # The name of the view type, which is used for persistence and view type lookup
title = None # The title of the view, which should be internationalized
priority = None # The relative priority of this view that affects the ordering (lower numbers shown first)
view_name = None # The name of the Django view to show this view
tab_id = None # The id to be used to show a tab for this view
is_movable = True # True if this course view can be moved
is_dynamic = False # True if this course view is dynamically added to the list of tabs
is_default = True # True if this course view is a default for the course (when enabled)
is_hideable = False # True if this course view's visibility can be toggled by the author
allow_multiple = False # True if this tab can be included more than once for a course.
@classmethod
def is_enabled(cls, course, user=None): # pylint: disable=unused-argument
"""Returns true if this course view is enabled in the course.
Args:
course (CourseDescriptor): the course using the feature
user (User): an optional user interacting with the course (defaults to None)
"""
raise NotImplementedError()
@classmethod
def validate(cls, tab_dict, raise_error=True): # pylint: disable=unused-argument
"""
Validates the given dict-type `tab_dict` object to ensure it contains the expected keys.
This method should be overridden by subclasses that require certain keys to be persisted in the tab.
"""
return True
@classmethod
def create_tab(cls, tab_dict):
"""
Returns the tab that will be shown to represent an instance of a view.
"""
return CourseViewTab(cls, tab_dict=tab_dict)
class CourseViewTypeManager(PluginManager):
"""
Manager for all of the course view types that have been made available.
All course view types should implement `CourseViewType`.
"""
NAMESPACE = COURSE_VIEW_TYPE_NAMESPACE
@classmethod
def get_course_view_types(cls):
"""
Returns the list of available course view types in their canonical order.
"""
def compare_course_view_types(first_type, second_type):
"""Compares two course view types, for use in sorting."""
first_priority = first_type.priority
second_priority = second_type.priority
if not first_priority == second_priority:
if not first_priority:
return 1
elif not second_priority:
return -1
else:
return first_priority - second_priority
first_name = first_type.name
second_name = second_type.name
if first_name < second_name:
return -1
elif first_name == second_name:
return 0
else:
return 1
course_view_types = cls.get_available_plugins().values()
course_view_types.sort(cmp=compare_course_view_types)
return course_view_types
class CourseViewTab(CourseTab):
"""
A tab that renders a course view.
"""
def __init__(self, course_view_type, tab_dict=None):
super(CourseViewTab, self).__init__(
name=tab_dict.get('name', course_view_type.title) if tab_dict else course_view_type.title,
tab_id=course_view_type.tab_id if course_view_type.tab_id else course_view_type.name,
link_func=link_reverse_func(course_view_type.view_name),
)
self.type = course_view_type.name
self.course_view_type = course_view_type
self.is_hideable = course_view_type.is_hideable
self.is_hidden = tab_dict.get('is_hidden', False) if tab_dict else False
self.is_collection = course_view_type.is_collection if hasattr(course_view_type, 'is_collection') else False
self.is_movable = course_view_type.is_movable
def is_enabled(self, course, user=None):
""" Returns True if the tab has been enabled for this course and this user, False otherwise. """
if not super(CourseViewTab, self).is_enabled(course, user=user):
return False
return self.course_view_type.is_enabled(course, user=user)
def __getitem__(self, key):
if key == 'is_hidden':
return self.is_hidden
else:
return super(CourseViewTab, self).__getitem__(key)
def __setitem__(self, key, value):
if key == 'is_hidden':
self.is_hidden = value
else:
super(CourseViewTab, self).__setitem__(key, value)
def to_json(self):
""" Return a dictionary representation of this tab. """
to_json_val = super(CourseViewTab, self).to_json()
if self.is_hidden:
to_json_val.update({'is_hidden': True})
return to_json_val
def items(self, course):
""" If this tab is a collection, this will fetch the items in the collection. """
for item in self.course_view_type.items(course):
yield item
class StaticTab(CourseTab):
"""
A custom tab.
"""
type = 'static_tab'
def __init__(self, tab_dict=None, name=None, url_slug=None):
def link_func(course, reverse_func):
""" Returns a url for a given course and reverse function. """
return reverse_func(self.type, args=[course.id.to_deprecated_string(), self.url_slug])
self.url_slug = tab_dict['url_slug'] if tab_dict else url_slug
super(StaticTab, self).__init__(
name=tab_dict['name'] if tab_dict else name,
tab_id='static_tab_{0}'.format(self.url_slug),
link_func=link_func,
)
def __getitem__(self, key):
if key == 'url_slug':
return self.url_slug
else:
return super(StaticTab, self).__getitem__(key)
def __setitem__(self, key, value):
if key == 'url_slug':
self.url_slug = value
else:
super(StaticTab, self).__setitem__(key, value)
def to_json(self):
""" Return a dictionary representation of this tab. """
to_json_val = super(StaticTab, self).to_json()
to_json_val.update({'url_slug': self.url_slug})
return to_json_val
def __eq__(self, other):
if not super(StaticTab, self).__eq__(other):
return False
return self.url_slug == other.get('url_slug')
"""
Tabs for courseware.
"""
from openedx.core.lib.api.plugins import PluginManager
_ = lambda text: text
# Stevedore extension point namespaces
COURSE_TAB_NAMESPACE = 'openedx.course_tab'
class CourseTabPluginManager(PluginManager):
"""
Manager for all of the course tabs that have been made available.
All course tabs should implement `CourseTab`.
"""
NAMESPACE = COURSE_TAB_NAMESPACE
@classmethod
def get_tab_types(cls):
"""
Returns the list of available course tabs in their canonical order.
"""
def compare_tabs(first_type, second_type):
"""Compares two course tabs, for use in sorting."""
first_priority = first_type.priority
second_priority = second_type.priority
if first_priority != second_priority:
if first_priority is None:
return 1
elif second_priority is None:
return -1
else:
return first_priority - second_priority
first_type = first_type.type
second_type = second_type.type
if first_type < second_type:
return -1
elif first_type == second_type:
return 0
else:
return 1
tab_types = cls.get_available_plugins().values()
tab_types.sort(cmp=compare_tabs)
return tab_types
...@@ -5,7 +5,7 @@ Tests for the plugin API ...@@ -5,7 +5,7 @@ Tests for the plugin API
from django.test import TestCase from django.test import TestCase
from openedx.core.lib.api.plugins import PluginError from openedx.core.lib.api.plugins import PluginError
from openedx.core.djangoapps.course_views.course_views import CourseViewTypeManager from openedx.core.lib.course_tabs import CourseTabPluginManager
class TestPluginApi(TestCase): class TestPluginApi(TestCase):
...@@ -17,8 +17,8 @@ class TestPluginApi(TestCase): ...@@ -17,8 +17,8 @@ class TestPluginApi(TestCase):
""" """
Verify that get_plugin works as expected. Verify that get_plugin works as expected.
""" """
course_view_type = CourseViewTypeManager.get_plugin("instructor") tab_type = CourseTabPluginManager.get_plugin("instructor")
self.assertEqual(course_view_type.title, "Instructor") self.assertEqual(tab_type.title, "Instructor")
with self.assertRaises(PluginError): with self.assertRaises(PluginError):
CourseViewTypeManager.get_plugin("no_such_type") CourseTabPluginManager.get_plugin("no_such_type")
...@@ -5,34 +5,34 @@ from unittest import TestCase ...@@ -5,34 +5,34 @@ from unittest import TestCase
import xmodule.tabs as xmodule_tabs import xmodule.tabs as xmodule_tabs
from openedx.core.djangoapps.course_views.course_views import CourseViewTypeManager from openedx.core.lib.course_tabs import CourseTabPluginManager
class CourseViewTypeManagerTestCase(TestCase): class CourseTabPluginManagerTestCase(TestCase):
"""Test cases for CourseViewTypeManager class""" """Test cases for CourseTabPluginManager class"""
@patch('openedx.core.djangoapps.course_views.course_views.CourseViewTypeManager.get_available_plugins') @patch('openedx.core.lib.course_tabs.CourseTabPluginManager.get_available_plugins')
def test_get_course_view_types(self, get_available_plugins): def test_get_tab_types(self, get_available_plugins):
""" """
Verify that get_course_view_types sorts appropriately Verify that get_course_view_types sorts appropriately
""" """
def create_mock_plugin(name, priority): def create_mock_plugin(tab_type, priority):
""" Create a mock plugin with the specified name and priority. """ """ Create a mock plugin with the specified name and priority. """
mock_plugin = Mock() mock_plugin = Mock()
mock_plugin.name = name mock_plugin.type = tab_type
mock_plugin.priority = priority mock_plugin.priority = priority
return mock_plugin return mock_plugin
mock_plugins = { mock_plugins = {
"Last": create_mock_plugin(name="Last", priority=None), "Last": create_mock_plugin(tab_type="Last", priority=None),
"Duplicate1": create_mock_plugin(name="Duplicate", priority=None), "Duplicate1": create_mock_plugin(tab_type="Duplicate", priority=None),
"Duplicate2": create_mock_plugin(name="Duplicate", priority=None), "Duplicate2": create_mock_plugin(tab_type="Duplicate", priority=None),
"First": create_mock_plugin(name="First", priority=1), "First": create_mock_plugin(tab_type="First", priority=1),
"Second": create_mock_plugin(name="Second", priority=1), "Second": create_mock_plugin(tab_type="Second", priority=1),
"Third": create_mock_plugin(name="Third", priority=3), "Third": create_mock_plugin(tab_type="Third", priority=3),
} }
get_available_plugins.return_value = mock_plugins get_available_plugins.return_value = mock_plugins
self.assertEqual( self.assertEqual(
[plugin.name for plugin in CourseViewTypeManager.get_course_view_types()], [plugin.type for plugin in CourseTabPluginManager.get_tab_types()],
["First", "Second", "Third", "Duplicate", "Duplicate", "Last"] ["First", "Second", "Third", "Duplicate", "Duplicate", "Last"]
) )
......
...@@ -6,7 +6,7 @@ from setuptools import setup ...@@ -6,7 +6,7 @@ from setuptools import setup
setup( setup(
name="Open edX", name="Open edX",
version="0.3", version="0.4",
install_requires=["distribute"], install_requires=["distribute"],
requires=[], requires=[],
# NOTE: These are not the names we should be installing. This tree should # NOTE: These are not the names we should be installing. This tree should
...@@ -18,24 +18,24 @@ setup( ...@@ -18,24 +18,24 @@ setup(
"cms", "cms",
], ],
entry_points={ entry_points={
"openedx.course_view_type": [ "openedx.course_tab": [
"ccx = lms.djangoapps.ccx.plugins:CcxCourseViewType", "ccx = lms.djangoapps.ccx.plugins:CcxCourseTab",
"courseware = lms.djangoapps.courseware.tabs:CoursewareViewType", "courseware = lms.djangoapps.courseware.tabs:CoursewareTab",
"course_info = lms.djangoapps.courseware.tabs:CourseInfoViewType", "course_info = lms.djangoapps.courseware.tabs:CourseInfoTab",
"discussion = lms.djangoapps.django_comment_client.forum.views:DiscussionCourseViewType", "discussion = lms.djangoapps.django_comment_client.forum.views:DiscussionTab",
"edxnotes = lms.djangoapps.edxnotes.plugins:EdxNotesCourseViewType", "edxnotes = lms.djangoapps.edxnotes.plugins:EdxNotesTab",
"external_discussion = lms.djangoapps.courseware.tabs:ExternalDiscussionCourseViewType", "external_discussion = lms.djangoapps.courseware.tabs:ExternalDiscussionCourseTab",
"external_link = lms.djangoapps.courseware.tabs:ExternalLinkCourseViewType", "external_link = lms.djangoapps.courseware.tabs:ExternalLinkCourseTab",
"html_textbooks = lms.djangoapps.courseware.tabs:HtmlTextbookCourseViews", "html_textbooks = lms.djangoapps.courseware.tabs:HtmlTextbookTabs",
"instructor = lms.djangoapps.instructor.views.instructor_dashboard:InstructorDashboardViewType", "instructor = lms.djangoapps.instructor.views.instructor_dashboard:InstructorDashboardTab",
"notes = lms.djangoapps.notes.views:NotesCourseViewType", "notes = lms.djangoapps.notes.views:NotesTab",
"pdf_textbooks = lms.djangoapps.courseware.tabs:PDFTextbookCourseViews", "pdf_textbooks = lms.djangoapps.courseware.tabs:PDFTextbookTabs",
"progress = lms.djangoapps.courseware.tabs:ProgressCourseViewType", "progress = lms.djangoapps.courseware.tabs:ProgressTab",
"static_tab = lms.djangoapps.courseware.tabs:StaticCourseViewType", "static_tab = xmodule.tabs:StaticTab",
"syllabus = lms.djangoapps.courseware.tabs:SyllabusCourseViewType", "syllabus = lms.djangoapps.courseware.tabs:SyllabusTab",
"teams = lms.djangoapps.teams.plugins:TeamsCourseViewType", "teams = lms.djangoapps.teams.plugins:TeamsTab",
"textbooks = lms.djangoapps.courseware.tabs:TextbookCourseViews", "textbooks = lms.djangoapps.courseware.tabs:TextbookTabs",
"wiki = lms.djangoapps.course_wiki.tab:WikiCourseViewType", "wiki = lms.djangoapps.course_wiki.tab:WikiTab",
# ORA 1 tabs (deprecated) # ORA 1 tabs (deprecated)
"peer_grading = lms.djangoapps.open_ended_grading.views:PeerGradingTab", "peer_grading = lms.djangoapps.open_ended_grading.views:PeerGradingTab",
......
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