Commit 69be9000 by Ned Batchelder

Merge pull request #9153 from edx/rc/2015-08-04

Rc/2015-08-04
parents 663b7b9c 25ff5353
......@@ -229,3 +229,5 @@ Pan Luo <pan.luo@ubc.ca>
Tyler Nickerson <nickersoft@gmail.com>
Vedran Karačić <vedran@edx.org>
William Ono <william.ono@ubc.ca>
Dongwook Yoon <dy252@cornell.edu>
Awais Qureshi <awais.qureshi@arbisoft.com>
......@@ -130,7 +130,7 @@ class TestReindexLibrary(ModuleStoreTestCase):
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
expected_calls = self._build_calls(self.first_lib, self.second_lib)
self.assertEqual(patched_index.mock_calls, expected_calls)
self.assertItemsEqual(patched_index.mock_calls, expected_calls)
def test_given_all_key_prompts_and_reindexes_all_libraries_cancelled(self):
""" Test that does not reindex anything when --all key is given and cancelled """
......
......@@ -1765,6 +1765,13 @@ class RerunCourseTest(ContentStoreTestCase):
self.assertEqual(1, len(source_videos))
self.assertEqual(source_videos, target_videos)
def test_rerun_course_resets_advertised_date(self):
source_course = CourseFactory.create(advertised_start="01-12-2015")
destination_course_key = self.post_rerun_request(source_course.id)
destination_course = self.store.get_course(destination_course_key)
self.assertEqual(None, destination_course.advertised_start)
def test_rerun_of_rerun(self):
source_course = CourseFactory.create()
rerun_course_key = self.post_rerun_request(source_course.id)
......
......@@ -375,6 +375,7 @@ def certificates_list_handler(request, course_key_string):
'course_modes': course_modes,
'certificate_web_view_url': certificate_web_view_url,
'is_active': is_active,
'is_global_staff': GlobalStaff().has_user(request.user),
'certificate_activation_handler_url': activation_handler_url
})
elif "application/json" in request.META.get('HTTP_ACCEPT'):
......
......@@ -786,6 +786,9 @@ def _rerun_course(request, org, number, run, fields):
# Mark the action as initiated
CourseRerunState.objects.initiated(source_course_key, destination_course_key, request.user, fields['display_name'])
# Clear the fields that must be reset for the rerun
fields['advertised_start'] = None
# Rerun the course as a new celery task
json_fields = json.dumps(fields, cls=EdxJSONEncoder)
rerun_course.delay(unicode(source_course_key), unicode(destination_course_key), request.user.id, json_fields)
......
......@@ -801,6 +801,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
child_info = None
release_date = _get_release_date(xblock, user)
if xblock.category != 'course':
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
else:
......@@ -836,7 +838,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
"studio_url": xblock_studio_url(xblock, parent_xblock),
"released_to_students": datetime.now(UTC) > xblock.start,
"release_date": _get_release_date(xblock, user),
"release_date": release_date,
"visibility_state": visibility_state,
"has_explicit_staff_lock": xblock.fields['visible_to_staff_only'].is_set_on(xblock),
"start": xblock.fields['start'].to_json(xblock.start),
......@@ -1050,7 +1052,15 @@ def _get_release_date(xblock, user=None):
Returns the release date for the xblock, or None if the release date has never been set.
"""
# If year of start date is less than 1900 then reset the start date to DEFAULT_START_DATE
if xblock.start.year < 1900 and user:
reset_to_default = False
try:
reset_to_default = xblock.start.year < 1900
except ValueError:
# For old mongo courses, accessing the start attribute calls `to_json()`,
# which raises a `ValueError` for years < 1900.
reset_to_default = True
if reset_to_default and user:
xblock.start = DEFAULT_START_DATE
xblock = _update_with_callback(xblock, user)
......
......@@ -1532,7 +1532,6 @@ class TestXBlockInfo(ItemTest):
def test_vertical_xblock_info(self):
vertical = modulestore().get_item(self.vertical.location)
vertical.start = datetime(year=1899, month=1, day=1, tzinfo=UTC)
xblock_info = create_xblock_info(
vertical,
......@@ -1553,6 +1552,29 @@ class TestXBlockInfo(ItemTest):
)
self.validate_component_xblock_info(xblock_info)
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_validate_start_date(self, store_type):
"""
Validate if start-date year is less than 1900 reset the date to DEFAULT_START_DATE.
"""
with self.store.default_store(store_type):
course = CourseFactory.create()
chapter = ItemFactory.create(
parent_location=course.location, category='chapter', display_name='Week 1'
)
chapter.start = datetime(year=1899, month=1, day=1, tzinfo=UTC)
xblock_info = create_xblock_info(
chapter,
include_child_info=True,
include_children_predicate=ALWAYS,
include_ancestor_info=True,
user=self.user
)
self.assertEqual(xblock_info['start'], DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ'))
def validate_course_xblock_info(self, xblock_info, has_child_info=True, course_outline=False):
"""
Validate that the xblock info is correct for the test course.
......@@ -1605,7 +1627,6 @@ class TestXBlockInfo(ItemTest):
self.assertEqual(xblock_info['display_name'], 'Unit 1')
self.assertTrue(xblock_info['published'])
self.assertEqual(xblock_info['edited_by'], 'testuser')
self.assertEqual(xblock_info['start'], DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ'))
# Validate that the correct ancestor info has been included
ancestor_info = xblock_info.get('ancestor_info', None)
......
......@@ -238,6 +238,8 @@ with open(CONFIG_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
############### XBlock filesystem field config ##########
if 'DJFS' in AUTH_TOKENS and AUTH_TOKENS['DJFS'] is not None:
DJFS = AUTH_TOKENS['DJFS']
if 'url_root' in DJFS:
DJFS['url_root'] = DJFS['url_root'].format(platform_revision=EDX_PLATFORM_REVISION)
EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', EMAIL_HOST_USER)
EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', EMAIL_HOST_PASSWORD)
......
......@@ -595,6 +595,15 @@ REQUIRE_EXCLUDE = ("build.txt",)
# It can also be a path to a custom class that subclasses require.environments.Environment and defines some "args" function that returns a list with the command arguments to execute.
REQUIRE_ENVIRONMENT = "node"
########################## DJANGO DEBUG TOOLBAR ###############################
# We don't enable Django Debug Toolbar universally, but whenever we do, we want
# to avoid patching settings. Patched settings can cause circular import
# problems: http://django-debug-toolbar.readthedocs.org/en/1.0/installation.html#explicit-setup
DEBUG_TOOLBAR_PATCH_SETTINGS = False
################################# TENDER ######################################
# If you want to enable Tender integration (http://tenderapp.com/),
......
......@@ -55,10 +55,12 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
course_modes: ['honor', 'test'],
certificate_web_view_url: '/users/1/courses/orgX/009/2016'
});
window.CMS.User = {isGlobalStaff: true};
});
afterEach(function() {
delete window.course;
delete window.CMS.User;
});
describe('Certificate Details Spec:', function() {
......@@ -141,6 +143,12 @@ function(_, Course, CertificatesCollection, CertificateModel, CertificateDetails
expect(this.view.$('.action-delete .delete')).toExist();
});
it('should not present a Delete action if user is not global staff', function () {
window.CMS.User = {isGlobalStaff: false};
appendSetFixtures(this.view.render().el);
expect(this.view.$('.action-delete .delete')).not.toExist();
});
it('should prompt the user when when clicking the Delete button', function(){
expect(this.view.$('.action-delete .delete')).toExist();
this.view.$('.action-delete .delete').click();
......
......@@ -86,12 +86,12 @@ function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, Ce
num: 'course_num',
revision: 'course_rev'
});
window.CMS.User = {isGlobalStaff: true};
});
afterEach(function() {
delete window.course;
delete window.CMS.User;
});
describe('Certificate editor view', function() {
......@@ -151,6 +151,12 @@ function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, Ce
expect(this.view.$('.action-delete')).toExist();
});
it('should not have delete button is user is not global staff', function() {
window.CMS.User = {isGlobalStaff: false};
appendSetFixtures(this.view.render().el);
expect(this.view.$('.action-delete')).not.toExist();
});
it('should save properly', function() {
var requests = AjaxHelpers.requests(this),
notificationSpy = ViewHelpers.createNotificationSpy();
......
......@@ -18,7 +18,7 @@ function(_, $, Course, CertificatePreview, TemplateHelpers, ViewHelpers, AjaxHel
preview_certificate: '.preview-certificate-link'
};
beforeEach(function() {
beforeEach(function() {
window.course = new Course({
id: '5',
name: 'Course Name',
......@@ -27,11 +27,13 @@ function(_, $, Course, CertificatePreview, TemplateHelpers, ViewHelpers, AjaxHel
num: 'course_num',
revision: 'course_rev'
});
window.CMS.User = {isGlobalStaff: true};
});
afterEach(function() {
delete window.course;
});
delete window.course;
delete window.CMS.User;
});
describe('Certificate Web Preview Spec:', function() {
......@@ -85,6 +87,12 @@ function(_, $, Course, CertificatePreview, TemplateHelpers, ViewHelpers, AjaxHel
expect(this.view.toggleCertificateActivation).toHaveBeenCalled();
});
it('toggle certificate activation button should not be present if user is not global staff', function () {
window.CMS.User = {isGlobalStaff: false};
appendSetFixtures(this.view.render().el);
expect(this.view.$(SELECTORS.activate_certificate)).not.toExist();
});
it('certificate deactivation works fine', function () {
var requests = AjaxHelpers.requests(this),
notificationSpy = ViewHelpers.createNotificationSpy();
......
......@@ -31,6 +31,16 @@ define(["jquery", "underscore", "js/views/modals/base_modal", "js/spec_helpers/m
expect(ModelHelpers.isShowingModal(modal)).toBeTruthy();
});
it('sends focus to the modal window after show is called', function() {
showMockModal();
waitsFor(function () {
// This is the implementation of "toBeFocused". However, simply calling that method
// with no wait seems to be flaky.
var modalWindow = ModelHelpers.getModalWindow(modal);
return $(modalWindow)[0] === $(modalWindow)[0].ownerDocument.activeElement;
}, 'Modal Window did not get focus', 5000);
});
it('is removed after hide is called', function () {
showMockModal();
modal.hide();
......
......@@ -3,8 +3,8 @@
*/
define(["jquery", "common/js/spec_helpers/template_helpers", "js/spec_helpers/view_helpers"],
function($, TemplateHelpers, ViewHelpers) {
var installModalTemplates, getModalElement, getModalTitle, isShowingModal, hideModalIfShowing,
pressModalButton, cancelModal, cancelModalIfShowing;
var installModalTemplates, getModalElement, getModalWindow, getModalTitle, isShowingModal,
hideModalIfShowing, pressModalButton, cancelModal, cancelModalIfShowing;
installModalTemplates = function(append) {
ViewHelpers.installViewTemplates(append);
......@@ -22,6 +22,11 @@ define(["jquery", "common/js/spec_helpers/template_helpers", "js/spec_helpers/vi
return modalElement;
};
getModalWindow = function(modal) {
var modalElement = getModalElement(modal);
return modalElement.find('.modal-window');
};
getModalTitle = function(modal) {
var modalElement = getModalElement(modal);
return modalElement.find('.modal-window-title').text();
......@@ -58,6 +63,7 @@ define(["jquery", "common/js/spec_helpers/template_helpers", "js/spec_helpers/vi
return $.extend(ViewHelpers, {
'getModalElement': getModalElement,
'getModalWindow': getModalWindow,
'getModalTitle': getModalTitle,
'installModalTemplates': installModalTemplates,
'isShowingModal': isShowingModal,
......
......@@ -34,6 +34,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
modalType: 'generic',
modalSize: 'lg',
title: '',
modalWindowClass: '.modal-window',
// A list of class names, separated by space.
viewSpecificClasses: ''
}),
......@@ -46,7 +47,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
if (parent) {
parentElement = parent.$el;
} else if (!parentElement) {
parentElement = this.$el.closest('.modal-window');
parentElement = this.$el.closest(this.options.modalWindowClass);
if (parentElement.length === 0) {
parentElement = $('body');
}
......@@ -87,6 +88,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
this.render();
this.resize();
$(window).resize(_.bind(this.resize, this));
// after showing and resizing, send focus
var modal = this.$el.find(this.options.modalWindowClass);
modal.focus();
},
hide: function() {
......@@ -132,7 +137,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
* Returns the action bar that contains the modal's action buttons.
*/
getActionBar: function() {
return this.$('.modal-window > div > .modal-actions');
return this.$(this.options.modalWindowClass + ' > div > .modal-actions');
},
/**
......@@ -146,7 +151,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
var top, left, modalWindow, modalWidth, modalHeight,
availableWidth, availableHeight, maxWidth, maxHeight;
modalWindow = this.$('.modal-window');
modalWindow = this.$el.find(this.options.modalWindowClass);
availableWidth = $(window).width();
availableHeight = $(window).height();
maxWidth = availableWidth * 0.80;
......
......@@ -336,36 +336,50 @@ body.howitworks .nav-not-signedin-hiw,
body.dashboard .nav-account-dashboard,
// course content
body.course.outline .nav-course-courseware .title,
body.course.updates .nav-course-courseware .title,
body.course.pages .nav-course-courseware .title,
body.course.uploads .nav-course-courseware .title,
body.course.outline .nav-course-courseware-outline,
body.course.updates .nav-course-courseware-updates,
body.course.pages .nav-course-courseware-pages,
body.course.uploads .nav-course-courseware-uploads,
body.course.textbooks .nav-course-courseware-textbooks,
body.course.view-outline .nav-course-courseware .title,
body.course.view-updates .nav-course-courseware .title,
body.course.view-static-pages .nav-course-courseware .title,
body.course.view-uploads .nav-course-courseware .title,
body.course.view-textbooks .nav-course-courseware .title,
body.course.view-video-uploads .nav-course-courseware .title,
body.course.view-outline .nav-course-courseware-outline,
body.course.view-updates .nav-course-courseware-updates,
body.course.view-static-pages .nav-course-courseware-pages,
body.course.view-uploads .nav-course-courseware-uploads,
body.course.view-textbooks .nav-course-courseware-textbooks,
body.course.view-video-uploads .nav-course-courseware-video,
// course settings
body.course.schedule .nav-course-settings .title,
body.course.grading .nav-course-settings .title,
body.course.team .nav-course-settings .title,
body.course.view-team .nav-course-settings .title,
body.course.view-group-configurations .nav-course-settings .title,
body.course.advanced .nav-course-settings .title,
body.course.view-certificates .nav-course-settings .title,
body.course.schedule .nav-course-settings-schedule,
body.course.grading .nav-course-settings-grading,
body.course.team .nav-course-settings-team,
body.course.view-team .nav-course-settings-team,
body.course.view-group-configurations .nav-course-settings-group-configurations,
body.course.advanced .nav-course-settings-advanced,
body.course.view-certificates .nav-course-settings-certificates,
// course tools
body.course.import .nav-course-tools .title,
body.course.export .nav-course-tools .title,
body.course.checklists .nav-course-tools .title,
body.course.view-import .nav-course-tools .title,
body.course.view-export .nav-course-tools .title,
body.course.view-checklists .nav-course-tools .title,
body.course.view-export-git .nav-course-tools .title,
body.course.view-import .nav-course-tools-import,
body.course.view-export .nav-course-tools-export,
body.course.view-checklists .nav-course-tools-checklists,
body.course.view-export-git .nav-course-tools-export-git,
// content library settings
body.course.view-team .nav-library-settings .title,
body.course.import .nav-course-tools-import,
body.course.export .nav-course-tools-export,
body.course.checklists .nav-course-tools-checklists,
body.course.view-team .nav-library-settings-team,
{
color: $blue;
......
......@@ -22,7 +22,9 @@ from django.utils.translation import ugettext as _
<script type="text/javascript">
window.CMS = window.CMS || {};
CMS.URL = CMS.URL || {};
CMS.User = CMS.User || {};
CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
CMS.User.isGlobalStaff = '${is_global_staff}'=='True' ? true : false;
</script>
</%block>
......
......@@ -4,10 +4,10 @@
aria-hidden=""
role="dialog">
<div class="modal-window-overlay"></div>
<div class="modal-window <%= viewSpecificClasses %> modal-<%= size %> modal-type-<%= type %>">
<div class="modal-window <%= viewSpecificClasses %> modal-<%= size %> modal-type-<%= type %>" tabindex="-1" aria-labelledby="modal-window-title">
<div class="<%= name %>-modal">
<div class="modal-header">
<h2 class="title modal-window-title"><%= title %></h2>
<h2 id="modal-window-title" class="title modal-window-title"><%= title %></h2>
<ul class="editor-modes action-list action-modes">
</ul>
</div>
......
......@@ -33,8 +33,10 @@
<li class="action action-edit">
<button class="edit"><i class="icon fa fa-pencil" aria-hidden="true"></i> <%= gettext("Edit") %></button>
</li>
<% if (CMS.User.isGlobalStaff) { %>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%= gettext('Delete') %>">
<button class="delete action-icon"><i class="icon fa fa-trash-o" aria-hidden="true"></i><span><%= gettext("Delete") %></span></button>
</li>
<% } %>
</ul>
</div>
......@@ -48,7 +48,7 @@
<div class="actions">
<button class="action action-primary" type="submit"><% if (isNew) { print(gettext("Create")) } else { print(gettext("Save")) } %></button>
<button class="action action-secondary action-cancel"><%= gettext("Cancel") %></button>
<% if (!isNew) { %>
<% if (!isNew && CMS.User.isGlobalStaff) { %>
<span class="wrapper-delete-button">
<a class="button action-delete delete" href="#"><%= gettext("Delete") %></a>
</span>
......
......@@ -7,6 +7,7 @@
<a href=<%= certificate_web_view_url %> class="button preview-certificate-link" target="_blank">
<%= gettext("Preview Certificate") %>
</a>
<% if (CMS.User.isGlobalStaff) { %>
<button class="button activate-cert">
<span>
<% if(!is_active) { %>
......@@ -15,3 +16,4 @@
<%= gettext("Deactivate") %></span>
<% } %>
</button>
<% } %>
......@@ -34,7 +34,7 @@
<ul class="actions textbook-actions">
<li class="action action-view">
<a href="//<%= CMS.URL.LMS_BASE %>/courses/<%= course.org %>/<%= course.num %>/<%= course.url_name %>/pdfbook/<%= bookindex %>/" class="view"><%= gettext("View Live") %></a>
<a href="//<%= CMS.URL.LMS_BASE %>/courses/<%= course.id %>/pdfbook/<%= bookindex %>/" class="view"><%= gettext("View Live") %></a>
</li>
<li class="action action-edit">
<button class="edit"><%= gettext("Edit") %></button>
......
<div class="wrapper wrapper-modal-window wrapper-modal-window-edit-xblock" aria-describedby="modal-window-description" aria-labelledby="modal-window-title" aria-hidden="" role="dialog">
<div class="modal-window-overlay"></div>
<div class="modal-window confirm modal-med modal-type-html modal-editor" style="top: 50px; left: 400px;">
<div class="modal-window confirm modal-med modal-type-html modal-editor" style="top: 50px; left: 400px;" tabindex="-1" aria-labelledby="modal-window-title">
<div class="edit-xblock-modal">
<div class="modal-header">
<h2 class="title modal-window-title">Editing visibility for: [Component Name]</h2>
<h2 id="modal-window-title" class="title modal-window-title">Editing visibility for: [Component Name]</h2>
</div>
<div class="modal-content">
......
......@@ -190,6 +190,11 @@ if settings.DEBUG:
except ImportError:
pass
import debug_toolbar
urlpatterns += (
url(r'^__debug__/', include(debug_toolbar.urls)),
)
# Custom error pages
# pylint: disable=invalid-name
handler404 = 'contentstore.views.render_404'
......
......@@ -54,6 +54,10 @@ class EmbargoMiddleware(object):
# accidentally lock ourselves out of Django admin
# during testing.
re.compile(r'^/admin/'),
# Do not block access to course metadata. This information is needed for
# sever-to-server calls.
re.compile(r'^/api/course_structure/v[\d+]/courses/{}/$'.format(settings.COURSE_ID_PATTERN)),
]
def __init__(self):
......
......@@ -170,3 +170,34 @@ class EmbargoMiddlewareAccessTests(UrlResetMixin, ModuleStoreTestCase):
# even though we would have been blocked by country
# access rules.
self.assertEqual(response.status_code, 200)
@patch.dict(settings.FEATURES, {'EMBARGO': True})
def test_always_allow_course_detail_access(self):
""" Access to the Course Structure API's course detail endpoint should always be granted. """
# Make the user staff so that it has permissions to access the views.
self.user.is_staff = True
self.user.save() # pylint: disable=no-member
# Blacklist an IP address
ip_address = "192.168.10.20"
IPFilter.objects.create(
blacklist=ip_address,
enabled=True
)
url = reverse('course_structure_api:v0:detail', kwargs={'course_id': unicode(self.course.id)})
response = self.client.get(
url,
HTTP_X_FORWARDED_FOR=ip_address,
REMOTE_ADDR=ip_address
)
self.assertEqual(response.status_code, 200)
# Test with a fully-restricted course
with restrict_course(self.course.id):
response = self.client.get(
url,
HTTP_X_FORWARDED_FOR=ip_address,
REMOTE_ADDR=ip_address
)
self.assertEqual(response.status_code, 200)
......@@ -137,9 +137,11 @@ def add_enrollment(user_id, course_id, mode='honor', is_active=True):
Enrolls a user in a course. If the mode is not specified, this will default to 'honor'.
Args:
Arguments:
user_id (str): The user to enroll.
course_id (str): The course to enroll the user in.
Keyword Arguments:
mode (str): Optional argument for the type of enrollment to create. Ex. 'audit', 'honor', 'verified',
'professional'. If not specified, this defaults to 'honor'.
is_active (boolean): Optional argument for making the new enrollment inactive. If not specified, is_active
......@@ -177,7 +179,7 @@ def add_enrollment(user_id, course_id, mode='honor', is_active=True):
}
}
"""
_validate_course_mode(course_id, mode)
_validate_course_mode(course_id, mode, is_active=is_active)
return _data_api().create_course_enrollment(user_id, course_id, mode, is_active)
......@@ -186,11 +188,14 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_
Update a course enrollment for the given user and course.
Args:
Arguments:
user_id (str): The user associated with the updated enrollment.
course_id (str): The course associated with the updated enrollment.
Keyword Arguments:
mode (str): The new course mode for this enrollment.
is_active (bool): Sets whether the enrollment is active or not.
enrollment_attributes (list): Attributes to be set the enrollment.
Returns:
A serializable dictionary representing the updated enrollment.
......@@ -226,7 +231,7 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_
"""
if mode is not None:
_validate_course_mode(course_id, mode)
_validate_course_mode(course_id, mode, is_active=is_active)
enrollment = _data_api().update_course_enrollment(user_id, course_id, mode=mode, is_active=is_active)
if enrollment is None:
msg = u"Course Enrollment not found for user {user} in course {course}".format(user=user_id, course=course_id)
......@@ -353,7 +358,7 @@ def get_enrollment_attributes(user_id, course_id):
return _data_api().get_enrollment_attributes(user_id, course_id)
def _validate_course_mode(course_id, mode):
def _validate_course_mode(course_id, mode, is_active=None):
"""Checks to see if the specified course mode is valid for the course.
If the requested course mode is not available for the course, raise an error with corresponding
......@@ -363,17 +368,24 @@ def _validate_course_mode(course_id, mode):
'honor', return true, allowing the enrollment to be 'honor' even if the mode is not explicitly
set for the course.
Args:
Arguments:
course_id (str): The course to check against for available course modes.
mode (str): The slug for the course mode specified in the enrollment.
Keyword Arguments:
is_active (bool): Whether the enrollment is to be activated or deactivated.
Returns:
None
Raises:
CourseModeNotFound: raised if the course mode is not found.
"""
course_enrollment_info = _data_api().get_course_enrollment_info(course_id)
# If the client has requested an enrollment deactivation, we want to include expired modes
# in the set of available modes. This allows us to unenroll users from expired modes.
include_expired = not is_active if is_active is not None else False
course_enrollment_info = _data_api().get_course_enrollment_info(course_id, include_expired=include_expired)
course_modes = course_enrollment_info["course_modes"]
available_modes = [m['slug'] for m in course_modes]
if mode not in available_modes:
......
......@@ -40,7 +40,11 @@ class CourseField(serializers.RelatedField):
def to_native(self, course, **kwargs):
course_modes = ModeSerializer(
CourseMode.modes_for_course(course.id, kwargs.get('include_expired', False), only_selectable=False)
CourseMode.modes_for_course(
course.id,
include_expired=kwargs.get('include_expired', False),
only_selectable=False
)
).data # pylint: disable=no-member
return {
......
......@@ -18,6 +18,7 @@ from django.conf import settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range
from django.test.utils import override_settings
import pytz
from course_modes.models import CourseMode
from embargo.models import CountryAccessRule, Country, RestrictedCourse
......@@ -716,6 +717,26 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
expected_status=expected_status,
)
def test_deactivate_enrollment_expired_mode(self):
"""Verify that an enrollment in an expired mode can be deactivated."""
for mode in (CourseMode.HONOR, CourseMode.VERIFIED):
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
)
# Create verified enrollment.
self.assert_enrollment_status(as_server=True, mode=CourseMode.VERIFIED)
# Change verified mode expiration.
mode = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=pytz.utc)
mode.save()
# Deactivate enrollment.
self.assert_enrollment_activation(False, CourseMode.VERIFIED)
def test_change_mode_from_user(self):
"""Users should not be able to alter the enrollment mode on an enrollment. """
# Create an honor and verified mode for a course. This allows an update.
......
......@@ -598,7 +598,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
data={
"message": (
u"The course mode '{mode}' is not available for course '{course_id}'."
).format(mode="honor", course_id=course_id),
).format(mode=mode, course_id=course_id),
"course_details": error.data
})
except CourseNotFoundError:
......
......@@ -108,7 +108,6 @@ class CreditCourseDashboardTest(ModuleStoreTestCase):
response = self._load_dashboard()
self.assertContains(response, "credit-eligibility-msg")
self.assertContains(response, "purchase-credit-btn")
self.assertContains(response, "purchase credit for this course expires")
def test_purchased_credit(self):
# Simulate that the user has purchased credit, but has not
......
......@@ -662,8 +662,16 @@ def dashboard(request):
user, course_org_filter, org_filter_out_set
)
if 'notlive' in request.GET:
redirect_message = _("The course you are looking for does not start until {date}.").format(
date=request.GET['notlive']
)
else:
redirect_message = ''
context = {
'enrollment_message': enrollment_message,
'redirect_message': redirect_message,
'course_enrollments': course_enrollments,
'course_optouts': course_optouts,
'message': message,
......
......@@ -363,11 +363,14 @@ class SAMLConfiguration(ConfigurationModel):
return self.public_key
if name == "SP_PRIVATE_KEY":
return self.private_key
if name == "TECHNICAL_CONTACT":
return {"givenName": "Technical Support", "emailAddress": settings.TECH_SUPPORT_EMAIL}
if name == "SUPPORT_CONTACT":
return {"givenName": "SAML Support", "emailAddress": settings.TECH_SUPPORT_EMAIL}
other_config = json.loads(self.other_config_str)
if name in ("TECHNICAL_CONTACT", "SUPPORT_CONTACT"):
contact = {
"givenName": "{} Support".format(settings.PLATFORM_NAME),
"emailAddress": settings.TECH_SUPPORT_EMAIL
}
contact.update(other_config.get(name, {}))
return contact
return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings
......
......@@ -28,22 +28,42 @@ class SAMLMetadataTest(SAMLTestCase):
@ddt.data('saml_key', 'saml_key_alt') # Test two slightly different key pair export formats
def test_metadata(self, key_name):
self.enable_saml(
private_key=self._get_private_key(key_name),
public_key=self._get_public_key(key_name),
entity_id="https://saml.example.none",
)
self.enable_saml()
doc = self._fetch_metadata()
# Check the ACS URL:
acs_node = doc.find(".//{}".format(etree.QName(SAML_XML_NS, 'AssertionConsumerService')))
self.assertIsNotNone(acs_node)
self.assertEqual(acs_node.attrib['Location'], 'http://example.none/auth/complete/tpa-saml/')
def test_default_contact_info(self):
self.enable_saml()
self.check_metadata_contacts(
xml=self._fetch_metadata(),
tech_name="edX Support",
tech_email="technical@example.com",
support_name="edX Support",
support_email="technical@example.com"
)
def test_custom_contact_info(self):
self.enable_saml(
other_config_str=(
'{'
'"TECHNICAL_CONTACT": {"givenName": "Jane Tech", "emailAddress": "jane@example.com"},'
'"SUPPORT_CONTACT": {"givenName": "Joe Support", "emailAddress": "joe@example.com"}'
'}'
)
)
self.check_metadata_contacts(
xml=self._fetch_metadata(),
tech_name="Jane Tech",
tech_email="jane@example.com",
support_name="Joe Support",
support_email="joe@example.com"
)
def test_signed_metadata(self):
self.enable_saml(
private_key=self._get_private_key(),
public_key=self._get_public_key(),
entity_id="https://saml.example.none",
other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }',
)
doc = self._fetch_metadata()
......@@ -62,3 +82,19 @@ class SAMLMetadataTest(SAMLTestCase):
self.fail('SAML metadata must be valid XML')
self.assertEqual(metadata_doc.tag, etree.QName(SAML_XML_NS, 'EntityDescriptor'))
return metadata_doc
def check_metadata_contacts(self, xml, tech_name, tech_email, support_name, support_email):
""" Validate that the contact info in the metadata has the expected values """
technical_node = xml.find(".//{}[@contactType='technical']".format(etree.QName(SAML_XML_NS, 'ContactPerson')))
self.assertIsNotNone(technical_node)
tech_name_node = technical_node.find(etree.QName(SAML_XML_NS, 'GivenName'))
self.assertEqual(tech_name_node.text, tech_name)
tech_email_node = technical_node.find(etree.QName(SAML_XML_NS, 'EmailAddress'))
self.assertEqual(tech_email_node.text, tech_email)
support_node = xml.find(".//{}[@contactType='support']".format(etree.QName(SAML_XML_NS, 'ContactPerson')))
self.assertIsNotNone(support_node)
support_name_node = support_node.find(etree.QName(SAML_XML_NS, 'GivenName'))
self.assertEqual(support_name_node.text, support_name)
support_email_node = support_node.find(etree.QName(SAML_XML_NS, 'EmailAddress'))
self.assertEqual(support_email_node.text, support_email)
......@@ -114,6 +114,15 @@ class SAMLTestCase(TestCase):
with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f:
return f.read()
def enable_saml(self, **kwargs):
""" Enable SAML support (via SAMLConfiguration, not for any particular provider) """
if 'private_key' not in kwargs:
kwargs['private_key'] = self._get_private_key()
if 'public_key' not in kwargs:
kwargs['public_key'] = self._get_public_key()
kwargs.setdefault('entity_id', "https://saml.example.none")
super(SAMLTestCase, self).enable_saml(**kwargs)
@contextmanager
def simulate_running_pipeline(pipeline_target, backend, email=None, fullname=None, username=None):
......
......@@ -860,7 +860,7 @@ class ChoiceResponse(LoncapaResponse):
The hint information goes into the msg= in new_cmap for display.
Each choice in the checkboxgroup can have 2 extended hints, matching the
case that the student has or has not selected that choice:
<checkboxgroup label="Select the best snack" direction="vertical">
<checkboxgroup label="Select the best snack">
<choice correct="true">Donut
<choicehint selected="tRuE">A Hint!</choicehint>
<choicehint selected="false">Another hint!</choicehint>
......@@ -986,7 +986,7 @@ class MultipleChoiceResponse(LoncapaResponse):
to translate back to choice_0 name style for recording in the logs, so
the logging is in terms of the regular names.
"""
# TODO: handle direction and randomize
# TODO: randomize
human_name = _('Multiple Choice')
tags = ['multiplechoiceresponse']
......@@ -1323,9 +1323,11 @@ class TrueFalseResponse(MultipleChoiceResponse):
def get_score(self, student_answers):
correct = set(self.correct_choices)
answers = set(student_answers.get(self.answer_id, []))
answers = student_answers.get(self.answer_id, [])
if not isinstance(answers, list):
answers = [answers]
if correct == answers:
if correct == set(answers):
return CorrectMap(self.answer_id, 'correct')
return CorrectMap(self.answer_id, 'incorrect')
......@@ -1336,7 +1338,7 @@ class TrueFalseResponse(MultipleChoiceResponse):
@registry.register
class OptionResponse(LoncapaResponse):
"""
TODO: handle direction and randomize
TODO: handle randomize
"""
human_name = _('Dropdown')
......
<problem>
<p>Select all the fruits from the list. In retrospect, the wordiness of these tests increases the dizziness!</p>
<choiceresponse>
<checkboxgroup label="Select all the fruits from the list" direction="vertical">
<checkboxgroup label="Select all the fruits from the list">
<choice correct="true" id="alpha">Apple
<choicehint selected="TrUe">You are right that apple is a fruit.
</choicehint>
......@@ -35,7 +35,7 @@
</choiceresponse>
<p>Select all the vegetables from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<checkboxgroup label="Select all the vegetables from the list">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.
</choicehint>
......@@ -68,7 +68,7 @@
</choiceresponse>
<p>Compoundhint vs. correctness</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="true">A</choice>
<choice correct="false">B</choice>
<choice correct="true">C</choice>
......@@ -78,7 +78,7 @@
</choiceresponse>
<p>If one label matches we use it, otherwise go with the default, and whitespace scattered around.</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="true">
A
......@@ -97,7 +97,7 @@
<p>Blank labels</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="true">A <choicehint selected="true" label="">aa</choicehint></choice>
<choice correct="true">B <choicehint selected="true">bb</choicehint></choice>
<compoundhint value="A B" label="">compoundo</compoundhint>
......@@ -106,7 +106,7 @@
<p>Case where the student selects nothing, but there's feedback</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="true">A <choicehint selected="true">aa</choicehint></choice>
<choice correct="false">B <choicehint selected="false">bb</choicehint></choice>
</checkboxgroup>
......
<problem>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<checkboxgroup label="Select all the vegetables from the list">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.
</choicehint>
......
......@@ -338,7 +338,7 @@ class CheckboxHintsTestTracking(HintTest):
<problem>
<p>question</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="true">Apple
<choicehint selected="true">A true</choicehint>
<choicehint selected="false">A false</choicehint>
......
......@@ -151,6 +151,11 @@ class TrueFalseResponseTest(ResponseTest):
self.assert_grade(problem, 'choice_foil_4', 'incorrect')
self.assert_grade(problem, 'not_a_choice', 'incorrect')
def test_single_correct_response(self):
problem = self.build_problem(choices=[True, False])
self.assert_grade(problem, 'choice_0', 'correct')
self.assert_grade(problem, ['choice_0'], 'correct')
class ImageResponseTest(ResponseTest):
xml_factory_class = ImageResponseXMLFactory
......
......@@ -4,5 +4,5 @@ setup(
name="capa",
version="0.1",
packages=find_packages(exclude=["tests"]),
install_requires=["distribute>=0.6.28"],
install_requires=["setuptools"],
)
......@@ -54,7 +54,7 @@ setup(
version="0.1",
packages=find_packages(exclude=["tests"]),
install_requires=[
'distribute',
'setuptools',
'docopt',
'capa',
'path.py',
......
// capa - styling
// capa - styling
// ====================
// Table of Contents
// Table of Contents
// * +Variables - Capa
// * +Extends - Capa
// * +Mixins - Status Icon - Capa
......@@ -17,16 +17,17 @@
// * +Problem - Annotation
// * +Problem - Choice Text Group
// * +Problem - Image Input Overrides
// * +Problem - Annotation Problem Overrides
// +Variables - Capa
// ====================
// ====================
$annotation-yellow: rgba(255,255,10,0.3);
$color-copy-tip: rgb(100,100,100);
$correct: $green-d1;
$incorrect: $red;
$incorrect: $red;
// +Extends - Capa
// ====================
// ====================
// Duplicated from _mixins.scss due to xmodule compilation, inheritance issues
%use-font-awesome {
font-family: FontAwesome;
......@@ -36,9 +37,9 @@ $incorrect: $red;
}
// +Mixins - Status Icon - Capa
// ====================
// ====================
@mixin status-icon($color: $gray, $fontAwesomeIcon: "\f00d"){
&:after {
@extend %use-font-awesome;
@include margin-left(17px);
......@@ -49,7 +50,7 @@ $incorrect: $red;
}
// +Resets - Deprecate Please
// ====================
// ====================
h2 {
margin-top: 0;
margin-bottom: ($baseline*0.75);
......@@ -123,7 +124,7 @@ div.problem-progress {
}
// +Problem - Base
// ====================
// ====================
div.problem {
@media print {
display: block;
......@@ -137,11 +138,15 @@ div.problem {
.inline {
display: inline;
+ p {
margin-top: $baseline;
}
}
}
// +Problem - Choice Group
// ====================
// ====================
div.problem {
.choicegroup {
@include clearfix();
......@@ -163,7 +168,7 @@ div.problem {
@include status-icon($correct, "\f00c");
border: 2px solid $correct;
// keep green for correct answers on hover.
// keep green for correct answers on hover.
&:hover {
border-color: $correct;
}
......@@ -173,7 +178,7 @@ div.problem {
@include status-icon($incorrect, "\f00d");
border: 2px solid $incorrect;
// keep red for incorrect answers on hover.
// keep red for incorrect answers on hover.
&:hover {
border-color: $incorrect;
}
......@@ -206,10 +211,10 @@ div.problem {
}
}
// +Problem - Status Indicators
// ====================
// Summary status indicators shown after the input area
div.problem {
// +Problem - Status Indicators
// ====================
// Summary status indicators shown after the input area
div.problem {
.indicator-container {
......@@ -240,8 +245,8 @@ div.problem {
}
// +Problem - Misc, Unclassified Mess
// ====================
div.problem {
// ====================
div.problem {
ol.enumerate {
li {
&:before {
......@@ -422,12 +427,12 @@ div.problem {
background: #f1f1f1;
}
}
}
}
// Hides equation previews in symbolic response problems when printing
[id^='display'].equation {
@media print {
display:none;
display: none;
}
}
......@@ -460,15 +465,6 @@ div.problem {
background: url('../images/correct-icon.png') center center no-repeat;
}
&.partially-correct {
display: inline-block;
position: relative;
top: 6px;
width: 25px;
height: 20px;
background: url('../images/partially-correct-icon.png') center center no-repeat;
}
&.incomplete, &.ui-icon-close {
display: inline-block;
position: relative;
......@@ -577,6 +573,12 @@ div.problem {
}
}
div.inline {
> span {
display: inline;
}
}
ul {
margin-bottom: lh();
margin-left: .75em;
......@@ -699,10 +701,10 @@ div.problem {
}
// +Problem - Text Input, Numerical Input
// ====================
// ====================
.problem {
.capa_inputtype.textline, .inputtype.formulaequationinput {
input {
@include box-sizing(border-box);
border: 2px solid $gray-l4;
......@@ -723,7 +725,7 @@ div.problem {
// CASE: incorrect answer
> .incorrect {
input {
input {
border: 2px solid $incorrect;
}
......@@ -734,8 +736,8 @@ div.problem {
// CASE: correct answer
> .correct {
input {
input {
border: 2px solid $correct;
}
......@@ -747,7 +749,7 @@ div.problem {
// CASE: unanswered
> .unanswered {
input {
input {
border: 2px solid $gray-l4;
}
......@@ -760,7 +762,7 @@ div.problem {
// +Problem - Option Input (Dropdown)
// ====================
// ====================
.problem {
.inputtype.option-input {
margin: (-$baseline/2) 0 $baseline;
......@@ -773,7 +775,7 @@ div.problem {
.indicator-container {
display: inline-block;
.status.correct:after, .status.incorrect:after {
.status.correct:after, .status.incorrect:after, .status.unanswered:after {
@include margin-left(0);
}
}
......@@ -781,8 +783,8 @@ div.problem {
}
// +Problem - CodeMirror
// ====================
div.problem {
// ====================
div.problem {
.CodeMirror {
border: 1px solid black;
......@@ -836,7 +838,7 @@ div.problem {
}
// +Problem - Actions
// ====================
// ====================
div.problem .action {
margin-top: $baseline;
......@@ -847,6 +849,9 @@ div.problem .action {
vertical-align: middle;
text-transform: uppercase;
font-weight: 600;
@media print {
display: none;
}
}
.save {
......@@ -877,8 +882,8 @@ div.problem .action {
}
// +Problem - Misc, Unclassified Mess Part 2
// ====================
div.problem {
// ====================
div.problem {
hr {
float: none;
clear: both;
......@@ -907,7 +912,7 @@ div.problem {
padding: lh();
border: 1px solid $gray-l3;
}
.detailed-solution {
> p:first-child {
@extend %t-strong;
......@@ -1159,7 +1164,7 @@ div.problem {
// +Problem - Rubric
// ====================
// ====================
div.problem {
.rubric {
tr {
......@@ -1216,7 +1221,7 @@ div.problem {
}
// +Problem - Annotation
// ====================
// ====================
div.problem {
.annotation-input {
margin: 0 0 1em 0;
......@@ -1318,7 +1323,7 @@ div.problem {
}
// +Problem - Choice Text Group
// ====================
// ====================
div.problem {
.choicetextgroup {
@extend .choicegroup;
......@@ -1365,10 +1370,44 @@ div.problem .imageinput.capa_inputtype {
width: 25px;
height: 20px;
}
.correct {
background: url('../images/correct-icon.png') center center no-repeat;
}
.incorrect {
background: url('../images/incorrect-icon.png') center center no-repeat;
}
.partially-correct {
background: url('../images/partially-correct-icon.png') center center no-repeat;
}
}
// +Problem - Annotation Problem Overrides
// ====================
// NOTE: temporary override until annotation problem inputs use same base html structure as other common capa input types.
div.problem .annotation-input {
.tag-status {
display: inline-block;
position: relative;
top: 3px;
width: 25px;
height: 20px;
}
.correct {
background: url('../images/correct-icon.png') center center no-repeat;
}
.incorrect {
background: url('../images/incorrect-icon.png') center center no-repeat;
}
.partially-correct {
background: url('../images/partially-correct-icon.png') center center no-repeat;
}
}
from pkg_resources import resource_string
import json
from xblock.core import XBlock
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
......@@ -41,7 +43,20 @@ class DiscussionFields(object):
sort_key = String(scope=Scope.settings)
def has_permission(user, permission, course_id):
"""
Copied from django_comment_client/permissions.py because I can't import
that file from here. It causes the xmodule_assets command to fail.
"""
return any(role.has_permission(permission)
for role in user.roles.filter(course_id=course_id))
@XBlock.wants('user')
class DiscussionModule(DiscussionFields, XModule):
"""
XModule for discussion forums.
"""
js = {
'coffee': [
resource_string(__name__, 'js/src/discussion/display.coffee')
......@@ -53,9 +68,26 @@ class DiscussionModule(DiscussionFields, XModule):
js_module_name = "InlineDiscussion"
def get_html(self):
course = self.get_course()
user = None
user_service = self.runtime.service(self, 'user')
if user_service:
user = user_service._django_user # pylint: disable=protected-access
if user:
course_key = course.id # pylint: disable=no-member
can_create_comment = has_permission(user, "create_comment", course_key)
can_create_subcomment = has_permission(user, "create_sub_comment", course_key)
can_create_thread = has_permission(user, "create_thread", course_key)
else:
can_create_comment = False
can_create_subcomment = False
can_create_thread = False
context = {
'discussion_id': self.discussion_id,
'course': self.get_course(),
'course': course,
'can_create_comment': json.dumps(can_create_comment),
'can_create_subcomment': json.dumps(can_create_subcomment),
'can_create_thread': can_create_thread,
}
if getattr(self.system, 'is_author_mode', False):
template = 'discussion/_discussion_module_studio.html'
......
......@@ -724,7 +724,7 @@ describe 'MarkdownEditingDescriptor', ->
<p>Choice checks</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="false">option1 [x]</choice>
<choice correct="true">correct</choice>
<choice correct="true">redundant</choice>
......
......@@ -188,7 +188,7 @@ describe 'Markdown to xml extended hint checkbox', ->
<problem>
<p>Select all the fruits from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the fruits from the list" direction="vertical">
<checkboxgroup label="Select all the fruits from the list">
<choice correct="true">Apple
<choicehint selected="true">You're right that apple is a fruit.</choicehint>
<choicehint selected="false">Remember that apple is also a fruit.</choicehint></choice>
......@@ -209,7 +209,7 @@ describe 'Markdown to xml extended hint checkbox', ->
<p>Select all the vegetables from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<checkboxgroup label="Select all the vegetables from the list">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.</choicehint>
<choicehint selected="false">poor banana.</choicehint></choice>
......@@ -266,7 +266,7 @@ describe 'Markdown to xml extended hint checkbox', ->
<problem>
<p>Select all the fruits from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the fruits from the list" direction="vertical">
<checkboxgroup label="Select all the fruits from the list">
<choice correct="true">Apple
<choicehint selected="true">You're right that apple is a fruit.</choicehint>
<choicehint selected="false">Remember that apple is also a fruit.</choicehint></choice>
......@@ -287,7 +287,7 @@ describe 'Markdown to xml extended hint checkbox', ->
<p>Select all the vegetables from the list</p>
<choiceresponse>
<checkboxgroup label="Select all the vegetables from the list" direction="vertical">
<checkboxgroup label="Select all the vegetables from the list">
<choice correct="false">Banana
<choicehint selected="true">No, sorry, a banana is a fruit.</choicehint>
<choicehint selected="false">poor banana.</choicehint></choice>
......@@ -753,7 +753,7 @@ describe 'Markdown to xml extended hint with multiline hints', ->
<problem>
<p>Checkboxes</p>
<choiceresponse>
<checkboxgroup label="Checkboxes" direction="vertical">
<checkboxgroup label="Checkboxes">
<choice correct="true">A
<choicehint selected="true">aaa</choicehint>
<choicehint selected="false">bbb</choicehint></choice>
......@@ -891,7 +891,7 @@ describe 'Markdown to xml extended hint with tricky syntax cases', ->
<p>q1</p>
<p>this [x]</p>
<choiceresponse>
<checkboxgroup label="q1" direction="vertical">
<checkboxgroup label="q1">
<choice correct="false">a [square]</choice>
<choice correct="true">b {{ this hint passes through }}</choice>
</checkboxgroup>
......
......@@ -353,7 +353,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
var groupString = '<choiceresponse>\n',
options, value, correct;
groupString += ' <checkboxgroup direction="vertical">\n';
groupString += ' <checkboxgroup>\n';
options = match.split('\n');
endHints = ''; // save these up to emit at the end
......
......@@ -44,8 +44,8 @@ def path_to_location(modulestore, usage_key):
If no path exists, return None.
If a path exists, return it as a list with target location first, and
the starting location last.
If a path exists, return it as a tuple with root location first, and
the target location last.
'''
# Standard DFS
......@@ -86,6 +86,7 @@ def path_to_location(modulestore, usage_key):
# pull out the location names
chapter = path[1].name if n > 1 else None
section = path[2].name if n > 2 else None
vertical = path[3].name if n > 3 else None
# Figure out the position
position = None
......@@ -109,7 +110,7 @@ def path_to_location(modulestore, usage_key):
position_list.append(str(child_locs.index(path[path_index + 1]) + 1))
position = "_".join(position_list)
return (course_id, chapter, section, position)
return (course_id, chapter, section, vertical, position, path[-1])
def navigation_index(position):
......
......@@ -309,7 +309,7 @@ class MongoConnection(object):
if self.database.connection.alive():
return True
else:
raise HeartbeatFailure("Can't connect to {}".format(self.database.name))
raise HeartbeatFailure("Can't connect to {}".format(self.database.name), 'mongo')
def get_structure(self, key, course_context=None):
"""
......
......@@ -213,9 +213,11 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
parent_loc = self.get_parent_location(branched_location)
SplitMongoModuleStore.delete_item(self, branched_location, user_id)
# publish parent w/o child if deleted element is direct only (not based on type of parent)
# publish vertical to behave more like the old mongo/draft modulestore - TNL-2593
if (
branch == ModuleStoreEnum.BranchName.draft and
branched_location.block_type in DIRECT_ONLY_CATEGORIES and parent_loc
branched_location.block_type in (DIRECT_ONLY_CATEGORIES + ['vertical']) and
parent_loc
):
# will publish if its not an orphan
self.publish(parent_loc.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
......
......@@ -99,7 +99,11 @@ class CourseFactory(XModuleFactory):
# pylint: disable=unused-argument
@classmethod
def _create(cls, target_class, **kwargs):
"""
Create and return a new course. For performance reasons, we do not emit
signals during this process, but if you need signals to run, you can
pass `emit_signals=True` to this method.
"""
# All class attributes (from this class and base classes) are
# passed in via **kwargs. However, some of those aren't actual field values,
# so pop those off for use separately
......@@ -110,20 +114,23 @@ class CourseFactory(XModuleFactory):
name = kwargs.get('name', kwargs.get('run', Location.clean(kwargs.get('display_name'))))
run = kwargs.pop('run', name)
user_id = kwargs.pop('user_id', ModuleStoreEnum.UserID.test)
emit_signals = kwargs.get('emit_signals', False)
# Pass the metadata just as field=value pairs
kwargs.update(kwargs.pop('metadata', {}))
default_store_override = kwargs.pop('default_store', None)
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
if default_store_override is not None:
with store.default_store(default_store_override):
course_key = store.make_course_key(org, number, run)
with store.bulk_operations(course_key, emit_signals=emit_signals):
if default_store_override is not None:
with store.default_store(default_store_override):
new_course = store.create_course(org, number, run, user_id, fields=kwargs)
else:
new_course = store.create_course(org, number, run, user_id, fields=kwargs)
else:
new_course = store.create_course(org, number, run, user_id, fields=kwargs)
last_course.loc = new_course.location
return new_course
last_course.loc = new_course.location
return new_course
class LibraryFactory(XModuleFactory):
......
......@@ -634,6 +634,27 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
vertical = self.store.get_item(vertical.location)
self.assertTrue(self._has_changes(vertical.location))
@ddt.data('draft', 'split')
def test_publish_automatically_after_delete_unit(self, default_ms):
"""
Check that sequential publishes automatically after deleting a unit
"""
self.initdb(default_ms)
test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id)
# create sequential and vertical to test against
sequential = self.store.create_item(self.user_id, test_course.id, 'sequential', 'test_sequential')
vertical = self.store.create_child(self.user_id, sequential.location, 'vertical', 'test_vertical')
# publish sequential changes
self.store.publish(sequential.location, self.user_id)
self.assertFalse(self._has_changes(sequential.location))
# delete vertical and check sequential has no changes
self.store.delete_item(vertical.location, self.user_id)
self.assertFalse(self._has_changes(sequential.location))
def setup_has_changes(self, default_ms):
"""
Common set up for has_changes tests below.
......@@ -854,7 +875,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
# Split:
# queries: active_versions, draft and published structures, definition (unnecessary)
# sends: update published (why?), draft, and active_versions
@ddt.data(('draft', 9, 2), ('split', 2, 2))
@ddt.data(('draft', 9, 2), ('split', 4, 3))
@ddt.unpack
def test_delete_private_vertical(self, default_ms, max_find, max_send):
"""
......@@ -1221,15 +1242,16 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
should_work = (
(self.problem_x1a_2,
(course_key, u"Chapter_x", u"Sequential_x1", '1')),
(course_key, u"Chapter_x", u"Sequential_x1", u'Vertical_x1a', '1', self.problem_x1a_2)),
(self.chapter_x,
(course_key, "Chapter_x", None, None)),
(course_key, "Chapter_x", None, None, None, self.chapter_x)),
)
for location, expected in should_work:
# each iteration has different find count, pop this iter's find count
with check_mongo_calls(num_finds.pop(0), num_sends):
self.assertEqual(path_to_location(self.store, location), expected)
path = path_to_location(self.store, location)
self.assertEqual(path, expected)
not_found = (
course_key.make_usage_key('video', 'WelcomeX'),
......@@ -1259,11 +1281,13 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
# only needs course_locations set
self.initdb('draft')
course_key = self.course_locations[self.XML_COURSEID1].course_key
video_key = course_key.make_usage_key('video', 'Welcome')
chapter_key = course_key.make_usage_key('chapter', 'Overview')
should_work = (
(course_key.make_usage_key('video', 'Welcome'),
(course_key, "Overview", "Welcome", None)),
(course_key.make_usage_key('chapter', 'Overview'),
(course_key, "Overview", None, None)),
(video_key,
(course_key, "Overview", "Welcome", None, None, video_key)),
(chapter_key,
(course_key, "Overview", None, None, None, chapter_key)),
)
for location, expected in should_work:
......
""" Test the behavior of split_mongo/MongoConnection """
import unittest
from mock import patch
from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection
from xmodule.exceptions import HeartbeatFailure
class TestHeartbeatFailureException(unittest.TestCase):
""" Test that a heartbeat failure is thrown at the appropriate times """
@patch('pymongo.MongoClient')
@patch('pymongo.database.Database')
def test_heartbeat_raises_exception_when_connection_alive_is_false(self, *calls):
# pylint: disable=W0613
with patch('mongodb_proxy.MongoProxy') as mock_proxy:
mock_proxy.return_value.alive.return_value = False
useless_conn = MongoConnection('useless', 'useless', 'useless')
with self.assertRaises(HeartbeatFailure):
useless_conn.heartbeat()
......@@ -149,7 +149,7 @@ class ProceduralCourseTestMixin(object):
"""
Contains methods for testing courses generated procedurally
"""
def populate_course(self, branching=2):
def populate_course(self, branching=2, emit_signals=False):
"""
Add k chapters, k^2 sections, k^3 verticals, k^4 problems to self.course (where k = branching)
"""
......@@ -172,4 +172,5 @@ class ProceduralCourseTestMixin(object):
)
descend(child, stack[1:])
descend(self.course, ['chapter', 'sequential', 'vertical', 'problem'])
with self.store.bulk_operations(self.course.id, emit_signals=emit_signals):
descend(self.course, ['chapter', 'sequential', 'vertical', 'problem'])
......@@ -116,12 +116,15 @@ class Progress(object):
return not self.__eq__(other)
def __str__(self):
''' Return a string representation of this string.
'''Return a string representation of this string. Rounds results to
two decimal places, stripping out any trailing zeroes.
subclassing note: implemented in terms of frac().
'''
(a, b) = self.frac()
return "{0}/{1}".format(a, b)
display = lambda n: '{:.2f}'.format(n).rstrip('0').rstrip('.')
return "{0}/{1}".format(display(a), display(b))
@staticmethod
def add_counts(a, b):
......
......@@ -29,7 +29,7 @@ data: |
<p>You can use the following example problem as a model.</p>
<p>The following languages are in the Indo-European family:</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="true" name="urdu">Urdu</choice>
<choice correct="false" name="finnish">Finnish</choice>
<choice correct="true" name="marathi">Marathi</choice>
......
......@@ -41,7 +41,7 @@ data: |
<p>Which of the following is a fruit? Check all that apply.</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<checkboxgroup>
<choice correct="true">apple
<choicehint selected="true">You are correct that an apple is a fruit because it is the fertilized ovary that comes from an apple tree and contains seeds.</choicehint>
<choicehint selected="false">Remember that an apple is also a fruit.</choicehint>
......
......@@ -2,6 +2,7 @@
metadata:
display_name: Drag and Drop
markdown: !!null
showanswer: never
data: |
<problem>
<p>
......
......@@ -2,6 +2,7 @@
metadata:
display_name: Custom Javascript Display and Grading
markdown: !!null
showanswer: never
data: |
<problem>
<p>
......
......@@ -1822,7 +1822,7 @@ class TestProblemCheckTracking(unittest.TestCase):
</multiplechoiceresponse>
<p>Which of the following are musical instruments?</p>
<choiceresponse>
<checkboxgroup direction="vertical" label="Which of the following are musical instruments?">
<checkboxgroup label="Which of the following are musical instruments?">
<choice correct="true">a piano</choice>
<choice correct="false">a tree</choice>
<choice correct="true">a guitar</choice>
......
......@@ -81,6 +81,9 @@ class ProgressTest(unittest.TestCase):
self.assertEqual(str(self.not_started), "0/17")
self.assertEqual(str(self.part_done), "2/6")
self.assertEqual(str(self.done), "7/7")
self.assertEqual(str(Progress(2.1234, 7)), '2.12/7')
self.assertEqual(str(Progress(2.0034, 7)), '2/7')
self.assertEqual(str(Progress(0.999, 7)), '1/7')
def test_ternary_str(self):
self.assertEqual(self.not_started.ternary_str(), "none")
......
......@@ -224,6 +224,13 @@ describe "DiscussionThreadListView", ->
checkThreadsOrdering(view, sort_order, type)
expect(view.$el.find(".forum-nav-thread-comments-count:visible").length).toEqual(if type == "votes" then 0 else 4)
expect(view.$el.find(".forum-nav-thread-votes-count:visible").length).toEqual(if type == "votes" then 4 else 0)
if type == "votes"
expect(
_.map(
view.$el.find(".forum-nav-thread-votes-count"),
(element) -> $(element).text().trim()
)
).toEqual(["+25 votes", "+20 votes", "+42 votes", "+12 votes"])
it "with sort preference date", ->
checkRender(@threads, "date", ["Thread1", "Thread4", "Thread2", "Thread3"])
......@@ -415,7 +422,7 @@ describe "DiscussionThreadListView", ->
it "for answered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: true})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-check")
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-check-square-o")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("answered question")
it "for unanswered question", ->
......
......@@ -80,16 +80,16 @@ describe "DiscussionThreadView", ->
expect(view.$('.display-vote').is(":visible")).toBe(not originallyClosed)
_.each(["tab", "inline"], (mode) =>
it 'Test that in #{mode} mode when a closed thread is opened the comment form is displayed', ->
it "Test that in #{mode} mode when a closed thread is opened the comment form is displayed", ->
checkCommentForm(true, mode)
it 'Test that in #{mode} mode when a open thread is closed the comment form is hidden', ->
it "Test that in #{mode} mode when a open thread is closed the comment form is hidden", ->
checkCommentForm(false, mode)
it 'Test that in #{mode} mode when a closed thread is opened the vote button is displayed and vote count is hidden', ->
it "Test that in #{mode} mode when a closed thread is opened the vote button is displayed and vote count is hidden", ->
checkVoteDisplay(true, mode)
it 'Test that in #{mode} mode when a open thread is closed the vote button is hidden and vote count is displayed', ->
it "Test that in #{mode} mode when a open thread is closed the vote button is hidden and vote count is displayed", ->
checkVoteDisplay(false, mode)
)
......
......@@ -180,7 +180,7 @@ describe "NewPostView", ->
eventSpy = jasmine.createSpy('eventSpy')
view.listenTo(view, "newPost:cancel", eventSpy)
view.$(".post-errors").html("<li class='post-error'>Title can't be empty</li>")
view.$("label[for$='post-type-discussion']").click()
view.$("label[for$='post-type-question']").click()
view.$(".js-post-title").val("Test Title")
view.$(".js-post-body textarea").val("Test body")
view.$(".wmd-preview p").html("Test body")
......@@ -192,8 +192,8 @@ describe "NewPostView", ->
view.$(".cancel").click()
expect(eventSpy).toHaveBeenCalled()
expect(view.$(".post-errors").html()).toEqual("");
expect($("input[id$='post-type-question']")).toBeChecked()
expect($("input[id$='post-type-discussion']")).not.toBeChecked()
expect($("input[id$='post-type-discussion']")).toBeChecked()
expect($("input[id$='post-type-question']")).not.toBeChecked()
expect(view.$(".js-post-title").val()).toEqual("");
expect(view.$(".js-post-body textarea").val()).toEqual("");
expect(view.$(".js-follow")).toBeChecked()
......
......@@ -5,6 +5,7 @@ if Backbone?
DiscussionUtil.loadRolesFromContainer()
element = $(elem)
window.$$course_id = element.data("course-id")
window.courseName = element.data("course-name")
user_info = element.data("user-info")
sort_preference = element.data("sort-preference")
threads = element.data("threads")
......
......@@ -248,7 +248,7 @@ if Backbone?
@$(".forum-nav-thread[data-id='#{thread_id}'] .forum-nav-thread-link").addClass("is-active").find(".forum-nav-thread-wrapper-1").prepend('<span class="sr">' + gettext("Current conversation") + '</span>')
goHome: ->
@template = _.template($("#discussion-home").html())
@template = _.template($("#discussion-home-template").html())
$(".forum-content").html(@template)
$(".forum-nav-thread-list a").removeClass("is-active").find(".sr").remove()
$("input.email-setting").bind "click", @updateEmailNotifications
......
......@@ -50,7 +50,13 @@ if Backbone?
renderTemplate: ->
@template = _.template($("#thread-template").html())
@template(@model.toJSON())
templateData = @model.toJSON()
container = $("#discussion-container")
if !container.length
# inline discussion
container = $(".discussion-module")
templateData.can_create_comment = container.data("user-create-comment")
@template(templateData)
render: ->
@$el.html(@renderTemplate())
......
......@@ -19,6 +19,11 @@ if Backbone?
templateData = @model.toJSON()
templateData.wmdId = @model.id ? (new Date()).getTime()
container = $("#discussion-container")
if !container.length
# inline discussion
container = $(".discussion-module")
templateData.create_sub_comment = container.data("user-create-subcomment")
@template(templateData)
render: ->
......
define([
'jquery',
'backbone',
'underscore',
'common/js/spec_helpers/ajax_helpers',
'common/js/components/views/paginated_view',
'common/js/components/collections/paging_collection'
], function (Backbone, _, AjaxHelpers, PaginatedView, PagingCollection) {
], function ($, Backbone, _, AjaxHelpers, PaginatedView, PagingCollection) {
'use strict';
describe('PaginatedView', function () {
var TestItemView = Backbone.View.extend({
......
......@@ -239,7 +239,7 @@ define(['jquery',
}
expect(collection.getPage()).toBe(newPage);
}
)
);
});
});
});
......
define([
'jquery',
'URI',
'underscore',
'common/js/spec_helpers/ajax_helpers',
'common/js/components/views/paging_footer',
'common/js/components/collections/paging_collection'
], function (URI, _, AjaxHelpers, PagingFooter, PagingCollection) {
], function ($, URI, _, AjaxHelpers, PagingFooter, PagingCollection) {
'use strict';
describe("PagingFooter", function () {
var pagingFooter,
......
define([
'underscore',
'common/js/components/views/paging_header',
'common/js/components/collections/paging_collection'
], function (PagingHeader, PagingCollection) {
], function (_, PagingHeader, PagingCollection) {
'use strict';
describe('PagingHeader', function () {
var pagingHeader,
......
......@@ -3,7 +3,8 @@
<div class="nav-item page">
<div class="pagination-form">
<label class="page-number-label" for="page-number-input"><%= gettext("Page number") %></label>
<input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" autocomplete="off" />
<input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" autocomplete="off" aria-describedby="page-number-input-helper"/>
<span class="sr field-helper" id="page-number-input-helper"><%= gettext("Enter the page number you'd like to quickly navigate to.") %></span>
</div>
<span class="current-page"><%= current_page %></span>
......
<div class="discussion-article blank-slate">
<section class="home-header">
<span class="label"><%- gettext("DISCUSSION HOME:") %></span>
<% if (window.courseName) { %>
<h1 class="home-title"><%- window.courseName %></h1>
<% } %>
</section>
<% if (window.ENABLE_DISCUSSION_HOME_PANEL) { %>
<span class="label label-settings">
<%- interpolate(gettext("How to use %(platform_name)s discussions"), {platform_name: window.PLATFORM_NAME}, true) %>
</span>
<table class="home-helpgrid">
<tr class="helpgrid-row helpgrid-row-navigation">
<td class="row-title"><%- gettext("Find discussions") %></td>
<td class="row-item">
<i class="icon fa fa-reorder"></i>
<span class="row-description"><%- gettext("Focus in on specific topics") %></span>
</td>
<td class="row-item">
<i class="icon fa fa-search"></i>
<span class="row-description"><%- gettext("Search for specific posts") %></span>
</td>
<td class="row-item">
<i class="icon fa fa-sort"></i>
<span class="row-description"><%- gettext("Sort by date, vote, or comments") %></span>
</td>
</tr>
<tr class="helpgrid-row helpgrid-row-participation">
<td class="row-title"><%- gettext("Engage with posts") %></td>
<td class="row-item">
<i class="icon fa fa-plus"></i>
<span class="row-description"><%- gettext("Upvote posts and good responses") %></span>
</td>
<td class="row-item">
<i class="icon fa fa-flag"></i>
<span class="row-description"><%- gettext("Report Forum Misuse") %></span>
</td>
<td class="row-item">
<i class="icon fa fa-star"></i>
<span class="row-description"><%- gettext("Follow posts for updates") %></span>
</td>
</tr>
<tr class="helpgrid-row helpgrid-row-notification">
<td class="row-title"><%- gettext('Receive updates') %></td>
<td class="row-item-full" colspan="3">
<label for="email-setting-checkbox">
<span class="sr"><%- gettext("Toggle Notifications Setting") %></span>
<span class="notification-checkbox">
<input type="checkbox" id="email-setting-checkbox" class="email-setting" name="email-notification"/>
<i class="icon fa fa-envelope"></i>
</span>
</label>
<span class="row-description"><%- gettext("Check this box to receive an email digest once a day notifying you about new, unread activity from posts you are following.") %></span>
</td>
</tr>
</table>
<% } %>
</div>
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-answer" role="checkbox" aria-checked="false">
<span class="sr"><%- gettext("Mark as Answer") %></span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked"><%- gettext("Mark as Answer") %></span>
<span class="label-checked"><%- gettext("Unmark as Answer") %></span>
</span>
<span class="action-icon"><i class="icon fa fa-ok"></i></span>
</a>
</li>
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-close" role="checkbox" aria-checked="false">
<span class="sr"><%- gettext("Close") %></span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked"><%- gettext("Close") %></span>
<span class="label-checked"><%- gettext("Open") %></span>
</span>
<span class="action-icon">
<i class="icon fa fa-lock"></i>
</span>
</a>
</li>
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-delete" role="button">
<span class="action-label"><%- gettext("Delete") %></span>
<span class="action-icon"><i class="icon fa fa-remove"></i></span>
</a>
</li>
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-edit" role="button">
<span class="action-label"><%- gettext("Edit") %></span>
<span class="action-icon"><i class="icon fa fa-pencil"></i></span>
</a>
</li>
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-endorse" role="checkbox" aria-checked="false">
<span class="sr"><%- gettext("Endorse") %></span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked"><%- gettext("Endorse") %></span>
<span class="label-checked"><%- gettext("Unendorse") %></span>
</span>
<span class="action-icon"><i class="icon fa fa-ok"></i></span>
</a>
</li>
<li class="actions-item">
<a href="javascript:void(0)" class="action-button action-follow" role="checkbox" aria-checked="false">
<span class="sr"><%- gettext("Follow") %></span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked"><%- gettext("Follow") %></span>
<span class="label-checked"><%- gettext("Unfollow") %></span>
</span>
<span class="action-icon"><i class="icon fa fa-star"></i></span>
</a>
</li>
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-pin" role="checkbox" aria-checked="false">
<span class="sr"><%- gettext("Pin") %></span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked"><%- gettext("Pin") %></span>
<span class="label-checked"><%- gettext("Unpin") %></span>
</span>
<span class="action-icon">
<i class="icon fa fa-thumb-tack"></i>
</span>
</a>
</li>
<li class="actions-item">
<a href="javascript:void(0)" class="action-list-item action-report" role="checkbox" aria-checked="false">
<span class="sr"><%- gettext("Report abuse") %></span>
<span class="action-label" aria-hidden="true">
<span class="label-unchecked"><%- gettext("Report") %></span>
<span class="label-checked"><%- gettext("Unreport") %></span>
</span>
<span class="action-icon">
<i class="icon fa fa-flag"></i>
</span>
</a>
</li>
<li class="actions-item">
<span aria-hidden="true" class="display-vote" >
<span class="vote-count"></span>
</span>
<a href="#" class="action-button action-vote" role="checkbox" aria-checked="false">
<% // Vote counts are populated by JS %>
<span class="sr"><%- gettext("Vote for this post,") %>&nbsp;</span>
<span class="sr js-sr-vote-count"></span>
<span class="action-label" aria-hidden="true">
<span class="vote-count"></span>
</span>
<span class="action-icon" aria-hidden="true">
<i class="icon fa fa-plus"></i>
</span>
</a>
</li>
<ul class="<%= contentType %>-actions-list">
<% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
<li class="actions-item is-visible">
<div class="more-wrapper">
<a href="javascript:void(0)" class="action-button action-more" role="button" aria-haspopup="true" aria-controls="action-menu-<%= contentId %>">
<span class="action-label"><%- gettext("More") %></span>
<span class="action-icon"><i class="icon fa fa-ellipsis-h"></i></span>
</a>
<div class="actions-dropdown" id="action-menu-<%= contentType %>" aria-expanded="false">
<ul class="actions-dropdown-list">
<% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
</ul>
</div>
</div>
</li>
</ul>
<li role="menuitem" class="topic-menu-item">
<span class="topic-title"><%- text %></span>
<ul role="menu" class="topic-submenu"><%= entries %></ul>
</li>
<li role="menuitem" class="topic-menu-item">
<a href="#" class="topic-title" data-discussion-id="<%- id %>" data-cohorted="<%- is_cohorted %>"><%- text %></a>
</li>
<form class="forum-new-post-form">
<ul class="post-errors" style="display: none"></ul>
<div class="forum-new-post-form-wrapper"></div>
<% if (cohort_options) { %>
<div class="post-field group-selector-wrapper <% if (!is_commentable_cohorted) { print('disabled'); } %>">
<label class="field-label">
<span class="field-label-text">
<% //Translators: This labels the selector for which group of students can view a post %>
<%- gettext("Visible To:") %>
</span><select aria-describedby="field_help_visible_to" class="field-input js-group-select" name="group_id" <% if (!is_commentable_cohorted) { print("disabled"); } %>>
<option value=""><%- gettext("All Groups") %></option>
<% _.each(cohort_options, function(opt) { %>
<option value="<%= opt.value %>" <% if (opt.selected) { print("selected"); } %>><%- opt.text %></option>
<% }); %>
</select>
</label><div class="field-help" id="field_help_visible_to">
<%- gettext("Discussion admins, moderators, and TAs can make their posts visible to all students or specify a single cohort.") %>
</div>
</div>
<% } %>
<div class="post-field">
<label class="field-label">
<span class="sr"><%- gettext("Title:") %></span>
<input aria-describedby="field_help_title" type="text" class="field-input js-post-title" name="title" placeholder="<%- gettext('Title') %>">
</label><span class="field-help" id="field_help_title">
<%- gettext("Add a clear and descriptive title to encourage participation.") %>
</span>
</div>
<div class="post-field js-post-body editor" name="body" data-placeholder="<%- gettext('Enter your question or comment') %>"></div>
<div class="post-options">
<label class="post-option is-enabled">
<input type="checkbox" name="follow" class="post-option-input js-follow" checked>
<i class="icon fa fa-star"></i><%- gettext("follow this post") %>
</label>
<% if (allow_anonymous) { %>
<label class="post-option">
<input type="checkbox" name="anonymous" class="post-option-input js-anon">
<%- gettext("post anonymously") %>
</label>
<% } %>
<% if (allow_anonymous_to_peers) { %>
<label class="post-option">
<input type="checkbox" name="anonymous_to_peers" class="post-option-input js-anon-peers">
<%- gettext("post anonymously to classmates") %>
</label>
<% } %>
</div>
<div>
<input type="submit" class="submit" value="<%- gettext('Add Post') %>">
<a href="#" class="cancel"><%- gettext('Cancel') %></a>
</div>
</form>
<% if (username) { %>
<a href="<%- user_url %>" class="username"><%- username %></a>
<% if (is_community_ta) { %>
<span class="user-label-community-ta"><%- gettext("Community TA") %></span>
<% } else if (is_staff) { %>
<span class="user-label-staff"><%- gettext("Staff") %></span>
<% } %>
<% } else { %>
<%- gettext('anonymous') %>
<% } %>
<div class="edit-post-form" id="comment_<%- id %>">
<h1><%- gettext("Editing comment") %></h1>
<ul class="edit-comment-form-errors"></ul>
<div class="form-row">
<div class="edit-comment-body" name="body" data-id="<%- id %>"><%- body %></div>
</div>
<input type="submit" id="edit-comment-submit" class="post-update" value="<%- gettext("Update comment") %>">
<a href="#" class="post-cancel"><%- gettext("Cancel") %></a>
</div>
<div id="comment_<%- id %>">
<div class="response-body"><%- body %></div>
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'comment',
primaryActions: [],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>
<p class="posted-details">
<%
var time_ago = interpolate(
'<span class="timeago" title="%(time)s">%(time)s</span>',
{time: created_at},
true
);
%>
<%= interpolate(
// Translators: 'timeago' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago)
gettext("posted %(time_ago)s by %(author)s"),
{time_ago: time_ago, author: author_display},
true
) %>
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon fa fa-flag"></i><%- gettext("Reported") %></span>
</div>
</div>
<div class="search-alert" id="search-alert-<%- cid %>">
<div class="search-alert-content">
<p class="message"><%= message %></p>
</div>
<div class="search-alert-controls">
<a href="#" class="dismiss control control-dismiss"><i class="icon fa fa-remove"></i></a>
</div>
</div>
<h1><%- gettext("Editing post") %></h1>
<ul class="post-errors"></ul>
<div class="forum-edit-post-form-wrapper"></div>
<div class="form-row">
<label class="sr" for="edit-post-title"><%- gettext("Edit post title") %></label>
<input type="text" id="edit-post-title" class="edit-post-title" name="title" value="<%-title %>" placeholder="<%- gettext('Title') %>">
</div>
<div class="form-row">
<div class="edit-post-body" name="body"><%- body %></div>
</div>
<input type="submit" id="edit-post-submit" class="post-update" value="<%- gettext("Update post") %>">
<a href="#" class="post-cancel"><%- gettext("Cancel") %></a>
<li data-id="<%- id %>" class="forum-nav-thread<% if (typeof(read) != "undefined" && !read) { %> is-unread<% } %>">
<a href="#" class="forum-nav-thread-link">
<div class="forum-nav-thread-wrapper-0">
<%
var icon_class, sr_text;
if (thread_type === "discussion") {
icon_class = "fa-comments";
// Translators: This is a label for a Discussion forum thread
sr_text = gettext("discussion");
} else if (endorsed) {
icon_class = "fa-check-square-o";
// Translators: This is a label for a Question forum thread with a marked answer
sr_text = gettext("answered question");
} else {
icon_class = "fa-question";
// Translators: This is a label for a Question forum thread without a marked answer
sr_text = gettext("unanswered question");
}
%>
<span class="sr"><%= sr_text %></span>
<i class="icon fa <%= icon_class %>"></i>
</div><div class="forum-nav-thread-wrapper-1">
<span class="forum-nav-thread-title"><%- title %></span>
<% if(typeof(subscribed) === "undefined") { var subscribed = null; } %>
<% if(pinned || subscribed || staff_authored || community_ta_authored) { %>
<ul class="forum-nav-thread-labels">
<% if (pinned) { %>
<li class="post-label-pinned">
<i class="icon fa fa-thumb-tack"></i>
<% // Translators: This is a label for a forum thread that has been pinned %>
<%- gettext("Pinned") %>
</li>
<% } %>
<% if (subscribed) { %>
<li class="post-label-following">
<i class="icon fa fa-star"></i>
<% // Translators: This is a label for a forum thread that the user is subscribed to %>
<%- gettext("Following") %>
</li>
<% } %>
<% if (staff_authored) { %>
<li class="post-label-by-staff">
<i class="icon fa fa-user"></i>
<% // Translators: This is a label for a forum thread that was authored by a member of the course staff %>
<%- gettext("By: Staff") %>
</li>
<% } %>
<% if (community_ta_authored) { %>
<li class="post-label-by-community-ta">
<i class="icon fa fa-user"></i>
<% // Translators: This is a label for a forum thread that was authored by a community TA %>
<%- gettext("By: Community TA") %>
</li>
<% } %>
</ul>
<% } %>
</div><div class="forum-nav-thread-wrapper-2">
<%
// Translators: 'votes_count' is a numerical placeholder for a specific discussion thread; 'span_start' and 'span_end' placeholders refer to HTML markup. Please translate the word 'votes'.
var fmt = ngettext(
"%(votes_count)s%(span_start)s vote %(span_end)s",
"%(votes_count)s%(span_start)s votes %(span_end)s",
votes['up_count']
);
%>
<span class="forum-nav-thread-votes-count">
+<%= interpolate(fmt, {
votes_count: votes['up_count'],
span_start: '<span class="sr">',
span_end: '</span>'
}, true)
%>
</span>
<span class="forum-nav-thread-comments-count <% if (unread_comments_count > 0) { %>is-unread<% } %>">
<%
var fmt;
// Counts in data do not include the post itself, but the UI should
var data = {
'span_sr_open': '<span class="sr">',
'span_close': '</span>',
'unread_comments_count': unread_comments_count + (read ? 0 : 1),
'comments_count': comments_count + 1
};
if (unread_comments_count > 0) {
// Translators: 'comments_count' and 'unread_comments_count' are numerical placeholders for a specific discussion thread; 'span_*' placeholders refer to HTML markup. Please translate the word 'comments'.
fmt = gettext('%(comments_count)s %(span_sr_open)scomments (%(unread_comments_count)s unread comments)%(span_close)s');
} else {
// Translators: 'comments_count' is a numerical placeholder for a specific discussion thread; 'span_*' placeholders refer to HTML markup. Please translate the word 'comments'.
fmt = gettext('%(comments_count)s %(span_sr_open)scomments %(span_close)s');
}
print(interpolate(fmt, data, true));
%>
</span>
</div>
</a>
</li>
<div class="edit-post-form">
<h1><%- gettext("Editing response") %></h1>
<ul class="edit-post-form-errors"></ul>
<div class="form-row">
<div class="edit-post-body" name="body" data-id="<%- id %>"><%- body %></div>
</div>
<input type="submit" id="edit-response-submit"class="post-update" value="<%- gettext("Update response") %>">
<a href="#" class="post-cancel"><%- gettext("Cancel") %></a>
</div>
<header>
<div class="response-header-content">
<%= author_display %>
<p class="posted-details">
<span class="timeago" title="<%= created_at %>"><%= created_at %></span>
<% if (obj.endorsement) { %>
-
<%
var fmt = null;
if (thread.get("thread_type") == "question") {
if (endorsement.username) {
// Translators: time_ago is a placeholder for a fuzzy, relative timestamp
// like "4 hours ago" or "about a month ago"
fmt = gettext("marked as answer %(time_ago)s by %(user)s");
} else {
// Translators: time_ago is a placeholder for a fuzzy, relative timestamp
// like "4 hours ago" or "about a month ago"
fmt = gettext("marked as answer %(time_ago)s");
}
} else {
if (endorsement.username) {
// Translators: time_ago is a placeholder for a fuzzy, relative timestamp
// like "4 hours ago" or "about a month ago"
fmt = gettext("endorsed %(time_ago)s by %(user)s");
} else {
// Translators: time_ago is a placeholder for a fuzzy, relative timestamp
// like "4 hours ago" or "about a month ago"
fmt = gettext("endorsed %(time_ago)s");
}
}
var time_ago = interpolate(
'<span class="timeago" title="%(time)s">%(time)s</span>',
{time: endorsement.time},
true
);
%>
<%= interpolate(fmt, {time_ago: time_ago, user: endorser_display}, true) %>
<% } %>
</p>
<div class="post-labels">
<span class="post-label-reported"><i class="icon fa fa-flag"></i><%- gettext("Reported") %></span>
</div>
</div>
<div class="response-header-actions">
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'response',
primaryActions: ['vote', thread.get('thread_type') == 'question' ? 'answer' : 'endorse'],
secondaryActions: ['edit', 'delete', 'report']
}
)
%>
</div>
</header>
<div class="response-body"><%- body %></div>
<div class="discussion-response"></div>
<a href="#" class="action-show-comments">
<%
var fmts = ngettext(
"Show Comment (%(num_comments)s)",
"Show Comments (%(num_comments)s)",
comments.length
);
print(interpolate(fmts, {num_comments: comments.length}, true));
%>
<i class="icon fa fa-caret-down"></i>
</a>
<ol class="comments">
<li class="new-comment">
<% if (create_sub_comment) { %>
<form class="comment-form" data-id="<%- wmdId %>">
<ul class="discussion-errors"></ul>
<label class="sr" for="add-new-comment"><%- gettext("Add a comment") %></label>
<div class="comment-body" id="add-new-comment" data-id="<%- wmdId %>"
data-placeholder="<%- gettext('Add a comment') %>"></div>
<div class="comment-post-control">
<a class="discussion-submit-comment control-button" href="#"><%- gettext("Submit") %></a>
</div>
</form>
<% } %>
</li>
</ol>
<div class="post-field">
<div class="field-label">
<span class="field-label-text">
<% // Translators: This is the label for a control to select a forum post type %>
<%- gettext("Post type:") %>
</span><fieldset class="field-input"><legend class="sr"><%- gettext("Post type:") %></legend>
<input aria-describedby="field_help_post_type" type="radio" name="<%= form_id %>-post-type" class="post-type-input" id="<%= form_id %>-post-type-question" value="question">
<label for="<%= form_id %>-post-type-question" class="post-type-label">
<i class="icon fa fa-question"></i>
<% // Translators: This is a forum post type %>
<%- gettext("Question") %>
</label>
<input aria-describedby="field_help_post_type" type="radio" name="<%= form_id %>-post-type" class="post-type-input" id="<%= form_id %>-post-type-discussion" value="discussion" checked>
<label for="<%= form_id %>-post-type-discussion" class="post-type-label">
<i class="icon fa fa-comments"></i>
<% // Translators: This is a forum post type %>
<%- gettext("Discussion") %>
</label>
</fieldset>
</div><span class="field-help" id="field_help_post_type">
<%- gettext("Questions raise issues that need answers. Discussions share ideas and start conversations.") %>
</span>
</div>
This source diff could not be displayed because it is too large. You can view the blob instead.
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