Commit 3bcf619f by Nimisha Asthagiri

Merge pull request #2910 from edx/frances/feature/studio-nav-manipulation2

Frances/feature/studio nav manipulation2
parents 767feb93 e86b4a12
......@@ -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.
......
@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
# 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
......
// 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;
}
.nav-introduction-supplementary {
.content-primary {
width: flex-grid(9, 12);
margin-right: flex-gutter();
.icon-question-sign {
display: inline-block;
vertical-align: baseline;
margin-right: ($baseline/4);
.add-pages {
@extend %ui-well;
margin: ($baseline*1.5) 0;
background-color: $gray-l4;
padding: ($baseline*2);
text-align: center;
color: $gray;
.new-button {
@include font-size(14);
margin-left: $baseline;
[class^="icon-"] {
margin-right: ($baseline/2);
}
}
}
}
.content-supplementary {
width: flex-grid(3, 12);
}
.wrapper-actions-list {
top: 6px;
.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;
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,
&.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;
}
.icon-eye-close {
display: none;
}
}
&.action-visible .toggle-checkbox:checked ~ .action-button {
background-color: $gray;
color: $white;
.icon-eye-open {
display: none;
}
.icon-eye-close {
display: inline-block;
}
}
}
}
}
.unit-body {
padding: 0;
......@@ -78,7 +179,8 @@
}
}
.component {
.component,
.course-nav-tab {
position: relative;
border: 1px solid $mediumGrey;
border-top: none;
......@@ -114,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
......@@ -130,17 +238,18 @@
display: none;
}
.component-actions {
.component-actions,
.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);
......@@ -179,6 +288,36 @@
}
}
// basic course nav items - overrides from above
.course-nav-tab {
padding: ($baseline*.75) ($baseline/4) ($baseline*.75) $baseline;
&.fixed {
@include transition(opacity $tmg-f2 ease-in-out 0s);
opacity: .7;
&:hover {
opacity: 1;
}
}
.course-nav-tab-header {
display: inline-block;
width:80%;
.title {
@extend %t-title4;
font-weight: 300;
color: $gray;
}
}
.course-nav-tab-actions {
display: inline-block;
padding: ($baseline/10);
}
}
.component.editing {
border-left: 1px solid $mediumGrey;
border-right: 1px solid $mediumGrey;
......
......@@ -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;
......
......@@ -4,7 +4,7 @@
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
%>
<%block name="title">Static Pages</%block>
<%block name="title">${_("Pages")}</%block>
<%block name="bodyclass">is-signedin course view-static-pages</%block>
<%block name="jsextra">
......@@ -18,7 +18,7 @@
});
new TabsEditView({
el: $('.main-wrapper'),
el: $('.tab-list'),
model: model,
mast: $('.wrapper-mast')
});
......@@ -31,7 +31,8 @@
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Static Pages")}
## Translators: Pages refer to the tabs that appear in the top navigation of each course.
<span class="sr">&gt; </span>${_("Pages")}
</h1>
<nav class="nav-actions">
......@@ -47,44 +48,74 @@
<div class="wrapper-content wrapper">
<section class="content">
<div class="introduction has-links">
<p class="copy">${_("Use Static Pages to share a syllabus, a calendar, handouts, or other supplements to your courseware.")}</p>
<p class="copy">${_("NOTE: all content on Static Pages will be visible to anyone who knows the URL, regardless of whether they are registered in the course or not.")}</p>
<nav class="nav-introduction-supplementary">
<ul>
<li class="nav-item">
<a rel="modal" href="#preview-lms-staticpages"><i class="icon-question-sign"></i>${_("What do static pages look like in my course?")}</a>
</li>
</ul>
</nav>
</div>
</section>
</div>
<article class="content-primary" role="main">
<div class="inner-wrapper">
<article class="unit-body">
<div class="tab-list">
<ol class="course-nav-tab-list components">
% 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
<div class="main-wrapper">
<div class="inner-wrapper">
<article class="unit-body">
</ul>
</div>
<div class="drag-handle is-fixed" data-tooltip="${_('Cannot be reordered')}">
<span class="sr">${_("Fixed page")}</span>
</div>
</li>
% endfor
<div class="tab-list">
<ol class='components'>
% for locator in components:
<li class="component" data-locator="${locator}"/>
% endfor
% for locator in components:
<li class="component" data-locator="${locator}"></li>
% endfor
<li class="new-component-item">
<li class="new-component-item">
</li>
</ol>
</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>
</div>
<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 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>
<p>${_("Pages are the navigational items that appear across the top of your course.")} <br /> <a rel="modal" href="#preview-lms-staticpages">${_("See an example")}</a></p>
</div>
</aside>
</section>
</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 &amp; 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()
......
"""
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
"""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,
)
"""
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
]
]
......
"""
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
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 &amp; 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 &amp; 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:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment