diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index 8aa947da68e484ff135fb1ca84664cc1a839abff..6bc24aa90e923386f0219ced98ea8eb1ac95b960 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -245,7 +245,7 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): __test__ = True # TODO: decrease query count as part of REVO-28 - QUERY_COUNT = 31 + QUERY_COUNT = 33 TEST_DATA = { # (providers, course_width, enable_ccx, view_as_ccx): ( # # of sql queries to default, @@ -274,7 +274,7 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase): __test__ = True # TODO: decrease query count as part of REVO-28 - QUERY_COUNT = 31 + QUERY_COUNT = 33 TEST_DATA = { ('no_overrides', 1, True, False): (QUERY_COUNT, 3), diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 6bd3cde360327e1e880c503a6ab944c7d7d723e6..22748d44f6639d21c3a7dbff53bf9734359b4533 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -269,8 +269,8 @@ class IndexQueryTestCase(ModuleStoreTestCase): NUM_PROBLEMS = 20 @ddt.data( - (ModuleStoreEnum.Type.mongo, 10, 173), - (ModuleStoreEnum.Type.split, 4, 169), + (ModuleStoreEnum.Type.mongo, 10, 175), + (ModuleStoreEnum.Type.split, 4, 171), ) @ddt.unpack def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): @@ -1425,8 +1425,8 @@ class ProgressPageTests(ProgressPageBaseTests): self.assertContains(resp, u"Download Your Certificate") @ddt.data( - (True, 53), - (False, 52), + (True, 55), + (False, 54), ) @ddt.unpack def test_progress_queries_paced_courses(self, self_paced, query_count): @@ -1439,8 +1439,8 @@ class ProgressPageTests(ProgressPageBaseTests): @patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False}) @ddt.data( - (False, 61, 42), - (True, 52, 37) + (False, 63, 44), + (True, 54, 39) ) @ddt.unpack def test_progress_queries(self, enable_waffle, initial, subsequent): diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index e611adb799a706a96e153cec645081d4a0141739..545d8a420ccbe850686aa3f4b27ff37a15607459 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -134,6 +134,7 @@ from xmodule.x_module import STUDENT_VIEW from ..context_processor import user_timezone_locale_prefs from ..entrance_exams import user_can_skip_entrance_exam from ..module_render import get_module, get_module_by_usage_id, get_module_for_descriptor +from ..tabs import _get_dynamic_tabs log = logging.getLogger("edx.courseware") @@ -602,7 +603,8 @@ class CourseTabView(EdxFragmentView): course = get_course_with_access(request.user, 'load', course_key) try: # Render the page - tab = CourseTabList.get_tab_by_type(course.tabs, tab_type) + course_tabs = course.tabs + _get_dynamic_tabs(course, request.user) + tab = CourseTabList.get_tab_by_type(course_tabs, tab_type) page_context = self.create_page_context(request, course=course, tab=tab, **kwargs) # Show warnings if the user has limited access diff --git a/lms/djangoapps/discussion/plugins.py b/lms/djangoapps/discussion/plugins.py index dae5ade9d3967866511ab93f6155c3814422a659..b279827c24a026be027df171c2f670f169137c1c 100644 --- a/lms/djangoapps/discussion/plugins.py +++ b/lms/djangoapps/discussion/plugins.py @@ -8,6 +8,7 @@ from django.utils.translation import ugettext_noop import lms.djangoapps.discussion.django_comment_client.utils as utils from lms.djangoapps.courseware.tabs import EnrolledTab +from openedx.features.lti_course_tab.tab import DiscussionLtiCourseTab from xmodule.tabs import TabFragmentViewMixin @@ -30,4 +31,7 @@ class DiscussionTab(TabFragmentViewMixin, EnrolledTab): def is_enabled(cls, course, user=None): if not super(DiscussionTab, cls).is_enabled(course, user): return False + # Disable the regular discussion tab if LTI-based external Discussion forum is enabled + if DiscussionLtiCourseTab.is_enabled(course, user): + return False return utils.is_discussion_enabled(course.id) diff --git a/lms/urls.py b/lms/urls.py index 085e604ad7fc00386b296ca2f96b2b368b3bf685..b416f3d57314a270e59e8b3dbe3d71a6993f52e7 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -746,6 +746,17 @@ urlpatterns += [ ), ] +urlpatterns += [ + url( + r'^courses/{}/lti_tab/(?P<provider_uuid>[^/]+)/$'.format( + settings.COURSE_ID_PATTERN, + ), + CourseTabView.as_view(), + name='lti_course_tab', + kwargs={'tab_type': 'lti_tab'}, + ), +] + urlpatterns += [ # This MUST be the last view in the courseware--it's a catch-all for custom tabs. url( diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index d0581da74724012b65e0b3698885967deaf56727..04f45d079c4fd884158e7041af7c7a989b1fb951 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -208,7 +208,7 @@ class TestCourseHomePage(CourseHomePageTestCase): # Fetch the view and verify the query counts # TODO: decrease query count as part of REVO-28 - with self.assertNumQueries(75, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): + with self.assertNumQueries(78, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with check_mongo_calls(4): url = course_home_url(self.course) self.client.get(url) diff --git a/openedx/features/course_experience/tests/views/test_course_updates.py b/openedx/features/course_experience/tests/views/test_course_updates.py index d602a413ed8d245fbc5bb2de4a69c8b05c25752f..0092588dcd4da1a2cdaeecf919ef8012dbe566e2 100644 --- a/openedx/features/course_experience/tests/views/test_course_updates.py +++ b/openedx/features/course_experience/tests/views/test_course_updates.py @@ -49,7 +49,7 @@ class TestCourseUpdatesPage(BaseCourseUpdatesTestCase): # Fetch the view and verify that the query counts haven't changed # TODO: decrease query count as part of REVO-28 - with self.assertNumQueries(49, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): + with self.assertNumQueries(52, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with check_mongo_calls(4): url = course_updates_url(self.course) self.client.get(url) diff --git a/openedx/features/lti_course_tab/__init__.py b/openedx/features/lti_course_tab/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openedx/features/lti_course_tab/tab.py b/openedx/features/lti_course_tab/tab.py new file mode 100644 index 0000000000000000000000000000000000000000..4919a05a8659b33242814ba05887c9154593e131 --- /dev/null +++ b/openedx/features/lti_course_tab/tab.py @@ -0,0 +1,247 @@ +""" +Code related to LTI course tab functionality. +""" +from typing import Dict +from urllib.parse import quote + +from django.contrib.auth.models import AbstractBaseUser +from django.contrib.sites.shortcuts import get_current_site +from django.http import HttpRequest +from django.utils.translation import get_language, to_locale, ugettext_lazy +from lti_consumer.lti_1p1.contrib.django import lti_embed +from lti_consumer.models import LtiConfiguration +from opaque_keys.edx.keys import CourseKey +from web_fragments.fragment import Fragment + +from lms.djangoapps.courseware.access import get_user_role +from lms.djangoapps.courseware.tabs import EnrolledTab +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration +from openedx.core.djangolib.markup import HTML +from common.djangoapps.student.models import anonymous_id_for_user +from xmodule.course_module import CourseDescriptor +from xmodule.tabs import TabFragmentViewMixin, key_checker + + +class LtiCourseLaunchMixin: + """ + Mixin that encapsulates all LTI-related functionality from the View + """ + + ROLE_MAP = { + 'student': 'Student', + 'staff': 'Administrator', + 'instructor': 'Instructor', + } + DEFAULT_ROLE = 'Student' + + def _get_additional_lti_parameters(self, course: CourseDescriptor, request: HttpRequest) -> Dict[str, str]: + lti_config = self._get_lti_config(course) + additional_config = lti_config.lti_config.get('additional_parameters', {}) + return additional_config + + @staticmethod + def _get_user_id(user: AbstractBaseUser, course_key: CourseKey): + return anonymous_id_for_user(user, course_key) + + def _get_lti_roles(self, user: AbstractBaseUser, course_key: CourseKey) -> str: + return self.ROLE_MAP.get( + get_user_role(user, course_key), + self.DEFAULT_ROLE, + ) + + @staticmethod + def _get_context_id(course_key: CourseKey) -> str: + return quote(str(course_key)) + + @staticmethod + def _get_resource_link_id(course_key: CourseKey, request: HttpRequest) -> str: + site = get_current_site(request) + return '{}-{}'.format( + site.domain, + str(course_key.make_usage_key('course', course_key.run)), + ) + + @staticmethod + def _get_result_sourcedid(context_id: str, resource_link_id: str, user_id: str) -> str: + return "{context}:{resource_link}:{user_id}".format( + context=context_id, + resource_link=resource_link_id, + user_id=user_id, + ) + + @staticmethod + def _get_context_title(course: CourseDescriptor) -> str: + return "{} - {}".format( + course.display_name_with_default, + course.display_org_with_default, + ) + + def _get_lti_config(self, course: CourseDescriptor) -> LtiConfiguration: + raise NotImplementedError + + def _get_lti_embed_code(self, course: CourseDescriptor, request: HttpRequest) -> str: + """ + Returns the LTI embed code for embedding in the current course context. + Args: + course (CourseDescriptor): CourseDescriptor object. + request (HttpRequest): Request object for view in which LTI will be embedded. + Returns: + HTML code to embed LTI in course page. + """ + course_key = course.id + lti_config = self._get_lti_config(course) + lti_consumer = lti_config.get_lti_consumer() + user_id = quote(self._get_user_id(request.user, course_key)) + context_id = quote(self._get_context_id(course_key)) + resource_link_id = quote(self._get_resource_link_id(course_key, request)) + roles = self._get_lti_roles(request.user, course_key) + context_title = self._get_context_title(course) + result_sourcedid = quote(self._get_result_sourcedid(context_id, resource_link_id, user_id)) + additional_params = self._get_additional_lti_parameters(course, request) + locale = to_locale(get_language()) + + return lti_embed( + html_element_id='lti-tab-launcher', + lti_consumer=lti_consumer, + resource_link_id=resource_link_id, + user_id=user_id, + roles=roles, + context_id=context_id, + context_title=context_title, + context_label=context_id, + result_sourcedid=result_sourcedid, + launch_presentation_locale=locale, + **additional_params, + ) + + # pylint: disable=unused-argument + def render_to_fragment(self, request: HttpRequest, course: CourseDescriptor, **kwargs) -> Fragment: + """ + Returns a fragment view for the LTI launch. + Args: + request (HttpRequest): request object + course (CourseDescriptor): A course object + Returns: + A Fragment that embeds LTI in a course page. + """ + lti_embed_html = self._get_lti_embed_code(course, request) + + fragment = Fragment( + HTML( + """ + <iframe + id='lti-tab-embed' + srcdoc='{srcdoc}' + > + </iframe> + """ + ).format( + srcdoc=lti_embed_html + ) + ) + fragment.add_css( + """ + #lti-tab-embed { + width: 100%; + min-height: 400px; + border: none; + } + """ + ) + return fragment + + +class LtiCourseTab(LtiCourseLaunchMixin, EnrolledTab): + """ + A tab to add custom LTI components to a course in a tab. + """ + type = 'lti_tab' + is_default = False + allow_multiple = True + + def _get_lti_config(self, course: CourseDescriptor) -> LtiConfiguration: + return LtiConfiguration.objects.get(config_id=self.lti_config_id) + + def __init__(self, tab_dict=None, name=None, lti_config_id=None): + def link_func(course, reverse_func): + """ Returns a function that returns the lti tab's URL. """ + return reverse_func('lti_course_tab', args=[str(course.id), self.lti_config_id]) + + self.lti_config_id = tab_dict.get('lti_config_id') if tab_dict else lti_config_id + + 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'] = 'lti_tab_{0}'.format(self.lti_config_id) + + super().__init__(tab_dict) + + @classmethod + def validate(cls, tab_dict, raise_error=True): + """ + Ensures that the specified tab_dict is valid. + """ + return ( + super().validate(tab_dict, raise_error) + and key_checker(['name', 'lti_config_id'])(tab_dict, raise_error) + ) + + def __getitem__(self, key): + if key == 'lti_config_id': + return self.lti_config_id + else: + return super().__getitem__(key) + + def __setitem__(self, key, value): + if key == 'lti_config_id': + self.lti_config_id = value + else: + super().__setitem__(key, value) + + def to_json(self): + """ + Return a dictionary representation of this tab. + """ + to_json_val = super().to_json() + to_json_val.update({'lti_config_id': self.lti_config_id}) + return to_json_val + + def __eq__(self, other): + if not super().__eq__(other): + return False + return self.lti_config_id == other.get('lti_config_id') + + def __hash__(self): + """ + Return a hash representation of Tab Object. + """ + return hash(repr(self)) + + +class DiscussionLtiCourseTab(LtiCourseLaunchMixin, TabFragmentViewMixin, EnrolledTab): + """ + Course tab that loads the associated LTI-based discussion provider in a tab. + """ + type = 'lti_discussion' + allow_multiple = False + is_dynamic = True + title = ugettext_lazy("Discussion") + + def _get_lti_config(self, course: CourseDescriptor) -> LtiConfiguration: + config = DiscussionsConfiguration.get(course.id) + return config.lti_configuration + + @classmethod + def is_enabled(cls, course, user=None): + if super().is_enabled(course, user): + config = DiscussionsConfiguration.get(course.id) + return ( + config.enabled and + config.lti_configuration is not None + ) + else: + return False diff --git a/openedx/features/lti_course_tab/tests.py b/openedx/features/lti_course_tab/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..a28401576ca1a8b3f338ce7f3724e33a365c7872 --- /dev/null +++ b/openedx/features/lti_course_tab/tests.py @@ -0,0 +1,39 @@ +""" +Tests for LTI Course tabs. +""" +from unittest.mock import Mock, patch + +from lms.djangoapps.courseware.tests.test_tabs import TabTestCase +from openedx.features.lti_course_tab.tab import DiscussionLtiCourseTab + + +class DiscussionLtiCourseTabTestCase(TabTestCase): + """Test cases for LTI Discussion Tab.""" + + def check_discussion_tab(self): + """Helper function for verifying the LTI discussion tab.""" + return self.check_tab( + tab_class=DiscussionLtiCourseTab, + dict_tab={'type': DiscussionLtiCourseTab.type, 'name': 'same'}, + expected_link=self.reverse('course_tab_view', args=[str(self.course.id), DiscussionLtiCourseTab.type]), + expected_tab_id=DiscussionLtiCourseTab.type, + invalid_dict_tab=None, + ) + + @patch('openedx.features.lti_course_tab.tab.DiscussionsConfiguration.get') + @patch('common.djangoapps.student.models.CourseEnrollment.is_enrolled') + def test_discussion_lti_tab(self, is_enrolled, discussion_config_get): + is_enrolled.return_value = True + mock_config = Mock() + mock_config.lti_configuration = {} + mock_config.enabled = False + discussion_config_get.return_value = mock_config + tab = self.check_discussion_tab() + self.check_can_display_results( + tab, for_staff_only=True, for_enrolled_users_only=True, expected_value=False + ) + mock_config.enabled = True + self.check_discussion_tab() + self.check_can_display_results( + tab, for_staff_only=True, for_enrolled_users_only=True + ) diff --git a/requirements/edx-sandbox/py35.txt b/requirements/edx-sandbox/py35.txt index f4cb5c1aa3d2021c35e1b268fb715f302e4a4d80..3c793990f36eb9a5ef470b246207c5c2221d5f08 100644 --- a/requirements/edx-sandbox/py35.txt +++ b/requirements/edx-sandbox/py35.txt @@ -20,7 +20,7 @@ matplotlib==2.2.4 # via -c requirements/edx-sandbox/../constraints.txt, mpmath==1.1.0 # via sympy networkx==2.2 # via -r requirements/edx-sandbox/py35.in nltk==3.5 # via -r requirements/edx-sandbox/shared.txt, chem -numpy==1.16.5 # via -c requirements/edx-sandbox/../constraints.txt, -r requirements/edx-sandbox/py35.in, chem, matplotlib, openedx-calc, scipy +numpy==1.16.5 # via -c requirements/edx-sandbox/../constraints.txt, -r requirements/edx-sandbox/py35.in, chem, matplotlib, openedx-calc openedx-calc==1.0.9 # via -r requirements/edx-sandbox/py35.in pycparser==2.20 # via -r requirements/edx-sandbox/shared.txt, cffi pyparsing==2.2.0 # via -r requirements/edx-sandbox/py35.in, chem, matplotlib, openedx-calc diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index c573d8ec8fd2eb32a8064c02b93b912000b0615d..00a7f1ac176a5a388e5a58acf95f6c01b91bca29 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -67,7 +67,7 @@ django-mysql==3.10.0 # via -r requirements/edx/base.in django-oauth-toolkit==1.3.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in django-object-actions==3.0.1 # via edx-enterprise django-pipeline==1.7.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in -django-pyfs==2.2 # via -r requirements/edx/base.in +django-pyfs==3.0 # via -r requirements/edx/base.in git+https://github.com/edx/django-ratelimit-backend.git@v2.0.1a5#egg=django-ratelimit-backend==2.0.1a5 # via -r requirements/edx/github.in django-ratelimit==3.0.1 # via -r requirements/edx/base.in django-require==1.0.11 # via -r requirements/edx/base.in @@ -126,7 +126,7 @@ future==0.18.2 # via django-ses, edx-celeryutils, edx-enterprise, pyc geoip2==3.0.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in glob2==0.7 # via -r requirements/edx/base.in gunicorn==20.0.4 # via -r requirements/edx/base.in -help-tokens==1.1.3 # via -r requirements/edx/base.in +help-tokens==2.0.0 # via -r requirements/edx/base.in html5lib==1.1 # via -r requirements/edx/base.in, ora2 icalendar==4.0.7 # via -r requirements/edx/base.in idna==2.10 # via -r requirements/edx/paver.txt, requests diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 1340e1838ab0eb67e3b82504efe475eafd52fa3d..3973dcd1deb54ca7782610dc8ffa813ecc5fbd7c 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -78,7 +78,7 @@ django-mysql==3.10.0 # via -r requirements/edx/testing.txt django-oauth-toolkit==1.3.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt django-object-actions==3.0.1 # via -r requirements/edx/testing.txt, edx-enterprise django-pipeline==1.7.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt -django-pyfs==2.2 # via -r requirements/edx/testing.txt +django-pyfs==3.0 # via -r requirements/edx/testing.txt git+https://github.com/edx/django-ratelimit-backend.git@v2.0.1a5#egg=django-ratelimit-backend==2.0.1a5 # via -r requirements/edx/testing.txt django-ratelimit==3.0.1 # via -r requirements/edx/testing.txt django-require==1.0.11 # via -r requirements/edx/testing.txt @@ -146,7 +146,7 @@ gitdb==4.0.5 # via -r requirements/edx/testing.txt, gitpython gitpython==3.1.12 # via -r requirements/edx/testing.txt, transifex-client glob2==0.7 # via -r requirements/edx/testing.txt gunicorn==20.0.4 # via -r requirements/edx/testing.txt -help-tokens==1.1.3 # via -r requirements/edx/testing.txt +help-tokens==2.0.0 # via -r requirements/edx/testing.txt html5lib==1.1 # via -r requirements/edx/testing.txt, ora2 httpretty==0.9.7 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt icalendar==4.0.7 # via -r requirements/edx/testing.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 05ff0b9856a8624e25581e963aebde0cf52c0b25..8a39ecbb1defb7d93e94b024f7ec5e1b4dcc36f2 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -76,7 +76,7 @@ django-mysql==3.10.0 # via -r requirements/edx/base.txt django-oauth-toolkit==1.3.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt django-object-actions==3.0.1 # via -r requirements/edx/base.txt, edx-enterprise django-pipeline==1.7.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt -django-pyfs==2.2 # via -r requirements/edx/base.txt +django-pyfs==3.0 # via -r requirements/edx/base.txt git+https://github.com/edx/django-ratelimit-backend.git@v2.0.1a5#egg=django-ratelimit-backend==2.0.1a5 # via -r requirements/edx/base.txt django-ratelimit==3.0.1 # via -r requirements/edx/base.txt django-require==1.0.11 # via -r requirements/edx/base.txt @@ -142,7 +142,7 @@ gitdb==4.0.5 # via gitpython gitpython==3.1.12 # via transifex-client glob2==0.7 # via -r requirements/edx/base.txt gunicorn==20.0.4 # via -r requirements/edx/base.txt -help-tokens==1.1.3 # via -r requirements/edx/base.txt +help-tokens==2.0.0 # via -r requirements/edx/base.txt html5lib==1.1 # via -r requirements/edx/base.txt, ora2 httpretty==0.9.7 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.in icalendar==4.0.7 # via -r requirements/edx/base.txt diff --git a/setup.py b/setup.py index 59a04229f739732bc3f9ad5dbc87ea23d1eb7e1b..44417e713e6208bbee274a770c86aef7e0737523 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,8 @@ setup( "external_link = lms.djangoapps.courseware.tabs:ExternalLinkCourseTab", "html_textbooks = lms.djangoapps.courseware.tabs:HtmlTextbookTabs", "instructor = lms.djangoapps.instructor.views.instructor_dashboard:InstructorDashboardTab", + "lti_discussion = openedx.features.lti_course_tab.tab:DiscussionLtiCourseTab", + "lti_tab = openedx.features.lti_course_tab.tab:LtiCourseTab", "pdf_textbooks = lms.djangoapps.courseware.tabs:PDFTextbookTabs", "progress = lms.djangoapps.courseware.tabs:ProgressTab", "static_tab = xmodule.tabs:StaticTab",