Changes for viewing built-in tabs in studio
Changed "Status Page" -> "Page".
UX:
support for displaying built-in tabs
restored drag and drop on Studio Pages
additional styling for fixed state on Studio Pages
add a new page action added to bottom of Studio Pages
Dev
changes for viewing tabs in studio,
refactored the tab code,
decoupled the code from django layer.
is_hideable flag on tabs
get_discussion method is needed to continue to support
external_discussion links for now since used by 6.00x course.
override the __eq__ operator to support comparing with
dict-type tabs.
Test
moved test code to common,
added acceptance test for built-in pages
added additional unit tests for tabs.
changed test_split_modulestore test to support serializing objects
that are fields in a Course.
Env:
updated environment configuration settings so they are
consistent for both cms and lms.
This commit is contained in:
@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Studio: Support for viewing built-in tabs on the Pages page. STUD-1193
|
||||
|
||||
Blades: Fixed bug when image mapped input's Show Answer multiplies rectangles on
|
||||
many inputtypes. BLD-810.
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
@shard_2
|
||||
Feature: CMS.Static Pages
|
||||
As a course author, I want to be able to add static pages
|
||||
Feature: CMS.Pages
|
||||
As a course author, I want to be able to add pages
|
||||
|
||||
Scenario: Users can add static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
Given I have opened the pages page in a new course
|
||||
Then I should not see any static pages
|
||||
When I add a new page
|
||||
When I add a new static page
|
||||
Then I should see a static page named "Empty"
|
||||
|
||||
Scenario: Users can delete static pages
|
||||
@@ -16,6 +15,10 @@ Feature: CMS.Static Pages
|
||||
When I confirm the prompt
|
||||
Then I should not see any static pages
|
||||
|
||||
Scenario: Users can see built-in pages
|
||||
Given I have opened the pages page in a new course
|
||||
Then I should see the default built-in pages
|
||||
|
||||
# Safari won't update the name properly
|
||||
@skip_safari
|
||||
Scenario: Users can edit static pages
|
||||
@@ -28,7 +31,7 @@ Feature: CMS.Static Pages
|
||||
@skip_safari
|
||||
Scenario: Users can reorder static pages
|
||||
Given I have created two different static pages
|
||||
When I reorder the tabs
|
||||
Then the tabs are in the reverse order
|
||||
When I reorder the static tabs
|
||||
Then the static tabs are in the reverse order
|
||||
And I reload the page
|
||||
Then the tabs are in the reverse order
|
||||
Then the static tabs are in the reverse order
|
||||
@@ -1,11 +1,12 @@
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
# pylint: disable=W0613
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_equal # pylint: disable=E0611
|
||||
|
||||
|
||||
@step(u'I go to the static pages page$')
|
||||
@step(u'I go to the pages page$')
|
||||
def go_to_static(step):
|
||||
menu_css = 'li.nav-course-courseware'
|
||||
static_css = 'li.nav-course-courseware-pages a'
|
||||
@@ -13,7 +14,7 @@ def go_to_static(step):
|
||||
world.css_click(static_css)
|
||||
|
||||
|
||||
@step(u'I add a new page$')
|
||||
@step(u'I add a new static page$')
|
||||
def add_page(step):
|
||||
button_css = 'a.new-button'
|
||||
world.css_click(button_css)
|
||||
@@ -32,6 +33,15 @@ def not_see_any_static_pages(step):
|
||||
assert (world.is_css_not_present(pages_css, wait_time=30))
|
||||
|
||||
|
||||
@step(u'I should see the default built-in pages')
|
||||
def see_default_built_in_pages(step):
|
||||
expected_pages = ['Courseware', 'Course Info', 'Discussion', 'Wiki', 'Progress']
|
||||
pages = world.css_find("div.course-nav-tab-header h3.title")
|
||||
assert_equal(len(expected_pages), len(pages))
|
||||
for i, page_name in enumerate(expected_pages):
|
||||
assert_equal(pages[i].text, page_name)
|
||||
|
||||
|
||||
@step(u'I "(edit|delete)" the static page$')
|
||||
def click_edit_or_delete(step, edit_or_delete):
|
||||
button_css = 'ul.component-actions a.%s-button' % edit_or_delete
|
||||
@@ -50,22 +60,27 @@ def change_name(step, new_name):
|
||||
world.css_click(save_button)
|
||||
|
||||
|
||||
@step(u'I reorder the tabs')
|
||||
@step(u'I reorder the static tabs')
|
||||
def reorder_tabs(_step):
|
||||
# For some reason, the drag_and_drop method did not work in this case.
|
||||
draggables = world.css_find('.drag-handle')
|
||||
draggables = world.css_find('.component .drag-handle')
|
||||
source = draggables.first
|
||||
target = draggables.last
|
||||
source.action_chains.click_and_hold(source._element).perform()
|
||||
source.action_chains.move_to_element_with_offset(target._element, 0, 50).perform()
|
||||
source.action_chains.click_and_hold(source._element).perform() # pylint: disable=protected-access
|
||||
source.action_chains.move_to_element_with_offset(target._element, 0, 50).perform() # pylint: disable=protected-access
|
||||
source.action_chains.release().perform()
|
||||
|
||||
|
||||
@step(u'I have created a static page')
|
||||
def create_static_page(step):
|
||||
step.given('I have opened the pages page in a new course')
|
||||
step.given('I add a new static page')
|
||||
|
||||
|
||||
@step(u'I have opened the pages page in a new course')
|
||||
def open_pages_page_in_new_course(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I go to the static pages page')
|
||||
step.given('I add a new page')
|
||||
step.given('I go to the pages page')
|
||||
|
||||
|
||||
@step(u'I have created two different static pages')
|
||||
@@ -73,12 +88,12 @@ def create_two_pages(step):
|
||||
step.given('I have created a static page')
|
||||
step.given('I "edit" the static page')
|
||||
step.given('I change the name to "First"')
|
||||
step.given('I add a new page')
|
||||
step.given('I add a new static page')
|
||||
# Verify order of tabs
|
||||
_verify_tab_names('First', 'Empty')
|
||||
|
||||
|
||||
@step(u'the tabs are in the reverse order')
|
||||
@step(u'the static tabs are in the reverse order')
|
||||
def tabs_in_reverse_order(step):
|
||||
_verify_tab_names('Empty', 'First')
|
||||
|
||||
@@ -22,6 +22,7 @@ from edxmako.shortcuts import render_to_response
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.modulestore.django import modulestore, loc_mapper
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.tabs import PDFTextbookTabs
|
||||
|
||||
from xmodule.modulestore.exceptions import (
|
||||
ItemNotFoundError, InvalidLocationError)
|
||||
@@ -39,7 +40,6 @@ from util.json_request import expect_json
|
||||
from util.string_utils import _has_non_ascii_characters
|
||||
|
||||
from .access import has_course_access
|
||||
from .tabs import initialize_course_tabs
|
||||
from .component import (
|
||||
OPEN_ENDED_COMPONENT_TYPES, NOTE_COMPONENT_TYPES,
|
||||
ADVANCED_COMPONENT_POLICY_KEY)
|
||||
@@ -411,8 +411,6 @@ def create_new_course(request):
|
||||
definition_data=overview_template.get('data')
|
||||
)
|
||||
|
||||
initialize_course_tabs(new_course, request.user)
|
||||
|
||||
new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True)
|
||||
# can't use auth.add_users here b/c it requires request.user to already have Instructor perms in this course
|
||||
# however, we can assume that b/c this user had authority to create the course, the user can add themselves
|
||||
@@ -657,8 +655,7 @@ def _config_course_advanced_components(request, course_module):
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes': NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
# Check to see if the user instantiated any notes or open ended
|
||||
# components
|
||||
# Check to see if the user instantiated any notes or open ended components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
@@ -841,8 +838,8 @@ def textbooks_list_handler(request, tag=None, package_id=None, branch=None, vers
|
||||
textbook["id"] = tid
|
||||
tids.add(tid)
|
||||
|
||||
if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs):
|
||||
course.tabs.append({"type": "pdf_textbooks"})
|
||||
if not any(tab['type'] == PDFTextbookTabs.type for tab in course.tabs):
|
||||
course.tabs.append(PDFTextbookTabs())
|
||||
course.pdf_textbooks = textbooks
|
||||
store.update_item(course, request.user.id)
|
||||
return JsonResponse(course.pdf_textbooks)
|
||||
@@ -858,10 +855,8 @@ def textbooks_list_handler(request, tag=None, package_id=None, branch=None, vers
|
||||
existing = course.pdf_textbooks
|
||||
existing.append(textbook)
|
||||
course.pdf_textbooks = existing
|
||||
if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs):
|
||||
tabs = course.tabs
|
||||
tabs.append({"type": "pdf_textbooks"})
|
||||
course.tabs = tabs
|
||||
if not any(tab['type'] == PDFTextbookTabs.type for tab in course.tabs):
|
||||
course.tabs.append(PDFTextbookTabs())
|
||||
store.update_item(course, request.user.id)
|
||||
resp = JsonResponse(textbook, status=201)
|
||||
resp["Location"] = locator.url_reverse('textbooks', textbook["id"])
|
||||
|
||||
@@ -5,6 +5,7 @@ from access import has_course_access
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
@@ -13,6 +14,7 @@ from edxmako.shortcuts import render_to_response
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from xmodule.tabs import CourseTabList, StaticTab, CourseTab
|
||||
|
||||
from ..utils import get_modulestore
|
||||
|
||||
@@ -20,33 +22,6 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
__all__ = ['tabs_handler']
|
||||
|
||||
|
||||
def initialize_course_tabs(course, user):
|
||||
"""
|
||||
set up the default tabs
|
||||
I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
at least a list populated with the minimal times
|
||||
@TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
"""
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
course.tabs = [
|
||||
# Translators: "Courseware" is the title of the page where you access a course's videos and problems.
|
||||
{"type": "courseware", "name": _("Courseware")},
|
||||
# Translators: "Course Info" is the name of the course's information and updates page
|
||||
{"type": "course_info", "name": _("Course Info")},
|
||||
# Translators: "Discussion" is the title of the course forum page
|
||||
{"type": "discussion", "name": _("Discussion")},
|
||||
# Translators: "Wiki" is the title of the course's wiki page
|
||||
{"type": "wiki", "name": _("Wiki")},
|
||||
# Translators: "Progress" is the title of the student's grade information page
|
||||
{"type": "progress", "name": _("Progress")},
|
||||
]
|
||||
|
||||
modulestore('direct').update_item(course, user.id)
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -108,12 +83,12 @@ def tabs_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course_item.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
if isinstance(tab, StaticTab):
|
||||
reordered_tabs.append(
|
||||
{'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name,
|
||||
}
|
||||
StaticTab(
|
||||
name=tab_items[static_tab_idx].display_name,
|
||||
url_slug=tab_items[static_tab_idx].location.name,
|
||||
)
|
||||
)
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
@@ -126,19 +101,19 @@ def tabs_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
else:
|
||||
raise NotImplementedError('Creating or changing tab content is not supported.')
|
||||
elif request.method == 'GET': # assume html
|
||||
# see tabs have been uninitialized (e.g. supporting courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item, request.user)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# get all tabs from the tabs list: static tabs (a.k.a. user-created tabs) and built-in tabs
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = old_location.replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
built_in_tabs = []
|
||||
for tab in CourseTabList.iterate_displayable(course_item, settings, include_instructor_tab=False):
|
||||
if isinstance(tab, StaticTab):
|
||||
static_tab_loc = old_location.replace(category='static_tab', name=tab.url_slug)
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
else:
|
||||
built_in_tabs.append(tab)
|
||||
|
||||
# create a list of components for each static tab
|
||||
components = [
|
||||
loc_mapper().translate_location(
|
||||
course_item.location.course_id, static_tab.location, False, True
|
||||
@@ -149,6 +124,7 @@ def tabs_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'context_course': course_item,
|
||||
'built_in_tabs': built_in_tabs,
|
||||
'components': components,
|
||||
'course_locator': locator
|
||||
})
|
||||
@@ -183,7 +159,7 @@ def primitive_delete(course, num):
|
||||
def primitive_insert(course, num, tab_type, name):
|
||||
"Inserts a new tab at the given number (0 based)."
|
||||
validate_args(num, tab_type)
|
||||
new_tab = {u'type': unicode(tab_type), u'name': unicode(name)}
|
||||
new_tab = CourseTab.from_json({u'type': unicode(tab_type), u'name': unicode(name)})
|
||||
tabs = course.tabs
|
||||
tabs.insert(num, new_tab)
|
||||
modulestore('direct').update_item(course, '**replace_user**')
|
||||
|
||||
@@ -20,22 +20,22 @@ class PrimitiveTabEdit(TestCase):
|
||||
tabs.primitive_delete(course, 6)
|
||||
tabs.primitive_delete(course, 2)
|
||||
self.assertFalse({u'type': u'textbooks'} in course.tabs)
|
||||
# Check that discussion has shifted down
|
||||
# Check that discussion has shifted up
|
||||
self.assertEquals(course.tabs[2], {'type': 'discussion', 'name': 'Discussion'})
|
||||
|
||||
def test_insert(self):
|
||||
"""Test primitive tab insertion."""
|
||||
course = CourseFactory.create(org='edX', course='999')
|
||||
tabs.primitive_insert(course, 2, 'atype', 'aname')
|
||||
self.assertEquals(course.tabs[2], {'type': 'atype', 'name': 'aname'})
|
||||
tabs.primitive_insert(course, 2, 'notes', 'aname')
|
||||
self.assertEquals(course.tabs[2], {'type': 'notes', 'name': 'aname'})
|
||||
with self.assertRaises(ValueError):
|
||||
tabs.primitive_insert(course, 0, 'atype', 'aname')
|
||||
tabs.primitive_insert(course, 0, 'notes', 'aname')
|
||||
with self.assertRaises(ValueError):
|
||||
tabs.primitive_insert(course, 3, 'static_tab', 'aname')
|
||||
|
||||
def test_save(self):
|
||||
"""Test course saving."""
|
||||
course = CourseFactory.create(org='edX', course='999')
|
||||
tabs.primitive_insert(course, 3, 'atype', 'aname')
|
||||
tabs.primitive_insert(course, 3, 'notes', 'aname')
|
||||
course2 = get_course_by_id(course.id)
|
||||
self.assertEquals(course2.tabs[3], {'type': 'atype', 'name': 'aname'})
|
||||
self.assertEquals(course2.tabs[3], {'type': 'notes', 'name': 'aname'})
|
||||
|
||||
@@ -89,6 +89,10 @@ STATICFILES_FINDERS += ('pipeline.finders.PipelineFinder', )
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
|
||||
# For consistency in user-experience, keep the value of this setting in sync with
|
||||
# the one in lms/envs/acceptance.py
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
|
||||
# HACK
|
||||
# Setting this flag to false causes imports to not load correctly in the lettuce python files
|
||||
# We do not yet understand why this occurs. Setting this to true is a stopgap measure
|
||||
|
||||
@@ -168,6 +168,8 @@ ENV_FEATURES = ENV_TOKENS.get('FEATURES', ENV_TOKENS.get('MITX_FEATURES', {}))
|
||||
for feature, value in ENV_FEATURES.items():
|
||||
FEATURES[feature] = value
|
||||
|
||||
WIKI_ENABLED = ENV_TOKENS.get('WIKI_ENABLED', WIKI_ENABLED)
|
||||
|
||||
LOGGING = get_logger_config(LOG_DIR,
|
||||
logging_env=ENV_TOKENS['LOGGING_ENV'],
|
||||
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
|
||||
|
||||
@@ -28,7 +28,7 @@ import imp
|
||||
import sys
|
||||
import lms.envs.common
|
||||
from lms.envs.common import (
|
||||
USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, ALL_LANGUAGES
|
||||
USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, ALL_LANGUAGES, WIKI_ENABLED
|
||||
)
|
||||
from path import path
|
||||
|
||||
@@ -43,7 +43,9 @@ FEATURES = {
|
||||
|
||||
'GITHUB_PUSH': False,
|
||||
|
||||
'ENABLE_DISCUSSION_SERVICE': False,
|
||||
# for consistency in user-experience, keep the value of this setting in sync with the
|
||||
# one in lms/envs/common.py
|
||||
'ENABLE_DISCUSSION_SERVICE': True,
|
||||
|
||||
'AUTH_USE_CERTIFICATES': False,
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ This config file runs the simplest dev environment"""
|
||||
from .common import *
|
||||
from logsettings import get_logger_config
|
||||
|
||||
# import settings from LMS for consistent behavior with CMS
|
||||
from lms.envs.dev import (WIKI_ENABLED)
|
||||
|
||||
DEBUG = True
|
||||
TEMPLATE_DEBUG = DEBUG
|
||||
LOGGING = get_logger_config(ENV_ROOT / "log",
|
||||
|
||||
@@ -17,6 +17,9 @@ import os
|
||||
from path import path
|
||||
from warnings import filterwarnings
|
||||
|
||||
# import settings from LMS for consistent behavior with CMS
|
||||
from lms.envs.test import (WIKI_ENABLED)
|
||||
|
||||
# Nose Test Runner
|
||||
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
|
||||
|
||||
@@ -222,3 +225,7 @@ MICROSITE_CONFIGURATION = {
|
||||
}
|
||||
MICROSITE_ROOT_DIR = COMMON_ROOT / 'test' / 'test_microsites'
|
||||
FEATURES['USE_MICROSITES'] = True
|
||||
|
||||
# For consistency in user-experience, keep the value of this setting in sync with
|
||||
# the one in lms/envs/test.py
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
|
||||
|
||||
@@ -17,6 +17,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
)
|
||||
|
||||
@options.mast.find('.new-tab').on('click', @addNewTab)
|
||||
$('.add-pages .new-tab').on('click', @addNewTab)
|
||||
@$('.components').sortable(
|
||||
handle: '.drag-handle'
|
||||
update: @tabMoved
|
||||
@@ -34,7 +35,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
tabs.push($(element).data('locator'))
|
||||
)
|
||||
|
||||
analytics.track "Reordered Static Pages",
|
||||
analytics.track "Reordered Pages",
|
||||
course: course_location_analytics
|
||||
|
||||
saving = new NotificationView.Mini({title: gettext("Saving…")})
|
||||
@@ -68,7 +69,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
{category: 'static_tab'}
|
||||
)
|
||||
|
||||
analytics.track "Added Static Page",
|
||||
analytics.track "Added Page",
|
||||
course: course_location_analytics
|
||||
|
||||
deleteTab: (event) =>
|
||||
@@ -82,7 +83,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
view.hide()
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
|
||||
analytics.track "Deleted Static Page",
|
||||
analytics.track "Deleted Page",
|
||||
course: course_location_analytics
|
||||
id: $component.data('locator')
|
||||
deleting = new NotificationView.Mini
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
// studio - views - course static pages
|
||||
// studio - views - course pages
|
||||
// ====================
|
||||
|
||||
.view-static-pages {
|
||||
|
||||
.new-static-page-button {
|
||||
@include grey-button;
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
// page structure
|
||||
.content-primary,
|
||||
.content-supplementary {
|
||||
@include box-sizing(border-box);
|
||||
float: left;
|
||||
}
|
||||
|
||||
.content-primary {
|
||||
width: flex-grid(9, 12);
|
||||
margin-right: flex-gutter();
|
||||
|
||||
.no-pages-content {
|
||||
.add-pages {
|
||||
@extend %ui-well;
|
||||
padding: ($baseline*2);
|
||||
margin: ($baseline*1.5) 0;
|
||||
background-color: $gray-l4;
|
||||
padding: ($baseline*2);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
|
||||
@@ -30,90 +31,96 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-list-wrap {
|
||||
top: 6px;
|
||||
.content-supplementary {
|
||||
width: flex-grid(3, 12);
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
.wrapper-actions-list {
|
||||
top: 6px;
|
||||
|
||||
.action-item {
|
||||
position: relative;
|
||||
.actions-list {
|
||||
|
||||
.action-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: ($baseline*1.5);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
|
||||
.action-button,
|
||||
.toggle-actions-view {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: $gray-l3;
|
||||
|
||||
.action-button,
|
||||
.toggle-actions-view {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: $gray-l3;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
color: $gray-l6;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-visible {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.action-visible label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-visible .toggle-checkbox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.action-visible .toggle-checkbox:hover ~ .action-button {
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
color: $gray-l6;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-visible .toggle-checkbox ~ .action-button {
|
||||
.icon-eye-open {
|
||||
display: inline-block;
|
||||
}
|
||||
&.action-visible {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-eye-close {
|
||||
display: none;
|
||||
}
|
||||
&.action-visible label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-visible .toggle-checkbox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.action-visible .toggle-checkbox:hover ~ .action-button,
|
||||
&.action-visible .toggle-checkbox:checked:hover ~ .action-button {
|
||||
background-color: $blue;
|
||||
color: $gray-l6;
|
||||
}
|
||||
|
||||
&.action-visible .toggle-checkbox ~ .action-button {
|
||||
.icon-eye-open {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.action-visible .toggle-checkbox:checked ~ .action-button {
|
||||
background-color: $gray;
|
||||
color: $white;
|
||||
.icon-eye-close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-eye-open {
|
||||
display: none;
|
||||
}
|
||||
&.action-visible .toggle-checkbox:checked ~ .action-button {
|
||||
background-color: $gray;
|
||||
color: $white;
|
||||
|
||||
.icon-eye-close {
|
||||
display: inline-block;
|
||||
}
|
||||
.icon-eye-open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-eye-close {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.unit-body {
|
||||
padding: 0;
|
||||
|
||||
@@ -209,6 +216,12 @@
|
||||
&:hover {
|
||||
background: url(../img/drag-handles.png) center no-repeat #fff;
|
||||
}
|
||||
|
||||
&.is-fixed {
|
||||
cursor: default;
|
||||
width: ($baseline*1.5);
|
||||
background: $gray-l4 none;
|
||||
}
|
||||
}
|
||||
|
||||
// uses similar styling as assets.scss, unit.scss
|
||||
@@ -229,14 +242,14 @@
|
||||
.course-nav-tab-actions {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
margin-right: $baseline*2;
|
||||
margin-right: ($baseline*2);
|
||||
padding: 8px 0px;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
|
||||
.action-item {
|
||||
display: inline-block;
|
||||
margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
|
||||
margin: ($baseline/4) 0 ($baseline/4) ($baseline/2);
|
||||
|
||||
.action-button {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
@@ -275,27 +288,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// basic course nav items
|
||||
// basic course nav items - overrides from above
|
||||
.course-nav-tab {
|
||||
padding: ($baseline*.75) $baseline;
|
||||
padding: ($baseline*.75) ($baseline/4) ($baseline*.75) $baseline;
|
||||
|
||||
&.locked {
|
||||
background-color: $gray-l6;
|
||||
&.fixed {
|
||||
@include transition(opacity $tmg-f2 ease-in-out 0s);
|
||||
opacity: .7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.course-nav-tab-header {
|
||||
display: inline-block;
|
||||
max-width: 80%;
|
||||
width:80%;
|
||||
|
||||
.title {
|
||||
@extend %t-title4;
|
||||
font-weight: 300;
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.course-nav-tab-actions {
|
||||
display: inline-block;
|
||||
padding: ($baseline/10);
|
||||
margin-right: ($baseline*1.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -487,7 +487,7 @@ body.course.unit,.view-unit {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
// Module Actions, also used for Static Pages
|
||||
// Module Actions, also used for Pages
|
||||
.module-actions {
|
||||
box-shadow: inset 0 1px 2px $shadow;
|
||||
border-top: 1px solid $gray-l1;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">Pages</%block>
|
||||
<%block name="title">${_("Pages")}</%block>
|
||||
<%block name="bodyclass">is-signedin course view-static-pages</%block>
|
||||
|
||||
<%block name="jsextra">
|
||||
@@ -31,6 +31,7 @@
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">${_("Content")}</small>
|
||||
## Translators: Pages refer to the tabs that appear in the top navigation of each course.
|
||||
<span class="sr">> </span>${_("Pages")}
|
||||
</h1>
|
||||
|
||||
@@ -53,27 +54,34 @@
|
||||
<article class="unit-body">
|
||||
|
||||
<div class="tab-list">
|
||||
<ol class="course-nav-tab-list">
|
||||
<ol class="course-nav-tab-list components">
|
||||
|
||||
<!-- for testing -->
|
||||
<li class="course-nav-tab locked">
|
||||
<div class="course-nav-tab-header">
|
||||
<h3 class="title">Wiki</h3>
|
||||
</div>
|
||||
<div class="course-nav-tab-actions actions-list-wrap">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-visible">
|
||||
<label for="[id]"><span class="sr">${_("Show this page")}</span></label>
|
||||
<input type="checkbox" id="[id]" class="toggle-checkbox" data-tooltip="${_('Show/hide page')}" />
|
||||
<div class="action-button"><i class="icon-eye-open"></i><i class="icon-eye-close"></i></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<!-- end for testing -->
|
||||
% for tab in built_in_tabs:
|
||||
<li class="course-nav-tab fixed">
|
||||
<div class="course-nav-tab-header">
|
||||
<h3 class="title">${_(tab.name)}</h3>
|
||||
</div>
|
||||
<div class="course-nav-tab-actions wrapper-actions-list">
|
||||
<ul class="actions-list">
|
||||
|
||||
% if tab.is_hideable:
|
||||
<li class="action-item action-visible">
|
||||
<label for="[id]"><span class="sr">${_("Show this page")}</span></label>
|
||||
<input type="checkbox" id="[id]" class="toggle-checkbox" data-tooltip="${_('Show/hide page')}" />
|
||||
<div class="action-button"><i class="icon-eye-open"></i><i class="icon-eye-close"></i></div>
|
||||
</li>
|
||||
%endif
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
<div class="drag-handle is-fixed" data-tooltip="${_('Cannot be reordered')}">
|
||||
<span class="sr">${_("Fixed page")}</span>
|
||||
</div>
|
||||
</li>
|
||||
% endfor
|
||||
|
||||
% for locator in components:
|
||||
<li class="component" data-locator="${locator}"/>
|
||||
<li class="component" data-locator="${locator}"></li>
|
||||
% endfor
|
||||
|
||||
<li class="new-component-item">
|
||||
@@ -81,6 +89,10 @@
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="add-pages">
|
||||
<p>${_("You can add additional custom pages to your course.")} <a href="#" class="button new-button new-tab"><i class="icon-plus"></i>${_("Add a New Page")}</a></p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
@@ -88,7 +100,7 @@
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("What are Pages?")}</h3>
|
||||
<p>${_("Pages are the items that appear in your course navigation. Some are required (Courseware, Course info, Discussion, Progress), some are optional (Wiki), and you can create your own static pages to hold additional content you want to provide to your students, like a syllabus, calendar, or handouts.")}</p>
|
||||
<p>${_("Pages are the items that appear in your course navigation. Some are required and cannot be moved or edited (Courseware, Course info, Discussion, Progress, Wiki), and you can add your own custom pages to hold additional content you want to provide to your students, like a syllabus, calendar, or handouts.")}</p>
|
||||
</div>
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("How do Pages look to students in my course?")}</h3>
|
||||
@@ -100,10 +112,10 @@
|
||||
</div>
|
||||
|
||||
<div class="content-modal" id="preview-lms-staticpages">
|
||||
<h3 class="title">${_("Static Pages in Your Course")}</h3>
|
||||
<h3 class="title">${_("Pages in Your Course")}</h3>
|
||||
<figure>
|
||||
<img src="${static.url("img/preview-lms-staticpages.png")}" alt="${_('Preview of Static Pages in your course')}" />
|
||||
<figcaption class="description">${_("The names of your Static Pages appear in your course's main navigation bar, along with Courseware, Course Info, Discussion, Wiki, and Progress.")}</figcaption>
|
||||
<img src="${static.url("img/preview-lms-staticpages.png")}" alt="${_('Preview of Pages in your course')}" />
|
||||
<figcaption class="description">${_("The names of your Pages appear in your course's main navigation bar, along with Courseware, Course Info, Discussion, Wiki, and Progress.")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<a href="#" rel="view" class="action action-modal-close">
|
||||
|
||||
@@ -118,7 +118,7 @@ require(["domReady!", "gettext", "js/views/feedback_prompt"], function(doc, gett
|
||||
<li class="item-detail">${_("Course Content (all Sections, Sub-sections, and Units)")}</li>
|
||||
<li class="item-detail">${_("Course Structure")}</li>
|
||||
<li class="item-detail">${_("Individual Problems")}</li>
|
||||
<li class="item-detail">${_("Static Pages")}</li>
|
||||
<li class="item-detail">${_("Pages")}</li>
|
||||
<li class="item-detail">${_("Course Assets")}</li>
|
||||
<li class="item-detail">${_("Course Settings")}</li>
|
||||
</ul>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<a href="${course_info_url}">${_("Updates")}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-course-courseware-pages">
|
||||
<a href="${tabs_url}">${_("Static Pages")}</a>
|
||||
<a href="${tabs_url}">${_("Pages")}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-course-courseware-uploads">
|
||||
<a href="${assets_url}">${_("Files & Uploads")}</a>
|
||||
|
||||
@@ -12,6 +12,7 @@ from xmodule.modulestore import Location
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.tabs import CourseTabList
|
||||
import json
|
||||
|
||||
from xblock.fields import Scope, List, String, Dict, Boolean, Integer
|
||||
@@ -19,7 +20,6 @@ from .fields import Date
|
||||
from xmodule.modulestore.locator import CourseLocator
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ class CourseFields(object):
|
||||
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
|
||||
display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings)
|
||||
show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings)
|
||||
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
|
||||
tabs = CourseTabList(help="List of tabs to enable in this course", scope=Scope.settings, default=[])
|
||||
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
|
||||
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
|
||||
discussion_topics = Dict(help="Map of topics names to ids", scope=Scope.settings)
|
||||
@@ -456,44 +456,15 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
self.syllabus_present = False
|
||||
else:
|
||||
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
|
||||
self._grading_policy = {}
|
||||
|
||||
self._grading_policy = {}
|
||||
self.set_grading_policy(self.grading_policy)
|
||||
|
||||
if self.discussion_topics == {}:
|
||||
self.discussion_topics = {_('General'): {'id': self.location.html_id()}}
|
||||
|
||||
# TODO check that this is still needed here and can't be by defaults.
|
||||
if not self.tabs:
|
||||
# When calling the various _tab methods, can omit the 'type':'blah' from the
|
||||
# first arg, since that's only used for dispatch
|
||||
tabs = []
|
||||
tabs.append({'type': 'courseware'})
|
||||
# Translators: "Course Info" is the name of the course's information and updates page
|
||||
tabs.append({'type': 'course_info', 'name': _('Course Info')})
|
||||
|
||||
if self.syllabus_present:
|
||||
tabs.append({'type': 'syllabus'})
|
||||
|
||||
tabs.append({'type': 'textbooks'})
|
||||
|
||||
# # If they have a discussion link specified, use that even if we feature
|
||||
# # flag discussions off. Disabling that is mostly a server safety feature
|
||||
# # at this point, and we don't need to worry about external sites.
|
||||
if self.discussion_link:
|
||||
tabs.append({'type': 'external_discussion', 'link': self.discussion_link})
|
||||
else:
|
||||
# Translators: "Discussion" is the title of the course forum page
|
||||
tabs.append({'type': 'discussion', 'name': _('Discussion')})
|
||||
|
||||
# Translators: "Wiki" is the title of the course's wiki page
|
||||
tabs.append({'type': 'wiki', 'name': _('Wiki')})
|
||||
|
||||
if not self.hide_progress_tab:
|
||||
# Translators: "Progress" is the title of the student's grade information page
|
||||
tabs.append({'type': 'progress', 'name': _('Progress')})
|
||||
|
||||
self.tabs = tabs
|
||||
if not getattr(self, "tabs", []):
|
||||
CourseTabList.initialize_default(self)
|
||||
|
||||
def set_grading_policy(self, course_policy):
|
||||
"""
|
||||
|
||||
@@ -264,7 +264,7 @@ class StaticTabFields(object):
|
||||
)
|
||||
data = String(
|
||||
default=textwrap.dedent(u"""\
|
||||
<p>This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.</p>
|
||||
<p>Add the content you want students to see on this page.</p>
|
||||
"""),
|
||||
scope=Scope.content,
|
||||
help="HTML for the additional pages"
|
||||
|
||||
@@ -34,6 +34,7 @@ from xmodule.modulestore import ModuleStoreWriteBase, Location, MONGO_MODULESTOR
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.inheritance import own_metadata, InheritanceMixin, inherit_metadata, InheritanceKeyValueStore
|
||||
from xmodule.modulestore.xml import LocationReader
|
||||
from xmodule.tabs import StaticTab, CourseTabList
|
||||
from xblock.core import XBlock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -708,13 +709,12 @@ class MongoModuleStore(ModuleStoreWriteBase):
|
||||
# TODO move this special casing to app tier (similar to attaching new element to parent)
|
||||
if location.category == 'static_tab':
|
||||
course = self._get_course_for_item(location)
|
||||
existing_tabs = course.tabs or []
|
||||
existing_tabs.append({
|
||||
'type': 'static_tab',
|
||||
'name': new_object.display_name,
|
||||
'url_slug': new_object.location.name
|
||||
})
|
||||
course.tabs = existing_tabs
|
||||
course.tabs.append(
|
||||
StaticTab(
|
||||
name=new_object.display_name,
|
||||
url_slug=new_object.location.name,
|
||||
)
|
||||
)
|
||||
self.update_item(course)
|
||||
|
||||
return new_object
|
||||
@@ -797,13 +797,11 @@ class MongoModuleStore(ModuleStoreWriteBase):
|
||||
if xblock.category == 'static_tab':
|
||||
course = self._get_course_for_item(xblock.location)
|
||||
# find the course's reference to this tab and update the name.
|
||||
for tab in course.tabs:
|
||||
if tab.get('url_slug') == xblock.location.name:
|
||||
# only update if changed
|
||||
if tab['name'] != xblock.display_name:
|
||||
tab['name'] = xblock.display_name
|
||||
self.update_item(course, user)
|
||||
break
|
||||
static_tab = CourseTabList.get_tab_by_slug(course, xblock.location.name)
|
||||
# only update if changed
|
||||
if static_tab and static_tab['name'] != xblock.display_name:
|
||||
static_tab['name'] = xblock.display_name
|
||||
self.update_item(course, user)
|
||||
|
||||
# recompute (and update) the metadata inheritance tree which is cached
|
||||
# was conditional on children or metadata having changed before dhm made one update to rule them all
|
||||
|
||||
@@ -1393,10 +1393,15 @@ class TestCourseCreation(SplitModuleTest):
|
||||
original_index = modulestore().get_course_index_info(original_locator)
|
||||
fields = {}
|
||||
for field in original.fields.values():
|
||||
value = getattr(original, field.name)
|
||||
if not isinstance(value, datetime.datetime):
|
||||
json_value = field.to_json(value)
|
||||
else:
|
||||
json_value = value
|
||||
if field.scope == Scope.content and field.name != 'location':
|
||||
fields[field.name] = getattr(original, field.name)
|
||||
fields[field.name] = json_value
|
||||
elif field.scope == Scope.settings:
|
||||
fields[field.name] = getattr(original, field.name)
|
||||
fields[field.name] = json_value
|
||||
fields['grading_policy']['GRADE_CUTOFFS'] = {'A': .9, 'B': .8, 'C': .65}
|
||||
fields['display_name'] = 'Derivative'
|
||||
new_draft = modulestore().create_course(
|
||||
|
||||
@@ -20,6 +20,7 @@ from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.mako_module import MakoDescriptorSystem
|
||||
from xmodule.x_module import XMLParsingSystem, policy_key
|
||||
from xmodule.modulestore.xml_exporter import DEFAULT_CONTENT_FIELDS
|
||||
from xmodule.tabs import CourseTabList
|
||||
|
||||
from xblock.fields import ScopeIds
|
||||
from xblock.field_data import DictFieldData
|
||||
@@ -662,9 +663,9 @@ class XMLModuleStore(ModuleStoreReadBase):
|
||||
# Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them)
|
||||
# from the course policy
|
||||
if category == "static_tab":
|
||||
for tab in course_descriptor.tabs or []:
|
||||
if tab.get('url_slug') == slug:
|
||||
module.display_name = tab['name']
|
||||
tab = CourseTabList.get_tab_by_slug(course=course_descriptor, url_slug=slug)
|
||||
if tab:
|
||||
module.display_name = tab.name
|
||||
module.data_dir = course_dir
|
||||
module.save()
|
||||
|
||||
|
||||
835
common/lib/xmodule/xmodule/tabs.py
Normal file
835
common/lib/xmodule/xmodule/tabs.py
Normal file
@@ -0,0 +1,835 @@
|
||||
"""
|
||||
Implement CourseTab
|
||||
"""
|
||||
# pylint: disable=incomplete-protocol
|
||||
# Note: pylint complains that we do not implement __delitem__ and __len__, although we implement __setitem__
|
||||
# and __getitem__. However, the former two do not apply to the CourseTab class so we do not implement them.
|
||||
# The reason we implement the latter two is to enable callers to continue to use the CourseTab object with
|
||||
# dict-type accessors.
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from xblock.fields import List
|
||||
|
||||
# We should only scrape strings for i18n in this file, since the target language is known only when
|
||||
# they are rendered in the template. So ugettext gets called in the template.
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
class CourseTab(object): # pylint: disable=incomplete-protocol
|
||||
"""
|
||||
The Course Tab class is a data abstraction for all tabs (i.e., course navigation links) within a course.
|
||||
It is an abstract class - to be inherited by various tab types.
|
||||
Derived classes are expected to override methods as needed.
|
||||
When a new tab class is created, it should define the type and add it in this class' factory method.
|
||||
"""
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
# Class property that specifies the type of the tab. It is generally a constant value for a
|
||||
# subclass, shared by all instances of the subclass.
|
||||
type = ''
|
||||
|
||||
def __init__(self, name, tab_id, link_func):
|
||||
"""
|
||||
Initializes class members with values passed in by subclasses.
|
||||
|
||||
Args:
|
||||
name: The name of the tab
|
||||
|
||||
tab_id: Intended to be a unique id for this tab, although it is currently not enforced
|
||||
within this module. It is used by the UI to determine which page is active.
|
||||
|
||||
link_func: A function that computes the link for the tab,
|
||||
given the course and a reverse-url function as input parameters
|
||||
"""
|
||||
|
||||
self.name = name
|
||||
|
||||
self.tab_id = tab_id
|
||||
|
||||
self.link_func = link_func
|
||||
|
||||
# indicates whether the tab can be hidden for a particular course
|
||||
self.is_hideable = False
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff): # pylint: disable=unused-argument
|
||||
"""
|
||||
Determines whether the tab should be displayed in the UI for the given course and a particular user.
|
||||
This method is to be overridden by subclasses when applicable. The base class implementation
|
||||
always returns True.
|
||||
|
||||
Args:
|
||||
course: An xModule CourseDescriptor
|
||||
|
||||
settings: The configuration settings, including values for:
|
||||
WIKI_ENABLED
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE']
|
||||
FEATURES['ENABLE_STUDENT_NOTES']
|
||||
FEATURES['ENABLE_TEXTBOOK']
|
||||
|
||||
is_user_authenticated: Indicates whether the user is authenticated. If the tab is of
|
||||
type AuthenticatedCourseTab and this value is False, then can_display will return False.
|
||||
|
||||
is_user_staff: Indicates whether the user has staff access to the course. If the tab is of
|
||||
type StaffTab and this value is False, then can_display will return False.
|
||||
|
||||
Returns:
|
||||
A boolean value to indicate whether this instance of the tab should be displayed to a
|
||||
given user for the given course.
|
||||
"""
|
||||
return True
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""
|
||||
Akin to the get method on Python dictionary objects, gracefully returns the value associated with the
|
||||
given key, or the default if key does not exist.
|
||||
"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""
|
||||
This method allows callers to access CourseTab members with the d[key] syntax as is done with
|
||||
Python dictionary objects.
|
||||
"""
|
||||
if key == 'name':
|
||||
return self.name
|
||||
elif key == 'type':
|
||||
return self.type
|
||||
elif key == 'tab_id':
|
||||
return self.tab_id
|
||||
else:
|
||||
raise KeyError('Key {0} not present in tab {1}'.format(key, self.to_json()))
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""
|
||||
This method allows callers to change CourseTab members with the d[key]=value syntax as is done with
|
||||
Python dictionary objects. For example: course_tab['name'] = new_name
|
||||
|
||||
Note: the 'type' member can be 'get', but not 'set'.
|
||||
"""
|
||||
if key == 'name':
|
||||
self.name = value
|
||||
elif key == 'tab_id':
|
||||
self.tab_id = value
|
||||
else:
|
||||
raise KeyError('Key {0} cannot be set in tab {1}'.format(key, self.to_json()))
|
||||
|
||||
def __eq__(self, other):
|
||||
"""
|
||||
Overrides the equal operator to check equality of member variables rather than the object's address.
|
||||
Also allows comparison with dict-type tabs (needed to support callers implemented before this class
|
||||
was implemented).
|
||||
"""
|
||||
|
||||
if type(other) is dict and not self.validate(other, raise_error=False):
|
||||
# 'other' is a dict-type tab and did not validate
|
||||
return False
|
||||
|
||||
# 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'])
|
||||
|
||||
# only compare the persisted/serialized members: 'type' and 'name'
|
||||
return self.type == other.get('type') and name_is_eq
|
||||
|
||||
def __ne__(self, other):
|
||||
"""
|
||||
Overrides the not equal operator as a partner to the equal operator.
|
||||
"""
|
||||
return not (self == other)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True): # pylint: disable=unused-argument
|
||||
"""
|
||||
Validates the given dict-type tab 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 key_checker(['type'])(tab, raise_error)
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Serializes the necessary members of the CourseTab object to a json-serializable representation.
|
||||
This method is overridden by subclasses that have more members to serialize.
|
||||
|
||||
Returns:
|
||||
a dictionary with keys for the properties of the CourseTab object.
|
||||
"""
|
||||
return {'type': self.type, 'name': self.name}
|
||||
|
||||
@staticmethod
|
||||
def from_json(tab):
|
||||
"""
|
||||
Deserializes a CourseTab from a json-like representation.
|
||||
|
||||
The subclass that is instantiated is determined by the value of the 'type' key in the
|
||||
given dict-type tab. The given dict-type tab is validated before instantiating the CourseTab object.
|
||||
|
||||
Args:
|
||||
tab: a dictionary with keys for the properties of the tab.
|
||||
|
||||
Raises:
|
||||
InvalidTabsException if the given tab doesn't have the right keys.
|
||||
"""
|
||||
sub_class_types = {
|
||||
'courseware': CoursewareTab,
|
||||
'course_info': CourseInfoTab,
|
||||
'wiki': WikiTab,
|
||||
'discussion': DiscussionTab,
|
||||
'external_discussion': ExternalDiscussionTab,
|
||||
'external_link': ExternalLinkTab,
|
||||
'textbooks': TextbookTabs,
|
||||
'pdf_textbooks': PDFTextbookTabs,
|
||||
'html_textbooks': HtmlTextbookTabs,
|
||||
'progress': ProgressTab,
|
||||
'static_tab': StaticTab,
|
||||
'peer_grading': PeerGradingTab,
|
||||
'staff_grading': StaffGradingTab,
|
||||
'open_ended': OpenEndedGradingTab,
|
||||
'notes': NotesTab,
|
||||
'syllabus': SyllabusTab,
|
||||
'instructor': InstructorTab, # not persisted
|
||||
}
|
||||
|
||||
tab_type = tab.get('type')
|
||||
if tab_type not in sub_class_types:
|
||||
raise InvalidTabsException(
|
||||
'Unknown tab type {0}. Known types: {1}'.format(tab_type, sub_class_types)
|
||||
)
|
||||
|
||||
tab_class = sub_class_types[tab['type']]
|
||||
tab_class.validate(tab)
|
||||
return tab_class(tab=tab)
|
||||
|
||||
|
||||
class AuthenticatedCourseTab(CourseTab):
|
||||
"""
|
||||
Abstract class for tabs that can be accessed by only authenticated users.
|
||||
"""
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff):
|
||||
return is_user_authenticated
|
||||
|
||||
|
||||
class StaffTab(AuthenticatedCourseTab):
|
||||
"""
|
||||
Abstract class for tabs that can be accessed by only users with staff access.
|
||||
"""
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff): # pylint: disable=unused-argument
|
||||
return is_user_staff
|
||||
|
||||
|
||||
class CoursewareTab(CourseTab):
|
||||
"""
|
||||
A tab containing the course content.
|
||||
"""
|
||||
|
||||
type = 'courseware'
|
||||
|
||||
def __init__(self, tab=None): # pylint: disable=unused-argument
|
||||
super(CoursewareTab, self).__init__(
|
||||
# Translators: 'Courseware' refers to the tab in the courseware that leads to the content of a course
|
||||
name=_('Courseware'), # support fixed name for the courseware tab
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func(self.type),
|
||||
)
|
||||
|
||||
|
||||
class CourseInfoTab(CourseTab):
|
||||
"""
|
||||
A tab containing information about the course.
|
||||
"""
|
||||
|
||||
type = 'course_info'
|
||||
|
||||
def __init__(self, tab=None):
|
||||
super(CourseInfoTab, self).__init__(
|
||||
# Translators: "Course Info" is the name of the course's information and updates page
|
||||
name=tab['name'] if tab else _('Course Info'),
|
||||
tab_id='info',
|
||||
link_func=link_reverse_func('info'),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True):
|
||||
return super(CourseInfoTab, cls).validate(tab, raise_error) and need_name(tab, raise_error)
|
||||
|
||||
|
||||
class ProgressTab(AuthenticatedCourseTab):
|
||||
"""
|
||||
A tab containing information about the authenticated user's progress.
|
||||
"""
|
||||
|
||||
type = 'progress'
|
||||
|
||||
def __init__(self, tab=None):
|
||||
super(ProgressTab, self).__init__(
|
||||
# Translators: "Progress" is the name of the student's course progress page
|
||||
name=tab['name'] if tab else _('Progress'),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func(self.type),
|
||||
)
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff):
|
||||
return not course.hide_progress_tab
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True):
|
||||
return super(ProgressTab, cls).validate(tab, raise_error) and need_name(tab, raise_error)
|
||||
|
||||
|
||||
class WikiTab(CourseTab):
|
||||
"""
|
||||
A tab containing the course wiki.
|
||||
"""
|
||||
|
||||
type = 'wiki'
|
||||
|
||||
def __init__(self, tab=None):
|
||||
# LATER - enable the following flag to enable hiding of the Wiki page
|
||||
# self.is_hideable = True
|
||||
|
||||
super(WikiTab, self).__init__(
|
||||
# Translators: "Wiki" is the name of the course's wiki page
|
||||
name=tab['name'] if tab else _('Wiki'),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func('course_wiki'),
|
||||
)
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff):
|
||||
return settings.WIKI_ENABLED
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True):
|
||||
return super(WikiTab, cls).validate(tab, raise_error) and need_name(tab, raise_error)
|
||||
|
||||
|
||||
class DiscussionTab(CourseTab):
|
||||
"""
|
||||
A tab only for the new Berkeley discussion forums.
|
||||
"""
|
||||
|
||||
type = 'discussion'
|
||||
|
||||
def __init__(self, tab=None):
|
||||
super(DiscussionTab, self).__init__(
|
||||
# Translators: "Discussion" is the title of the course forum page
|
||||
name=tab['name'] if tab else _('Discussion'),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func('django_comment_client.forum.views.forum_form_discussion'),
|
||||
)
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff):
|
||||
return settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE')
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True):
|
||||
return super(DiscussionTab, cls).validate(tab, raise_error) and need_name(tab, raise_error)
|
||||
|
||||
|
||||
class LinkTab(CourseTab):
|
||||
"""
|
||||
Abstract class for tabs that contain external links.
|
||||
"""
|
||||
link_value = ''
|
||||
|
||||
def __init__(self, name, tab_id, link_value):
|
||||
self.link_value = link_value
|
||||
super(LinkTab, self).__init__(
|
||||
name=name,
|
||||
tab_id=tab_id,
|
||||
link_func=link_value_func(self.link_value),
|
||||
)
|
||||
|
||||
def __getitem__(self, key):
|
||||
if key == 'link':
|
||||
return self.link_value
|
||||
else:
|
||||
return super(LinkTab, self).__getitem__(key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key == 'link':
|
||||
self.link_value = value
|
||||
else:
|
||||
super(LinkTab, self).__setitem__(key, value)
|
||||
|
||||
def to_json(self):
|
||||
to_json_val = super(LinkTab, self).to_json()
|
||||
to_json_val.update({'link': self.link_value})
|
||||
return to_json_val
|
||||
|
||||
def __eq__(self, other):
|
||||
if not super(LinkTab, self).__eq__(other):
|
||||
return False
|
||||
return self.link_value == other.get('link')
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True):
|
||||
return super(LinkTab, cls).validate(tab, raise_error) and key_checker(['link'])(tab, raise_error)
|
||||
|
||||
|
||||
class ExternalDiscussionTab(LinkTab):
|
||||
"""
|
||||
A tab that links to an external discussion service.
|
||||
"""
|
||||
|
||||
type = 'external_discussion'
|
||||
|
||||
def __init__(self, tab=None, link_value=None):
|
||||
super(ExternalDiscussionTab, self).__init__(
|
||||
# Translators: 'Discussion' refers to the tab in the courseware that leads to the discussion forums
|
||||
name=_('Discussion'),
|
||||
tab_id='discussion',
|
||||
link_value=tab['link'] if tab else link_value,
|
||||
)
|
||||
|
||||
|
||||
class ExternalLinkTab(LinkTab):
|
||||
"""
|
||||
A tab containing an external link.
|
||||
"""
|
||||
type = 'external_link'
|
||||
|
||||
def __init__(self, tab):
|
||||
super(ExternalLinkTab, self).__init__(
|
||||
name=tab['name'],
|
||||
tab_id=None, # External links are never active.
|
||||
link_value=tab['link'],
|
||||
)
|
||||
|
||||
|
||||
class StaticTab(CourseTab):
|
||||
"""
|
||||
A custom tab.
|
||||
"""
|
||||
type = 'static_tab'
|
||||
url_slug = ''
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True):
|
||||
return super(StaticTab, cls).validate(tab, raise_error) and key_checker(['name', 'url_slug'])(tab, raise_error)
|
||||
|
||||
def __init__(self, tab=None, name=None, url_slug=None):
|
||||
self.url_slug = tab['url_slug'] if tab else url_slug
|
||||
tab_name = tab['name'] if tab else name
|
||||
super(StaticTab, self).__init__(
|
||||
name=tab_name,
|
||||
tab_id='static_tab_{0}'.format(self.url_slug),
|
||||
link_func=lambda course, reverse_func: reverse_func(self.type, args=[course.id, self.url_slug]),
|
||||
)
|
||||
|
||||
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):
|
||||
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 SingleTextbookTab(CourseTab):
|
||||
"""
|
||||
A tab representing a single textbook. It is created temporarily when enumerating all textbooks within a
|
||||
Textbook collection tab. It should not be serialized or persisted.
|
||||
"""
|
||||
type = 'single_textbook'
|
||||
|
||||
def to_json(self):
|
||||
raise NotImplementedError('SingleTextbookTab should not be serialized.')
|
||||
|
||||
|
||||
class TextbookTabsBase(AuthenticatedCourseTab):
|
||||
"""
|
||||
Abstract class for textbook collection tabs classes.
|
||||
"""
|
||||
def __init__(self, tab=None): # pylint: disable=unused-argument
|
||||
super(TextbookTabsBase, self).__init__('', '', '')
|
||||
|
||||
@abstractmethod
|
||||
def books(self, course):
|
||||
"""
|
||||
A generator for iterating through all the SingleTextbookTab book objects associated with this
|
||||
collection of textbooks.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TextbookTabs(TextbookTabsBase):
|
||||
"""
|
||||
A tab representing the collection of all textbook tabs.
|
||||
"""
|
||||
type = 'textbooks'
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff):
|
||||
return settings.FEATURES.get('ENABLE_TEXTBOOK')
|
||||
|
||||
def books(self, course):
|
||||
for index, textbook in enumerate(course.textbooks):
|
||||
yield SingleTextbookTab(
|
||||
name=textbook.title,
|
||||
tab_id='textbook/{0}'.format(index),
|
||||
link_func=lambda course, reverse_func: reverse_func('book', args=[course.id, index]),
|
||||
)
|
||||
|
||||
|
||||
class PDFTextbookTabs(TextbookTabsBase):
|
||||
"""
|
||||
A tab representing the collection of all PDF textbook tabs.
|
||||
"""
|
||||
type = 'pdf_textbooks'
|
||||
|
||||
def books(self, course):
|
||||
for index, textbook in enumerate(course.pdf_textbooks):
|
||||
yield SingleTextbookTab(
|
||||
name=textbook['tab_title'],
|
||||
tab_id='pdftextbook/{0}'.format(index),
|
||||
link_func=lambda course, reverse_func: reverse_func('pdf_book', args=[course.id, index]),
|
||||
)
|
||||
|
||||
|
||||
class HtmlTextbookTabs(TextbookTabsBase):
|
||||
"""
|
||||
A tab representing the collection of all Html textbook tabs.
|
||||
"""
|
||||
type = 'html_textbooks'
|
||||
|
||||
def books(self, course):
|
||||
for index, textbook in enumerate(course.html_textbooks):
|
||||
yield SingleTextbookTab(
|
||||
name=textbook['tab_title'],
|
||||
tab_id='htmltextbook/{0}'.format(index),
|
||||
link_func=lambda course, reverse_func: reverse_func('html_book', args=[course.id, index]),
|
||||
)
|
||||
|
||||
|
||||
class GradingTab(object):
|
||||
"""
|
||||
Abstract class for tabs that involve Grading.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class StaffGradingTab(StaffTab, GradingTab):
|
||||
"""
|
||||
A tab for staff grading.
|
||||
"""
|
||||
type = 'staff_grading'
|
||||
|
||||
def __init__(self, tab=None): # pylint: disable=unused-argument
|
||||
super(StaffGradingTab, self).__init__(
|
||||
# Translators: "Staff grading" appears on a tab that allows
|
||||
# staff to view open-ended problems that require staff grading
|
||||
name=_("Staff grading"),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func(self.type),
|
||||
)
|
||||
|
||||
|
||||
class PeerGradingTab(AuthenticatedCourseTab, GradingTab):
|
||||
"""
|
||||
A tab for peer grading.
|
||||
"""
|
||||
type = 'peer_grading'
|
||||
|
||||
def __init__(self, tab=None): # pylint: disable=unused-argument
|
||||
super(PeerGradingTab, self).__init__(
|
||||
# Translators: "Peer grading" appears on a tab that allows
|
||||
# students to view open-ended problems that require grading
|
||||
name=_("Peer grading"),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func(self.type),
|
||||
)
|
||||
|
||||
|
||||
class OpenEndedGradingTab(AuthenticatedCourseTab, GradingTab):
|
||||
"""
|
||||
A tab for open ended grading.
|
||||
"""
|
||||
type = 'open_ended'
|
||||
|
||||
def __init__(self, tab=None): # pylint: disable=unused-argument
|
||||
super(OpenEndedGradingTab, self).__init__(
|
||||
# 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
|
||||
name=_("Open Ended Panel"),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func('open_ended_notifications'),
|
||||
)
|
||||
|
||||
|
||||
class SyllabusTab(CourseTab):
|
||||
"""
|
||||
A tab for the course syllabus.
|
||||
"""
|
||||
type = 'syllabus'
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff):
|
||||
return hasattr(course, 'syllabus_present') and course.syllabus_present
|
||||
|
||||
def __init__(self, tab=None): # pylint: disable=unused-argument
|
||||
super(SyllabusTab, self).__init__(
|
||||
# Translators: "Syllabus" appears on a tab that, when clicked, opens the syllabus of the course.
|
||||
name=_('Syllabus'),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func(self.type),
|
||||
)
|
||||
|
||||
|
||||
class NotesTab(AuthenticatedCourseTab):
|
||||
"""
|
||||
A tab for the course notes.
|
||||
"""
|
||||
type = 'notes'
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff):
|
||||
return settings.FEATURES.get('ENABLE_STUDENT_NOTES')
|
||||
|
||||
def __init__(self, tab=None):
|
||||
super(NotesTab, self).__init__(
|
||||
name=tab['name'],
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func(self.type),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, tab, raise_error=True):
|
||||
return super(NotesTab, cls).validate(tab, raise_error) and need_name(tab, raise_error)
|
||||
|
||||
|
||||
class InstructorTab(StaffTab):
|
||||
"""
|
||||
A tab for the course instructors.
|
||||
"""
|
||||
type = 'instructor'
|
||||
|
||||
def __init__(self, tab=None): # pylint: disable=unused-argument
|
||||
super(InstructorTab, self).__init__(
|
||||
# Translators: 'Instructor' appears on the tab that leads to the instructor dashboard, which is
|
||||
# a portal where an instructor can get data and perform various actions on their course
|
||||
name=_('Instructor'),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func('instructor_dashboard'),
|
||||
)
|
||||
|
||||
|
||||
class CourseTabList(List):
|
||||
"""
|
||||
An XBlock field class that encapsulates a collection of Tabs in a course.
|
||||
It is automatically created and can be retrieved through a CourseDescriptor object: course.tabs
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def initialize_default(course):
|
||||
"""
|
||||
An explicit initialize method is used to set the default values, rather than implementing an
|
||||
__init__ method. This is because the default values are dependent on other information from
|
||||
within the course.
|
||||
"""
|
||||
|
||||
course.tabs.extend([
|
||||
CoursewareTab(),
|
||||
CourseInfoTab(),
|
||||
])
|
||||
|
||||
# Presence of syllabus tab is indicated by a course attribute
|
||||
if hasattr(course, 'syllabus_present') and course.syllabus_present:
|
||||
course.tabs.append(SyllabusTab())
|
||||
|
||||
# If the course has a discussion link specified, use that even if we feature
|
||||
# flag discussions off. Disabling that is mostly a server safety feature
|
||||
# at this point, and we don't need to worry about external sites.
|
||||
if course.discussion_link:
|
||||
discussion_tab = ExternalDiscussionTab(link_value=course.discussion_link)
|
||||
else:
|
||||
discussion_tab = DiscussionTab()
|
||||
|
||||
course.tabs.extend([
|
||||
TextbookTabs(),
|
||||
discussion_tab,
|
||||
WikiTab(),
|
||||
ProgressTab(),
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def get_discussion(course):
|
||||
"""
|
||||
Returns the discussion tab for the given course. It can be either of type DiscussionTab
|
||||
or ExternalDiscussionTab. The returned tab object is self-aware of the 'link' that it corresponds to.
|
||||
"""
|
||||
|
||||
# the discussion_link setting overrides everything else, even if there is a discussion tab in the course tabs
|
||||
if course.discussion_link:
|
||||
return ExternalDiscussionTab(link_value=course.discussion_link)
|
||||
|
||||
# find one of the discussion tab types in the course tabs
|
||||
for tab in course.tabs:
|
||||
if isinstance(tab, DiscussionTab) or isinstance(tab, ExternalDiscussionTab):
|
||||
return tab
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_tab_by_slug(course, url_slug):
|
||||
"""
|
||||
Look for a tab with the specified 'url_slug'. Returns the tab or None if not found.
|
||||
"""
|
||||
for tab in course.tabs:
|
||||
# The validation code checks that these exist.
|
||||
if tab.get('url_slug') == url_slug:
|
||||
return tab
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def iterate_displayable(course, settings, is_user_authenticated=True, is_user_staff=True, include_instructor_tab=False):
|
||||
"""
|
||||
Generator method for iterating through all tabs that can be displayed for the given course and
|
||||
the given user with the provided access settings.
|
||||
"""
|
||||
for tab in course.tabs:
|
||||
if tab.can_display(course, settings, is_user_authenticated, is_user_staff):
|
||||
if isinstance(tab, TextbookTabsBase):
|
||||
for book in tab.books(course):
|
||||
yield book
|
||||
else:
|
||||
yield tab
|
||||
if include_instructor_tab:
|
||||
instructor_tab = InstructorTab()
|
||||
if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff):
|
||||
yield instructor_tab
|
||||
|
||||
@classmethod
|
||||
def _validate_tabs(cls, tabs):
|
||||
"""
|
||||
Check that the tabs set for the specified course is valid. If it
|
||||
isn't, raise InvalidTabsException with the complaint.
|
||||
|
||||
Specific rules checked:
|
||||
- if no tabs specified, that's fine
|
||||
- if tabs specified, first two must have type 'courseware' and 'course_info', in that order.
|
||||
|
||||
"""
|
||||
if tabs is None or len(tabs) == 0:
|
||||
return
|
||||
|
||||
if len(tabs) < 2:
|
||||
raise InvalidTabsException("Expected at least two tabs. tabs: '{0}'".format(tabs))
|
||||
|
||||
if tabs[0].get('type') != CoursewareTab.type:
|
||||
raise InvalidTabsException(
|
||||
"Expected first tab to have type 'courseware'. tabs: '{0}'".format(tabs))
|
||||
|
||||
if tabs[1].get('type') != CourseInfoTab.type:
|
||||
raise InvalidTabsException(
|
||||
"Expected second tab to have type 'course_info'. tabs: '{0}'".format(tabs))
|
||||
|
||||
# the following tabs should appear only once
|
||||
for tab_type in [
|
||||
CoursewareTab.type,
|
||||
CourseInfoTab.type,
|
||||
NotesTab.type,
|
||||
TextbookTabs.type,
|
||||
PDFTextbookTabs.type,
|
||||
HtmlTextbookTabs.type,
|
||||
]:
|
||||
cls._validate_num_tabs_of_type(tabs, tab_type, 1)
|
||||
|
||||
@staticmethod
|
||||
def _validate_num_tabs_of_type(tabs, tab_type, max_num):
|
||||
"""
|
||||
Check that the number of times that the given 'tab_type' appears in 'tabs' is less than or equal to 'max_num'.
|
||||
"""
|
||||
count = sum(1 for tab in tabs if tab.get('type') == tab_type)
|
||||
if count > max_num:
|
||||
raise InvalidTabsException(
|
||||
"Tab of type '{0}' appears {1} time(s). Expected maximum of {2} time(s).".format(
|
||||
tab_type, count, max_num
|
||||
))
|
||||
|
||||
def to_json(self, values):
|
||||
"""
|
||||
Overrides the to_json method to serialize all the CourseTab objects to a json-serializable representation.
|
||||
"""
|
||||
json_data = []
|
||||
if values:
|
||||
for val in values:
|
||||
if isinstance(val, CourseTab):
|
||||
json_data.append(val.to_json())
|
||||
elif isinstance(val, dict):
|
||||
json_data.append(val)
|
||||
else:
|
||||
continue
|
||||
return json_data
|
||||
|
||||
def from_json(self, values):
|
||||
"""
|
||||
Overrides the from_json method to de-serialize the CourseTab objects from a json-like representation.
|
||||
"""
|
||||
self._validate_tabs(values)
|
||||
return [CourseTab.from_json(tab) for tab in values]
|
||||
|
||||
|
||||
#### Link Functions
|
||||
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])
|
||||
|
||||
|
||||
def link_value_func(value):
|
||||
"""
|
||||
Returns a function takes in a course and reverse_url_func, and returns the given value.
|
||||
"""
|
||||
return lambda course, reverse_url_func: value
|
||||
|
||||
|
||||
#### Validators
|
||||
# A validator takes a dict and raises InvalidTabsException if required fields are missing or otherwise wrong.
|
||||
# (e.g. "is there a 'name' field?). Validators can assume that the type field is valid.
|
||||
def key_checker(expected_keys):
|
||||
"""
|
||||
Returns a function that checks that specified keys are present in a dict.
|
||||
"""
|
||||
|
||||
def check(actual_dict, raise_error=True):
|
||||
"""
|
||||
Function that checks whether all keys in the expected_keys object is in the given actual_dict object.
|
||||
"""
|
||||
missing = set(expected_keys) - set(actual_dict.keys())
|
||||
if not missing:
|
||||
return True
|
||||
if raise_error:
|
||||
raise InvalidTabsException(
|
||||
"Expected keys '{0}' are not present in the given dict: {1}".format(expected_keys, actual_dict)
|
||||
)
|
||||
else:
|
||||
return False
|
||||
|
||||
return check
|
||||
|
||||
|
||||
def need_name(dictionary, raise_error=True):
|
||||
"""
|
||||
Returns whether the 'name' key exists in the given dictionary.
|
||||
"""
|
||||
return key_checker(['name'])(dictionary, raise_error)
|
||||
|
||||
|
||||
class InvalidTabsException(Exception):
|
||||
"""
|
||||
A complaint about invalid tabs.
|
||||
"""
|
||||
pass
|
||||
586
common/lib/xmodule/xmodule/tests/test_tabs.py
Normal file
586
common/lib/xmodule/xmodule/tests/test_tabs.py
Normal file
@@ -0,0 +1,586 @@
|
||||
"""Tests for Tab classes"""
|
||||
from mock import MagicMock
|
||||
import xmodule.tabs as tabs
|
||||
import unittest
|
||||
|
||||
|
||||
class TabTestCase(unittest.TestCase):
|
||||
"""Base class for Tab-related test cases."""
|
||||
def setUp(self):
|
||||
|
||||
self.course = MagicMock()
|
||||
self.course.id = 'edX/toy/2012_Fall'
|
||||
self.fake_dict_tab = {'fake_key': 'fake_value'}
|
||||
self.settings = MagicMock()
|
||||
self.settings.FEATURES = {}
|
||||
self.reverse = lambda name, args: "name/{0}/args/{1}".format(name, ",".join(str(a) for a in args))
|
||||
|
||||
def check_tab(
|
||||
self,
|
||||
tab_class,
|
||||
dict_tab,
|
||||
expected_link,
|
||||
expected_tab_id,
|
||||
expected_name='same',
|
||||
invalid_dict_tab=None,
|
||||
):
|
||||
"""
|
||||
Helper method to verify a tab class.
|
||||
|
||||
'tab_class' is the class of the tab that is being tested
|
||||
'dict_tab' is the raw dictionary value of the tab
|
||||
'expected_link' is the expected value for the hyperlink of the tab
|
||||
'expected_tab_id' is the expected value for the unique id of the tab
|
||||
'expected_name' is the expected value for the name of the tab
|
||||
'invalid_dict_tab' is an invalid dictionary value for the tab.
|
||||
Can be 'None' if the given tab class does not have any keys to validate.
|
||||
"""
|
||||
# create tab
|
||||
tab = tab_class(dict_tab)
|
||||
|
||||
# name is as expected
|
||||
self.assertEqual(tab.name, expected_name)
|
||||
|
||||
# link is as expected
|
||||
self.assertEqual(tab.link_func(self.course, self.reverse), expected_link)
|
||||
|
||||
# verify active page name
|
||||
self.assertEqual(tab.tab_id, expected_tab_id)
|
||||
|
||||
# validate tab
|
||||
self.assertTrue(tab.validate(dict_tab))
|
||||
if invalid_dict_tab:
|
||||
with self.assertRaises(tabs.InvalidTabsException):
|
||||
tab.validate(invalid_dict_tab)
|
||||
|
||||
# check get and set methods
|
||||
self.check_get_and_set_methods(tab)
|
||||
|
||||
# check to_json and from_json methods
|
||||
serialized_tab = tab.to_json()
|
||||
deserialized_tab = tab_class.from_json(serialized_tab)
|
||||
self.assertEquals(serialized_tab, deserialized_tab)
|
||||
|
||||
# check equality methods
|
||||
self.assertEquals(tab, dict_tab) # test __eq__
|
||||
ne_dict_tab = dict_tab
|
||||
ne_dict_tab['type'] = 'fake_type'
|
||||
self.assertNotEquals(tab, ne_dict_tab) # test __ne__: incorrect type
|
||||
self.assertNotEquals(tab, {'fake_key': 'fake_value'}) # test __ne__: missing type
|
||||
|
||||
# return tab for any additional tests
|
||||
return tab
|
||||
|
||||
def check_can_display_results(self, tab, expected_value=True, for_authenticated_users_only=False, for_staff_only=False):
|
||||
"""Check can display results for various users"""
|
||||
if for_staff_only:
|
||||
self.assertEquals(
|
||||
expected_value,
|
||||
tab.can_display(self.course, self.settings, is_user_authenticated=False, is_user_staff=True)
|
||||
)
|
||||
if for_authenticated_users_only:
|
||||
self.assertEquals(
|
||||
expected_value,
|
||||
tab.can_display(self.course, self.settings, is_user_authenticated=True, is_user_staff=False)
|
||||
)
|
||||
if not for_staff_only and not for_authenticated_users_only:
|
||||
self.assertEquals(
|
||||
expected_value,
|
||||
tab.can_display(self.course, self.settings, is_user_authenticated=False, is_user_staff=False)
|
||||
)
|
||||
|
||||
def check_get_and_set_methods(self, tab):
|
||||
"""test __getitem__ and __setitem__ calls"""
|
||||
self.assertEquals(tab['type'], tab.type)
|
||||
self.assertEquals(tab['tab_id'], tab.tab_id)
|
||||
with self.assertRaises(KeyError):
|
||||
_ = tab['invalid_key']
|
||||
|
||||
self.check_get_and_set_method_for_key(tab, 'name')
|
||||
self.check_get_and_set_method_for_key(tab, 'tab_id')
|
||||
with self.assertRaises(KeyError):
|
||||
tab['invalid_key'] = 'New Value'
|
||||
|
||||
def check_get_and_set_method_for_key(self, tab, key):
|
||||
"""test __getitem__ and __setitem__ for the given key"""
|
||||
old_value = tab[key]
|
||||
new_value = 'New Value'
|
||||
tab[key] = new_value
|
||||
self.assertEquals(tab[key], new_value)
|
||||
tab[key] = old_value
|
||||
self.assertEquals(tab[key], old_value)
|
||||
|
||||
|
||||
class ProgressTestCase(TabTestCase):
|
||||
"""Test cases for Progress Tab."""
|
||||
|
||||
def check_progress_tab(self):
|
||||
"""Helper function for verifying the progress tab."""
|
||||
return self.check_tab(
|
||||
tab_class=tabs.ProgressTab,
|
||||
dict_tab={'type': tabs.ProgressTab.type, 'name': 'same'},
|
||||
expected_link=self.reverse('progress', args=[self.course.id]),
|
||||
expected_tab_id=tabs.ProgressTab.type,
|
||||
invalid_dict_tab=None,
|
||||
)
|
||||
|
||||
def test_progress(self):
|
||||
|
||||
self.course.hide_progress_tab = False
|
||||
tab = self.check_progress_tab()
|
||||
self.check_can_display_results(tab, for_authenticated_users_only=True)
|
||||
|
||||
self.course.hide_progress_tab = True
|
||||
self.check_progress_tab()
|
||||
self.check_can_display_results(tab, for_authenticated_users_only=True, expected_value=False)
|
||||
|
||||
|
||||
class WikiTestCase(TabTestCase):
|
||||
"""Test cases for Wiki Tab."""
|
||||
|
||||
def check_wiki_tab(self):
|
||||
"""Helper function for verifying the wiki tab."""
|
||||
return self.check_tab(
|
||||
tab_class=tabs.WikiTab,
|
||||
dict_tab={'type': tabs.WikiTab.type, 'name': 'same'},
|
||||
expected_link=self.reverse('course_wiki', args=[self.course.id]),
|
||||
expected_tab_id=tabs.WikiTab.type,
|
||||
invalid_dict_tab=self.fake_dict_tab,
|
||||
)
|
||||
|
||||
def test_wiki_enabled(self):
|
||||
|
||||
self.settings.WIKI_ENABLED = True
|
||||
tab = self.check_wiki_tab()
|
||||
self.check_can_display_results(tab)
|
||||
|
||||
def test_wiki_enabled_false(self):
|
||||
|
||||
self.settings.WIKI_ENABLED = False
|
||||
tab = self.check_wiki_tab()
|
||||
self.check_can_display_results(tab, expected_value=False)
|
||||
|
||||
|
||||
class ExternalLinkTestCase(TabTestCase):
|
||||
"""Test cases for External Link Tab."""
|
||||
|
||||
def test_external_link(self):
|
||||
|
||||
link_value = 'link_value'
|
||||
tab = self.check_tab(
|
||||
tab_class=tabs.ExternalLinkTab,
|
||||
dict_tab={'type': tabs.ExternalLinkTab.type, 'name': 'same', 'link': link_value},
|
||||
expected_link=link_value,
|
||||
expected_tab_id=None,
|
||||
invalid_dict_tab=self.fake_dict_tab,
|
||||
)
|
||||
self.check_can_display_results(tab)
|
||||
self.check_get_and_set_method_for_key(tab, 'link')
|
||||
|
||||
|
||||
class StaticTabTestCase(TabTestCase):
|
||||
"""Test cases for Static Tab."""
|
||||
|
||||
def test_static_tab(self):
|
||||
|
||||
url_slug = 'schmug'
|
||||
|
||||
tab = self.check_tab(
|
||||
tab_class=tabs.StaticTab,
|
||||
dict_tab={'type': tabs.StaticTab.type, 'name': 'same', 'url_slug': url_slug},
|
||||
expected_link=self.reverse('static_tab', args=[self.course.id, url_slug]),
|
||||
expected_tab_id='static_tab_schmug',
|
||||
invalid_dict_tab=self.fake_dict_tab,
|
||||
)
|
||||
self.check_can_display_results(tab)
|
||||
self.check_get_and_set_method_for_key(tab, 'url_slug')
|
||||
|
||||
|
||||
class TextbooksTestCase(TabTestCase):
|
||||
"""Test cases for Textbook Tab."""
|
||||
|
||||
def setUp(self):
|
||||
super(TextbooksTestCase, self).setUp()
|
||||
|
||||
self.dict_tab = MagicMock()
|
||||
book1 = MagicMock()
|
||||
book2 = MagicMock()
|
||||
book1.title = 'Book1: Algebra'
|
||||
book2.title = 'Book2: Topology'
|
||||
books = [book1, book2]
|
||||
self.course.textbooks = books
|
||||
self.course.pdf_textbooks = books
|
||||
self.course.html_textbooks = books
|
||||
self.course.tabs = [
|
||||
tabs.CoursewareTab(),
|
||||
tabs.CourseInfoTab(),
|
||||
tabs.TextbookTabs(),
|
||||
tabs.PDFTextbookTabs(),
|
||||
tabs.HtmlTextbookTabs(),
|
||||
]
|
||||
self.num_textbook_tabs = sum(1 for tab in self.course.tabs if isinstance(tab, tabs.TextbookTabsBase))
|
||||
self.num_textbooks = self.num_textbook_tabs * len(books)
|
||||
|
||||
def test_textbooks_enabled(self):
|
||||
|
||||
type_to_reverse_name = {'textbook': 'book', 'pdftextbook': 'pdf_book', 'htmltextbook': 'html_book'}
|
||||
|
||||
self.settings.FEATURES['ENABLE_TEXTBOOK'] = True
|
||||
num_textbooks_found = 0
|
||||
for tab in tabs.CourseTabList.iterate_displayable(self.course, self.settings):
|
||||
# verify all textbook type tabs
|
||||
if isinstance(tab, tabs.SingleTextbookTab):
|
||||
book_type, book_index = tab.tab_id.split("/", 1)
|
||||
expected_link = self.reverse(type_to_reverse_name[book_type], args=[self.course.id, book_index])
|
||||
self.assertEqual(tab.link_func(self.course, self.reverse), expected_link)
|
||||
self.assertTrue(tab.name.startswith('Book{0}:'.format(1 + int(book_index))))
|
||||
num_textbooks_found = num_textbooks_found + 1
|
||||
self.assertEquals(num_textbooks_found, self.num_textbooks)
|
||||
|
||||
def test_textbooks_disabled(self):
|
||||
|
||||
self.settings.FEATURES['ENABLE_TEXTBOOK'] = False
|
||||
tab = tabs.TextbookTabs(self.dict_tab)
|
||||
self.check_can_display_results(tab, for_authenticated_users_only=True, expected_value=False)
|
||||
|
||||
|
||||
class GradingTestCase(TabTestCase):
|
||||
"""Test cases for Grading related Tabs."""
|
||||
|
||||
def check_grading_tab(self, tab_class, name, link_value):
|
||||
"""Helper function for verifying the grading tab."""
|
||||
return self.check_tab(
|
||||
tab_class=tab_class,
|
||||
dict_tab={'type': tab_class.type, 'name': name},
|
||||
expected_name=name,
|
||||
expected_link=self.reverse(link_value, args=[self.course.id]),
|
||||
expected_tab_id=tab_class.type,
|
||||
invalid_dict_tab=None,
|
||||
)
|
||||
|
||||
def test_grading_tabs(self):
|
||||
|
||||
peer_grading_tab = self.check_grading_tab(
|
||||
tabs.PeerGradingTab,
|
||||
'Peer grading',
|
||||
'peer_grading'
|
||||
)
|
||||
self.check_can_display_results(peer_grading_tab, for_authenticated_users_only=True)
|
||||
open_ended_grading_tab = self.check_grading_tab(
|
||||
tabs.OpenEndedGradingTab,
|
||||
'Open Ended Panel',
|
||||
'open_ended_notifications'
|
||||
)
|
||||
self.check_can_display_results(open_ended_grading_tab, for_authenticated_users_only=True)
|
||||
staff_grading_tab = self.check_grading_tab(
|
||||
tabs.StaffGradingTab,
|
||||
'Staff grading',
|
||||
'staff_grading'
|
||||
)
|
||||
self.check_can_display_results(staff_grading_tab, for_staff_only=True)
|
||||
|
||||
|
||||
class NotesTestCase(TabTestCase):
|
||||
"""Test cases for Notes Tab."""
|
||||
|
||||
def check_notes_tab(self):
|
||||
"""Helper function for verifying the notes tab."""
|
||||
return self.check_tab(
|
||||
tab_class=tabs.NotesTab,
|
||||
dict_tab={'type': tabs.NotesTab.type, 'name': 'same'},
|
||||
expected_link=self.reverse('notes', args=[self.course.id]),
|
||||
expected_tab_id=tabs.NotesTab.type,
|
||||
invalid_dict_tab=self.fake_dict_tab,
|
||||
)
|
||||
|
||||
def test_notes_tabs_enabled(self):
|
||||
self.settings.FEATURES['ENABLE_STUDENT_NOTES'] = True
|
||||
tab = self.check_notes_tab()
|
||||
self.check_can_display_results(tab, for_authenticated_users_only=True)
|
||||
|
||||
def test_notes_tabs_disabled(self):
|
||||
self.settings.FEATURES['ENABLE_STUDENT_NOTES'] = False
|
||||
tab = self.check_notes_tab()
|
||||
self.check_can_display_results(tab, expected_value=False)
|
||||
|
||||
|
||||
class SyllabusTestCase(TabTestCase):
|
||||
"""Test cases for Syllabus Tab."""
|
||||
|
||||
def check_syllabus_tab(self, expected_can_display_value):
|
||||
"""Helper function for verifying the syllabus tab."""
|
||||
|
||||
name = 'Syllabus'
|
||||
tab = self.check_tab(
|
||||
tab_class=tabs.SyllabusTab,
|
||||
dict_tab={'type': tabs.SyllabusTab.type, 'name': name},
|
||||
expected_name=name,
|
||||
expected_link=self.reverse('syllabus', args=[self.course.id]),
|
||||
expected_tab_id=tabs.SyllabusTab.type,
|
||||
invalid_dict_tab=None,
|
||||
)
|
||||
self.check_can_display_results(tab, expected_value=expected_can_display_value)
|
||||
|
||||
def test_syllabus_tab_enabled(self):
|
||||
self.course.syllabus_present = True
|
||||
self.check_syllabus_tab(True)
|
||||
|
||||
def test_syllabus_tab_disabled(self):
|
||||
self.course.syllabus_present = False
|
||||
self.check_syllabus_tab(False)
|
||||
|
||||
|
||||
class InstructorTestCase(TabTestCase):
|
||||
"""Test cases for Instructor Tab."""
|
||||
|
||||
def test_instructor_tab(self):
|
||||
name = 'Instructor'
|
||||
tab = self.check_tab(
|
||||
tab_class=tabs.InstructorTab,
|
||||
dict_tab={'type': tabs.InstructorTab.type, 'name': name},
|
||||
expected_name=name,
|
||||
expected_link=self.reverse('instructor_dashboard', args=[self.course.id]),
|
||||
expected_tab_id=tabs.InstructorTab.type,
|
||||
invalid_dict_tab=None,
|
||||
)
|
||||
self.check_can_display_results(tab, for_staff_only=True)
|
||||
|
||||
|
||||
class KeyCheckerTestCase(unittest.TestCase):
|
||||
"""Test cases for KeyChecker class"""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.valid_keys = ['a', 'b']
|
||||
self.invalid_keys = ['a', 'v', 'g']
|
||||
self.dict_value = {'a': 1, 'b': 2, 'c': 3}
|
||||
|
||||
def test_key_checker(self):
|
||||
|
||||
self.assertTrue(tabs.key_checker(self.valid_keys)(self.dict_value, raise_error=False))
|
||||
self.assertFalse(tabs.key_checker(self.invalid_keys)(self.dict_value, raise_error=False))
|
||||
with self.assertRaises(tabs.InvalidTabsException):
|
||||
tabs.key_checker(self.invalid_keys)(self.dict_value)
|
||||
|
||||
|
||||
class NeedNameTestCase(unittest.TestCase):
|
||||
"""Test cases for NeedName validator"""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.valid_dict1 = {'a': 1, 'name': 2}
|
||||
self.valid_dict2 = {'name': 1}
|
||||
self.valid_dict3 = {'a': 1, 'name': 2, 'b': 3}
|
||||
self.invalid_dict = {'a': 1, 'b': 2}
|
||||
|
||||
def test_need_name(self):
|
||||
self.assertTrue(tabs.need_name(self.valid_dict1))
|
||||
self.assertTrue(tabs.need_name(self.valid_dict2))
|
||||
self.assertTrue(tabs.need_name(self.valid_dict3))
|
||||
with self.assertRaises(tabs.InvalidTabsException):
|
||||
tabs.need_name(self.invalid_dict)
|
||||
|
||||
|
||||
class ValidateTabsTestCase(unittest.TestCase):
|
||||
"""Test cases for validating tabs."""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
# invalid tabs
|
||||
self.invalid_tabs = [
|
||||
# less than 2 tabs
|
||||
[{'type': tabs.CoursewareTab.type}],
|
||||
# missing course_info
|
||||
[{'type': tabs.CoursewareTab.type}, {'type': tabs.DiscussionTab.type, 'name': 'fake_name'}],
|
||||
# incorrect order
|
||||
[{'type': tabs.CourseInfoTab.type, 'name': 'fake_name'}, {'type': tabs.CoursewareTab.type}],
|
||||
# invalid type
|
||||
[{'type': tabs.CoursewareTab.type}, {'type': tabs.CourseInfoTab.type, 'name': 'fake_name'}, {'type': 'fake_type'}],
|
||||
]
|
||||
|
||||
# tab types that should appear only once
|
||||
unique_tab_types = [
|
||||
tabs.CourseInfoTab.type,
|
||||
tabs.CoursewareTab.type,
|
||||
tabs.NotesTab.type,
|
||||
tabs.TextbookTabs.type,
|
||||
tabs.PDFTextbookTabs.type,
|
||||
tabs.HtmlTextbookTabs.type,
|
||||
]
|
||||
|
||||
for unique_tab_type in unique_tab_types:
|
||||
self.invalid_tabs.append([
|
||||
{'type': tabs.CoursewareTab.type},
|
||||
{'type': tabs.CourseInfoTab.type, 'name': 'fake_name'},
|
||||
# add the unique tab multiple times
|
||||
{'type': unique_tab_type},
|
||||
{'type': unique_tab_type},
|
||||
])
|
||||
|
||||
# valid tabs
|
||||
self.valid_tabs = [
|
||||
# empty list
|
||||
[],
|
||||
# all valid tabs
|
||||
[
|
||||
{'type': tabs.CoursewareTab.type},
|
||||
{'type': tabs.CourseInfoTab.type, 'name': 'fake_name'},
|
||||
{'type': tabs.WikiTab.type, 'name': 'fake_name'},
|
||||
{'type': tabs.DiscussionTab.type, 'name': 'fake_name'},
|
||||
{'type': tabs.ExternalLinkTab.type, 'name': 'fake_name', 'link': 'fake_link'},
|
||||
{'type': tabs.TextbookTabs.type},
|
||||
{'type': tabs.PDFTextbookTabs.type},
|
||||
{'type': tabs.HtmlTextbookTabs.type},
|
||||
{'type': tabs.ProgressTab.type, 'name': 'fake_name'},
|
||||
{'type': tabs.StaticTab.type, 'name': 'fake_name', 'url_slug': 'schlug'},
|
||||
{'type': tabs.PeerGradingTab.type},
|
||||
{'type': tabs.StaffGradingTab.type},
|
||||
{'type': tabs.OpenEndedGradingTab.type},
|
||||
{'type': tabs.NotesTab.type, 'name': 'fake_name'},
|
||||
{'type': tabs.SyllabusTab.type},
|
||||
],
|
||||
# with external discussion
|
||||
[
|
||||
{'type': tabs.CoursewareTab.type},
|
||||
{'type': tabs.CourseInfoTab.type, 'name': 'fake_name'},
|
||||
{'type': tabs.ExternalDiscussionTab.type, 'name': 'fake_name', 'link': 'fake_link'}
|
||||
],
|
||||
]
|
||||
|
||||
def test_validate_tabs(self):
|
||||
tab_list = tabs.CourseTabList()
|
||||
for invalid_tab_list in self.invalid_tabs:
|
||||
with self.assertRaises(tabs.InvalidTabsException):
|
||||
tab_list.from_json(invalid_tab_list)
|
||||
|
||||
for valid_tab_list in self.valid_tabs:
|
||||
from_json_result = tab_list.from_json(valid_tab_list)
|
||||
self.assertEquals(len(from_json_result), len(valid_tab_list))
|
||||
|
||||
|
||||
class CourseTabListTestCase(TabTestCase):
|
||||
"""Testing the generator method for iterating through displayable tabs"""
|
||||
|
||||
def test_initialize_default_without_syllabus(self):
|
||||
self.course.tabs = []
|
||||
self.course.syllabus_present = False
|
||||
tabs.CourseTabList.initialize_default(self.course)
|
||||
self.assertTrue(tabs.SyllabusTab() not in self.course.tabs)
|
||||
|
||||
def test_initialize_default_with_syllabus(self):
|
||||
self.course.tabs = []
|
||||
self.course.syllabus_present = True
|
||||
tabs.CourseTabList.initialize_default(self.course)
|
||||
self.assertTrue(tabs.SyllabusTab() in self.course.tabs)
|
||||
|
||||
def test_initialize_default_with_external_link(self):
|
||||
self.course.tabs = []
|
||||
self.course.discussion_link = "other_discussion_link"
|
||||
tabs.CourseTabList.initialize_default(self.course)
|
||||
self.assertTrue(tabs.ExternalDiscussionTab(link_value="other_discussion_link") in self.course.tabs)
|
||||
self.assertTrue(tabs.DiscussionTab() not in self.course.tabs)
|
||||
|
||||
def test_initialize_default_without_external_link(self):
|
||||
self.course.tabs = []
|
||||
self.course.discussion_link = ""
|
||||
tabs.CourseTabList.initialize_default(self.course)
|
||||
self.assertTrue(tabs.ExternalDiscussionTab() not in self.course.tabs)
|
||||
self.assertTrue(tabs.DiscussionTab() in self.course.tabs)
|
||||
|
||||
def test_iterate_displayable(self):
|
||||
self.settings.FEATURES['ENABLE_TEXTBOOK'] = True
|
||||
self.course.tabs = [
|
||||
tabs.CoursewareTab(),
|
||||
tabs.CourseInfoTab(),
|
||||
tabs.WikiTab(),
|
||||
]
|
||||
|
||||
for i, tab in enumerate(tabs.CourseTabList.iterate_displayable(
|
||||
self.course,
|
||||
self.settings,
|
||||
include_instructor_tab=True,
|
||||
)):
|
||||
if i == len(self.course.tabs):
|
||||
self.assertEquals(tab.type, tabs.InstructorTab.type)
|
||||
else:
|
||||
self.assertEquals(tab.type, self.course.tabs[i].type)
|
||||
|
||||
|
||||
class DiscussionLinkTestCase(TabTestCase):
|
||||
"""Test cases for discussion link tab."""
|
||||
|
||||
def setUp(self):
|
||||
super(DiscussionLinkTestCase, self).setUp()
|
||||
|
||||
self.tabs_with_discussion = [
|
||||
tabs.CoursewareTab(),
|
||||
tabs.CourseInfoTab(),
|
||||
tabs.DiscussionTab(),
|
||||
tabs.TextbookTabs(),
|
||||
]
|
||||
self.tabs_without_discussion = [
|
||||
tabs.CoursewareTab(),
|
||||
tabs.CourseInfoTab(),
|
||||
tabs.TextbookTabs(),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _reverse(course):
|
||||
"""custom reverse function"""
|
||||
def reverse_discussion_link(viewname, args):
|
||||
"""reverse lookup for discussion link"""
|
||||
if viewname == "django_comment_client.forum.views.forum_form_discussion" and args == [course.id]:
|
||||
return "default_discussion_link"
|
||||
return reverse_discussion_link
|
||||
|
||||
def check_discussion(self, tab_list, expected_discussion_link, expected_can_display_value, discussion_link_in_course=""):
|
||||
"""Helper function to verify whether the discussion tab exists and can be displayed"""
|
||||
self.course.tabs = tab_list
|
||||
self.course.discussion_link = discussion_link_in_course
|
||||
discussion = tabs.CourseTabList.get_discussion(self.course)
|
||||
self.assertEquals(
|
||||
(
|
||||
discussion is not None and
|
||||
discussion.can_display(self.course, self.settings, True, True) and
|
||||
(discussion.link_func(self.course, self._reverse(self.course)) == expected_discussion_link)
|
||||
),
|
||||
expected_can_display_value
|
||||
)
|
||||
|
||||
def test_explicit_discussion_link(self):
|
||||
"""Test that setting discussion_link overrides everything else"""
|
||||
self.settings.FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
|
||||
self.check_discussion(
|
||||
tab_list=self.tabs_with_discussion,
|
||||
discussion_link_in_course="other_discussion_link",
|
||||
expected_discussion_link="other_discussion_link",
|
||||
expected_can_display_value=True,
|
||||
)
|
||||
|
||||
def test_discussions_disabled(self):
|
||||
"""Test that other cases return None with discussions disabled"""
|
||||
self.settings.FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
|
||||
for tab_list in [[], self.tabs_with_discussion, self.tabs_without_discussion]:
|
||||
self.check_discussion(
|
||||
tab_list=tab_list,
|
||||
expected_discussion_link=not None,
|
||||
expected_can_display_value=False,
|
||||
)
|
||||
|
||||
def test_tabs_with_discussion(self):
|
||||
"""Test a course with a discussion tab configured"""
|
||||
self.settings.FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
self.check_discussion(
|
||||
tab_list=self.tabs_with_discussion,
|
||||
expected_discussion_link="default_discussion_link",
|
||||
expected_can_display_value=True,
|
||||
)
|
||||
|
||||
def test_tabs_without_discussion(self):
|
||||
"""Test a course with tabs configured but without a discussion tab"""
|
||||
self.settings.FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
self.check_discussion(
|
||||
tab_list=self.tabs_without_discussion,
|
||||
expected_discussion_link=not None,
|
||||
expected_can_display_value=False,
|
||||
)
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
Static Pages page for a course.
|
||||
Pages page for a course.
|
||||
"""
|
||||
|
||||
from .course_page import CoursePage
|
||||
|
||||
|
||||
class StaticPagesPage(CoursePage):
|
||||
class PagesPage(CoursePage):
|
||||
"""
|
||||
Static Pages page for a course.
|
||||
Pages page for a course.
|
||||
"""
|
||||
|
||||
url_path = "tabs"
|
||||
|
||||
@@ -21,7 +21,7 @@ class UnitPage(PageObject):
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""URL to the static pages UI in a course."""
|
||||
"""URL to the pages UI in a course."""
|
||||
return "{}/unit/{}".format(BASE_URL, self.unit_locator)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
|
||||
@@ -10,7 +10,7 @@ from ..pages.studio.auto_auth import AutoAuthPage
|
||||
from ..pages.studio.checklists import ChecklistsPage
|
||||
from ..pages.studio.course_import import ImportPage
|
||||
from ..pages.studio.course_info import CourseUpdatesPage
|
||||
from ..pages.studio.edit_tabs import StaticPagesPage
|
||||
from ..pages.studio.edit_tabs import PagesPage
|
||||
from ..pages.studio.export import ExportPage
|
||||
from ..pages.studio.howitworks import HowitworksPage
|
||||
from ..pages.studio.index import DashboardPage
|
||||
@@ -93,7 +93,7 @@ class CoursePagesTest(UniqueCourseTest):
|
||||
clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'])
|
||||
for clz in [
|
||||
AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage,
|
||||
StaticPagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage,
|
||||
PagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage,
|
||||
AdvancedSettingsPage, GradingPage, TextbooksPage
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
"""
|
||||
Tabs configuration. By the time the tab is being rendered, it's just a name,
|
||||
link, and css class (CourseTab tuple). Tabs are specified in course policy.
|
||||
Each tab has a type, and possibly some type-specific parameters.
|
||||
|
||||
To add a new tab type, add a TabImpl to the VALID_TAB_TYPES dict below--it will
|
||||
contain a validation function that checks whether config for the tab type is
|
||||
valid, and a generator function that takes the config, user, and course, and
|
||||
actually generates the CourseTab.
|
||||
"""
|
||||
|
||||
from collections import namedtuple
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from courseware.access import has_access
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.module_render import get_module
|
||||
from open_ended_grading import open_ended_notifications
|
||||
|
||||
import waffle
|
||||
|
||||
# We only need to scrape strings for i18n in this file, since ugettext is
|
||||
# called on them in the template:
|
||||
# https://github.com/edx/edx-platform/blob/master/lms/templates/courseware/course_navigation.html#L29
|
||||
_ = lambda text: text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidTabsException(Exception):
|
||||
"""
|
||||
A complaint about invalid tabs.
|
||||
"""
|
||||
pass
|
||||
|
||||
CourseTabBase = namedtuple('CourseTab', 'name link is_active has_img img')
|
||||
|
||||
|
||||
def CourseTab(name, link, is_active, has_img=False, img=""):
|
||||
return CourseTabBase(name, link, is_active, has_img, img)
|
||||
|
||||
# encapsulate implementation for a tab:
|
||||
# - a validation function: takes the config dict and raises
|
||||
# InvalidTabsException if required fields are missing or otherwise
|
||||
# wrong. (e.g. "is there a 'name' field?). Validators can assume
|
||||
# that the type field is valid.
|
||||
#
|
||||
# - a function that takes a config, a user, and a course, an active_page and
|
||||
# return a list of CourseTabs. (e.g. "return a CourseTab with specified
|
||||
# name, link to courseware, and is_active=True/False"). The function can
|
||||
# assume that it is only called with configs of the appropriate type that
|
||||
# have passed the corresponding validator.
|
||||
TabImpl = namedtuple('TabImpl', 'validator generator')
|
||||
|
||||
|
||||
##### Generators for various tabs.
|
||||
def _courseware(tab, user, course, active_page, request):
|
||||
"""
|
||||
This returns a tab containing the course content.
|
||||
"""
|
||||
link = reverse('courseware', args=[course.id])
|
||||
if waffle.flag_is_active(request, 'merge_course_tabs'):
|
||||
# Translators: 'Course Content' refers to the tab in the courseware
|
||||
# that leads to the content of a course
|
||||
return [CourseTab(_('Course Content'), link, active_page == "courseware")]
|
||||
else:
|
||||
# Translators: 'Courseware' refers to the tab in the courseware
|
||||
# that leads to the content of a course
|
||||
return [CourseTab(_('Courseware'), link, active_page == "courseware")]
|
||||
|
||||
|
||||
def _course_info(tab, user, course, active_page, request):
|
||||
"""
|
||||
This returns a tab containing information about the course.
|
||||
"""
|
||||
link = reverse('info', args=[course.id])
|
||||
return [CourseTab(tab['name'], link, active_page == "info")]
|
||||
|
||||
|
||||
def _progress(tab, user, course, active_page, request):
|
||||
"""
|
||||
This returns a tab containing information about the authenticated user's progress.
|
||||
"""
|
||||
if user.is_authenticated():
|
||||
link = reverse('progress', args=[course.id])
|
||||
return [CourseTab(tab['name'], link, active_page == "progress")]
|
||||
return []
|
||||
|
||||
|
||||
def _wiki(tab, user, course, active_page, request):
|
||||
"""
|
||||
This returns a tab containing the course wiki.
|
||||
"""
|
||||
if settings.WIKI_ENABLED:
|
||||
link = reverse('course_wiki', args=[course.id])
|
||||
return [CourseTab(tab['name'], link, active_page == 'wiki')]
|
||||
return []
|
||||
|
||||
|
||||
def _discussion(tab, user, course, active_page, request):
|
||||
"""
|
||||
This tab format only supports the new Berkeley discussion forums.
|
||||
"""
|
||||
if settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
|
||||
link = reverse('django_comment_client.forum.views.forum_form_discussion',
|
||||
args=[course.id])
|
||||
return [CourseTab(tab['name'], link, active_page == 'discussion')]
|
||||
return []
|
||||
|
||||
|
||||
def _external_discussion(tab, user, course, active_page, request):
|
||||
"""
|
||||
This returns a tab that links to an external discussion service
|
||||
"""
|
||||
# Translators: 'Discussion' refers to the tab in the courseware
|
||||
# that leads to the discussion forums
|
||||
return [CourseTab(_('Discussion'), tab['link'], active_page == 'discussion')]
|
||||
|
||||
|
||||
def _external_link(tab, user, course, active_page, request):
|
||||
# external links are never active
|
||||
return [CourseTab(tab['name'], tab['link'], False)]
|
||||
|
||||
|
||||
def _static_tab(tab, user, course, active_page, request):
|
||||
link = reverse('static_tab', args=[course.id, tab['url_slug']])
|
||||
active_str = 'static_tab_{0}'.format(tab['url_slug'])
|
||||
return [CourseTab(tab['name'], link, active_page == active_str)]
|
||||
|
||||
|
||||
def _textbooks(tab, user, course, active_page, request):
|
||||
"""
|
||||
Generates one tab per textbook. Only displays if user is authenticated.
|
||||
"""
|
||||
if user.is_authenticated() and settings.FEATURES.get('ENABLE_TEXTBOOK'):
|
||||
# since there can be more than one textbook, active_page is e.g. "book/0".
|
||||
return [CourseTab(textbook.title, reverse('book', args=[course.id, index]),
|
||||
active_page == "textbook/{0}".format(index))
|
||||
for index, textbook in enumerate(course.textbooks)]
|
||||
return []
|
||||
|
||||
|
||||
def _pdf_textbooks(tab, user, course, active_page, request):
|
||||
"""
|
||||
Generates one tab per textbook. Only displays if user is authenticated.
|
||||
"""
|
||||
if user.is_authenticated():
|
||||
# since there can be more than one textbook, active_page is e.g. "book/0".
|
||||
return [CourseTab(textbook['tab_title'], reverse('pdf_book', args=[course.id, index]),
|
||||
active_page == "pdftextbook/{0}".format(index))
|
||||
for index, textbook in enumerate(course.pdf_textbooks)]
|
||||
return []
|
||||
|
||||
|
||||
def _html_textbooks(tab, user, course, active_page, request):
|
||||
"""
|
||||
Generates one tab per textbook. Only displays if user is authenticated.
|
||||
"""
|
||||
if user.is_authenticated():
|
||||
# since there can be more than one textbook, active_page is e.g. "book/0".
|
||||
return [CourseTab(textbook['tab_title'], reverse('html_book', args=[course.id, index]),
|
||||
active_page == "htmltextbook/{0}".format(index))
|
||||
for index, textbook in enumerate(course.html_textbooks)]
|
||||
return []
|
||||
|
||||
|
||||
def _staff_grading(tab, user, course, active_page, request):
|
||||
if has_access(user, course, 'staff'):
|
||||
link = reverse('staff_grading', args=[course.id])
|
||||
|
||||
# Translators: "Staff grading" appears on a tab that allows
|
||||
# staff to view openended problems that require staff grading
|
||||
tab_name = _("Staff grading")
|
||||
|
||||
notifications = open_ended_notifications.staff_grading_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
|
||||
def _syllabus(tab, user, course, active_page, request):
|
||||
"""Display the syllabus tab"""
|
||||
link = reverse('syllabus', args=[course.id])
|
||||
return [CourseTab(_('Syllabus'), link, active_page == 'syllabus')]
|
||||
|
||||
|
||||
def _peer_grading(tab, user, course, active_page, request):
|
||||
if user.is_authenticated():
|
||||
link = reverse('peer_grading', args=[course.id])
|
||||
|
||||
# Translators: "Peer grading" appears on a tab that allows
|
||||
# students to view openended problems that require grading
|
||||
tab_name = _("Peer grading")
|
||||
|
||||
notifications = open_ended_notifications.peer_grading_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
|
||||
def _combined_open_ended_grading(tab, user, course, active_page, request):
|
||||
if user.is_authenticated():
|
||||
link = reverse('open_ended_notifications', args=[course.id])
|
||||
|
||||
# Translators: "Open Ended Panel" appears on a tab that, when clicked,
|
||||
# opens up a panel that displays information about openended problems
|
||||
# that a user has submitted or needs to grade
|
||||
tab_name = _("Open Ended Panel")
|
||||
|
||||
notifications = open_ended_notifications.combined_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "open_ended", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
|
||||
def _notes_tab(tab, user, course, active_page, request):
|
||||
if user.is_authenticated() and settings.FEATURES.get('ENABLE_STUDENT_NOTES'):
|
||||
link = reverse('notes', args=[course.id])
|
||||
return [CourseTab(tab['name'], link, active_page == 'notes')]
|
||||
return []
|
||||
|
||||
def _instructor(course, active_page):
|
||||
link = reverse('instructor_dashboard', args=[course.id])
|
||||
# Translators: 'Instructor' appears on the tab that leads to
|
||||
# the instructor dashboard, which is a portal where an instructor
|
||||
# can get data and perform various actions on their course
|
||||
return CourseTab(_('Instructor'), link, active_page == 'instructor')
|
||||
|
||||
#### Validators
|
||||
def key_checker(expected_keys):
|
||||
"""
|
||||
Returns a function that checks that specified keys are present in a dict
|
||||
"""
|
||||
def check(dictionary):
|
||||
for key in expected_keys:
|
||||
if key not in dictionary:
|
||||
raise InvalidTabsException(
|
||||
"Key {0} not present in {1}".format(key, dictionary)
|
||||
)
|
||||
return check
|
||||
|
||||
|
||||
need_name = key_checker(['name'])
|
||||
|
||||
|
||||
def null_validator(d):
|
||||
"""
|
||||
Don't check anything--use for tabs that don't need any params. (e.g. textbook)
|
||||
"""
|
||||
pass
|
||||
|
||||
##### The main tab config dict.
|
||||
|
||||
# type -> TabImpl
|
||||
VALID_TAB_TYPES = {
|
||||
'courseware': TabImpl(null_validator, _courseware),
|
||||
'course_info': TabImpl(need_name, _course_info),
|
||||
'wiki': TabImpl(need_name, _wiki),
|
||||
'discussion': TabImpl(need_name, _discussion),
|
||||
'external_discussion': TabImpl(key_checker(['link']), _external_discussion),
|
||||
'external_link': TabImpl(key_checker(['name', 'link']), _external_link),
|
||||
'textbooks': TabImpl(null_validator, _textbooks),
|
||||
'pdf_textbooks': TabImpl(null_validator, _pdf_textbooks),
|
||||
'html_textbooks': TabImpl(null_validator, _html_textbooks),
|
||||
'progress': TabImpl(need_name, _progress),
|
||||
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
|
||||
'peer_grading': TabImpl(null_validator, _peer_grading),
|
||||
'staff_grading': TabImpl(null_validator, _staff_grading),
|
||||
'open_ended': TabImpl(null_validator, _combined_open_ended_grading),
|
||||
'notes': TabImpl(null_validator, _notes_tab),
|
||||
'syllabus': TabImpl(null_validator, _syllabus)
|
||||
}
|
||||
|
||||
|
||||
### External interface below this.
|
||||
|
||||
def validate_tabs(course):
|
||||
"""
|
||||
Check that the tabs set for the specified course is valid. If it
|
||||
isn't, raise InvalidTabsException with the complaint.
|
||||
|
||||
Specific rules checked:
|
||||
- if no tabs specified, that's fine
|
||||
- if tabs specified, first two must have type 'courseware' and 'course_info', in that order.
|
||||
- All the tabs must have a type in VALID_TAB_TYPES.
|
||||
|
||||
"""
|
||||
tabs = course.tabs
|
||||
if tabs is None:
|
||||
return
|
||||
|
||||
if len(tabs) < 2:
|
||||
raise InvalidTabsException("Expected at least two tabs. tabs: '{0}'".format(tabs))
|
||||
|
||||
if tabs[0]['type'] != 'courseware':
|
||||
raise InvalidTabsException(
|
||||
"Expected first tab to have type 'courseware'. tabs: '{0}'".format(tabs))
|
||||
|
||||
if tabs[1]['type'] != 'course_info':
|
||||
raise InvalidTabsException(
|
||||
"Expected second tab to have type 'course_info'. tabs: '{0}'".format(tabs))
|
||||
|
||||
for t in tabs:
|
||||
if t['type'] not in VALID_TAB_TYPES:
|
||||
raise InvalidTabsException("Unknown tab type {0}. Known types: {1}"
|
||||
.format(t['type'], VALID_TAB_TYPES))
|
||||
# the type-specific validator checks the rest of the tab config
|
||||
VALID_TAB_TYPES[t['type']].validator(t)
|
||||
|
||||
# Possible other checks: make sure tabs that should only appear once (e.g. courseware)
|
||||
# are actually unique (otherwise, will break active tag code)
|
||||
|
||||
|
||||
def get_course_tabs(user, course, active_page, request):
|
||||
"""
|
||||
Return the tabs to show a particular user, as a list of CourseTab items.
|
||||
"""
|
||||
if not hasattr(course, 'tabs') or not course.tabs:
|
||||
return get_default_tabs(user, course, active_page, request)
|
||||
|
||||
# TODO (vshnayder): There needs to be a place to call this right after course
|
||||
# load, but not from inside xmodule, since that doesn't (and probably
|
||||
# shouldn't) know about the details of what tabs are supported, etc.
|
||||
validate_tabs(course)
|
||||
|
||||
tabs = []
|
||||
|
||||
if waffle.flag_is_active(request, 'merge_course_tabs'):
|
||||
course_tabs = [tab for tab in course.tabs if tab['type'] != "course_info"]
|
||||
else:
|
||||
course_tabs = course.tabs
|
||||
|
||||
for tab in course_tabs:
|
||||
# expect handlers to return lists--handles things that are turned off
|
||||
# via feature flags, and things like 'textbook' which might generate
|
||||
# multiple tabs.
|
||||
gen = VALID_TAB_TYPES[tab['type']].generator
|
||||
tabs.extend(gen(tab, user, course, active_page, request))
|
||||
|
||||
# Instructor tab is special--automatically added if user is staff for the course
|
||||
if has_access(user, course, 'staff'):
|
||||
tabs.append(_instructor(course, active_page))
|
||||
|
||||
return tabs
|
||||
|
||||
|
||||
def get_discussion_link(course):
|
||||
"""
|
||||
Return the URL for the discussion tab for the given `course`.
|
||||
|
||||
If they have a discussion link specified, use that even if we disable
|
||||
discussions. Disabling discussions is mostly a server safety feature at
|
||||
this point, and we don't need to worry about external sites. Otherwise,
|
||||
if the course has a discussion tab or uses the default tabs, return the
|
||||
discussion view URL. Otherwise, return None to indicate the lack of a
|
||||
discussion tab.
|
||||
"""
|
||||
if course.discussion_link:
|
||||
return course.discussion_link
|
||||
elif not settings.FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
|
||||
return None
|
||||
elif hasattr(course, 'tabs') and course.tabs and not any([tab['type'] == 'discussion' for tab in course.tabs]):
|
||||
return None
|
||||
else:
|
||||
return reverse('django_comment_client.forum.views.forum_form_discussion', args=[course.id])
|
||||
|
||||
|
||||
def get_default_tabs(user, course, active_page, request):
|
||||
"""
|
||||
Return the default set of tabs.
|
||||
"""
|
||||
# When calling the various _tab methods, can omit the 'type':'blah' from the
|
||||
# first arg, since that's only used for dispatch
|
||||
tabs = []
|
||||
|
||||
tabs.extend(_courseware({''}, user, course, active_page, request))
|
||||
|
||||
if not waffle.flag_is_active(request, 'merge_course_tabs'):
|
||||
tabs.extend(_course_info({'name': 'Course Info'}, user, course, active_page, request))
|
||||
|
||||
if hasattr(course, 'syllabus_present') and course.syllabus_present:
|
||||
link = reverse('syllabus', args=[course.id])
|
||||
tabs.append(CourseTab('Syllabus', link, active_page == 'syllabus'))
|
||||
|
||||
tabs.extend(_textbooks({}, user, course, active_page, request))
|
||||
|
||||
discussion_link = get_discussion_link(course)
|
||||
if discussion_link:
|
||||
tabs.append(CourseTab('Discussion', discussion_link, active_page == 'discussion'))
|
||||
|
||||
tabs.extend(_wiki({'name': 'Wiki', 'type': 'wiki'}, user, course, active_page, request))
|
||||
|
||||
if user.is_authenticated() and not course.hide_progress_tab:
|
||||
tabs.extend(_progress({'name': 'Progress'}, user, course, active_page, request))
|
||||
|
||||
if has_access(user, course, 'staff'):
|
||||
tabs.append(_instructor(course, active_page))
|
||||
|
||||
return tabs
|
||||
|
||||
|
||||
def get_static_tab_by_slug(course, tab_slug):
|
||||
"""
|
||||
Look for a tab with type 'static_tab' and the specified 'tab_slug'. Returns
|
||||
the tab (a config dict), or None if not found.
|
||||
"""
|
||||
if course.tabs is None:
|
||||
return None
|
||||
for tab in course.tabs:
|
||||
# The validation code checks that these exist.
|
||||
if tab['type'] == 'static_tab' and tab['url_slug'] == tab_slug:
|
||||
return tab
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_static_tab_contents(request, course, tab):
|
||||
loc = Location(course.location.tag, course.location.org, course.location.course, 'static_tab', tab['url_slug'])
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(course.id,
|
||||
request.user, modulestore().get_instance(course.id, loc), depth=0)
|
||||
tab_module = get_module(request.user, request, loc, field_data_cache, course.id,
|
||||
static_asset_path=course.static_asset_path)
|
||||
|
||||
logging.debug('course_module = {0}'.format(tab_module))
|
||||
|
||||
html = ''
|
||||
|
||||
if tab_module is not None:
|
||||
try:
|
||||
html = tab_module.render('student_view').content
|
||||
except Exception: # pylint: disable=broad-except
|
||||
html = render_to_string('courseware/error-message.html', None)
|
||||
log.exception("Error rendering course={course}, tab={tab_url}".format(
|
||||
course=course,
|
||||
tab_url=tab['url_slug']
|
||||
))
|
||||
|
||||
return html
|
||||
@@ -1,176 +1,26 @@
|
||||
from django.test import TestCase
|
||||
"""
|
||||
Test cases for tabs.
|
||||
"""
|
||||
from mock import MagicMock, Mock, patch
|
||||
|
||||
from courseware import tabs
|
||||
from courseware.courses import get_course_by_id
|
||||
from courseware.views import get_static_tab_contents
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.tabs import CourseTabList
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
|
||||
|
||||
FAKE_REQUEST = None
|
||||
|
||||
def tab_constructor(active_page, course, user, tab={'name': 'same'}, generator=tabs._progress):
|
||||
return generator(tab, user, course, active_page, FAKE_REQUEST)
|
||||
|
||||
class ProgressTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.user = MagicMock()
|
||||
self.anonymous_user = MagicMock()
|
||||
self.course = MagicMock()
|
||||
self.user.is_authenticated.return_value = True
|
||||
self.anonymous_user.is_authenticated.return_value = False
|
||||
self.course.id = 'edX/toy/2012_Fall'
|
||||
self.tab = {'name': 'same'}
|
||||
self.progress_page = 'progress'
|
||||
self.stagnation_page = 'stagnation'
|
||||
|
||||
def test_progress(self):
|
||||
|
||||
self.assertEqual(tab_constructor(self.stagnation_page, self.course, self.anonymous_user), [])
|
||||
|
||||
self.assertEqual(tab_constructor(self.progress_page, self.course, self.user)[0].name, 'same')
|
||||
|
||||
tab_list = tab_constructor(self.progress_page, self.course, self.user)
|
||||
expected_link = reverse('progress', args=[self.course.id])
|
||||
self.assertEqual(tab_list[0].link, expected_link)
|
||||
|
||||
self.assertEqual(tab_constructor(self.stagnation_page, self.course, self.user)[0].is_active, False)
|
||||
|
||||
self.assertEqual(tab_constructor(self.progress_page, self.course, self.user)[0].is_active, True)
|
||||
|
||||
|
||||
class WikiTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.user = MagicMock()
|
||||
self.course = MagicMock()
|
||||
self.course.id = 'edX/toy/2012_Fall'
|
||||
self.tab = {'name': 'same'}
|
||||
self.wiki_page = 'wiki'
|
||||
self.miki_page = 'miki'
|
||||
|
||||
@override_settings(WIKI_ENABLED=True)
|
||||
def test_wiki_enabled(self):
|
||||
|
||||
tab_list = tab_constructor(self.wiki_page, self.course, self.user, generator=tabs._wiki)
|
||||
self.assertEqual(tab_list[0].name, 'same')
|
||||
|
||||
tab_list = tab_constructor(self.wiki_page, self.course, self.user, generator=tabs._wiki)
|
||||
expected_link = reverse('course_wiki', args=[self.course.id])
|
||||
self.assertEqual(tab_list[0].link, expected_link)
|
||||
|
||||
tab_list = tab_constructor(self.wiki_page, self.course, self.user, generator=tabs._wiki)
|
||||
self.assertEqual(tab_list[0].is_active, True)
|
||||
|
||||
tab_list = tab_constructor(self.miki_page, self.course, self.user, generator=tabs._wiki)
|
||||
self.assertEqual(tab_list[0].is_active, False)
|
||||
|
||||
@override_settings(WIKI_ENABLED=False)
|
||||
def test_wiki_enabled_false(self):
|
||||
|
||||
tab_list = tab_constructor(self.wiki_page, self.course, self.user, generator=tabs._wiki)
|
||||
self.assertEqual(tab_list, [])
|
||||
|
||||
|
||||
class ExternalLinkTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.user = MagicMock()
|
||||
self.course = MagicMock()
|
||||
self.tabby = {'name': 'same', 'link': 'blink'}
|
||||
self.no_page = None
|
||||
self.true = True
|
||||
|
||||
def test_external_link(self):
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.no_page, self.course, self.user, tab=self.tabby, generator=tabs._external_link
|
||||
)
|
||||
self.assertEqual(tab_list[0].name, 'same')
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.no_page, self.course, self.user, tab=self.tabby, generator=tabs._external_link
|
||||
)
|
||||
self.assertEqual(tab_list[0].link, 'blink')
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.no_page, self.course, self.user, tab=self.tabby, generator=tabs._external_link
|
||||
)
|
||||
self.assertEqual(tab_list[0].is_active, False)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.true, self.course, self.user, tab=self.tabby, generator=tabs._external_link
|
||||
)
|
||||
self.assertEqual(tab_list[0].is_active, False)
|
||||
|
||||
|
||||
class StaticTabTestCase(ModuleStoreTestCase):
|
||||
"""Tests for static tabs."""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.user = MagicMock()
|
||||
self.course = MagicMock()
|
||||
self.tabby = {'name': 'same', 'url_slug': 'schmug'}
|
||||
self.course.id = 'edX/toy/2012_Fall'
|
||||
self.schmug = 'static_tab_schmug'
|
||||
self.schlug = 'static_tab_schlug'
|
||||
|
||||
def test_static_tab(self):
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.schmug, self.course, self.user, tab=self.tabby, generator=tabs._static_tab
|
||||
)
|
||||
self.assertEqual(tab_list[0].name, 'same')
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.schmug, self.course, self.user, tab=self.tabby, generator=tabs._static_tab
|
||||
)
|
||||
expected_link = reverse('static_tab', args=[self.course.id,self.tabby['url_slug']])
|
||||
self.assertEqual(tab_list[0].link, expected_link)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.schmug, self.course, self.user, tab=self.tabby, generator=tabs._static_tab
|
||||
)
|
||||
self.assertEqual(tab_list[0].is_active, True)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.schlug, self.course, self.user, tab=self.tabby, generator=tabs._static_tab
|
||||
)
|
||||
self.assertEqual(tab_list[0].is_active, False)
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
def test_get_static_tab_contents(self):
|
||||
course = get_course_by_id('edX/toy/2012_Fall')
|
||||
request = get_request_for_user(UserFactory.create())
|
||||
tab = tabs.get_static_tab_by_slug(course, 'resources')
|
||||
|
||||
# Test render works okay
|
||||
tab_content = tabs.get_static_tab_contents(request, course, tab)
|
||||
self.assertIn('edX/toy/2012_Fall', tab_content)
|
||||
self.assertIn('static_tab', tab_content)
|
||||
|
||||
# Test when render raises an exception
|
||||
with patch('courseware.tabs.get_module') as mock_module_render:
|
||||
mock_module_render.return_value = MagicMock(
|
||||
render=Mock(side_effect=Exception('Render failed!'))
|
||||
)
|
||||
static_tab = tabs.get_static_tab_contents(request, course, tab)
|
||||
self.assertIn("this module is temporarily unavailable", static_tab)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class StaticTabDateTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
|
||||
"""Test cases for Static Tab Dates."""
|
||||
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create()
|
||||
self.page = ItemFactory.create(
|
||||
@@ -191,6 +41,25 @@ class StaticTabDateTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn("OOGIE BLOOGIE", resp.content)
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
def test_get_static_tab_contents(self):
|
||||
course = get_course_by_id('edX/toy/2012_Fall')
|
||||
request = get_request_for_user(UserFactory.create())
|
||||
tab = CourseTabList.get_tab_by_slug(course, 'resources')
|
||||
|
||||
# Test render works okay
|
||||
tab_content = get_static_tab_contents(request, course, tab)
|
||||
self.assertIn('edX/toy/2012_Fall', tab_content)
|
||||
self.assertIn('static_tab', tab_content)
|
||||
|
||||
# Test when render raises an exception
|
||||
with patch('courseware.views.get_module') as mock_module_render:
|
||||
mock_module_render.return_value = MagicMock(
|
||||
render=Mock(side_effect=Exception('Render failed!'))
|
||||
)
|
||||
static_tab = get_static_tab_contents(request, course, tab)
|
||||
self.assertIn("this module is temporarily unavailable", static_tab)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class StaticTabDateTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
|
||||
@@ -219,194 +88,3 @@ class StaticTabDateTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(self.xml_data, resp.content)
|
||||
|
||||
|
||||
class TextbooksTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.user = MagicMock()
|
||||
self.anonymous_user = MagicMock()
|
||||
self.course = MagicMock()
|
||||
self.tab = MagicMock()
|
||||
A = MagicMock()
|
||||
T = MagicMock()
|
||||
A.title = 'Algebra'
|
||||
T.title = 'Topology'
|
||||
self.course.textbooks = [A, T]
|
||||
self.user.is_authenticated.return_value = True
|
||||
self.anonymous_user.is_authenticated.return_value = False
|
||||
self.course.id = 'edX/toy/2012_Fall'
|
||||
self.textbook_0 = 'textbook/0'
|
||||
self.textbook_1 = 'textbook/1'
|
||||
self.prohibited_page = 'you_shouldnt_be_seein_this'
|
||||
|
||||
@override_settings(FEATURES={'ENABLE_TEXTBOOK': True})
|
||||
def test_textbooks1(self):
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.textbook_0, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list[0].name, 'Algebra')
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.textbook_0, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
expected_link = reverse('book', args=[self.course.id, 0])
|
||||
self.assertEqual(tab_list[0].link, expected_link)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.textbook_0, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list[0].is_active, True)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.prohibited_page, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list[0].is_active, False)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.textbook_1, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list[1].name, 'Topology')
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.textbook_1, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
expected_link = reverse('book', args=[self.course.id, 1])
|
||||
self.assertEqual(tab_list[1].link, expected_link)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.textbook_1, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list[1].is_active, True)
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.prohibited_page, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list[1].is_active, False)
|
||||
|
||||
@override_settings(FEATURES={'ENABLE_TEXTBOOK': False})
|
||||
def test_textbooks0(self):
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.prohibited_page, self.course, self.user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list, [])
|
||||
|
||||
tab_list = tab_constructor(
|
||||
self.prohibited_page, self.course, self.anonymous_user, tab=self.tab, generator=tabs._textbooks
|
||||
)
|
||||
self.assertEqual(tab_list, [])
|
||||
|
||||
|
||||
class KeyCheckerTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.valid_keys = ['a', 'b']
|
||||
self.invalid_keys = ['a', 'v', 'g']
|
||||
self.dictio = {'a': 1, 'b': 2, 'c': 3}
|
||||
|
||||
def test_key_checker(self):
|
||||
|
||||
self.assertIsNone(tabs.key_checker(self.valid_keys)(self.dictio))
|
||||
self.assertRaises(tabs.InvalidTabsException,
|
||||
tabs.key_checker(self.invalid_keys), self.dictio)
|
||||
|
||||
|
||||
class NullValidatorTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.dummy = {}
|
||||
|
||||
def test_null_validator(self):
|
||||
self.assertIsNone(tabs.null_validator(self.dummy))
|
||||
|
||||
|
||||
class ValidateTabsTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.courses = [MagicMock() for i in range(0, 5)]
|
||||
|
||||
self.courses[0].tabs = None
|
||||
|
||||
self.courses[1].tabs = [{'type': 'courseware'}, {'type': 'fax'}]
|
||||
|
||||
self.courses[2].tabs = [{'type': 'shadow'}, {'type': 'course_info'}]
|
||||
|
||||
self.courses[3].tabs = [{'type': 'courseware'}, {'type': 'course_info', 'name': 'alice'},
|
||||
{'type': 'wiki', 'name': 'alice'}, {'type': 'discussion', 'name': 'alice'},
|
||||
{'type': 'external_link', 'name': 'alice', 'link': 'blink'},
|
||||
{'type': 'textbooks'}, {'type': 'progress', 'name': 'alice'},
|
||||
{'type': 'static_tab', 'name': 'alice', 'url_slug': 'schlug'},
|
||||
{'type': 'staff_grading'}]
|
||||
|
||||
self.courses[4].tabs = [{'type': 'courseware'}, {'type': 'course_info'}, {'type': 'flying'}]
|
||||
|
||||
def test_validate_tabs(self):
|
||||
self.assertIsNone(tabs.validate_tabs(self.courses[0]))
|
||||
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[1])
|
||||
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2])
|
||||
self.assertIsNone(tabs.validate_tabs(self.courses[3]))
|
||||
self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4])
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class DiscussionLinkTestCase(ModuleStoreTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.tabs_with_discussion = [
|
||||
{'type': 'courseware'},
|
||||
{'type': 'course_info'},
|
||||
{'type': 'discussion'},
|
||||
{'type': 'textbooks'},
|
||||
]
|
||||
self.tabs_without_discussion = [
|
||||
{'type': 'courseware'},
|
||||
{'type': 'course_info'},
|
||||
{'type': 'textbooks'},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _patch_reverse(course):
|
||||
def patched_reverse(viewname, args):
|
||||
if viewname == "django_comment_client.forum.views.forum_form_discussion" and args == [course.id]:
|
||||
return "default_discussion_link"
|
||||
else:
|
||||
return None
|
||||
return patch("courseware.tabs.reverse", patched_reverse)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": False})
|
||||
def test_explicit_discussion_link(self):
|
||||
"""Test that setting discussion_link overrides everything else"""
|
||||
course = CourseFactory.create(discussion_link="other_discussion_link", tabs=self.tabs_with_discussion)
|
||||
self.assertEqual(tabs.get_discussion_link(course), "other_discussion_link")
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": False})
|
||||
def test_discussions_disabled(self):
|
||||
"""Test that other cases return None with discussions disabled"""
|
||||
for i, t in enumerate([None, self.tabs_with_discussion, self.tabs_without_discussion]):
|
||||
course = CourseFactory.create(tabs=t, number=str(i))
|
||||
self.assertEqual(tabs.get_discussion_link(course), None)
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def test_no_tabs(self):
|
||||
"""Test a course without tabs configured"""
|
||||
course = CourseFactory.create(tabs=None)
|
||||
with self._patch_reverse(course):
|
||||
self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link")
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def test_tabs_with_discussion(self):
|
||||
"""Test a course with a discussion tab configured"""
|
||||
course = CourseFactory.create(tabs=self.tabs_with_discussion)
|
||||
with self._patch_reverse(course):
|
||||
self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link")
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def test_tabs_without_discussion(self):
|
||||
"""Test a course with tabs configured but without a discussion tab"""
|
||||
course = CourseFactory.create(tabs=self.tabs_without_discussion)
|
||||
self.assertEqual(tabs.get_discussion_link(course), None)
|
||||
|
||||
@@ -20,13 +20,13 @@ from markupsafe import escape
|
||||
from courseware import grades
|
||||
from courseware.access import has_access
|
||||
from courseware.courses import get_courses, get_course_with_access, sort_by_announcement
|
||||
import courseware.tabs as tabs
|
||||
from courseware.masquerade import setup_masquerade
|
||||
from courseware.model_data import FieldDataCache
|
||||
from .module_render import toc_for_course, get_module_for_descriptor
|
||||
from .module_render import toc_for_course, get_module_for_descriptor, get_module
|
||||
from courseware.models import StudentModule, StudentModuleHistory
|
||||
from course_modes.models import CourseMode
|
||||
|
||||
from open_ended_grading import open_ended_notifications
|
||||
from student.models import UserTestGroup, CourseEnrollment
|
||||
from student.views import course_from_id, single_course_reverification_info
|
||||
from util.cache import cache, cache_if_anonymous
|
||||
@@ -36,6 +36,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.tabs import CourseTabList, StaffGradingTab, PeerGradingTab, OpenEndedGradingTab
|
||||
import shoppingcart
|
||||
|
||||
from microsite_configuration import microsite
|
||||
@@ -96,10 +97,12 @@ def render_accordion(request, course, chapter, section, field_data_cache):
|
||||
request.user = user # keep just one instance of User
|
||||
toc = toc_for_course(user, request, course, chapter, section, field_data_cache)
|
||||
|
||||
context = dict([('toc', toc),
|
||||
('course_id', course.id),
|
||||
('csrf', csrf(request)['csrf_token']),
|
||||
('due_date_display_format', course.due_date_display_format)] + template_imports.items())
|
||||
context = dict([
|
||||
('toc', toc),
|
||||
('course_id', course.id),
|
||||
('csrf', csrf(request)['csrf_token']),
|
||||
('due_date_display_format', course.due_date_display_format)
|
||||
] + template_imports.items())
|
||||
return render_to_string('courseware/accordion.html', context)
|
||||
|
||||
|
||||
@@ -267,7 +270,7 @@ def index(request, course_id, chapter=None, section=None,
|
||||
'masquerade': masq,
|
||||
'xqa_server': settings.FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'),
|
||||
'reverifications': fetch_reverify_banner_info(request, course_id),
|
||||
}
|
||||
}
|
||||
|
||||
# Only show the chat if it's enabled by the course and in the
|
||||
# settings.
|
||||
@@ -359,19 +362,21 @@ def index(request, course_id, chapter=None, section=None,
|
||||
if settings.DEBUG:
|
||||
raise
|
||||
else:
|
||||
log.exception("Error in index view: user={user}, course={course},"
|
||||
" chapter={chapter} section={section}"
|
||||
"position={position}".format(
|
||||
user=user,
|
||||
course=course,
|
||||
chapter=chapter,
|
||||
section=section,
|
||||
position=position
|
||||
))
|
||||
log.exception(
|
||||
"Error in index view: user={user}, course={course},"
|
||||
" chapter={chapter} section={section}"
|
||||
"position={position}".format(
|
||||
user=user,
|
||||
course=course,
|
||||
chapter=chapter,
|
||||
section=section,
|
||||
position=position
|
||||
))
|
||||
try:
|
||||
result = render_to_response('courseware/courseware-error.html',
|
||||
{'staff_access': staff_access,
|
||||
'course': course})
|
||||
result = render_to_response('courseware/courseware-error.html', {
|
||||
'staff_access': staff_access,
|
||||
'course': course
|
||||
})
|
||||
except:
|
||||
# Let the exception propagate, relying on global config to at
|
||||
# at least return a nice error message
|
||||
@@ -476,11 +481,11 @@ def static_tab(request, course_id, tab_slug):
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
tab = tabs.get_static_tab_by_slug(course, tab_slug)
|
||||
tab = CourseTabList.get_tab_by_slug(course, tab_slug)
|
||||
if tab is None:
|
||||
raise Http404
|
||||
|
||||
contents = tabs.get_static_tab_contents(
|
||||
contents = get_static_tab_contents(
|
||||
request,
|
||||
course,
|
||||
tab
|
||||
@@ -488,12 +493,11 @@ def static_tab(request, course_id, tab_slug):
|
||||
if contents is None:
|
||||
raise Http404
|
||||
|
||||
staff_access = has_access(request.user, course, 'staff')
|
||||
return render_to_response('courseware/static_tab.html',
|
||||
{'course': course,
|
||||
'tab': tab,
|
||||
'tab_contents': contents,
|
||||
'staff_access': staff_access, })
|
||||
return render_to_response('courseware/static_tab.html', {
|
||||
'course': course,
|
||||
'tab': tab,
|
||||
'tab_contents': contents,
|
||||
})
|
||||
|
||||
# TODO arjun: remove when custom tabs in place, see courseware/syllabus.py
|
||||
|
||||
@@ -508,8 +512,10 @@ def syllabus(request, course_id):
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
staff_access = has_access(request.user, course, 'staff')
|
||||
|
||||
return render_to_response('courseware/syllabus.html', {'course': course,
|
||||
'staff_access': staff_access, })
|
||||
return render_to_response('courseware/syllabus.html', {
|
||||
'course': course,
|
||||
'staff_access': staff_access,
|
||||
})
|
||||
|
||||
|
||||
def registered_for_course(course, user):
|
||||
@@ -563,15 +569,16 @@ def course_about(request, course_id):
|
||||
# see if we have already filled up all allowed enrollments
|
||||
is_course_full = CourseEnrollment.is_course_full(course)
|
||||
|
||||
return render_to_response('courseware/course_about.html',
|
||||
{'course': course,
|
||||
'registered': registered,
|
||||
'course_target': course_target,
|
||||
'registration_price': registration_price,
|
||||
'in_cart': in_cart,
|
||||
'reg_then_add_to_cart_link': reg_then_add_to_cart_link,
|
||||
'show_courseware_link': show_courseware_link,
|
||||
'is_course_full': is_course_full})
|
||||
return render_to_response('courseware/course_about.html', {
|
||||
'course': course,
|
||||
'registered': registered,
|
||||
'course_target': course_target,
|
||||
'registration_price': registration_price,
|
||||
'in_cart': in_cart,
|
||||
'reg_then_add_to_cart_link': reg_then_add_to_cart_link,
|
||||
'show_courseware_link': show_courseware_link,
|
||||
'is_course_full': is_course_full
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -603,17 +610,14 @@ def mktg_course_about(request, course_id):
|
||||
settings.FEATURES.get('ENABLE_LMS_MIGRATION'))
|
||||
course_modes = CourseMode.modes_for_course(course.id)
|
||||
|
||||
return render_to_response(
|
||||
'courseware/mktg_course_about.html',
|
||||
{
|
||||
'course': course,
|
||||
'registered': registered,
|
||||
'allow_registration': allow_registration,
|
||||
'course_target': course_target,
|
||||
'show_courseware_link': show_courseware_link,
|
||||
'course_modes': course_modes,
|
||||
}
|
||||
)
|
||||
return render_to_response('courseware/mktg_course_about.html', {
|
||||
'course': course,
|
||||
'registered': registered,
|
||||
'allow_registration': allow_registration,
|
||||
'course_target': course_target,
|
||||
'show_courseware_link': show_courseware_link,
|
||||
'course_modes': course_modes,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -711,9 +715,11 @@ def submission_history(request, course_id, student_username, location):
|
||||
|
||||
try:
|
||||
student = User.objects.get(username=student_username)
|
||||
student_module = StudentModule.objects.get(course_id=course_id,
|
||||
module_state_key=location,
|
||||
student_id=student.id)
|
||||
student_module = StudentModule.objects.get(
|
||||
course_id=course_id,
|
||||
module_state_key=location,
|
||||
student_id=student.id
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
return HttpResponse(escape("User {0} does not exist.".format(student_username)))
|
||||
except StudentModule.DoesNotExist:
|
||||
@@ -738,3 +744,56 @@ def submission_history(request, course_id, student_username, location):
|
||||
}
|
||||
|
||||
return render_to_response('courseware/submission_history.html', context)
|
||||
|
||||
|
||||
def notification_image_for_tab(course_tab, user, course):
|
||||
"""
|
||||
Returns the notification image path for the given course_tab if applicable, otherwise None.
|
||||
"""
|
||||
|
||||
tab_notification_handlers = {
|
||||
StaffGradingTab.type: open_ended_notifications.staff_grading_notifications,
|
||||
PeerGradingTab.type: open_ended_notifications.peer_grading_notifications,
|
||||
OpenEndedGradingTab.type: open_ended_notifications.combined_notifications
|
||||
}
|
||||
|
||||
if course_tab.type in tab_notification_handlers:
|
||||
notifications = tab_notification_handlers[course_tab.type](course, user)
|
||||
if notifications and notifications['pending_grading']:
|
||||
return notifications['img_path']
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_static_tab_contents(request, course, tab):
|
||||
"""
|
||||
Returns the contents for the given static tab
|
||||
"""
|
||||
loc = Location(
|
||||
course.location.tag,
|
||||
course.location.org,
|
||||
course.location.course,
|
||||
tab.type,
|
||||
tab.url_slug,
|
||||
)
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
course.id, request.user, modulestore().get_instance(course.id, loc), depth=0
|
||||
)
|
||||
tab_module = get_module(
|
||||
request.user, request, loc, field_data_cache, course.id, static_asset_path=course.static_asset_path
|
||||
)
|
||||
|
||||
logging.debug('course_module = {0}'.format(tab_module))
|
||||
|
||||
html = ''
|
||||
if tab_module is not None:
|
||||
try:
|
||||
html = tab_module.render('student_view').content
|
||||
except Exception: # pylint: disable=broad-except
|
||||
html = render_to_string('courseware/error-message.html', None)
|
||||
log.exception("Error rendering course={course}, tab={tab_url}".format(
|
||||
course=course,
|
||||
tab_url=tab['url_slug']
|
||||
))
|
||||
|
||||
return html
|
||||
|
||||
@@ -1257,7 +1257,7 @@ def grade_summary(request, course_id):
|
||||
"""Display the grade summary for a course."""
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
# For now, just a static page
|
||||
# For now, just a page
|
||||
context = {'course': course,
|
||||
'staff_access': True, }
|
||||
return render_to_response('courseware/grade_summary.html', context)
|
||||
|
||||
@@ -94,6 +94,8 @@ BULK_EMAIL_DEFAULT_FROM_EMAIL = "test@test.org"
|
||||
|
||||
# Forums are disabled in test.py to speed up unit tests, but we do not have
|
||||
# per-test control for acceptance tests
|
||||
# For consistency in user-experience, keep the value of this setting in sync with
|
||||
# the one in cms/envs/acceptance.py
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
|
||||
@@ -43,9 +43,6 @@ EMAIL_BACKEND = 'django_ses.SESBackend'
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage'
|
||||
|
||||
# Enable Berkeley forums
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
|
||||
# IMPORTANT: With this enabled, the server must always be behind a proxy that
|
||||
# strips the header HTTP_X_FORWARDED_PROTO from client requests. Otherwise,
|
||||
# a user can fool our server into thinking it was an https connection.
|
||||
|
||||
@@ -77,7 +77,10 @@ FEATURES = {
|
||||
# set to None to do no university selection
|
||||
|
||||
'ENABLE_TEXTBOOK': True,
|
||||
|
||||
# for consistency in user-experience, keep the value of this setting in sync with the one in cms/envs/common.py
|
||||
'ENABLE_DISCUSSION_SERVICE': True,
|
||||
|
||||
# discussion home panel, which includes a subscription on/off setting for discussion digest emails.
|
||||
# this should remain off in production until digest notifications are online.
|
||||
'ENABLE_DISCUSSION_HOME_PANEL': False,
|
||||
|
||||
@@ -25,7 +25,8 @@ FEATURES['DISABLE_START_DATES'] = True
|
||||
|
||||
# Most tests don't use the discussion service, so we turn it off to speed them up.
|
||||
# Tests that do can enable this flag, but must use the UrlResetMixin class to force urls.py
|
||||
# to reload
|
||||
# to reload. For consistency in user-experience, keep the value of this setting in sync with
|
||||
# the one in cms/envs/test.py
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
|
||||
|
||||
FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
|
||||
@@ -12,26 +12,36 @@ def url_class(is_active):
|
||||
return "active"
|
||||
return ""
|
||||
%>
|
||||
<%! from courseware.tabs import get_course_tabs %>
|
||||
<%! from xmodule.tabs import CourseTabList %>
|
||||
<%! from courseware.access import has_access %>
|
||||
<%! from django.conf import settings %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from courseware.views import notification_image_for_tab %>
|
||||
<% import waffle %>
|
||||
|
||||
<nav class="${active_page} course-material">
|
||||
<div class="inner-wrapper">
|
||||
<ol class="course-tabs">
|
||||
% for tab in get_course_tabs(user, course, active_page, request):
|
||||
% if waffle.flag_is_active(request, 'visual_treatment') or waffle.flag_is_active(request, 'merge_course_tabs'):
|
||||
% for tab in CourseTabList.iterate_displayable(course, settings, user.is_authenticated(), has_access(user, course, 'staff'), include_instructor_tab=True):
|
||||
<%
|
||||
tab_is_active = (tab.tab_id == active_page)
|
||||
tab_image = notification_image_for_tab(tab, user, course)
|
||||
%>
|
||||
% if waffle.flag_is_active(request, 'visual_treatment'):
|
||||
<li class="${"prominent" if tab.name in ("Courseware", "Course Content") else ""}">
|
||||
% else:
|
||||
<li>
|
||||
% endif
|
||||
<a href="${tab.link | h}" class="${url_class(tab.is_active)}">
|
||||
<a href="${tab.link_func(course, reverse) | h}" class="${url_class(tab_is_active)}">
|
||||
${_(tab.name) | h}
|
||||
% if tab.is_active == True:
|
||||
% if tab_is_active:
|
||||
<span class="sr">, current location</span>
|
||||
%endif
|
||||
% if tab.has_img == True:
|
||||
<img src="${tab.img}"/>
|
||||
% if tab_image:
|
||||
## Translators: 'needs attention' is an alternative string for the
|
||||
## notification image that indicates the tab "needs attention".
|
||||
<img src="${tab_image}" alt="${_('needs attention')}" />
|
||||
%endif
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -11,31 +11,3 @@ import waffle
|
||||
section_name=prev_section.display_name_with_default,
|
||||
)
|
||||
)}</p>
|
||||
|
||||
% if waffle.flag_is_active(request, 'merge_course_tabs'):
|
||||
<%! from courseware.courses import get_course_info_section %>
|
||||
|
||||
<section class="container">
|
||||
<div class="info-wrapper">
|
||||
% if user.is_authenticated():
|
||||
<section class="updates">
|
||||
<h1>${_("Course Updates & News")}</h1>
|
||||
${get_course_info_section(request, course, 'updates')}
|
||||
</section>
|
||||
<section aria-label="${_('Handout Navigation')}" class="handouts">
|
||||
<h1>${course.info_sidebar_name}</h1>
|
||||
${get_course_info_section(request, course, 'handouts')}
|
||||
</section>
|
||||
% else:
|
||||
<section class="updates">
|
||||
<h1>${_("Course Updates & News")}</h1>
|
||||
${get_course_info_section(request, course, 'guest_updates')}
|
||||
</section>
|
||||
<section aria-label="${_('Handout Navigation')}" class="handouts">
|
||||
<h1>${_("Course Handouts")}</h1>
|
||||
${get_course_info_section(request, course, 'guest_handouts')}
|
||||
</section>
|
||||
% endif
|
||||
</div>
|
||||
</section>
|
||||
% endif
|
||||
|
||||
@@ -22,10 +22,7 @@
|
||||
<li class="course-item">
|
||||
<article class="course ${enrollment.mode}">
|
||||
<%
|
||||
if waffle.flag_is_active(request, 'merge_course_tabs'):
|
||||
course_target = reverse('courseware', args=[course.id])
|
||||
else:
|
||||
course_target = reverse('info', args=[course.id])
|
||||
course_target = reverse('info', args=[course.id])
|
||||
%>
|
||||
|
||||
% if show_courseware_link:
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<%! from datetime import datetime %>
|
||||
<%! import pytz %>
|
||||
<%! from django.conf import settings %>
|
||||
<%! from courseware.tabs import get_discussion_link %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from xmodule.tabs import CourseTabList %>
|
||||
<%! from microsite_configuration import microsite %>
|
||||
<%! platform_name = microsite.get_value("platform_name", settings.PLATFORM_NAME) %>
|
||||
|
||||
@@ -31,7 +32,8 @@
|
||||
</header>
|
||||
|
||||
<%
|
||||
discussion_link = get_discussion_link(course) if course else None
|
||||
discussion_tab = CourseTabList.get_discussion(course) if course else None
|
||||
discussion_link = discussion_tab.link_func(course, reverse) if (discussion_tab and discussion_tab.can_display(course, settings, True, True)) else None
|
||||
%>
|
||||
|
||||
% if discussion_link:
|
||||
|
||||
Reference in New Issue
Block a user