Commit 9d344f67 by Max Rothman

Merge branch 'rc/2015-07-14' into release

Conflicts:
	cms/djangoapps/contentstore/views/tests/test_import_export.py
	lms/djangoapps/commerce/urls.py
	lms/templates/courseware/progress.html
parents 1058c36d 035aa497
......@@ -225,4 +225,5 @@ Alessandro Verdura <finalmente2@tin.it>
Sven Marnach <sven@marnach.net>
Richard Moch <richard.moch@gmail.com>
Albert Liang <albertliangcode@gmail.com>
Pa Luo <pan.luo@ubc.ca>
Tyler Nickerson <nickersoft@gmail.com>
......@@ -2,12 +2,3 @@ source 'https://rubygems.org'
gem 'sass', '3.3.5'
gem 'bourbon', '~> 4.0.2'
gem 'neat', '~> 1.6.0'
gem 'colorize', '~> 0.5.8'
gem 'launchy', '~> 2.1.2'
gem 'sys-proctable', '~> 0.9.3'
gem 'dalli', '~> 2.6.4'
# These gems aren't actually required; they are used by Linux and Mac to
# detect when files change. If these gems are not installed, the system
# will fall back to polling files.
gem 'rb-inotify', '~> 0.9'
gem 'rb-fsevent', '~> 0.9.3'
GEM
remote: https://rubygems.org/
specs:
addressable (2.3.5)
bourbon (4.0.2)
sass (~> 3.3)
thor
colorize (0.5.8)
dalli (2.6.4)
ffi (1.9.0)
launchy (2.1.2)
addressable (~> 2.3)
neat (1.6.0)
bourbon (>= 3.1)
sass (>= 3.3)
rb-fsevent (0.9.3)
rb-inotify (0.9.2)
ffi (>= 0.5.0)
sass (3.3.5)
sys-proctable (0.9.3)
thor (0.19.1)
PLATFORMS
......@@ -25,11 +15,5 @@ PLATFORMS
DEPENDENCIES
bourbon (~> 4.0.2)
colorize (~> 0.5.8)
dalli (~> 2.6.4)
launchy (~> 2.1.2)
neat (~> 1.6.0)
rb-fsevent (~> 0.9.3)
rb-inotify (~> 0.9)
sass (= 3.3.5)
sys-proctable (~> 0.9.3)
......@@ -35,7 +35,7 @@ The Open edX Portal
See the `Open edX Portal`_ to learn more about Open edX. You can find
information about the edX roadmap, as well as about hosting, extending, and
contributing to Open edX. In addition, the Open edX Portal provides product
announcements, the Open edX blog, and other rich community resources.
announcements, the Open edX blog, and other rich community resources.
To comment on blog posts or the edX roadmap, you must create an account and log
in. If you do not have an account, follow these steps.
......@@ -55,9 +55,16 @@ Documentation is managed in the `edx-documentation`_ repository. Documentation
is built using `Sphinx`_: you can `view the built documentation on
ReadTheDocs`_.
You can also check out `Confluence`_, our wiki system. Once you sign up for
an account, you'll be able to create new pages and edit existing pages, just
like in any other wiki system. You only need one account for both Confluence
and `JIRA`_, our issue tracker.
.. _Sphinx: http://sphinx-doc.org/
.. _view the built documentation on ReadTheDocs: http://docs.edx.org/
.. _edx-documentation: https://github.com/edx/edx-documentation
.. _Confluence: http://openedx.atlassian.net/wiki/
.. _JIRA: https://openedx.atlassian.net/
Getting Help
......
......@@ -59,12 +59,12 @@ def click_new_component_button(step, component_button_css):
def _click_advanced():
css = 'ul.problem-type-tabs a[href="#tab3"]'
css = 'ul.problem-type-tabs a[href="#tab2"]'
world.css_click(css)
# Wait for the advanced tab items to be displayed
tab3_css = 'div.ui-tabs-panel#tab3'
world.wait_for_visible(tab3_css)
tab2_css = 'div.ui-tabs-panel#tab2'
world.wait_for_visible(tab2_css)
def _find_matching_link(category, component_type):
......
......@@ -7,6 +7,7 @@ Sample invocation: ./manage.py export_convert_format mycourse.tar.gz ~/newformat
import os
from path import path
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from tempfile import mkdtemp
import tarfile
......@@ -32,8 +33,8 @@ class Command(BaseCommand):
output_path = args[1]
# Create temp directories to extract the source and create the target archive.
temp_source_dir = mkdtemp()
temp_target_dir = mkdtemp()
temp_source_dir = mkdtemp(dir=settings.DATA_DIR)
temp_target_dir = mkdtemp(dir=settings.DATA_DIR)
try:
extract_source(source_archive, temp_source_dir)
......
......@@ -3,6 +3,7 @@ Test for export_convert_format.
"""
from unittest import TestCase
from django.core.management import call_command, CommandError
from django.conf import settings
from tempfile import mkdtemp
import shutil
from path import path
......@@ -18,7 +19,7 @@ class ConvertExportFormat(TestCase):
""" Common setup. """
super(ConvertExportFormat, self).setUp()
self.temp_dir = mkdtemp()
self.temp_dir = mkdtemp(dir=settings.DATA_DIR)
self.addCleanup(shutil.rmtree, self.temp_dir)
self.data_dir = path(__file__).realpath().parent / 'data'
self.version0 = self.data_dir / "Version0_drafts.tar.gz"
......@@ -52,8 +53,8 @@ class ConvertExportFormat(TestCase):
"""
Helper function for determining if 2 archives are equal.
"""
temp_dir_1 = mkdtemp()
temp_dir_2 = mkdtemp()
temp_dir_1 = mkdtemp(dir=settings.DATA_DIR)
temp_dir_2 = mkdtemp(dir=settings.DATA_DIR)
try:
extract_source(file1, temp_dir_1)
extract_source(file2, temp_dir_2)
......
......@@ -52,6 +52,7 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
self.assertIsNone(details.language, "language somehow initialized" + str(details.language))
self.assertIsNone(details.has_cert_config)
def test_encoder(self):
details = CourseDetails.fetch(self.course.id)
......@@ -1008,6 +1009,41 @@ class CourseMetadataEditingTest(CourseTestCase):
tab_list.append(self.notes_tab)
self.assertEqual(tab_list, course.tabs)
@override_settings(FEATURES={'CERTIFICATES_HTML_VIEW': True})
def test_web_view_certifcate_configuration_settings(self):
"""
Test that has_cert_config is updated based on cert_html_view_enabled setting.
"""
test_model = CourseMetadata.update_from_json(
self.course,
{
"cert_html_view_enabled": {"value": "true"}
},
user=self.user
)
self.assertIn('cert_html_view_enabled', test_model)
url = get_url(self.course.id)
response = self.client.get_json(url)
course_detail_json = json.loads(response.content)
self.assertFalse(course_detail_json['has_cert_config'])
# Now add a certificate configuration
certificates = [
{
'id': 1,
'name': 'Certificate Config Name',
'course_title': 'Title override',
'org_logo_path': '/c4x/test/CSS101/asset/org_logo.png',
'signatories': [],
'is_active': True
}
]
self.course.certificates = {'certificates': certificates}
modulestore().update_item(self.course, self.user.id)
response = self.client.get_json(url)
course_detail_json = json.loads(response.content)
self.assertTrue(course_detail_json['has_cert_config'])
class CourseGraderUpdatesTest(CourseTestCase):
"""
......
""" Tests for utils. """
import collections
import copy
import mock
from datetime import datetime, timedelta
from pytz import UTC
import mock
import ddt
from pytz import UTC
from django.test import TestCase
from django.test.utils import override_settings
from contentstore import utils
from contentstore.tests.utils import CourseTestCase
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore
from contentstore import utils
from contentstore.tests.utils import CourseTestCase
class LMSLinksTestCase(TestCase):
""" Tests for LMS links. """
def about_page_test(self):
""" Get URL for about page, no marketing site """
# default for ENABLE_MKTG_SITE is False.
......@@ -109,6 +109,7 @@ class ExtraPanelTabTestCase(TestCase):
return course
@ddt.ddt
class CourseImageTestCase(ModuleStoreTestCase):
"""Tests for course image URLs."""
......@@ -146,6 +147,16 @@ class CourseImageTestCase(ModuleStoreTestCase):
utils.course_image_url(course)
)
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_empty_image_name(self, default_store):
""" Verify that empty image names are cleaned """
course_image = u''
course = CourseFactory.create(course_image=course_image, default_store=default_store)
self.assertEquals(
course_image,
utils.course_image_url(course),
)
class XBlockVisibilityTestCase(ModuleStoreTestCase):
"""Tests for xblock visibility for students."""
......@@ -386,6 +397,7 @@ class GroupVisibilityTest(CourseTestCase):
"""
Test content group access rules.
"""
def setUp(self):
super(GroupVisibilityTest, self).setUp()
......
......@@ -4,12 +4,12 @@ Common utility functions useful throughout the contentstore
# pylint: disable=no-member
import logging
from opaque_keys import InvalidKeyError
import re
from datetime import datetime
from pytz import UTC
from django.conf import settings
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
from django_comment_common.models import assign_default_role
from django_comment_common.utils import seed_permissions_roles
......@@ -160,7 +160,10 @@ def get_lms_link_for_certificate_web_view(user_id, course_key, mode):
def course_image_url(course):
"""Returns the image url for the course."""
loc = StaticContent.compute_location(course.location.course_key, course.course_image)
try:
loc = StaticContent.compute_location(course.location.course_key, course.course_image)
except InvalidKeyError:
return ''
path = StaticContent.serialize_asset_key_with_slash(loc)
return path
......@@ -310,3 +313,22 @@ def reverse_usage_url(handler_name, usage_key, kwargs=None):
Creates the URL for handlers that use usage_keys as URL parameters.
"""
return reverse_url(handler_name, 'usage_key_string', usage_key, kwargs)
def has_active_web_certificate(course):
"""
Returns True if given course has active web certificate configuration.
If given course has no active web certificate configuration returns False.
Returns None If `CERTIFICATES_HTML_VIEW` is not enabled of course has not enabled
`cert_html_view_enabled` settings.
"""
cert_config = None
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False) and course.cert_html_view_enabled:
cert_config = False
certificates = getattr(course, 'certificates', {})
configurations = certificates.get('certificates', [])
for config in configurations:
if config.get('is_active'):
cert_config = True
break
return cert_config
......@@ -33,7 +33,7 @@ from django.views.decorators.http import require_http_methods
from contentstore.utils import reverse_course_url
from edxmako.shortcuts import render_to_response
from opaque_keys.edx.keys import CourseKey, AssetKey
from student.auth import has_studio_read_access
from student.auth import has_studio_write_access
from util.db import generate_int_id, MYSQL_MAX_INT
from util.json_request import JsonResponse
from xmodule.modulestore import EdxJSONEncoder
......@@ -53,7 +53,7 @@ def _get_course_and_check_access(course_key, user, depth=0):
Internal method used to calculate and return the locator and
course module for the view functions in this file.
"""
if not has_studio_read_access(user, course_key):
if not has_studio_write_access(user, course_key):
raise PermissionDenied()
course_module = modulestore().get_course(course_key, depth=depth)
return course_module
......
......@@ -227,7 +227,7 @@ def get_component_templates(courselike, library=False):
"""
Returns the applicable component templates that can be used by the specified course or library.
"""
def create_template_dict(name, cat, boilerplate_name=None, tab="common"):
def create_template_dict(name, cat, boilerplate_name=None, tab="common", hinted=False):
"""
Creates a component template dict.
......@@ -235,13 +235,15 @@ def get_component_templates(courselike, library=False):
display_name: the user-visible name of the component
category: the type of component (problem, html, etc.)
boilerplate_name: name of boilerplate for filling in default values. May be None.
tab: common(default)/advanced/hint, which tab it goes in
hinted: True if hinted problem else False
tab: common(default)/advanced, which tab it goes in
"""
return {
"display_name": name,
"category": cat,
"boilerplate_name": boilerplate_name,
"hinted": hinted,
"tab": tab
}
......@@ -277,20 +279,20 @@ def get_component_templates(courselike, library=False):
for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None)
if not filter_templates or filter_templates(template, courselike):
# Tab can be 'common' 'advanced' 'hint'
# Tab can be 'common' 'advanced'
# Default setting is common/advanced depending on the presence of markdown
tab = 'common'
if template['metadata'].get('markdown') is None:
tab = 'advanced'
# Then the problem can override that with a tab: attribute (note: not nested in metadata)
tab = template.get('tab', tab)
hinted = template.get('hinted', False)
templates_for_category.append(
create_template_dict(
_(template['metadata'].get('display_name')), # pylint: disable=translation-of-non-string
category,
template.get('template_id'),
tab
tab,
hinted,
)
)
......
......@@ -5,6 +5,7 @@ Group Configuration Tests.
"""
import json
import mock
import ddt
from django.conf import settings
from django.test.utils import override_settings
......@@ -19,6 +20,7 @@ from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from contentstore.views.certificates import CertificateManager
from django.test.utils import override_settings
from contentstore.utils import get_lms_link_for_certificate_web_view
......@@ -230,6 +232,19 @@ class CertificatesListHandlerTestCase(CourseTestCase, CertificatesBaseTestCase,
self._remove_ids(content) # pylint: disable=unused-variable
self.assertEqual(content, expected)
def test_cannot_create_certificate_if_user_has_no_write_permissions(self):
"""
Tests user without write permissions on course should not able to create certificate
"""
user = UserFactory()
self.client.login(username=user.username, password='test')
response = self.client.ajax_post(
self._url(),
data=CERTIFICATE_JSON
)
self.assertEqual(response.status_code, 403)
@override_settings(LMS_BASE=None)
def test_no_lms_base_for_certificate_web_view_link(self):
test_link = get_lms_link_for_certificate_web_view(
......@@ -330,6 +345,7 @@ class CertificatesListHandlerTestCase(CourseTestCase, CertificatesBaseTestCase,
self.assertNotEqual(new_certificate.get('id'), prev_certificate.get('id'))
@ddt.ddt
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
class CertificatesDetailHandlerTestCase(CourseTestCase, CertificatesBaseTestCase, HelperMethods):
"""
......@@ -433,6 +449,21 @@ class CertificatesDetailHandlerTestCase(CourseTestCase, CertificatesBaseTestCase
self.assertEqual(certificates[0].get('name'), 'Name 0')
self.assertEqual(certificates[0].get('description'), 'Description 0')
def test_delete_certificate_without_write_permissions(self):
"""
Tests certificate deletion without write permission on course.
"""
self._add_course_certificates(count=2, signatory_count=1)
user = UserFactory()
self.client.login(username=user.username, password='test')
response = self.client.delete(
self._url(cid=1),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 403)
def test_delete_non_existing_certificate(self):
"""
Try to delete a non existing certificate. It should return status code 404 Not found.
......@@ -523,6 +554,25 @@ class CertificatesDetailHandlerTestCase(CourseTestCase, CertificatesBaseTestCase
certificates = course.certificates['certificates']
self.assertEqual(certificates[0].get('is_active'), is_active)
@ddt.data(True, False)
def test_certificate_activation_without_write_permissions(self, activate):
"""
Tests certificate Activate and Deactivate should not be allowed if user
does not have write permissions on course.
"""
test_url = reverse_course_url('certificates.certificate_activation_handler', self.course.id)
self._add_course_certificates(count=1, signatory_count=2)
user = UserFactory()
self.client.login(username=user.username, password='test')
response = self.client.post(
test_url,
data=json.dumps({"is_active": activate}),
content_type="application/json",
HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEquals(response.status_code, 403)
def test_certificate_activation_failure(self):
"""
Certificate activation should fail when user has not read access to course then permission denied exception
......
......@@ -29,8 +29,8 @@ class CreditEligibilityTest(CourseTestCase):
"""
response = self.client.get_html(self.course_details_url)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Credit Eligibility Requirements")
self.assertNotContains(response, "Steps needed for credit eligibility")
self.assertNotContains(response, "Course Credit Requirements")
self.assertNotContains(response, "Steps required to earn course credit")
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_CREDIT_ELIGIBILITY': True})
def test_course_details_with_enabled_setting(self):
......@@ -41,8 +41,8 @@ class CreditEligibilityTest(CourseTestCase):
# course is not set as credit course
response = self.client.get_html(self.course_details_url)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Credit Eligibility Requirements")
self.assertNotContains(response, "Steps needed for credit eligibility")
self.assertNotContains(response, "Course Credit Requirements")
self.assertNotContains(response, "Steps required to earn course credit")
# verify that credit eligibility requirements block shows if the
# course is set as credit course and it has eligibility requirements
......@@ -55,5 +55,5 @@ class CreditEligibilityTest(CourseTestCase):
response = self.client.get_html(self.course_details_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Credit Eligibility Requirements")
self.assertContains(response, "Steps needed for credit eligibility")
self.assertContains(response, "Course Credit Requirements")
self.assertContains(response, "Steps required to earn course credit")
......@@ -209,6 +209,19 @@ class ImportTestCase(CourseTestCase):
return outside_tar
def _edx_platform_tar(self):
"""
Tarfile with file that extracts to edx-platform directory.
Extracting this tarfile in directory <dir> will also put its contents
directly in <dir> (rather than <dir/tarname>).
"""
outside_tar = self.unsafe_common_dir / "unsafe_file.tar.gz"
with tarfile.open(outside_tar, "w:gz") as tar:
tar.addfile(tarfile.TarInfo(os.path.join(os.path.abspath("."), "a_file")))
return outside_tar
def test_unsafe_tar(self):
"""
Check that safety measure work.
......@@ -233,6 +246,12 @@ class ImportTestCase(CourseTestCase):
try_tar(self._symlink_tar())
try_tar(self._outside_tar())
try_tar(self._outside_tar2())
try_tar(self._edx_platform_tar())
# test trying to open a tar outside of the normal data directory
with self.settings(DATA_DIR='/not/the/data/dir'):
try_tar(self._edx_platform_tar())
# Check that `import_status` returns the appropriate stage (i.e.,
# either 3, indicating all previous steps are completed, or 0,
# indicating no upload in progress)
......@@ -294,13 +313,19 @@ class ImportTestCase(CourseTestCase):
self.assertIn(test_block3.url_name, children)
self.assertIn(test_block4.url_name, children)
extract_dir = path(tempfile.mkdtemp())
extract_dir = path(tempfile.mkdtemp(dir=settings.DATA_DIR))
# the extract_dir needs to be passed as a relative dir to
# import_library_from_xml
extract_dir_relative = path.relpath(extract_dir, settings.DATA_DIR)
try:
tar = tarfile.open(path(TEST_DATA_DIR) / 'imports' / 'library.HhJfPD.tar.gz')
safetar_extractall(tar, extract_dir)
with tarfile.open(path(TEST_DATA_DIR) / 'imports' / 'library.HhJfPD.tar.gz') as tar:
safetar_extractall(tar, extract_dir)
library_items = import_library_from_xml(
self.store, self.user.id,
settings.GITHUB_REPO_ROOT, [extract_dir / 'library'],
self.store,
self.user.id,
settings.GITHUB_REPO_ROOT,
[extract_dir_relative / 'library'],
load_error_modules=False,
static_content_store=contentstore(),
target_id=lib_key
......
......@@ -8,7 +8,7 @@ from django.conf import settings
from opaque_keys.edx.locations import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
from contentstore.utils import course_image_url
from contentstore.utils import course_image_url, has_active_web_certificate
from models.settings import course_grading
from xmodule.fields import Date
from xmodule.modulestore.django import modulestore
......@@ -52,7 +52,8 @@ class CourseDetails(object):
self.entrance_exam_minimum_score_pct = settings.FEATURES.get(
'ENTRANCE_EXAM_MIN_SCORE_PCT',
'50'
) # minimum passing score for entrance exam content module/tree
) # minimum passing score for entrance exam content module/tree,
self.has_cert_config = None # course has active certificate configuration
@classmethod
def _fetch_about_attribute(cls, course_key, attribute):
......@@ -84,6 +85,7 @@ class CourseDetails(object):
course_details.language = descriptor.language
# Default course license is "All Rights Reserved"
course_details.license = getattr(descriptor, "license", "all-rights-reserved")
course_details.has_cert_config = has_active_web_certificate(descriptor)
for attribute in ABOUT_ATTRIBUTES:
value = cls._fetch_about_attribute(course_key, attribute)
......
......@@ -39,6 +39,7 @@ INSTALLED_APPS += ('django_extensions',)
TEST_ROOT = REPO_ROOT / "test_root" # pylint: disable=no-value-for-parameter
GITHUB_REPO_ROOT = (TEST_ROOT / "data").abspath()
LOG_DIR = (TEST_ROOT / "log").abspath()
DATA_DIR = TEST_ROOT / "data"
# Configure modulestore to use the test folder within the repo
update_module_store_settings(
......
......@@ -97,6 +97,9 @@ FEATURES['ENABLE_COURSEWARE_INDEX'] = True
FEATURES['ENABLE_LIBRARY_INDEX'] = True
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
########################## Certificates Web/HTML View #######################
FEATURES['CERTIFICATES_HTML_VIEW'] = True
################################# DJANGO-REQUIRE ###############################
# Whether to run django-require in debug mode.
......@@ -115,6 +118,3 @@ MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE)
# Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
########################## Certificates Web/HTML View #######################
FEATURES['CERTIFICATES_HTML_VIEW'] = True
......@@ -65,6 +65,7 @@ TEST_ROOT = path('test_root')
STATIC_ROOT = TEST_ROOT / "staticfiles"
GITHUB_REPO_ROOT = TEST_ROOT / "data"
DATA_DIR = TEST_ROOT / "data"
COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
# For testing "push to lms"
......
......@@ -28,7 +28,7 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h
appendSetFixtures(sandbox({id: "page-notification"}))
it "successful AJAX request does not pop an error notification", ->
server = AjaxHelpers['server'](200, this)
server = AjaxHelpers.server(this, [200, {}, ''])
expect($("#page-notification")).toBeEmpty()
$.ajax("/test")
......@@ -37,7 +37,7 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h
expect($("#page-notification")).toBeEmpty()
it "AJAX request with error should pop an error notification", ->
server = AjaxHelpers['server'](500, this)
server = AjaxHelpers.server(this, [500, {}, ''])
$.ajax("/test")
server.respond()
......@@ -45,7 +45,7 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h
expect($("#page-notification")).toContain('div.wrapper-notification-error')
it "can override AJAX request with error so it does not pop an error notification", ->
server = AjaxHelpers['server'](500, this)
server = AjaxHelpers.server(this, [500, {}, ''])
$.ajax
url: "/test"
......
......@@ -34,7 +34,7 @@ define ["js/models/section", "common/js/spec_helpers/ajax_helpers", "js/utils/mo
})
it "show/hide a notification when it saves to the server", ->
server = AjaxHelpers['server'](200, this)
server = AjaxHelpers.server(this, [200, {}, ''])
@model.save()
expect(Section.prototype.showNotification).toHaveBeenCalled()
......@@ -43,7 +43,7 @@ define ["js/models/section", "common/js/spec_helpers/ajax_helpers", "js/utils/mo
it "don't hide notification when saving fails", ->
# this is handled by the global AJAX error handler
server = AjaxHelpers['server'](500, this)
server = AjaxHelpers.server(this, [500, {}, ''])
@model.save()
server.respond()
......
......@@ -167,22 +167,23 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
@courseInfoEdit.render()
@event = {preventDefault : () -> 'no op'}
@courseInfoEdit.onNew(@event)
@requests = AjaxHelpers["requests"](this)
it "shows push notification checkbox as selected by default", ->
expect(@courseInfoEdit.$el.find('.toggle-checkbox')).toBeChecked()
it "sends correct default value for push_notification_selected", ->
requests = AjaxHelpers.requests(this);
@courseInfoEdit.$el.find('.save-button').click()
requestSent = JSON.parse(@requests[@requests.length - 1].requestBody)
requestSent = JSON.parse(requests[requests.length - 1].requestBody)
expect(requestSent.push_notification_selected).toEqual(true)
it "sends correct value for push_notification_selected when it is unselected", ->
requests = AjaxHelpers.requests(this);
# unselect push notification
@courseInfoEdit.$el.find('.toggle-checkbox').attr('checked', false);
@courseInfoEdit.$el.find('.save-button').click()
requestSent = JSON.parse(@requests[@requests.length - 1].requestBody)
requestSent = JSON.parse(requests[requests.length - 1].requestBody)
expect(requestSent.push_notification_selected).toEqual(false)
describe "Course Handouts", ->
......
......@@ -32,6 +32,11 @@ define(["backbone"], function (Backbone) {
return -1;
} else if (isPrimaryBlankTemplate(b)) {
return 1;
// Hinted problems should be shown at the end
} else if (a.hinted && !b.hinted) {
return 1;
} else if (!a.hinted && b.hinted) {
return -1;
} else if (a.display_name > b.display_name) {
return 1;
} else if (a.display_name < b.display_name) {
......
define(["backbone", "underscore", "gettext", "js/models/validation_helpers"],
function(Backbone, _, gettext, ValidationHelpers) {
define(["backbone", "underscore", "gettext", "js/models/validation_helpers", "js/utils/date_utils"],
function(Backbone, _, gettext, ValidationHelpers, DateUtils) {
var CourseDetails = Backbone.Model.extend({
defaults: {
......@@ -28,14 +28,21 @@ var CourseDetails = Backbone.Model.extend({
// Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs
// A bit funny in that the video key validation is asynchronous; so, it won't stop the validation.
var errors = {};
newattrs = DateUtils.convertDateStringsToObjects(
newattrs, ["start_date", "end_date", "enrollment_start", "enrollment_end"]
);
if (newattrs.start_date === null) {
errors.start_date = gettext("The course must have an assigned start date.");
}
if (this.hasChanged("start_date") && this.get("has_cert_config") === false){
errors.start_date = gettext("The course must have at least one active certificate configuration before it can be started.");
}
if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) {
errors.end_date = gettext("The course end date cannot be before the course start date.");
errors.end_date = gettext("The course end date must be later than the course start date.");
}
if (newattrs.start_date && newattrs.enrollment_start && newattrs.start_date < newattrs.enrollment_start) {
errors.enrollment_start = gettext("The course start date cannot be before the enrollment start date.");
errors.enrollment_start = gettext("The course start date must be later than the enrollment start date.");
}
if (newattrs.enrollment_start && newattrs.enrollment_end && newattrs.enrollment_start >= newattrs.enrollment_end) {
errors.enrollment_end = gettext("The enrollment start date cannot be after the enrollment end date.");
......
define(
[
'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'squire'
'jquery', 'underscore', 'squire'
],
function ($, _, AjaxHelpers, Squire) {
function ($, _, Squire) {
'use strict';
describe('FileUploader', function () {
var FileUploaderTemplate = readFixtures(
......
define(
[
'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'squire'
'jquery', 'underscore', 'squire'
],
function ($, _, AjaxHelpers, Squire) {
function ($, _, Squire) {
'use strict';
// TODO: fix BLD-1100 Disabled due to intermittent failure on master and in PR builds
xdescribe('VideoTranslations', function () {
......
......@@ -47,7 +47,8 @@ define([
});
describe('Initial display', function() {
it('can render itself', function() {
// TODO fix this, see TNL-1475
xit('can render itself', function() {
var view = initializePage();
expect(view.$('.ui-loading')).toBeVisible();
view.render();
......
......@@ -31,7 +31,8 @@ define([
entrance_exam_enabled : '',
entrance_exam_minimum_score_pct: '50',
license: null,
language: ''
language: '',
has_cert_config: false
},
mockSettingsPage = readFixtures('mock/mock-settings-page.underscore');
......@@ -71,6 +72,13 @@ define([
);
});
it('Changing course start date without active certificate configuration should result in error', function () {
this.view.$el.find('#course-start-date')
.val('10/06/2014')
.trigger('change');
expect(this.view.$el.find('span.message-error').text()).toContain("course must have at least one active certificate configuration");
});
it('Selecting a course in pre-requisite drop down should save it as part of course details', function () {
var pre_requisite_courses = ['test/CSS101/2012_T1'];
var requests = AjaxHelpers.requests(this),
......
......@@ -35,9 +35,29 @@ define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) {
);
};
var parseDateFromString = function(stringDate){
if (stringDate && typeof stringDate === "string"){
return new Date(stringDate);
}
else {
return stringDate;
}
};
var convertDateStringsToObjects = function(obj, dateFields){
for (var i = 0; i < dateFields.length; i++){
if (obj[dateFields[i]]){
obj[dateFields[i]] = parseDateFromString(obj[dateFields[i]]);
}
}
return obj;
};
return {
getDate: getDate,
setDate: setDate,
renderDate: renderDate
renderDate: renderDate,
convertDateStringsToObjects: convertDateStringsToObjects,
parseDateFromString: parseDateFromString
};
});
......@@ -54,8 +54,8 @@ var DetailsView = ValidatingView.extend({
if (options.showMinGradeWarning || false) {
new NotificationView.Warning({
title: gettext("Credit Eligibility Requirements"),
message: gettext("Minimum passing grade for credit is not set."),
title: gettext("Course Credit Requirements"),
message: gettext("The minimum grade for course credit is not set."),
closeIcon: true
}).show();
}
......
......@@ -124,11 +124,11 @@
// green button
.add-xblock-component-button {
@extend %t-action3;
@include margin-right($baseline*0.75);
position: relative;
display: inline-block;
width: ($baseline*5);
height: ($baseline*5);
margin-right: ($baseline*0.75);
margin-bottom: ($baseline/2);
border: 1px solid $green-d2;
border-radius: ($baseline/4);
......@@ -164,7 +164,7 @@
.cancel-button {
@include white-button;
margin: $baseline 0 ($baseline/2) ($baseline/2);
@include margin($baseline, 0, ($baseline/2), ($baseline/2));
}
.problem-type-tabs {
......@@ -225,13 +225,13 @@
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset;
li:first-child {
margin-left: $baseline;
@include margin-left($baseline);
}
li {
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
opacity: 0.8;
float: left;
@include float(left);
display: inline-block;
width: auto;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset;
......@@ -248,6 +248,11 @@
border: 0px;
opacity: 1.0;
}
// reset to remove jquery-ui float
a.link-tab {
float: none;
}
}
a {
......@@ -286,7 +291,7 @@ $outline-indent-width: $baseline;
.icon {
display: inline-block;
vertical-align: middle;
margin-right: ($baseline/2);
@include margin-right($baseline/2);
}
}
}
......
......@@ -97,6 +97,10 @@
}
// in form -UI hints/tips/messages
.header-help {
margin: 0 0 $baseline 0;
}
.instructions {
@extend %t-copy-sub1;
margin: 0 0 $baseline 0;
......
......@@ -4,10 +4,7 @@
<a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a>
</li>
<li>
<a class="link-tab" href="#tab2"><%= gettext("Common Problems with Hints and Feedback") %></a>
</li>
<li>
<a class="link-tab" href="#tab3"><%= gettext("Advanced") %></a>
<a class="link-tab" href="#tab2"><%= gettext("Advanced") %></a>
</li>
</ul>
<div class="tab current" id="tab1">
......@@ -35,20 +32,6 @@
<div class="tab" id="tab2">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].tab == "hint") { %>
<li class="editor-manual">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
</ul>
</div>
<div class="tab" id="tab3">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].tab == "advanced") { %>
<li class="editor-manual">
<a href="#" data-category="<%= templates[i].category %>"
......
......@@ -22,7 +22,7 @@
<div class="input-wrap field text add-certificate-course-title">
<label for="certificate-course-title-<%= uniqueId %>"><%= gettext("Course Title Override") %></label>
<input id="certificate-course-title-<%= uniqueId %>" class="certificate-course-title-input input-text" name="certificate-course-title" type="text" placeholder="<%= gettext("Course title") %>" value="<%= course_title %>" aria-describedby="certificate-course-title-<%=uniqueId %>-tip" />
<span id="certificate-course-title-<%= uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Title of the course") %></span>
<span id="certificate-course-title-<%= uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Specify an alternative to the official course title to display on certificates. Leave blank to use the official course title.") %></span>
</div>
<div class="input-wrap field text add-org-logo">
<label for="certificate-org-logo-<%= uniqueId %>"><%= gettext("Organization Logo") %></label>
......
......@@ -22,7 +22,7 @@
<div class="input-wrap field text add-signatory-name <% if(error && error.name) { print('error'); } %>">
<label for="signatory-name-<%= signatory_number %>"><%= gettext("Name ") %></label>
<input id="signatory-name-<%= signatory_number %>" class="collection-name-input input-text signatory-name-input" name="signatory-name" type="text" placeholder="<%= gettext("Name of the signatory") %>" value="<%= name %>" aria-describedby="signatory-name-<%= signatory_number %>-tip" maxlength="40" />
<span id="signatory-name-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("Maximum 40 characters") %></span>
<span id="signatory-name-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("The name of this signatory as it should appear on certificates. Maximum 40 characters.") %></span>
<% if(error && error.name) { %>
<span class="message-error"><%= error.name %></span>
<% } %>
......@@ -30,7 +30,7 @@
<div class="input-wrap field text add-signatory-title <% if(error && error.title) { print('error'); } %>">
<label for="signatory-title-<%= signatory_number %>"><%= gettext("Title ") %></label>
<textarea id="signatory-title-<%= signatory_number %>" class="collection-name-input text input-text signatory-title-input" name="signatory-title" cols="40" rows="2" placeholder="<%= gettext("Title of the signatory") %>" aria-describedby="signatory-title-<%= signatory_number %>-tip" maxlength="80"><%= title %></textarea>
<span id="signatory-title-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("2 Lines, 40 characters each") %></span>
<span id="signatory-title-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("The title of this signatory as it should appear on certificates. Maximum 2 lines, 40 characters each.") %></span>
<% if(error && error.title) { %>
<span class="message-error"><%= error.title %></span>
<% } %>
......@@ -38,7 +38,7 @@
<div class="input-wrap field text add-signatory-organization <% if(error && error.organization) { print('error'); } %>">
<label for="signatory-organization-<%= signatory_number %>"><%= gettext("Organization ") %></label>
<input id="signatory-organization-<%= signatory_number %>" class="collection-name-input input-text signatory-organization-input" name="signatory-organization" type="text" placeholder="<%= gettext("Organization of the signatory") %>" value="<%= organization %>" aria-describedby="signatory-organization-<%= signatory_number %>-tip" maxlength="40" />
<span id="signatory-organization-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("Maximum 40 characters") %></span>
<span id="signatory-organization-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("The organization that this signatory belongs to, as it should appear on certificates. Maximum 40 characters.") %></span>
<% if(error && error.organization) { %>
<span class="message-error"><%= error.organization %></span>
<% } %>
......
......@@ -53,7 +53,7 @@ from django.core.urlresolvers import reverse
<li class="field text required create-user-email">
<label for="user-email-input">${_("User's Email Address")}</label>
<input id="user-email-input" class="user-email-input" name="user-email" type="text" placeholder="${_('example: username@domain.com')}" value="">
<span class="tip tip-stacked">${_("Please provide the email address of the course staff member you'd like to add")}</span>
<span class="tip tip-stacked">${_("Provide the email address of the user you want to add as Staff")}</span>
</li>
</ol>
</fieldset>
......@@ -94,14 +94,15 @@ from django.core.urlresolvers import reverse
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Course Team Roles")}</h3>
<p>${_("Course team members, or staff, are course co-authors. They have full writing and editing privileges on all course content.")}</p>
<p>${_("Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.")}</p>
<p>${_("Admins are course team members who can add and remove other course team members.")}</p>
<p>${_("All course team members are automatically enrolled in the course and can access content in Studio, the LMS, and Insights.")}</p>
</div>
% if show_transfer_ownership_hint:
<div class="bit">
<h3 class="title-3">${_("Transferring Ownership")}</h3>
<p>${_("Every course must have an Admin. If you're the Admin and you want transfer ownership of the course, click Add admin access to make another user the Admin, then ask that user to remove you from the Course Team list.")}</p>
<p>${_("Every course must have an Admin. If you are the Admin and you want to transfer ownership of the course, click <strong>Add admin access</strong> to make another user the Admin, then ask that user to remove you from the Course Team list.")}</p>
</div>
% endif
</aside>
......
......@@ -95,9 +95,9 @@ from django.core.urlresolvers import reverse
<div class="bit">
<h3 class="title-3">${_("Library Access Roles")}</h3>
<p>${_("There are three access roles for libraries: User, Staff, and Admin.")}</p>
<p>${_("Users can view library content and can reference or use library components in their courses, but they cannot edit the contents of a library.")}</p>
<p>${_("Staff are content co-authors. They have full editing privileges on the contents of a library.")}</p>
<p>${_("Admins have full editing privileges and can also add and remove other team members. There must be at least one user with Admin privileges in a library.")}</p>
<p>${_("Library Users can view library content and can reference or use library components in their courses, but they cannot edit the contents of a library.")}</p>
<p>${_("Library Staff are content co-authors. They have full editing privileges on the contents of a library.")}</p>
<p>${_("Library Admins have full editing privileges and can also add and remove other team members. There must be at least one user with the Admin role in a library.")}</p>
</div>
</aside>
</section>
......
......@@ -112,8 +112,8 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
<p>${_(
'Your course summary page will not be viewable until your course '
'has been announced. To provide content for the page and preview '
'it, follow the instructions provided by your Program Manager.')
}</p>
'it, follow the instructions provided by your Program Manager.')}
</p>
</div>
</div>
% endif
......@@ -123,16 +123,18 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
% if credit_eligibility_enabled and is_credit_course:
<section class="group-settings basic">
<header>
<h2 class="title-2">${_("Credit Eligibility Requirements")}</h2>
<span class="tip">${_("Steps needed for credit eligibility")}</span>
<h2 class="title-2">${_("Course Credit Requirements")}</h2>
<span class="tip">${_("Steps required to earn course credit")}</span>
</header>
<span class="header-help tip">A requirement appears in this list when you publish the unit that contains the requirement.</span>
% if credit_requirements:
<ol class="list-input">
% if 'grade' in credit_requirements:
<li class="field text is-not-editable" id="credit-minimum-passing-grade">
<label>${_("Minimum Passing Grade")}</label>
<label>${_("Minimum Grade")}</label>
% for requirement in credit_requirements['grade']:
<label for="${requirement['name']}" class="sr">${_("Minimum Passing Grade")}</label>
<label for="${requirement['name']}" class="sr">${_("Minimum Grade")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${'{0:.0f}%'.format(float(requirement['criteria']['min_grade'] or 0)*100)}" readonly />
% endfor
......@@ -152,12 +154,11 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
% if 'reverification' in credit_requirements:
<li class="field text is-not-editable" id="credit-reverification-requirements">
<label>${_("Successful In-Course Reverification")}</label>
<label>${_("ID Verification")}</label>
% for requirement in credit_requirements['reverification']:
## Translators: 'Access to Assessment 1' means the access for a requirement with name 'Assessment 1'
<label for="${requirement['name']}" class="sr">${_('In-Course Reverification {number}').format(number=loop.index+1)}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="${requirement['name']}" value="${_('Access to {display_name}').format(display_name=requirement['display_name'])}" readonly />
class="long" id="${requirement['name']}" value="${requirement['display_name']}" readonly />
% endfor
</li>
% endif
......
......@@ -79,15 +79,15 @@
% if settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course:
<section class="group-settings grade-rules">
<header>
<h2 class="title-2">${_("Credit Grade &amp; Eligibility")}</h2>
<span class="tip">${_("Settings for credit eligibility")}</span>
<h2 class="title-2">${_("Credit Eligibility")}</h2>
<span class="tip">${_("Settings for course credit eligibility")}</span>
</header>
<ol class="list-input">
<li class="field text" id="field-course-minimum_grade_credit">
<label for="course-minimum_grade_credit">${_("Minimum Passing Grade to Earn Credit:")}</label>
<label for="course-minimum_grade_credit">${_("Minimum Credit-Eligible Grade:")}</label>
<input type="text" class="short time" id="course-minimum_grade_credit" value="0" placeholder="80%" autocomplete="off" aria-describedby="minimum_grade_description"/>
<span class="tip tip-inline" id="minimum_grade_description">${_("Must be greater than or equal to passing grade")}</span>
<span class="tip tip-inline" id="minimum_grade_description">${_("Must be greater than or equal to the course passing grade")}</span>
</li>
</ol>
</section>
......
......@@ -86,6 +86,11 @@ class CourseMode(models.Model):
""" meta attributes of this model """
unique_together = ('course_id', 'mode_slug', 'currency')
def save(self, force_insert=False, force_update=False, using=None):
# Ensure currency is always lowercase.
self.currency = self.currency.lower()
super(CourseMode, self).save(force_insert, force_update, using)
@classmethod
def all_modes_for_courses(cls, course_id_list):
"""Find all modes for a list of course IDs, including expired modes.
......@@ -308,7 +313,7 @@ class CourseMode(models.Model):
"""
modes = cls.modes_for_course(course_id)
for mode in modes:
if (mode.currency == currency) and (mode.slug == 'verified'):
if (mode.currency.lower() == currency.lower()) and (mode.slug == 'verified'):
return mode.min_price
return 0
......@@ -490,7 +495,7 @@ class CourseMode(models.Model):
If there is no mode found, will return the price of DEFAULT_MODE, which is 0
"""
modes = cls.modes_for_course(course_id)
return min(mode.min_price for mode in modes if mode.currency == currency)
return min(mode.min_price for mode in modes if mode.currency.lower() == currency.lower())
@classmethod
def enrollment_mode_display(cls, mode, verification_status):
......
......@@ -39,6 +39,15 @@ class CourseModeModelTest(TestCase):
currency=currency,
)
def test_save(self):
""" Verify currency is always lowercase. """
cm, __ = self.create_mode('honor', 'honor', 0, '', 'USD')
self.assertEqual(cm.currency, 'usd')
cm.currency = 'GHS'
cm.save()
self.assertEqual(cm.currency, 'ghs')
def test_modes_for_course_empty(self):
"""
If we can't find any modes, we should get back the default mode
......
......@@ -325,6 +325,22 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
self.assertEquals(course_modes, expected_modes)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@patch.dict(settings.FEATURES, {"IS_EDX_DOMAIN": True})
def test_hide_nav(self):
# Create the course modes
for mode in ["honor", "verified"]:
CourseModeFactory(mode_slug=mode, course_id=self.course.id)
# Load the track selection page
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
# Verify that the header navigation links are hidden for the edx.org version
self.assertNotContains(response, "How it Works")
self.assertNotContains(response, "Find courses")
self.assertNotContains(response, "Schools & Partners")
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
......
......@@ -119,7 +119,8 @@ class ChooseModeView(View):
"course_num": course.display_number_with_default,
"chosen_price": chosen_price,
"error": error,
"responsive": True
"responsive": True,
"nav_hidden": True,
}
if "verified" in modes:
context["suggested_prices"] = [
......
......@@ -53,7 +53,7 @@ def marketing_link(name):
if link_map[name] is not None:
return reverse(link_map[name])
else:
log.warning("Cannot find corresponding link for name: {name}".format(name=name))
log.debug("Cannot find corresponding link for name: %s", name)
return '#'
......
......@@ -5,8 +5,8 @@ course level, such as available course modes.
"""
from django.utils import importlib
import logging
from django.core.cache import cache
from django.conf import settings
from django.core.cache import cache
from enrollment import errors
log = logging.getLogger(__name__)
......@@ -181,7 +181,7 @@ def add_enrollment(user_id, course_id, mode='honor', is_active=True):
return _data_api().create_course_enrollment(user_id, course_id, mode, is_active)
def update_enrollment(user_id, course_id, mode=None, is_active=None):
def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_attributes=None):
"""Updates the course mode for the enrolled user.
Update a course enrollment for the given user and course.
......@@ -232,6 +232,10 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None):
msg = u"Course Enrollment not found for user {user} in course {course}".format(user=user_id, course=course_id)
log.warn(msg)
raise errors.EnrollmentNotFoundError(msg)
else:
if enrollment_attributes is not None:
set_enrollment_attributes(user_id, course_id, enrollment_attributes)
return enrollment
......@@ -302,6 +306,53 @@ def get_course_enrollment_details(course_id, include_expired=False):
return course_enrollment_details
def set_enrollment_attributes(user_id, course_id, attributes):
"""Set enrollment attributes for the enrollment of given user in the
course provided.
Args:
course_id (str): The Course to set enrollment attributes for.
user_id (str): The User to set enrollment attributes for.
attributes (list): Attributes to be set.
Example:
>>>set_enrollment_attributes(
"Bob",
"course-v1-edX-DemoX-1T2015",
[
{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
},
]
)
"""
_data_api().add_or_update_enrollment_attr(user_id, course_id, attributes)
def get_enrollment_attributes(user_id, course_id):
"""Retrieve enrollment attributes for given user for provided course.
Args:
user_id: The User to get enrollment attributes for
course_id (str): The Course to get enrollment attributes for.
Example:
>>>get_enrollment_attributes("Bob", "course-v1-edX-DemoX-1T2015")
[
{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
},
]
Returns: list
"""
return _data_api().get_enrollment_attributes(user_id, course_id)
def _validate_course_mode(course_id, mode):
"""Checks to see if the specified course mode is valid for the course.
......
......@@ -9,12 +9,12 @@ from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
from enrollment.errors import (
CourseNotFoundError, CourseEnrollmentClosedError, CourseEnrollmentFullError,
CourseEnrollmentExistsError, UserNotFoundError,
CourseEnrollmentExistsError, UserNotFoundError, InvalidEnrollmentAttribute
)
from enrollment.serializers import CourseEnrollmentSerializer, CourseField
from student.models import (
CourseEnrollment, NonExistentCourseError, EnrollmentClosedError,
CourseFullError, AlreadyEnrolledError,
CourseFullError, AlreadyEnrolledError, CourseEnrollmentAttribute
)
log = logging.getLogger(__name__)
......@@ -136,12 +136,112 @@ def update_course_enrollment(username, course_id, mode=None, is_active=None):
return None
def add_or_update_enrollment_attr(user_id, course_id, attributes):
"""Set enrollment attributes for the enrollment of given user in the
course provided.
Args:
course_id (str): The Course to set enrollment attributes for.
user_id (str): The User to set enrollment attributes for.
attributes (list): Attributes to be set.
Example:
>>>add_or_update_enrollment_attr(
"Bob",
"course-v1-edX-DemoX-1T2015",
[
{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
},
]
)
"""
course_key = CourseKey.from_string(course_id)
user = _get_user(user_id)
enrollment = CourseEnrollment.get_enrollment(user, course_key)
if not _invalid_attribute(attributes) and enrollment is not None:
CourseEnrollmentAttribute.add_enrollment_attr(enrollment, attributes)
def get_enrollment_attributes(user_id, course_id):
"""Retrieve enrollment attributes for given user for provided course.
Args:
user_id: The User to get enrollment attributes for
course_id (str): The Course to get enrollment attributes for.
Example:
>>>get_enrollment_attributes("Bob", "course-v1-edX-DemoX-1T2015")
[
{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
},
]
Returns: list
"""
course_key = CourseKey.from_string(course_id)
user = _get_user(user_id)
enrollment = CourseEnrollment.get_enrollment(user, course_key)
return CourseEnrollmentAttribute.get_enrollment_attributes(enrollment)
def _get_user(user_id):
"""Retrieve user with provided user_id
Args:
user_id(str): username of the user for which object is to retrieve
Returns: obj
"""
try:
return User.objects.get(username=user_id)
except User.DoesNotExist:
msg = u"Not user with username '{username}' found.".format(username=user_id)
log.warn(msg)
raise UserNotFoundError(msg)
def _update_enrollment(enrollment, is_active=None, mode=None):
enrollment.update_enrollment(is_active=is_active, mode=mode)
enrollment.save()
return CourseEnrollmentSerializer(enrollment).data # pylint: disable=no-member
def _invalid_attribute(attributes):
"""Validate enrollment attribute
Args:
attributes(dict): dict of attribute
Return:
list of invalid attributes
"""
invalid_attributes = []
for attribute in attributes:
if "namespace" not in attribute:
msg = u"'namespace' not in enrollment attribute"
log.warn(msg)
invalid_attributes.append("namespace")
raise InvalidEnrollmentAttribute(msg)
if "name" not in attribute:
msg = u"'name' not in enrollment attribute"
log.warn(msg)
invalid_attributes.append("name")
raise InvalidEnrollmentAttribute(msg)
if "value" not in attribute:
msg = u"'value' not in enrollment attribute"
log.warn(msg)
invalid_attributes.append("value")
raise InvalidEnrollmentAttribute(msg)
return invalid_attributes
def get_course_enrollment_info(course_id, include_expired=False):
"""Returns all course enrollment information for the given course.
......
......@@ -50,3 +50,8 @@ class EnrollmentNotFoundError(CourseEnrollmentError):
class EnrollmentApiLoadError(CourseEnrollmentError):
"""The data API could not be loaded."""
pass
class InvalidEnrollmentAttribute(CourseEnrollmentError):
"""Enrollment Attributes could not be validated"""
pass
......@@ -19,6 +19,8 @@ _ENROLLMENTS = []
_COURSES = []
_ENROLLMENT_ATTRIBUTES = []
# pylint: disable=unused-argument
def get_course_enrollments(student_id):
......@@ -78,6 +80,23 @@ def add_enrollment(student_id, course_id, is_active=True, mode='honor'):
return enrollment
# pylint: disable=unused-argument
def add_or_update_enrollment_attr(user_id, course_id, attributes):
"""Add or update enrollment attribute array"""
for attribute in attributes:
_ENROLLMENT_ATTRIBUTES.append({
'namespace': attribute['namespace'],
'name': attribute['name'],
'value': attribute['value']
})
# pylint: disable=unused-argument
def get_enrollment_attributes(user_id, course_id):
"""Retrieve enrollment attribute array"""
return _ENROLLMENT_ATTRIBUTES
def add_course(course_id, enrollment_start=None, enrollment_end=None, invite_only=False, course_modes=None):
"""Append course to the courses array."""
course_info = {
......
......@@ -143,6 +143,29 @@ class EnrollmentTest(TestCase):
result = api.update_enrollment(self.USERNAME, self.COURSE_ID, mode='verified')
self.assertEquals('verified', result['mode'])
def test_update_enrollment_attributes(self):
# Add fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit', 'credit'])
# Enroll in the course and verify the URL we get sent to
result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode='audit')
get_result = api.get_enrollment(self.USERNAME, self.COURSE_ID)
self.assertEquals(result, get_result)
enrollment_attributes = [
{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
}
]
result = api.update_enrollment(
self.USERNAME, self.COURSE_ID, mode='credit', enrollment_attributes=enrollment_attributes
)
self.assertEquals('credit', result['mode'])
attributes = api.get_enrollment_attributes(self.USERNAME, self.COURSE_ID)
self.assertEquals(enrollment_attributes[0], attributes[0])
def test_get_course_details(self):
# Add a fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit'])
......
......@@ -170,6 +170,45 @@ class EnrollmentDataTest(ModuleStoreTestCase):
self.assertEqual(self.user.username, result['user'])
self.assertEqual(enrollment, result)
@ddt.data(
# Default (no course modes in the database)
# Expect that users are automatically enrolled as "honor".
([], 'credit'),
# Audit / Verified / Honor
# We should always go to the "choose your course" page.
# We should also be enrolled as "honor" by default.
(['honor', 'verified', 'audit', 'credit'], 'credit'),
)
@ddt.unpack
def test_add_or_update_enrollment_attr(self, course_modes, enrollment_mode):
# Create the course modes (if any) required for this test case
self._create_course_modes(course_modes)
data.create_course_enrollment(self.user.username, unicode(self.course.id), enrollment_mode, True)
enrollment_attributes = [
{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
}
]
data.add_or_update_enrollment_attr(self.user.username, unicode(self.course.id), enrollment_attributes)
enrollment_attr = data.get_enrollment_attributes(self.user.username, unicode(self.course.id))
self.assertEqual(enrollment_attr[0], enrollment_attributes[0])
enrollment_attributes = [
{
"namespace": "credit",
"name": "provider_id",
"value": "ASU",
}
]
data.add_or_update_enrollment_attr(self.user.username, unicode(self.course.id), enrollment_attributes)
enrollment_attr = data.get_enrollment_attributes(self.user.username, unicode(self.course.id))
self.assertEqual(enrollment_attr[0], enrollment_attributes[0])
@raises(CourseNotFoundError)
def test_non_existent_course(self):
data.get_course_enrollment_info("this/is/bananas")
......
......@@ -46,6 +46,7 @@ class EnrollmentTestMixin(object):
as_server=False,
mode=CourseMode.HONOR,
is_active=None,
enrollment_attributes=None,
):
"""
Enroll in the course and verify the response's status code. If the expected status is 200, also validates
......@@ -62,7 +63,8 @@ class EnrollmentTestMixin(object):
'course_details': {
'course_id': course_id
},
'user': username
'user': username,
'enrollment_attributes': enrollment_attributes
}
if is_active is not None:
......@@ -547,6 +549,78 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
self.assertTrue(is_active)
self.assertEqual(course_mode, CourseMode.VERIFIED)
def test_enrollment_with_credit_mode(self):
"""With the right API key, update an existing enrollment with credit
mode and set enrollment attributes.
"""
for mode in [CourseMode.HONOR, CourseMode.CREDIT_MODE]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
)
# Create an enrollment
self.assert_enrollment_status(as_server=True)
# Check that the enrollment is honor.
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(course_mode, CourseMode.HONOR)
# Check that the enrollment upgraded to credit.
enrollment_attributes = [{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
}]
self.assert_enrollment_status(
as_server=True,
mode=CourseMode.CREDIT_MODE,
expected_status=status.HTTP_200_OK,
enrollment_attributes=enrollment_attributes
)
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(course_mode, CourseMode.CREDIT_MODE)
def test_enrollment_with_invalid_attr(self):
"""Check response status is bad request when invalid enrollment
attributes are passed
"""
for mode in [CourseMode.HONOR, CourseMode.CREDIT_MODE]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
)
# Create an enrollment
self.assert_enrollment_status(as_server=True)
# Check that the enrollment is honor.
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(course_mode, CourseMode.HONOR)
# Check that the enrollment upgraded to credit.
enrollment_attributes = [{
"namespace": "credit",
"name": "invalid",
"value": "hogwarts",
}]
self.assert_enrollment_status(
as_server=True,
mode=CourseMode.CREDIT_MODE,
expected_status=status.HTTP_400_BAD_REQUEST,
enrollment_attributes=enrollment_attributes
)
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(course_mode, CourseMode.HONOR)
def test_downgrade_enrollment_with_mode(self):
"""With the right API key, downgrade an existing enrollment with a new mode. """
# Create an honor and verified mode for a course. This allows an update.
......
......@@ -5,7 +5,6 @@ consist primarily of authentication, request validation, and serialization.
"""
import logging
from ipware.ip import get_ip
from django.core.exceptions import ObjectDoesNotExist
from django.utils.decorators import method_decorator
from opaque_keys import InvalidKeyError
......@@ -33,7 +32,11 @@ from enrollment.errors import (
)
from student.models import User
log = logging.getLogger(__name__)
REQUIRED_ATTRIBUTES = {
"credit": ["credit:provider_id"],
}
class EnrollmentCrossDomainSessionAuth(SessionAuthenticationAllowInactiveUser, SessionAuthenticationCrossDomainCsrf):
......@@ -264,9 +267,13 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
If honor mode is not supported for the course, the request fails and returns the available modes.
A server-to-server call can be used by this command to enroll a user in other modes, such as "verified"
or "professional". If the mode is not supported for the course, the request will fail and return the
available modes.
A server-to-server call can be used by this command to enroll a user in other modes, such as "verified",
"professional" or "credit". If the mode is not supported for the course, the request will fail and
return the available modes.
You can include other parameters as enrollment attributes for specific course mode as needed. For
example, for credit mode, you can include parameters namespace:'credit', name:'provider_id',
value:'UniversityX' to specify credit provider attribute.
**Example Requests**:
......@@ -274,6 +281,12 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
POST /api/enrollment/v1/enrollment{"mode": "honor", "course_details":{"course_id": "edX/DemoX/Demo_Course"}}
POST /api/enrollment/v1/enrollment{
"mode": "credit",
"course_details":{"course_id": "edX/DemoX/Demo_Course"},
"enrollment_attributes":[{"namespace": "credit","name": "provider_id","value": "hogwarts",},]
}
**Post Parameters**
* user: The username of the currently logged in user. Optional.
......@@ -292,6 +305,12 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
* email_opt_in: A Boolean indicating whether the user
wishes to opt into email from the organization running this course. Optional.
* enrollment_attributes: A list of dictionary that contains:
* namespace: Namespace of the attribute
* name: Name of the attribute
* value: Value of the attribute
**Response Values**
A collection of course enrollments for the user, or for the newly created enrollment.
......@@ -335,7 +354,6 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
* user: The username of the user.
"""
authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth
permission_classes = ApiKeyHeaderPermissionIsAuthenticated,
throttle_classes = EnrollmentUserThrottle,
......@@ -370,6 +388,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
go through `add_enrollment()`, which allows creation of new and reactivation of old enrollments.
"""
# Get the User, Course ID, and Mode from the request.
username = request.DATA.get('user', request.user.username)
course_id = request.DATA.get('course_details', {}).get('course_id')
......@@ -438,9 +457,17 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
}
)
enrollment_attributes = request.DATA.get('enrollment_attributes')
enrollment = api.get_enrollment(username, unicode(course_id))
mode_changed = enrollment and mode is not None and enrollment['mode'] != mode
active_changed = enrollment and is_active is not None and enrollment['is_active'] != is_active
missing_attrs = []
if enrollment_attributes:
actual_attrs = [
u"{namespace}:{name}".format(**attr)
for attr in enrollment_attributes
]
missing_attrs = set(REQUIRED_ATTRIBUTES.get(mode, [])) - set(actual_attrs)
if has_api_key_permissions and (mode_changed or active_changed):
if mode_changed and active_changed and not is_active:
# if the requester wanted to deactivate but specified the wrong mode, fail
......@@ -451,7 +478,21 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
)
log.warning(msg)
return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": msg})
response = api.update_enrollment(username, unicode(course_id), mode=mode, is_active=is_active)
if len(missing_attrs) > 0:
msg = u"Missing enrollment attributes: requested mode={} required attributes={}".format(
mode, REQUIRED_ATTRIBUTES.get(mode)
)
log.warning(msg)
return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": msg})
response = api.update_enrollment(
username,
unicode(course_id),
mode=mode,
is_active=is_active,
enrollment_attributes=enrollment_attributes
)
else:
# Will reactivate inactive enrollments.
response = api.add_enrollment(username, unicode(course_id), mode=mode, is_active=is_active)
......
......@@ -23,8 +23,9 @@ VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline"
VERIFY_STATUS_NEED_TO_REVERIFY = "verify_need_to_reverify"
def check_verify_status_by_course(user, course_enrollment_pairs, all_course_modes):
"""Determine the per-course verification statuses for a given user.
def check_verify_status_by_course(user, course_enrollments, all_course_modes):
"""
Determine the per-course verification statuses for a given user.
The possible statuses are:
* VERIFY_STATUS_NEED_TO_VERIFY: The student has not yet submitted photos for verification.
......@@ -46,8 +47,7 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
Arguments:
user (User): The currently logged-in user.
course_enrollment_pairs (list): The courses the user is enrolled in.
The list should contain tuples of `(Course, CourseEnrollment)`.
course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in.
all_course_modes (list): List of all course modes for the student's enrolled courses,
including modes that have expired.
......@@ -75,15 +75,15 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
recent_verification_datetime = None
for course, enrollment in course_enrollment_pairs:
for enrollment in course_enrollments:
# Get the verified mode (if any) for this course
# We pass in the course modes we have already loaded to avoid
# another database hit, as well as to ensure that expired
# course modes are included in the search.
verified_mode = CourseMode.verified_mode_for_course(
course.id,
modes=all_course_modes[course.id]
enrollment.course_id,
modes=all_course_modes[enrollment.course_id]
)
# If no verified mode has ever been offered, or the user hasn't enrolled
......@@ -156,7 +156,7 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
if deadline is not None and deadline > now:
days_until_deadline = (deadline - now).days
status_by_course[course.id] = {
status_by_course[enrollment.course_id] = {
'status': status,
'days_until_deadline': days_until_deadline
}
......
......@@ -850,6 +850,13 @@ class CourseEnrollment(models.Model):
unique_together = (('user', 'course_id'),)
ordering = ('user', 'course_id')
def __init__(self, *args, **kwargs):
super(CourseEnrollment, self).__init__(*args, **kwargs)
# Private variable for storing course_overview to minimize calls to the database.
# When the property .course_overview is accessed for the first time, this variable will be set.
self._course_overview = None
def __unicode__(self):
return (
"[CourseEnrollment] {}: {} ({}); active: ({})"
......@@ -1318,10 +1325,21 @@ class CourseEnrollment(models.Model):
@property
def course_overview(self):
"""
Return a CourseOverview of this enrollment's course.
"""
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
return CourseOverview.get_from_id(self.course_id)
Returns a CourseOverview of the course to which this enrollment refers.
Returns None if an error occurred while trying to load the course.
Note:
If the course is re-published within the lifetime of this
CourseEnrollment object, then the value of this property will
become stale.
"""
if not self._course_overview:
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
try:
self._course_overview = CourseOverview.get_from_id(self.course_id)
except (CourseOverview.DoesNotExist, IOError):
self._course_overview = None
return self._course_overview
def is_verified_enrollment(self):
"""
......@@ -1854,3 +1872,47 @@ class CourseEnrollmentAttribute(models.Model):
name=self.name,
value=self.value,
)
@classmethod
def add_enrollment_attr(cls, enrollment, data_list):
"""Delete all the enrollment attributes for the given enrollment and
add new attributes.
Args:
enrollment(CourseEnrollment): 'CourseEnrollment' for which attribute is to be added
data(list): list of dictionaries containing data to save
"""
cls.objects.filter(enrollment=enrollment).delete()
attributes = [
cls(enrollment=enrollment, namespace=data['namespace'], name=data['name'], value=data['value'])
for data in data_list
]
cls.objects.bulk_create(attributes)
@classmethod
def get_enrollment_attributes(cls, enrollment):
"""Retrieve list of all enrollment attributes.
Args:
enrollment(CourseEnrollment): 'CourseEnrollment' for which list is to retrieve
Returns: list
Example:
>>> CourseEnrollmentAttribute.get_enrollment_attributes(CourseEnrollment)
[
{
"namespace": "credit",
"name": "provider_id",
"value": "hogwarts",
},
]
"""
return [
{
"namespace": attribute.namespace,
"name": attribute.name,
"value": attribute.value,
}
for attribute in cls.objects.filter(enrollment=enrollment)
]
......@@ -47,9 +47,14 @@ class CertificateDisplayTest(ModuleStoreTestCase):
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True})
def test_display_download_certificate_button(self, enrollment_mode):
"""
Tests if CERTIFICATES_HTML_VIEW is True and there is no active certificate configuration available
Tests if CERTIFICATES_HTML_VIEW is True
and course has enabled web certificates via cert_html_view_enabled setting
and no active certificate configuration available
then any of the Download certificate button should not be visible.
"""
self.course.cert_html_view_enabled = True
self.course.save()
self.store.update_item(self.course, self.user.id)
self._create_certificate(enrollment_mode)
self._check_can_not_download_certificate()
......@@ -59,8 +64,7 @@ class CertificateDisplayTest(ModuleStoreTestCase):
def test_linked_student_to_web_view_credential(self, enrollment_mode):
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id),
verify_uuid='abcdefg12345678'
course_id=unicode(self.course.id)
)
self._create_certificate(enrollment_mode)
......@@ -75,6 +79,7 @@ class CertificateDisplayTest(ModuleStoreTestCase):
}
]
self.course.certificates = {'certificates': certificates}
self.course.cert_html_view_enabled = True
self.course.save() # pylint: disable=no-member
self.store.update_item(self.course, self.user.id)
......
......@@ -14,7 +14,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.error_module import ErrorDescriptor
from django.test.client import Client
from student.models import CourseEnrollment
from student.views import get_course_enrollment_pairs
from student.views import get_course_enrollments
from util.milestones_helpers import (
get_pre_requisite_courses_not_completed,
set_prerequisite_courses,
......@@ -73,13 +73,13 @@ class TestCourseListing(ModuleStoreTestCase):
self._create_course_with_access_groups(course_location)
# get dashboard
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 1)
self.assertEqual(courses_list[0][0].id, course_location)
self.assertEqual(courses_list[0].course_id, course_location)
CourseEnrollment.unenroll(self.student, course_location)
# get dashboard
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 0)
def test_errored_course_regular_access(self):
......@@ -95,7 +95,7 @@ class TestCourseListing(ModuleStoreTestCase):
self.assertIsInstance(modulestore().get_course(course_key), ErrorDescriptor)
# get courses through iterating all courses
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(courses_list, [])
def test_course_listing_errored_deleted_courses(self):
......@@ -112,9 +112,9 @@ class TestCourseListing(ModuleStoreTestCase):
self._create_course_with_access_groups(course_location, default_store=ModuleStoreEnum.Type.mongo)
mongo_store.delete_course(course_location, ModuleStoreEnum.UserID.test)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 1, courses_list)
self.assertEqual(courses_list[0][0].id, good_location)
self.assertEqual(courses_list[0].course_id, good_location)
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_course_listing_has_pre_requisite_courses(self):
......@@ -142,9 +142,11 @@ class TestCourseListing(ModuleStoreTestCase):
set_prerequisite_courses(course_location, pre_requisite_courses)
# get dashboard
course_enrollment_pairs = list(get_course_enrollment_pairs(self.student, None, []))
courses_having_prerequisites = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if course.pre_requisite_courses)
course_enrollments = list(get_course_enrollments(self.student, None, []))
courses_having_prerequisites = frozenset(
enrollment.course_id for enrollment in course_enrollments
if enrollment.course_overview.pre_requisite_courses
)
courses_requirements_not_met = get_pre_requisite_courses_not_completed(
self.student,
courses_having_prerequisites
......
......@@ -4,6 +4,7 @@ Tests for credit courses on the student dashboard.
import unittest
import datetime
from mock import patch
import pytz
from mock import patch
......
......@@ -15,7 +15,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.tests.factories import CourseModeFactory
from student.models import CourseEnrollment, DashboardConfiguration
from student.views import get_course_enrollment_pairs, _get_recently_enrolled_courses
from student.views import get_course_enrollments, _get_recently_enrolled_courses # pylint: disable=protected-access
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
......@@ -67,7 +67,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
self._configure_message_timeout(60)
# get courses through iterating all courses
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 2)
recent_course_list = _get_recently_enrolled_courses(courses_list)
......@@ -78,7 +78,7 @@ class TestRecentEnrollments(ModuleStoreTestCase):
Tests that the recent enrollment list is empty if configured to zero seconds.
"""
self._configure_message_timeout(0)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 2)
recent_course_list = _get_recently_enrolled_courses(courses_list)
......@@ -106,16 +106,16 @@ class TestRecentEnrollments(ModuleStoreTestCase):
enrollment.save()
courses.append(course)
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
courses_list = list(get_course_enrollments(self.student, None, []))
self.assertEqual(len(courses_list), 6)
recent_course_list = _get_recently_enrolled_courses(courses_list)
self.assertEqual(len(recent_course_list), 5)
self.assertEqual(recent_course_list[1][0], courses[0])
self.assertEqual(recent_course_list[2][0], courses[1])
self.assertEqual(recent_course_list[3][0], courses[2])
self.assertEqual(recent_course_list[4][0], courses[3])
self.assertEqual(recent_course_list[1].course, courses[0])
self.assertEqual(recent_course_list[2].course, courses[1])
self.assertEqual(recent_course_list[3].course, courses[2])
self.assertEqual(recent_course_list[4].course, courses[3])
def test_dashboard_rendering(self):
"""
......
# -*- coding: utf-8 -*-
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
Miscellaneous tests for the student app.
"""
from datetime import datetime, timedelta
import logging
......@@ -28,8 +25,8 @@ from student.views import (process_survey_link, _cert_info,
from student.tests.factories import UserFactory, CourseModeFactory
from util.testing import EventTestMixin
from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ModuleStoreEnum
# These imports refer to lms djangoapps.
# Their testcases are only run under lms.
......@@ -193,6 +190,7 @@ class CourseEndingTest(TestCase):
self.assertIsNone(_cert_info(user, course2, cert_status, course_mode))
@ddt.ddt
class DashboardTest(ModuleStoreTestCase):
"""
Tests for dashboard utility functions
......@@ -487,6 +485,61 @@ class DashboardTest(ModuleStoreTestCase):
)
self.assertContains(response, expected_url)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.data((ModuleStoreEnum.Type.mongo, 1), (ModuleStoreEnum.Type.split, 3))
@ddt.unpack
def test_dashboard_metadata_caching(self, modulestore_type, expected_mongo_calls):
"""
Check that the student dashboard makes use of course metadata caching.
The first time the student dashboard displays a specific course, it will
make a call to the module store. After that first request, though, the
course's metadata should be cached as a CourseOverview.
Arguments:
modulestore_type (ModuleStoreEnum.Type): Type of modulestore to create
test course in.
expected_mongo_calls (int >=0): Number of MongoDB queries expected for
a single call to the module store.
Note to future developers:
If you break this test so that the "check_mongo_calls(0)" fails,
please do NOT change it to "check_mongo_calls(n>1)". Instead, change
your code to not load courses from the module store. This may
involve adding fields to CourseOverview so that loading a full
CourseDescriptor isn't necessary.
"""
# Create a course, log in the user, and enroll them in the course.
test_course = CourseFactory.create(default_store=modulestore_type)
self.client.login(username="jack", password="test")
CourseEnrollment.enroll(self.user, test_course.id)
# The first request will result in a modulestore query.
with check_mongo_calls(expected_mongo_calls):
response_1 = self.client.get(reverse('dashboard'))
self.assertEquals(response_1.status_code, 200)
# Subsequent requests will only result in SQL queries to load the
# CourseOverview object that has been created.
with check_mongo_calls(0):
response_2 = self.client.get(reverse('dashboard'))
self.assertEquals(response_2.status_code, 200)
response_3 = self.client.get(reverse('dashboard'))
self.assertEquals(response_3.status_code, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@patch.dict(settings.FEATURES, {"IS_EDX_DOMAIN": True})
def test_dashboard_header_nav_has_find_courses(self):
self.client.login(username="jack", password="test")
response = self.client.get(reverse("dashboard"))
# "Find courses" is shown in the side panel
self.assertContains(response, "Find courses")
# But other links are hidden in the navigation
self.assertNotContains(response, "How it Works")
self.assertNotContains(response, "Schools & Partners")
class UserSettingsEventTestMixin(EventTestMixin):
"""
......
......@@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore
NAMESPACE_CHOICES = {
......@@ -86,33 +87,46 @@ def set_prerequisite_courses(course_key, prerequisite_course_keys):
add_prerequisite_course(course_key, prerequisite_course_key)
def get_pre_requisite_courses_not_completed(user, enrolled_courses):
def get_pre_requisite_courses_not_completed(user, enrolled_courses): # pylint: disable=invalid-name
"""
It would make dict of prerequisite courses not completed by user among courses
user has enrolled in. It calls the fulfilment api of milestones app and
iterates over all fulfilment milestones not achieved to make dict of
prerequisite courses yet to be completed.
Makes a dict mapping courses to their unfulfilled milestones using the
fulfillment API of the milestones app.
Arguments:
user (User): the user for whom we are checking prerequisites.
enrolled_courses (CourseKey): a list of keys for the courses to be
checked. The given user must be enrolled in all of these courses.
Returns:
dict[CourseKey: dict[
'courses': list[dict['key': CourseKey, 'display': str]]
]]
If a course has no incomplete prerequisites, it will be excluded from the
dictionary.
"""
if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
return {}
from milestones import api as milestones_api
pre_requisite_courses = {}
if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
from milestones import api as milestones_api
for course_key in enrolled_courses:
required_courses = []
fulfilment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id})
for milestone_key, milestone_value in fulfilment_paths.items(): # pylint: disable=unused-variable
for key, value in milestone_value.items():
if key == 'courses' and value:
for required_course in value:
required_course_key = CourseKey.from_string(required_course)
required_course_descriptor = modulestore().get_course(required_course_key)
required_courses.append({
'key': required_course_key,
'display': get_course_display_name(required_course_descriptor)
})
# if there are required courses add to dict
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
for course_key in enrolled_courses:
required_courses = []
fulfillment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id})
for __, milestone_value in fulfillment_paths.items():
for key, value in milestone_value.items():
if key == 'courses' and value:
for required_course in value:
required_course_key = CourseKey.from_string(required_course)
required_course_overview = CourseOverview.get_from_id(required_course_key)
required_courses.append({
'key': required_course_key,
'display': get_course_display_string(required_course_overview)
})
# If there are required courses, add them to the result dict.
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
return pre_requisite_courses
......@@ -129,15 +143,18 @@ def get_prerequisite_courses_display(course_descriptor):
required_course_descriptor = modulestore().get_course(course_key)
prc = {
'key': course_key,
'display': get_course_display_name(required_course_descriptor)
'display': get_course_display_string(required_course_descriptor)
}
pre_requisite_courses.append(prc)
return pre_requisite_courses
def get_course_display_name(descriptor):
def get_course_display_string(descriptor):
"""
It would return display name from given course descriptor
Returns a string to display for a course or course overview.
Arguments:
descriptor (CourseDescriptor|CourseOverview): a course or course overview.
"""
return ' '.join([
descriptor.display_org_with_default,
......
......@@ -8,6 +8,6 @@ setup(
"pyparsing==2.0.1",
"numpy",
"scipy",
"nltk==2.0.5",
"nltk<3.0",
],
)
......@@ -20,6 +20,7 @@ from xmodule.tabs import CourseTabList
from xmodule.mixin import LicenseMixin
import json
from xblock.core import XBlock
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from .fields import Date
from django.utils.timezone import UTC
......@@ -712,6 +713,12 @@ class CourseFields(object):
scope=Scope.settings,
default=""
)
cert_html_view_enabled = Boolean(
display_name=_("Certificate Web/HTML View Enabled"),
help=_("If true, certificate Web/HTML views are enabled for the course."),
scope=Scope.settings,
default=False,
)
cert_html_view_overrides = Dict(
# Translators: This field is the container for course-specific certifcate configuration values
display_name=_("Certificate Web/HTML View Overrides"),
......@@ -1315,11 +1322,15 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
except UndefinedContext:
module = self
def possibly_scored(usage_key):
"""Can this XBlock type can have a score or children?"""
return usage_key.block_type in self.block_types_affecting_grading
all_descriptors = []
graded_sections = {}
def yield_descriptor_descendents(module_descriptor):
for child in module_descriptor.get_children():
for child in module_descriptor.get_children(usage_key_filter=possibly_scored):
yield child
for module_descriptor in yield_descriptor_descendents(child):
yield module_descriptor
......@@ -1345,6 +1356,15 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
return {'graded_sections': graded_sections,
'all_descriptors': all_descriptors, }
@lazy
def block_types_affecting_grading(self):
"""Return all block types that could impact grading (i.e. scored, or having children)."""
return frozenset(
cat for (cat, xblock_class) in XBlock.load_classes() if (
getattr(xblock_class, 'has_score', False) or getattr(xblock_class, 'has_children', False)
)
)
@staticmethod
def make_id(org, course, url_name):
return '/'.join([org, course, url_name])
......
......@@ -333,6 +333,30 @@ function (VideoPlayer) {
});
});
describe('onSeek Youtube', function(){
beforeEach(function () {
state = jasmine.initializePlayerYouTube();
state.videoEl = $('video, iframe');
});
describe('when the video is playing', function () {
beforeEach(function(){
state.videoPlayer.onStateChange({
data: YT.PlayerState.PLAYING
});
});
it('Video has started playing', function () {
expect($('.video_control')).toHaveClass('pause');
});
it('seek the player', function () {
state.videoPlayer.seekTo(10);
expect(state.videoPlayer.currentTime).toBe(10);
});
});
});
describe('onSeek', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
......
......@@ -493,8 +493,6 @@ function (HTML5Video, Resizer) {
if (this.videoPlayer.isPlaying()) {
this.videoPlayer.stopTimer();
} else {
this.videoPlayer.currentTime = time;
}
var isUnplayed = this.videoPlayer.isUnstarted() ||
this.videoPlayer.isCued();
......@@ -521,6 +519,8 @@ function (HTML5Video, Resizer) {
if (this.videoPlayer.isPlaying()) {
this.videoPlayer.runTimer();
}
// Update the the current time when user seek. (YoutubePlayer)
this.videoPlayer.currentTime = time;
}
function runTimer() {
......
......@@ -2397,6 +2397,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
parent_block.edit_info.edited_by = user_id
parent_block.edit_info.previous_version = parent_block.edit_info.update_version
parent_block.edit_info.update_version = new_id
# remove the source_version reference
parent_block.edit_info.source_version = None
self.decache_block(usage_locator.course_key, new_id, parent_block_key)
self._remove_subtree(BlockKey.from_usage_key(usage_locator), new_blocks)
......
......@@ -592,6 +592,49 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
# Verify that changes are present
self.assertTrue(self.store.has_changes(component))
@ddt.data('draft', 'split')
def test_unit_stuck_in_published_mode_after_delete(self, default_ms):
"""
Test that a unit does not get stuck in published mode
after discarding a component changes and deleting a component
"""
self.initdb(default_ms)
test_course = self.store.create_course('testx', 'GreekHero', 'test_run', self.user_id)
# Create a dummy vertical & html component to test against
vertical = self.store.create_item(
self.user_id,
test_course.id,
'vertical',
block_id='test_vertical'
)
component = self.store.create_child(
self.user_id,
vertical.location,
'html',
block_id='html_component'
)
# publish vertical changes
self.store.publish(vertical.location, self.user_id)
self.assertFalse(self._has_changes(vertical.location))
# Change a component, then check that there now are changes
component = self.store.get_item(component.location)
component.display_name = 'Changed Display Name'
self.store.update_item(component, self.user_id)
self.assertTrue(self._has_changes(vertical.location))
# Discard changes and verify that there are no changes
self.store.revert_to_published(vertical.location, self.user_id)
self.assertFalse(self._has_changes(vertical.location))
# Delete the component and verify that the unit has changes
self.store.delete_item(component.location, self.user_id)
vertical = self.store.get_item(vertical.location)
self.assertTrue(self._has_changes(vertical.location))
def setup_has_changes(self, default_ms):
"""
Common set up for has_changes tests below.
......
......@@ -41,9 +41,7 @@ class RandomizeModule(RandomizeFields, XModule):
def __init__(self, *args, **kwargs):
super(RandomizeModule, self).__init__(*args, **kwargs)
# NOTE: calling self.get_children() creates a circular reference--
# it calls get_child_descriptors() internally, but that doesn't work until
# we've picked a choice
# NOTE: calling self.get_children() doesn't work until we've picked a choice
num_choices = len(self.descriptor.get_children())
if self.choice > num_choices:
......@@ -59,14 +57,23 @@ class RandomizeModule(RandomizeFields, XModule):
self.choice = random.randrange(0, num_choices)
if self.choice is not None:
self.child_descriptor = self.descriptor.get_children()[self.choice]
# Now get_children() should return a list with one element
log.debug("children of randomize module (should be only 1): %s",
self.get_children())
self.child = self.get_children()[0]
else:
self.child_descriptor = None
self.child = None
log.debug("children of randomize module (should be only 1): %s", self.child)
@property
def child_descriptor(self):
""" Return descriptor of selected choice """
if self.choice is None:
return None
return self.descriptor.get_children()[self.choice]
@property
def child(self):
""" Return module instance of selected choice """
child_descriptor = self.child_descriptor
if child_descriptor is None:
return None
return self.system.get_module(child_descriptor)
def get_child_descriptors(self):
"""
......
......@@ -27,7 +27,7 @@ metadata:
||A fruit is the fertilized ovary from a flower.||
||A fruit contains seeds of the plant.||
tab: hint
hinted: true
data: |
<problem>
......
......@@ -20,7 +20,7 @@ metadata:
||A fruit is the fertilized ovary from a flower.||
||A fruit contains seeds of the plant.||
tab: hint
hinted: true
data: |
<problem>
......
......@@ -24,7 +24,7 @@ metadata:
The mean is calculated by summing the set of numbers and dividing by n. In this case: (1 + 5 + 6 + 3 + 5) / 5 = 20 / 5 = 4.
[explanation]
tab: hint
hinted: true
data: |
<problem>
......
......@@ -23,7 +23,7 @@ metadata:
||A fruit is the fertilized ovary from a flower.||
||A fruit contains seeds of the plant.||
tab: hint
hinted: true
data: |
<problem>
......
......@@ -23,7 +23,7 @@ metadata:
||Consider the square miles, not population.||
||Consider all 50 states, not just the continental United States.||
tab: hint
hinted: true
data: |
<problem>
......
import unittest
from datetime import datetime, timedelta
from django.utils.timezone import UTC
from opaque_keys.edx.locator import BlockUsageLocator
from xblock.fields import ScopeIds
from xmodule.randomize_module import RandomizeModule
from .test_course_module import DummySystem as DummyImportSystem
ORG = 'test_org'
COURSE = 'test_course'
START = '2013-01-01T01:00:00'
_TODAY = datetime.now(UTC())
_LAST_WEEK = _TODAY - timedelta(days=7)
_NEXT_WEEK = _TODAY + timedelta(days=7)
class RandomizeModuleTestCase(unittest.TestCase):
"""Make sure the randomize module works"""
@staticmethod
def get_dummy_course(start):
"""Get a dummy course"""
system = DummyImportSystem(load_error_modules=True)
def setUp(self):
"""
Initialize dummy testing course.
"""
super(RandomizeModuleTestCase, self).setUp()
self.system = DummyImportSystem(load_error_modules=True)
self.system.seed = None
self.course = self.get_dummy_course()
self.modulestore = self.system.modulestore
start_xml = '''
def get_dummy_course(self, start=_TODAY):
"""Get a dummy course"""
self.start_xml = '''
<course org="{org}" course="{course}"
graceperiod="1 day" url_name="test"
start="{start}"
>
<chapter url="hi" url_name="ch" display_name="CH">
start="{start}">
<chapter url="ch1" url_name="chapter1" display_name="CH1">
<randomize url_name="my_randomize">
<html url_name="a" display_name="A">Two houses, ...</html>
<html url_name="b" display_name="B">Three houses, ...</html>
</randomize>
</chapter>
<chapter url="ch2" url_name="chapter2" display_name="CH2">
</chapter>
</course>
'''.format(org=ORG, course=COURSE, start=start)
return system.process_xml(start_xml)
return self.system.process_xml(self.start_xml)
def test_import(self):
"""
......@@ -38,5 +57,48 @@ class RandomizeModuleTestCase(unittest.TestCase):
"""
self.get_dummy_course(START)
# TODO: add tests that create a module and check. Passing state is a good way to
# check that child access works...
def test_course_has_started(self):
"""
Test CourseDescriptor.has_started.
"""
self.course.start = _LAST_WEEK
self.assertTrue(self.course.has_started())
self.course.start = _NEXT_WEEK
self.assertFalse(self.course.has_started())
def test_children(self):
""" Check course/randomize module works fine """
self.assertTrue(self.course.has_children)
self.assertEquals(len(self.course.get_children()), 2)
def inner_get_module(descriptor):
"""
Override systems.get_module
This method will be called when any call is made to self.system.get_module
"""
if isinstance(descriptor, BlockUsageLocator):
location = descriptor
descriptor = self.modulestore.get_item(location, depth=None)
descriptor.xmodule_runtime = self.get_dummy_course()
descriptor.xmodule_runtime.descriptor_runtime = descriptor._runtime # pylint: disable=protected-access
descriptor.xmodule_runtime.get_module = inner_get_module
return descriptor
self.system.get_module = inner_get_module
# Get randomize_descriptor from the course & verify its children
randomize_descriptor = inner_get_module(self.course.id.make_usage_key('randomize', 'my_randomize'))
self.assertTrue(randomize_descriptor.has_children)
self.assertEquals(len(randomize_descriptor.get_children()), 2)
# Call RandomizeModule which will select an element from the list of available items
randomize_module = RandomizeModule(
randomize_descriptor,
self.system,
scope_ids=ScopeIds(None, None, self.course.id, self.course.id)
)
# Verify the selected child
self.assertEquals(len(randomize_module.get_child_descriptors()), 1, "No child is chosen")
self.assertIn(randomize_module.child.display_name, ['A', 'B'], "Unwanted child selected")
......@@ -587,6 +587,12 @@ class XModuleMixin(XModuleFields, XBlock):
if field.scope.user == UserScope.ONE:
field._del_cached_value(self) # pylint: disable=protected-access
# not the most elegant way of doing this, but if we're removing
# a field from the module's field_data_cache, we should also
# remove it from its _dirty_fields
# pylint: disable=protected-access
if field in self._dirty_fields:
del self._dirty_fields[field]
# Set the new xmodule_runtime and field_data (which are user-specific)
self.xmodule_runtime = xmodule_runtime
......
......@@ -18,12 +18,12 @@ define(['sinon', 'underscore'], function(sinon, _) {
* Get a reference to the mocked server, and respond
* to all requests with the specified statusCode.
*/
fakeServer = function (statusCode, that) {
fakeServer = function (that, response) {
var server = sinon.fakeServer.create();
that.after(function() {
server.restore();
});
server.respondWith([statusCode, {}, '']);
server.respondWith(response);
return server;
};
......
/**
* Adds rwd classes and click handlers.
*/
(function($) {
'use strict';
var rwd = (function() {
var _fn = {
header: 'header.global-new',
resultsUrl: 'course-search',
init: function() {
_fn.$header = $( _fn.header );
_fn.$footer = $( _fn.footer );
_fn.$navContainer = _fn.$header.find('.nav-container');
_fn.$globalNav = _fn.$header.find('.nav-global');
_fn.add.elements();
_fn.add.classes();
_fn.eventHandlers.init();
},
add: {
classes: function() {
// Add any RWD-specific classes
_fn.$header.addClass('rwd');
},
elements: function() {
_fn.add.burger();
_fn.add.registerLink();
},
burger: function() {
_fn.$navContainer.prepend([
'<a href="#" class="mobile-menu-button" aria-label="menu">',
'<i class="icon fa fa-bars" aria-hidden="true"></i>',
'</a>'
].join(''));
},
registerLink: function() {
var $register = _fn.$header.find('.cta-register'),
$li = {},
$a = {},
count = 0;
// Add if register link is shown
if ( $register.length > 0 ) {
count = _fn.$globalNav.find('li').length + 1;
// Create new li
$li = $('<li/>');
$li.addClass('desktop-hide nav-global-0' + count);
// Clone register link and remove classes
$a = $register.clone();
$a.removeClass();
// append to DOM
$a.appendTo( $li );
_fn.$globalNav.append( $li );
}
}
},
eventHandlers: {
init: function() {
_fn.eventHandlers.click();
},
click: function() {
// Toggle menu
_fn.$header.on( 'click', '.mobile-menu-button', _fn.toggleMenu );
}
},
toggleMenu: function( event ) {
event.preventDefault();
_fn.$globalNav.toggleClass('show');
}
};
return {
init: _fn.init
};
})();
rwd.init();
})(jQuery);
......@@ -56,4 +56,4 @@ class CertificatePage(PageObject):
"""
returns Facebook share button
"""
return self.q(css='a.action-share-facebook')
return self.q(css='button.action-share-facebook')
......@@ -49,20 +49,6 @@ class DashboardPage(PageObject):
return self.q(css='h3.course-title > a').map(_get_course_name).results
@property
def sidebar_menu_title(self):
"""
Return the title value for sidebar menu.
"""
return self.q(css='.user-info span.title').text[0]
@property
def sidebar_menu_description(self):
"""
Return the description text for sidebar menu.
"""
return self.q(css='.user-info span.copy').text[0]
def get_enrollment_mode(self, course_name):
"""Get the enrollment mode for a given course on the dashboard.
......
......@@ -281,6 +281,36 @@ class ContainerPage(PageObject):
"""
return "is-editing" in self.q(css=self.NAME_FIELD_WRAPPER_SELECTOR).first.attrs("class")[0]
def get_category_tab_names(self, category_type):
"""
Returns list of tab name in a category.
Arguments:
category_type (str): category type
Returns:
list
"""
self.q(css='.add-xblock-component-button[data-type={}]'.format(category_type)).first.click()
return self.q(css='.{}-type-tabs>li>a'.format(category_type)).text
def get_category_tab_components(self, category_type, tab_index):
"""
Return list of component names in a tab in a category.
Arguments:
category_type (str): category type
tab_index (int): tab index in a category
Returns:
list
"""
css = '#tab{tab_index} a[data-category={category_type}] span'.format(
tab_index=tab_index,
category_type=category_type
)
return self.q(css=css).html
class XBlockWrapper(PageObject):
"""
......
......@@ -256,6 +256,9 @@ class StudioLibraryContainerXBlockWrapper(XBlockWrapper):
self.q(css=btn_selector).first.click()
# This causes a reload (see cms/static/xmodule_js/public/js/library_content_edit.js)
# Check that the ajax request that caused the reload is done.
self.wait_for_ajax()
# Then check that we are still on the right page.
self.wait_for(lambda: self.is_browser_on_page(), 'StudioLibraryContainerXBlockWrapper has reloaded.')
# Wait longer than the default 60 seconds, because this was intermittently failing on jenkins
# with the screenshot showing that the Loading indicator was still visible. See TE-745.
......
......@@ -201,4 +201,5 @@ class AdvancedSettingsPage(CoursePage):
'social_sharing_url',
'teams_configuration',
'video_bumper',
'cert_html_view_enabled',
]
......@@ -36,8 +36,11 @@ class CertificateWebViewTest(EventsTestMixin, UniqueCourseTest):
self.course_info["display_name"],
settings=course_settings
)
self.course_fixture.add_advanced_settings({
"cert_html_view_enabled": {"value": "true"}
})
self.course_fixture.install()
self.user_id = "99" # we have createad a user with this id in fixture
self.user_id = "99" # we have created a user with this id in fixture
self.cert_fixture = CertificateConfigFixture(self.course_id, test_certificate_config)
# Load certificate web view page for use by the tests
......
......@@ -468,6 +468,7 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
self.assert_default_image_has_public_access(profile_page)
@flaky # TODO fix this, see TNL-2704
def test_user_can_upload_the_profile_image_with_success(self):
"""
Scenario: Upload profile image works correctly.
......
......@@ -268,12 +268,6 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
course_names = self.dashboard_page.wait_for_page().available_courses
self.assertIn(self.course_info["display_name"], course_names)
self.assertEqual("want to change your account settings?", self.dashboard_page.sidebar_menu_title.lower())
self.assertEqual(
"click the arrow next to your username above.",
self.dashboard_page.sidebar_menu_description.lower()
)
def test_register_failure(self):
# Navigate to the registration page
self.register_page.visit()
......
......@@ -8,6 +8,7 @@ from unittest import skip
from ...fixtures.course import XBlockFixtureDesc
from ...pages.studio.component_editor import ComponentEditorView, ComponentVisibilityEditorView
from ...pages.studio.container import ContainerPage
from ...pages.studio.html_component_editor import HtmlComponentEditorView
from ...pages.studio.utils import add_discussion, drag
from ...pages.lms.courseware import CoursewarePage
......@@ -1056,3 +1057,61 @@ class DisplayNameTest(ContainerBase):
title_on_unit_page = test_block.name
container = test_block.go_to_container()
self.assertEqual(container.name, title_on_unit_page)
class ProblemCategoryTabsTest(ContainerBase):
"""
Test to verify tabs in problem category.
"""
def setUp(self, is_staff=True):
super(ProblemCategoryTabsTest, self).setUp(is_staff=is_staff)
def populate_course_fixture(self, course_fixture):
"""
Sets up course structure.
"""
course_fixture.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit')
)
)
)
def test_correct_tabs_present(self):
"""
Scenario: Verify that correct tabs are present in problem category.
Given I am a staff user
When I go to unit page
Then I only see `Common Problem Types` and `Advanced` tabs in `problem` category
"""
self.go_to_unit_page()
page = ContainerPage(self.browser, None)
self.assertEqual(page.get_category_tab_names('problem'), ['Common Problem Types', 'Advanced'])
def test_common_problem_types_tab(self):
"""
Scenario: Verify that correct components are present in Common Problem Types tab.
Given I am a staff user
When I go to unit page
Then I see correct components under `Common Problem Types` tab in `problem` category
"""
self.go_to_unit_page()
page = ContainerPage(self.browser, None)
expected_components = [
"Blank Common Problem",
"Checkboxes",
"Dropdown",
"Multiple Choice",
"Numerical Input",
"Text Input",
"Checkboxes with Hints and Feedback",
"Dropdown with Hints and Feedback",
"Multiple Choice with Hints and Feedback",
"Numerical Input with Hints and Feedback",
"Text Input with Hints and Feedback",
]
self.assertEqual(page.get_category_tab_components('problem', 1), expected_components)
"""
Acceptance tests for course in studio
"""
from flaky import flaky
from nose.plugins.attrib import attr
from .base_studio_test import StudioCourseTest
......@@ -126,6 +127,7 @@ class CourseTeamPageTest(StudioCourseTest):
self.log_in(self.other_user)
self._assert_current_course(visible=True)
@flaky # TODO fix this, see TNL-2667
def test_added_users_cannot_add_or_delete_other_users(self):
"""
Scenario: Added users cannot delete or add other users
......
......@@ -2,7 +2,6 @@
Acceptance tests for Library Content in LMS
"""
import ddt
from flaky import flaky
from nose.plugins.attrib import attr
import textwrap
......@@ -138,7 +137,6 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest, TestWithSe
self.assertIn(expected_text, library_container.validation_not_configured_warning_text)
self.assertIn(expected_action, library_container.validation_not_configured_warning_text)
@flaky # TODO fix this, see TE-745
def test_out_of_date_message(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
......@@ -149,6 +147,7 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest, TestWithSe
When I click on the update link
Then I can see that the content no longer needs to be updated
"""
# Formerly flaky: see TE-745
expected_text = "This component is out of date. The library has new content."
library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[1])
......
"""
Acceptance tests for Studio's Settings Details pages
"""
from flaky import flaky
from unittest import skip
from .base_studio_test import StudioCourseTest
......@@ -41,7 +40,7 @@ class SettingsMilestonesTest(StudioCourseTest):
self.assertTrue(self.settings_detail.pre_requisite_course_options)
@flaky # TODO: fix this. SOL-449
@skip # TODO: fix this. SOL-449
def test_prerequisite_course_save_successfully(self):
"""
Scenario: Selecting course from Pre-Requisite course drop down save the selected course as pre-requisite
......
No preview for this file type
No preview for this file type
......@@ -75,10 +75,10 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-07-06 12:00+0000\n"
"PO-Revision-Date: 2015-06-30 04:09+0000\n"
"Last-Translator: Nabeel El-Dughailib <nabeel@qordoba.com>\n"
"Language-Team: Arabic (http://www.transifex.com/projects/p/edx-platform/language/ar/)\n"
"POT-Creation-Date: 2015-07-13 04:31+0000\n"
"PO-Revision-Date: 2015-07-10 19:12+0000\n"
"Last-Translator: Sarina Canelake <sarina@edx.org>\n"
"Language-Team: Arabic (http://www.transifex.com/p/edx-platform/language/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
......@@ -1583,7 +1583,6 @@ msgstr "مسافة عمودية"
#. Translators: this is a message from the raw HTML editor displayed in the
#. browser when a user needs to edit HTML
#: common/lib/xmodule/xmodule/js/src/html/edit.js
#: lms/djangoapps/teams/static/teams/js/views/topic_card.js
#: lms/templates/search/course_search_item.underscore
#: lms/templates/search/dashboard_search_item.underscore
msgid "View"
......@@ -2141,44 +2140,12 @@ msgstr "نأسف لحدوث مشكلة في حذف هذا التعليق. يُر
msgid "Are you sure you want to delete this response?"
msgstr "هل أنت واثق من أنّك تودّ حذف هذا الرد؟"
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added
#. ascending"
#: common/static/common/js/components/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, filtered by "
"%(asset_type)s, sorted by %(sort_name)s ascending"
msgid "Showing %(first_index)s out of %(num_items)s total"
msgstr ""
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added
#. descending"
#: common/static/common/js/components/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, filtered by "
"%(asset_type)s, sorted by %(sort_name)s descending"
msgstr ""
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, sorted by Date Added ascending"
#: common/static/common/js/components/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, sorted by "
"%(sort_name)s ascending"
msgstr ""
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, sorted by Date Added descending"
#: common/static/common/js/components/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, sorted by "
"%(sort_name)s descending"
msgstr ""
#. Translators: turns into "25 total" to be used in other sentences, e.g.
#. "Showing 0-9 out of 25 total".
#: common/static/common/js/components/views/paging_header.js
msgid "%(total_items)s total"
msgid "Showing %(first_index)s-%(last_index)s out of %(num_items)s total"
msgstr ""
#: common/static/js/capa/drag_and_drop/base_image.js
......@@ -2436,6 +2403,14 @@ msgstr "الرد"
msgid "Tags:"
msgstr "العلامات: "
#: lms/templates/edxnotes/tab-item.underscore
msgid "name"
msgstr "الاسم "
#: lms/djangoapps/teams/static/teams/js/collections/topic.js
msgid "team count"
msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/teams_tab.js
msgid ""
"Course teams are organized into topics created by course instructors. Try to"
......@@ -2464,6 +2439,10 @@ msgstr[3] ""
msgstr[4] ""
msgstr[5] ""
#: lms/djangoapps/teams/static/teams/js/views/topic_card.js
msgid "View Teams in the %(topic_name)s Topic"
msgstr ""
#: lms/static/coffee/src/calculator.js
msgid "Open Calculator"
msgstr "فتح الآلة الحاسبة "
......@@ -4389,12 +4368,18 @@ msgid "The course must have an assigned start date."
msgstr "يجب أن يكون للمساق تاريخ بدء معيّن."
#: cms/static/js/models/settings/course_details.js
msgid "The course end date cannot be before the course start date."
msgstr "لايمكن لتاريخ انتهاء المساق أن يسبق تاريخ بدئه."
msgid ""
"The course must have at least one active certificate configuration before it"
" can be started."
msgstr ""
#: cms/static/js/models/settings/course_details.js
msgid "The course end date must be later than the course start date."
msgstr ""
#: cms/static/js/models/settings/course_details.js
msgid "The course start date cannot be before the enrollment start date."
msgstr "لايمكن لتاريخ بدء المساق أن يسبق تاريخ التسجيل فيه."
msgid "The course start date must be later than the enrollment start date."
msgstr ""
#: cms/static/js/models/settings/course_details.js
msgid "The enrollment start date cannot be after the enrollment end date."
......@@ -4768,6 +4753,46 @@ msgstr "غير مقيَّم"
msgid "Date added"
msgstr "تاريخ الإضافة "
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added
#. ascending"
#: cms/static/js/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, filtered by "
"%(asset_type)s, sorted by %(sort_name)s ascending"
msgstr ""
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added
#. descending"
#: cms/static/js/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, filtered by "
"%(asset_type)s, sorted by %(sort_name)s descending"
msgstr ""
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, sorted by Date Added ascending"
#: cms/static/js/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, sorted by "
"%(sort_name)s ascending"
msgstr ""
#. Translators: sample result:
#. "Showing 0-9 out of 25 total, sorted by Date Added descending"
#: cms/static/js/views/paging_header.js
msgid ""
"Showing %(current_item_range)s out of %(total_items_count)s, sorted by "
"%(sort_name)s descending"
msgstr ""
#. Translators: turns into "25 total" to be used in other sentences, e.g.
#. "Showing 0-9 out of 25 total".
#: cms/static/js/views/paging_header.js
msgid "%(total_items)s total"
msgstr ""
#. Translators: This is listed as the duration for a video
#. that has not yet reached the point in its processing by
#. the servers where its duration is determined.
......@@ -4985,11 +5010,11 @@ msgstr ""
"بك غير مطبّقة بعد. وفي حال كنت تواجه أي مشاكل، يُرجى مراجعة أزواج سياستك. . "
#: cms/static/js/views/settings/main.js
msgid "Credit Eligibility Requirements"
msgid "Course Credit Requirements"
msgstr ""
#: cms/static/js/views/settings/main.js
msgid "Minimum passing grade for credit is not set."
msgid "The minimum grade for course credit is not set."
msgstr ""
#: cms/static/js/views/settings/main.js
......@@ -5273,10 +5298,6 @@ msgid "Last Edited:"
msgstr "آخر مراجعة في:"
#: lms/templates/edxnotes/tab-item.underscore
msgid "name"
msgstr "الاسم "
#: lms/templates/edxnotes/tab-item.underscore
msgid "Clear search results"
msgstr "حذف نتائج البحث"
......@@ -6309,7 +6330,9 @@ msgid "Course title"
msgstr ""
#: cms/templates/js/certificate-editor.underscore
msgid "Title of the course"
msgid ""
"Specify an alternative to the official course title to display on "
"certificates. Leave blank to use the official course title."
msgstr ""
#: cms/templates/js/certificate-editor.underscore
......@@ -6757,6 +6780,10 @@ msgstr "لم تُضِف بعد أي كتب إلى هذا المساق."
msgid "Add your first textbook"
msgstr "أضِف أوّل كتاب لك."
#: cms/templates/js/paging-header.underscore
msgid "Previous"
msgstr ""
#: cms/templates/js/previous-video-upload-list.underscore
msgid "Previous Uploads"
msgstr "تحميلات سابقة"
......@@ -6893,7 +6920,9 @@ msgid "Name of the signatory"
msgstr ""
#: cms/templates/js/signatory-editor.underscore
msgid "Maximum 40 characters"
msgid ""
"The name of this signatory as it should appear on certificates. Maximum 40 "
"characters."
msgstr ""
#: cms/templates/js/signatory-editor.underscore
......@@ -6901,7 +6930,9 @@ msgid "Title of the signatory"
msgstr ""
#: cms/templates/js/signatory-editor.underscore
msgid "2 Lines, 40 characters each"
msgid ""
"The title of this signatory as it should appear on certificates. Maximum 2 "
"lines, 40 characters each."
msgstr ""
#: cms/templates/js/signatory-editor.underscore
......@@ -6909,6 +6940,12 @@ msgid "Organization of the signatory"
msgstr ""
#: cms/templates/js/signatory-editor.underscore
msgid ""
"The organization that this signatory belongs to, as it should appear on "
"certificates. Maximum 40 characters."
msgstr ""
#: cms/templates/js/signatory-editor.underscore
msgid "Signature Image"
msgstr ""
......
No preview for this file type
No preview for this file type
No preview for this file type
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