Merge branch 'master' into release-mergeback-to-master

This commit is contained in:
Diana Huang
2017-03-23 15:59:04 -04:00
committed by GitHub
146 changed files with 2385 additions and 1354 deletions

View File

@@ -1,5 +1,5 @@
{
"extends": "eslint-config-edx",
"extends": "eslint-config-edx-es5",
"globals": { // Try to avoid adding any new globals.
// Old compatibility things and hacks
"edx": true,

View File

@@ -33,4 +33,5 @@ class Router(AlternateEnvironmentRouter):
"""
return {
'openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache': 'lms',
'openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache_v2': 'lms',
}

View File

@@ -41,7 +41,7 @@ class ContentStoreImportTest(SignalDisconnectTestMixin, ModuleStoreTestCase):
self.client.login(username=self.user.username, password=self.user_password)
# block_structure.update_course_in_cache cannot succeed in tests, as it needs to be run async on an lms worker
self.task_patcher = patch('openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache')
self.task_patcher = patch('openedx.core.djangoapps.content.block_structure.tasks.update_course_in_cache_v2')
self._mock_lms_task = self.task_patcher.start()
def tearDown(self):

View File

@@ -6,6 +6,7 @@ from datetime import datetime
import unittest
import decimal
import ddt
import httpretty
import freezegun
from mock import patch
from nose.plugins.attrib import attr
@@ -25,12 +26,14 @@ from student.models import CourseEnrollment
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from util.testing import UrlResetMixin
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
from util.tests.mixins.enterprise import EnterpriseServiceMockMixin
from util import organizations_helpers as organizations_api
@attr(shard=3)
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase, EnterpriseServiceMockMixin):
"""
Course Mode View tests
"""
@@ -44,6 +47,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
self.client.login(username=self.user.username, password="edx")
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@httpretty.activate
@ddt.data(
# is_active?, enrollment_mode, redirect?
(True, 'verified', True),
@@ -69,6 +73,14 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
user=self.user
)
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
# Configure whether we're upgrading or not
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
@@ -118,17 +130,101 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
self.assertRedirects(response, 'http://testserver/test_basket/?sku=TEST', fetch_redirect_response=False)
ecomm_test_utils.update_commerce_config(enabled=False)
@httpretty.activate
def test_no_enrollment(self):
# Create the course modes
for mode in ('audit', 'honor', 'verified'):
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
# User visits the track selection page directly without ever enrolling
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
@httpretty.activate
def test_enterprise_learner_context(self):
"""
Test: Track selection page should show the enterprise context message if user belongs to the Enterprise.
"""
# Create the course modes
for mode in ('audit', 'honor', 'verified'):
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
# User visits the track selection page directly without ever enrolling
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
self.assertContains(
response,
'Welcome, {username}! You are about to enroll in {course_name}, from {partner_names}, '
'sponsored by TestShib. Please select your enrollment information below.'.format(
username=self.user.username,
course_name=self.course.display_name_with_default_escaped,
partner_names=self.course.org
)
)
@httpretty.activate
def test_enterprise_learner_context_with_multiple_organizations(self):
"""
Test: Track selection page should show the enterprise context message with multiple organization names
if user belongs to the Enterprise.
"""
# Create the course modes
for mode in ('audit', 'honor', 'verified'):
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
# Creating organization
for i in xrange(2):
test_organization_data = {
'name': 'test organization ' + str(i),
'short_name': 'test_organization_' + str(i),
'description': 'Test Organization Description',
'active': True,
'logo': '/logo_test1.png/'
}
test_org = organizations_api.add_organization(organization_data=test_organization_data)
organizations_api.add_organization_course(organization_data=test_org, course_id=unicode(self.course.id))
# User visits the track selection page directly without ever enrolling
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
self.assertContains(
response,
'Welcome, {username}! You are about to enroll in {course_name}, from test organization 0 and '
'test organization 1, sponsored by TestShib. Please select your enrollment information below.'.format(
username=self.user.username,
course_name=self.course.display_name_with_default_escaped
)
)
@httpretty.activate
@ddt.data(
'',
'1,,2',
@@ -155,6 +251,14 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
user=self.user
)
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
# Verify that the prices render correctly
response = self.client.get(
reverse('course_modes_choose', args=[unicode(self.course.id)]),
@@ -165,6 +269,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
# TODO: Fix it so that response.templates works w/ mako templates, and then assert
# that the right template rendered
@httpretty.activate
@ddt.data(
(['honor', 'verified', 'credit'], True),
(['honor', 'verified'], False),
@@ -175,6 +280,14 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
for mode in available_modes:
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
# Check whether credit upsell is shown on the page
# This should *only* be shown when a credit mode is available
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
@@ -375,11 +488,20 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@with_comprehensive_theme("edx.org")
@httpretty.activate
def test_hide_nav(self):
# Create the course modes
for mode in ["honor", "verified"]:
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
# Load the track selection page
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
@@ -406,7 +528,7 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase, EnterpriseServiceMockMixin):
"""Test embargo restrictions on the track selection page. """
URLCONF_MODULES = ['openedx.core.djangoapps.embargo']
@@ -433,6 +555,15 @@ class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
response = self.client.get(self.url)
self.assertRedirects(response, redirect_url)
@httpretty.activate
def test_embargo_allow(self):
self.mock_enterprise_learner_api()
# Create a service user and log in.
UserFactory.create(
username='enterprise_worker',
email="bob@example.com",
password="edx",
)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)

View File

@@ -26,6 +26,8 @@ from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.embargo import api as embargo_api
from student.models import CourseEnrollment
from util.db import outer_atomic
from util import enterprise_helpers as enterprise_api
from util import organizations_helpers as organization_api
class ChooseModeView(View):
@@ -148,6 +150,20 @@ class ChooseModeView(View):
"responsive": True,
"nav_hidden": True,
}
enterprise_learner_data = enterprise_api.get_enterprise_learner_data(site=request.site, user=request.user)
if enterprise_learner_data:
context["show_enterprise_context"] = True
context["partner_names"] = partner_name = course.display_organization \
if course.display_organization else course.org
context["enterprise_name"] = enterprise_learner_data[0]['enterprise_customer']['name']
context["username"] = request.user.username
organizations = organization_api.get_course_organizations(course_id=course.id)
if organizations:
context["partner_names"] = ' and '.join([
org.get('name', partner_name) for org in organizations
])
if "verified" in modes:
verified_mode = modes["verified"]
context["suggested_prices"] = [

View File

@@ -6,7 +6,14 @@ This middleware will only call on the newrelic agent if there are any metrics
to report for this request, so it will not incur any processing overhead for
request handlers which do not record custom metrics.
"""
import newrelic.agent
import logging
log = logging.getLogger(__name__)
try:
import newrelic.agent
except ImportError:
log.warning("Unable to load NewRelic agent module")
newrelic = None # pylint: disable=invalid-name
import request_cache
REQUEST_CACHE_KEY = 'newrelic_custom_metrics'
@@ -40,6 +47,8 @@ class NewRelicCustomMetrics(object):
"""
Report the collected custom metrics to New Relic.
"""
if not newrelic:
return
metrics_cache = cls._get_metrics_cache()
for metric_name, metric_value in metrics_cache.iteritems():
newrelic.agent.add_custom_parameter(metric_name, metric_value)

View File

@@ -18,6 +18,7 @@ from course_modes.tests.factories import CourseModeFactory
from student.models import CourseEnrollment, DashboardConfiguration
from student.views import get_course_enrollments, _get_recently_enrolled_courses
from common.test.utils import XssTestMixin
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
@attr(shard=3)
@@ -211,3 +212,29 @@ class TestRecentEnrollments(ModuleStoreTestCase, XssTestMixin):
self.client.login(username=self.student.username, password=self.PASSWORD)
response = self.client.get(reverse("dashboard"))
self.assertNotContains(response, "donate-container")
@ddt.data(
(True, False,),
(True, True,),
(False, False,),
(False, True,),
)
@ddt.unpack
def test_donate_button_with_enabled_site_configuration(self, enable_donation_config, enable_donation_site_config):
# Enable the enrollment success message and donations
self._configure_message_timeout(10000)
# DonationConfiguration has low precedence if 'ENABLE_DONATIONS' is enable in SiteConfiguration
DonationConfiguration(enabled=enable_donation_config).save()
CourseModeFactory.create(mode_slug="audit", course_id=self.course.id, min_price=0)
self.enrollment.mode = "audit"
self.enrollment.save()
self.client.login(username=self.student.username, password=self.PASSWORD)
with with_site_configuration_context(configuration={'ENABLE_DONATIONS': enable_donation_site_config}):
response = self.client.get(reverse("dashboard"))
if enable_donation_site_config:
self.assertContains(response, "donate-container")
else:
self.assertNotContains(response, "donate-container")

View File

@@ -936,7 +936,10 @@ def _allow_donation(course_modes, course_id, enrollment):
flat_unexpired_modes,
flat_all_modes
)
donations_enabled = DonationConfiguration.current().enabled
donations_enabled = configuration_helpers.get_value(
'ENABLE_DONATIONS',
DonationConfiguration.current().enabled
)
return (
donations_enabled and
enrollment.mode in course_modes[course_id] and

View File

@@ -0,0 +1,44 @@
"""
Unit tests for Video stub server implementation.
"""
import unittest
import requests
from terrain.stubs.video_source import VideoSourceHttpService
from django.conf import settings
HLS_MANIFEST_TEXT = """
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=264787,RESOLUTION=1280x720
history_264kbit/history_264kbit.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=328415,RESOLUTION=1920x1080
history_328kbit/history_328kbit.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=70750,RESOLUTION=640x360
history_70kbit/history_70kbit.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=148269,RESOLUTION=960x540
history_148kbit/history_148kbit.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=41276,RESOLUTION=640x360
history_41kbit/history_41kbit.m3u8
"""
class StubVideoServiceTest(unittest.TestCase):
"""
Test cases for the video stub service.
"""
def setUp(self):
"""
Start the stub server.
"""
super(StubVideoServiceTest, self).setUp()
self.server = VideoSourceHttpService()
self.server.config['root_dir'] = '{}/data/video'.format(settings.TEST_ROOT)
self.addCleanup(self.server.shutdown)
def test_get_hls_manifest(self):
"""
Verify that correct hls manifest is received.
"""
response = requests.get("http://127.0.0.1:{port}/hls/history.m3u8".format(port=self.server.port))
self.assertTrue(response.ok)
self.assertEqual(response.text, HLS_MANIFEST_TEXT.lstrip())
self.assertEqual(response.headers['Access-Control-Allow-Origin'], '*')

View File

@@ -24,6 +24,13 @@ class VideoSourceRequestHandler(SimpleHTTPRequestHandler):
path = '{}{}'.format(root_dir, path)
return path.split('?')[0]
def end_headers(self):
"""
This is required by hls.js to play hls videos.
"""
self.send_header('Access-Control-Allow-Origin', '*')
SimpleHTTPRequestHandler.end_headers(self)
class VideoSourceHttpService(StubHttpService):
"""

View File

@@ -9,6 +9,7 @@ from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.shortcuts import redirect
from django.utils.http import urlencode
from django.core.cache import cache
from edx_rest_api_client.client import EdxRestApiClient
try:
from enterprise import utils as enterprise_utils
@@ -18,6 +19,8 @@ except ImportError:
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.token_utils import JwtBuilder
from slumber.exceptions import HttpClientError, HttpServerError
import hashlib
import six
ENTERPRISE_CUSTOMER_BRANDING_OVERRIDE_DETAILS = 'enterprise_customer_branding_override_details'
@@ -71,6 +74,108 @@ class EnterpriseApiClient(object):
LOGGER.exception(message)
raise EnterpriseApiException(message)
def fetch_enterprise_learner_data(self, site, user):
"""
Fetch information related to enterprise from the Enterprise Service.
Example:
fetch_enterprise_learner_data(site, user)
Argument:
site: (Site) site instance
user: (User) django auth user
Returns:
dict: {
"enterprise_api_response_for_learner": {
"count": 1,
"num_pages": 1,
"current_page": 1,
"results": [
{
"enterprise_customer": {
"uuid": "cf246b88-d5f6-4908-a522-fc307e0b0c59",
"name": "TestShib",
"catalog": 2,
"active": true,
"site": {
"domain": "example.com",
"name": "example.com"
},
"enable_data_sharing_consent": true,
"enforce_data_sharing_consent": "at_login",
"enterprise_customer_users": [
1
],
"branding_configuration": {
"enterprise_customer": "cf246b88-d5f6-4908-a522-fc307e0b0c59",
"logo": "https://open.edx.org/sites/all/themes/edx_open/logo.png"
},
"enterprise_customer_entitlements": [
{
"enterprise_customer": "cf246b88-d5f6-4908-a522-fc307e0b0c59",
"entitlement_id": 69
}
]
},
"user_id": 5,
"user": {
"username": "staff",
"first_name": "",
"last_name": "",
"email": "staff@example.com",
"is_staff": true,
"is_active": true,
"date_joined": "2016-09-01T19:18:26.026495Z"
},
"data_sharing_consent": [
{
"user": 1,
"state": "enabled",
"enabled": true
}
]
}
],
"next": null,
"start": 0,
"previous": null
}
}
Raises:
ConnectionError: requests exception "ConnectionError", raised if if ecommerce is unable to connect
to enterprise api server.
SlumberBaseException: base slumber exception "SlumberBaseException", raised if API response contains
http error status like 4xx, 5xx etc.
Timeout: requests exception "Timeout", raised if enterprise API is taking too long for returning
a response. This exception is raised for both connection timeout and read timeout.
"""
api_resource_name = 'enterprise-learner'
cache_key = get_cache_key(
site_domain=site.domain,
resource=api_resource_name,
username=user.username
)
response = cache.get(cache_key)
if not response:
try:
endpoint = getattr(self.client, api_resource_name)
querystring = {'username': user.username}
response = endpoint().get(**querystring)
cache.set(cache_key, response, settings.ENTERPRISE_API_CACHE_TIMEOUT)
except (HttpClientError, HttpServerError):
message = ("An error occurred while getting EnterpriseLearner data for user {username}".format(
username=user.username
))
LOGGER.exception(message)
return None
return response
def data_sharing_consent_required(view_func):
"""
@@ -225,3 +330,39 @@ def get_enterprise_branding_filter_param(request):
"""
return request.session.get(ENTERPRISE_CUSTOMER_BRANDING_OVERRIDE_DETAILS, None)
def get_cache_key(**kwargs):
"""
Get MD5 encoded cache key for given arguments.
Here is the format of key before MD5 encryption.
key1:value1__key2:value2 ...
Example:
>>> get_cache_key(site_domain="example.com", resource="enterprise-learner")
# Here is key format for above call
# "site_domain:example.com__resource:enterprise-learner"
a54349175618ff1659dee0978e3149ca
Arguments:
**kwargs: Key word arguments that need to be present in cache key.
Returns:
An MD5 encoded key uniquely identified by the key word arguments.
"""
key = '__'.join(['{}:{}'.format(item, value) for item, value in six.iteritems(kwargs)])
return hashlib.md5(key).hexdigest()
def get_enterprise_learner_data(site, user):
"""
Client API operation adapter/wrapper
"""
if not enterprise_enabled():
return None
enterprise_learner_data = EnterpriseApiClient().fetch_enterprise_learner_data(site=site, user=user)
if enterprise_learner_data:
return enterprise_learner_data['results']

View File

@@ -4,9 +4,11 @@ Online Contextual Help.
"""
import ConfigParser
from django.conf import settings
import logging
from django.conf import settings
from openedx.core.release import doc_version
log = logging.getLogger(__name__)
@@ -76,7 +78,7 @@ def common_doc_url(request, config_file_object): # pylint: disable=unused-argum
return "{url_base}/{language}/{version}/{page_path}".format(
url_base=doc_base_url,
language=get_config_value_with_default("locales", settings.LANGUAGE_CODE),
version=config_file_object.get("help_settings", "version"),
version=doc_version(),
page_path=get_config_value_with_default("pages", page_token),
)
@@ -102,7 +104,7 @@ def common_doc_url(request, config_file_object): # pylint: disable=unused-argum
# Construct and return the URL for the PDF link.
return "{pdf_base}/{version}/{pdf_file}".format(
pdf_base=pdf_base_url,
version=config_file_object.get("help_settings", "version"),
version=doc_version(),
pdf_file=config_file_object.get("pdf_settings", "pdf_file"),
)

View File

@@ -57,6 +57,80 @@ class EnterpriseServiceMockMixin(object):
status=500
)
def mock_enterprise_learner_api(
self,
catalog_id=1,
entitlement_id=1,
learner_id=1,
enterprise_customer_uuid='cf246b88-d5f6-4908-a522-fc307e0b0c59'
):
"""
Helper function to register enterprise learner API endpoint.
"""
enterprise_learner_api_response = {
'count': 1,
'num_pages': 1,
'current_page': 1,
'results': [
{
'id': learner_id,
'enterprise_customer': {
'uuid': enterprise_customer_uuid,
'name': 'TestShib',
'catalog': catalog_id,
'active': True,
'site': {
'domain': 'example.com',
'name': 'example.com'
},
'enable_data_sharing_consent': True,
'enforce_data_sharing_consent': 'at_login',
'enterprise_customer_users': [
1
],
'branding_configuration': {
'enterprise_customer': enterprise_customer_uuid,
'logo': 'https://open.edx.org/sites/all/themes/edx_open/logo.png'
},
'enterprise_customer_entitlements': [
{
'enterprise_customer': enterprise_customer_uuid,
'entitlement_id': entitlement_id
}
]
},
'user_id': 5,
'user': {
'username': 'verified',
'first_name': '',
'last_name': '',
'email': 'verified@example.com',
'is_staff': True,
'is_active': True,
'date_joined': '2016-09-01T19:18:26.026495Z'
},
'data_sharing_consent': [
{
'user': 1,
'state': 'enabled',
'enabled': True
}
]
}
],
'next': None,
'start': 0,
'previous': None
}
enterprise_learner_api_response_json = json.dumps(enterprise_learner_api_response)
httpretty.register_uri(
method=httpretty.GET,
uri=self.get_enterprise_url('enterprise-learner'),
body=enterprise_learner_api_response_json,
content_type='application/json'
)
class EnterpriseTestConsentRequired(object):
"""

View File

@@ -8,6 +8,7 @@ from mock import patch
from django.conf import settings
from django.test import TestCase
from openedx.core.release import doc_version
from util.help_context_processor import common_doc_url
@@ -33,34 +34,24 @@ class HelpContextProcessorTest(TestCase):
def test_get_doc_url(self):
# Test default values.
self.assertRegexpMatches(
self._get_doc_url(),
"http://edx.readthedocs.io/projects/open-edx-learner-guide/en/.*/index.html"
)
doc = "http://edx.readthedocs.io/projects/open-edx-learner-guide/en/{}/index.html"
self.assertEqual(self._get_doc_url(), doc.format(doc_version()))
# Provide a known page_token.
self.assertRegexpMatches(
self._get_doc_url('profile'),
"http://edx.readthedocs.io/projects/open-edx-learner-guide/en/.*/sfd_dashboard_profile/index.html"
)
doc = "http://edx.readthedocs.io/projects/open-edx-learner-guide/en/{}/sfd_dashboard_profile/index.html"
self.assertEqual(self._get_doc_url('profile'), doc.format(doc_version()))
# Use settings.DOC_LINK_BASE_URL to override default base_url.
doc = "settings_base_url/en/{}/SFD_instructor_dash_help.html"
with patch('django.conf.settings.DOC_LINK_BASE_URL', 'settings_base_url'):
self.assertRegexpMatches(
self._get_doc_url('instructor'),
"settings_base_url/en/.*/SFD_instructor_dash_help.html"
)
self.assertEqual(self._get_doc_url('instructor'), doc.format(doc_version()))
def test_get_pdf_url(self):
# Test default values.
self.assertRegexpMatches(
self._get_pdf_url(),
"https://media.readthedocs.org/pdf/open-edx-learner-guide/.*/open-edx-learner-guide.pdf"
)
doc = "https://media.readthedocs.org/pdf/open-edx-learner-guide/{}/open-edx-learner-guide.pdf"
self.assertEqual(self._get_pdf_url(), doc.format(doc_version()))
# Use settings.DOC_LINK_BASE_URL to override default base_url.
doc = "settings_base_url/{}/open-edx-learner-guide.pdf"
with patch('django.conf.settings.DOC_LINK_BASE_URL', 'settings_base_url'):
self.assertRegexpMatches(
self._get_pdf_url(),
"settings_base_url/.*/open-edx-learner-guide.pdf"
)
self.assertEqual(self._get_pdf_url(), doc.format(doc_version()))

View File

@@ -15,6 +15,8 @@ XQUEUE_METRIC_NAME = 'edxapp.xqueue'
# Wait time for response from Xqueue.
XQUEUE_TIMEOUT = 35 # seconds
CONNECT_TIMEOUT = 3.05 # seconds
READ_TIMEOUT = 10 # seconds
def make_hashkey(seed):
@@ -134,12 +136,18 @@ class XQueueInterface(object):
def _http_post(self, url, data, files=None):
try:
r = self.session.post(url, data=data, files=files)
response = self.session.post(
url, data=data, files=files, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT)
)
except requests.exceptions.ConnectionError, err:
log.error(err)
return (1, 'cannot connect to server')
if r.status_code not in [200]:
return (1, 'unexpected HTTP status code [%d]' % r.status_code)
except requests.exceptions.ReadTimeout, err:
log.error(err)
return (1, 'failed to read from the server')
return parse_xreply(r.text)
if response.status_code not in [200]:
return (1, 'unexpected HTTP status code [%d]' % response.status_code)
return parse_xreply(response.text)

View File

@@ -675,6 +675,9 @@ class CourseFields(object):
scope=Scope.settings,
)
# Note: Although users enter the entrance exam minimum score
# as a percentage value, it is internally converted and stored
# as a decimal value less than 1.
entrance_exam_minimum_score_pct = Float(
display_name=_("Entrance Exam Minimum Score (%)"),
help=_(

View File

@@ -1,4 +1,4 @@
<h3 class="hd hd-2 problem-header">Custom Javascript Display and Grading</h3>
<h3 class="hd hd-3 problem-header">Custom Javascript Display and Grading</h3>
<div class="problem">
<div>

View File

@@ -1,4 +1,4 @@
<h3 class="hd hd-2 problem-header">Problem Header</h3>
<h3 class="hd hd-3 problem-header">Problem Header</h3>
<div class='problem-progress'></div>

View File

@@ -1,4 +1,4 @@
<h3 class="hd hd-2 problem-header">Problem Header</h3>
<h3 class="hd hd-3 problem-header">Problem Header</h3>
<div class='problem-progress'></div>

View File

@@ -41,7 +41,8 @@
expect(state.videoPlayer.player.video.play).toHaveBeenCalled();
});
it('player state was changed', function(done) {
// Failing on master. See TNL-6748.
xit('player state was changed', function(done) {
jasmine.waitUntil(function() {
return state.videoPlayer.player.getPlayerState() === STATUS.PLAYING;
}).always(done);

View File

@@ -436,7 +436,7 @@ function(VideoPlayer) {
state.speed = '2.0';
state.videoPlayer.onPlay();
expect(state.videoPlayer.setPlaybackRate)
.toHaveBeenCalledWith('2.0', true);
.toHaveBeenCalledWith('2.0');
state.videoPlayer.onPlay();
expect(state.videoPlayer.setPlaybackRate.calls.count())
.toEqual(1);
@@ -943,9 +943,8 @@ function(VideoPlayer) {
state.isHtml5Mode.and.returnValue(false);
state.videoPlayer.isPlaying.and.returnValue(true);
VideoPlayer.prototype.setPlaybackRate.call(state, '0.75');
expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60);
expect(state.videoPlayer.player.loadVideoById)
.toHaveBeenCalledWith('videoId', 60);
expect(state.videoPlayer.player.setPlaybackRate)
.toHaveBeenCalledWith('0.75');
});
it('in Flash mode and video not started', function() {
@@ -953,15 +952,7 @@ function(VideoPlayer) {
state.isHtml5Mode.and.returnValue(false);
state.videoPlayer.isPlaying.and.returnValue(false);
VideoPlayer.prototype.setPlaybackRate.call(state, '0.75');
expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60);
expect(state.videoPlayer.seekTo).toHaveBeenCalledWith(60);
expect(state.trigger).toHaveBeenCalledWith(
'videoProgressSlider.updateStartEndTimeRegion',
{
duration: 60
});
expect(state.videoPlayer.player.cueVideoById)
.toHaveBeenCalledWith('videoId', 60);
expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('0.75');
});
it('in HTML5 mode', function() {
@@ -975,9 +966,7 @@ function(VideoPlayer) {
state.videoPlayer.isPlaying.and.returnValue(false);
VideoPlayer.prototype.setPlaybackRate.call(state, '1.0');
expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60);
expect(state.videoPlayer.player.cueVideoById)
.toHaveBeenCalledWith('videoId', 60);
expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('1.0');
});
});
});

View File

@@ -109,16 +109,7 @@ function(HTML5Video, Resizer) {
// starts playing. Just after that configurations can be applied.
state.videoPlayer.ready = _.once(function() {
if (!state.isFlashMode() && state.speed != '1.0') {
// Work around a bug in the Youtube API that causes videos to
// play at normal speed rather than at the configured speed in
// Safari. Setting the playback rate to 1.0 *after* playing
// started and then to the actual value tricks the player into
// picking up the speed setting.
if (state.browserIsSafari && state.isYoutubeType()) {
state.videoPlayer.setPlaybackRate(1.0, false);
}
state.videoPlayer.setPlaybackRate(state.speed, true);
state.videoPlayer.setPlaybackRate(state.speed);
}
});
@@ -381,73 +372,8 @@ function(HTML5Video, Resizer) {
}
}
function setPlaybackRate(newSpeed, useCueVideoById) {
var duration = this.videoPlayer.duration(),
time = this.videoPlayer.currentTime,
methodName, youtubeId;
// There is a bug which prevents YouTube API to correctly set the speed
// to 1.0 from another speed in Firefox when in HTML5 mode. There is a
// fix which basically reloads the video at speed 1.0 when this change
// is requested (instead of simply requesting a speed change to 1.0).
// This has to be done only when the video is being watched in Firefox.
// We need to figure out what browser is currently executing this code.
//
// TODO: Check the status of
// http://code.google.com/p/gdata-issues/issues/detail?id=4654
// When the YouTube team fixes the API bug, we can remove this
// temporary bug fix.
// If useCueVideoById is true it will reload video again.
// Used useCueVideoById to fix the issue video not playing if we change
// the speed before playing the video.
if (
this.isHtml5Mode() && !(this.browserIsFirefox &&
(useCueVideoById || newSpeed === '1.0') && this.isYoutubeType())
) {
this.videoPlayer.player.setPlaybackRate(newSpeed);
} else {
// We request the reloading of the video in the case when YouTube
// is in Flash player mode, or when we are in Firefox, and the new
// speed is 1.0. The second case is necessary to avoid the bug
// where in Firefox speed switching to 1.0 in HTML5 player mode is
// handled incorrectly by YouTube API.
methodName = 'cueVideoById';
youtubeId = this.youtubeId(newSpeed);
if (this.videoPlayer.isPlaying()) {
methodName = 'loadVideoById';
}
this.videoPlayer.player[methodName](youtubeId, time);
// We need to call play() explicitly because after the call
// to functions cueVideoById() followed by seekTo() the video
// is in a PAUSED state.
//
// Why? This is how the YouTube API is implemented.
// sjson.search() only works if time is defined.
if (!_.isUndefined(time)) {
this.videoPlayer.updatePlayTime(time);
}
if (time > 0 && this.isFlashMode()) {
this.videoPlayer.seekTo(time);
this.trigger(
'videoProgressSlider.updateStartEndTimeRegion',
{
duration: duration
}
);
}
// In Html5 mode if video speed is changed before playing in firefox and
// changed speed is not '1.0' then manually trigger setPlaybackRate method.
// In browsers other than firefox like safari user can set speed to '1.0'
// if its not already set to '1.0' so in that case we don't have to
// call 'setPlaybackRate'
if (this.isHtml5Mode() && newSpeed != '1.0') {
this.videoPlayer.player.setPlaybackRate(newSpeed);
}
}
function setPlaybackRate(newSpeed) {
this.videoPlayer.player.setPlaybackRate(newSpeed);
}
function onSpeedChange(newSpeed) {

View File

@@ -14,7 +14,6 @@ from lxml import etree
from xblock.core import XBlock
from xblock.fields import Integer, Scope, Boolean, String
from xblock.fragment import Fragment
import newrelic.agent
from .exceptions import NotFoundError
from .fields import Date
@@ -25,6 +24,11 @@ from .xml_module import XmlDescriptor
log = logging.getLogger(__name__)
try:
import newrelic.agent
except ImportError:
newrelic = None # pylint: disable=invalid-name
# HACK: This shouldn't be hard-coded to two types
# OBSOLETE: This obsoletes 'type'
class_priority = ['video', 'problem']
@@ -385,6 +389,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
"""
Capture basic information about this sequence in New Relic.
"""
if not newrelic:
return
newrelic.agent.add_custom_parameter('seq.block_id', unicode(self.location))
newrelic.agent.add_custom_parameter('seq.display_name', self.display_name or '')
newrelic.agent.add_custom_parameter('seq.position', self.position)
@@ -396,6 +402,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
the sequence as a whole. We send this information to New Relic so that
we can do better performance analysis of courseware.
"""
if not newrelic:
return
# Basic count of the number of Units (a.k.a. VerticalBlocks) we have in
# this learning sequence
newrelic.agent.add_custom_parameter('seq.num_units', len(display_items))
@@ -414,6 +422,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
"""
Capture information about the current selected Unit within the Sequence.
"""
if not newrelic:
return
# Positions are stored with indexing starting at 1. If we get into a
# weird state where the saved position is out of bounds (e.g. the
# content was changed), avoid going into any details about this unit.

View File

@@ -8,6 +8,7 @@ Tests of the Capa XModule
import datetime
import json
import random
import requests
import os
import textwrap
import unittest
@@ -242,6 +243,22 @@ class CapaModuleTest(unittest.TestCase):
problem = CapaFactory.create()
self.assertFalse(problem.answer_available())
@ddt.data(
(requests.exceptions.ReadTimeout, (1, 'failed to read from the server')),
(requests.exceptions.ConnectionError, (1, 'cannot connect to server')),
)
@ddt.unpack
def test_xqueue_request_exception(self, exception, result):
"""
Makes sure that platform will raise appropriate exception in case of
connect/read timeout(s) to request to xqueue
"""
xqueue_interface = XQueueInterface("http://example.com/xqueue", Mock())
with patch.object(xqueue_interface.session, 'post', side_effect=exception):
# pylint: disable = protected-access
response = xqueue_interface._http_post('http://some/fake/url', {})
self.assertEqual(response, result)
def test_showanswer_attempted(self):
problem = CapaFactory.create(showanswer='attempted')
self.assertFalse(problem.answer_available())

View File

@@ -24,7 +24,6 @@
initialize: function(options) {
var match;
this.$el = options.el;
this.readOnly = options.readOnly;
this.showByDefault = options.showByDefault || false;
@@ -32,6 +31,12 @@
this.listenTo(this.model, 'change', this.render);
this.escKey = 27;
if (options.startHeader !== undefined) {
this.startHeader = options.startHeader;
} else {
this.startHeader = 4; // Start the header levels at H<startHeader>
}
match = this.page_re.exec(window.location.href);
if (match) {
this.page = parseInt(match[1], 10);
@@ -73,7 +78,6 @@
var discussionHtml,
user = new DiscussionUser(response.user_info),
self = this;
$elem.focus();
window.user = user;
@@ -120,6 +124,7 @@
collection: this.discussion,
course_settings: this.courseSettings,
topicId: discussionId,
startHeader: this.startHeader,
is_commentable_cohorted: response.is_commentable_cohorted
});
@@ -146,6 +151,7 @@
el: this.$('.forum-content'),
model: thread,
mode: 'inline',
startHeader: this.startHeader,
courseSettings: this.courseSettings
});
this.threadView.render();

View File

@@ -16,6 +16,7 @@
initialize: function(options) {
this.container = options.container || $('.thread-content-wrapper');
this.mode = options.mode || 'inline';
this.startHeader = options.startHeader;
this.course_settings = options.course_settings;
this.threadType = this.model.get('thread_type');
this.topicId = this.model.get('commentable_id');
@@ -28,8 +29,10 @@
var formId = _.uniqueId('form-'),
threadTypeTemplate = edx.HtmlUtils.template($('#thread-type-template').html()),
$threadTypeSelector = $(threadTypeTemplate({form_id: formId}).toString()),
context,
mainTemplate = edx.HtmlUtils.template($('#thread-edit-template').html());
edx.HtmlUtils.setHtml(this.$el, mainTemplate(this.model.toJSON()));
context = $.extend({mode: this.mode, startHeader: this.startHeader}, this.model.attributes);
edx.HtmlUtils.setHtml(this.$el, mainTemplate(context));
this.container.append(this.$el);
this.$submitBtn = this.$('.post-update');
this.addField($threadTypeSelector);

View File

@@ -30,15 +30,16 @@
var _ref;
DiscussionThreadShowView.__super__.initialize.call(this);
this.mode = options.mode || 'inline';
this.startHeader = options.startHeader;
if ((_ref = this.mode) !== 'tab' && _ref !== 'inline') {
throw new Error('invalid mode: ' + this.mode);
}
};
DiscussionThreadShowView.prototype.renderTemplate = function() {
var context;
context = $.extend({
var context = $.extend({
mode: this.mode,
startHeader: this.startHeader,
flagged: this.model.isFlagged(),
author_display: this.getAuthorDisplay(),
cid: this.model.cid,

View File

@@ -80,6 +80,7 @@
this.mode = options.mode || 'inline';
this.context = options.context || 'course';
this.options = _.extend({}, options);
this.startHeader = options.startHeader;
if ((_ref = this.mode) !== 'tab' && _ref !== 'inline') {
throw new Error('invalid mode: ' + this.mode);
}
@@ -91,6 +92,7 @@
self.model = collection.get(id);
}
});
this.createShowView();
this.responses = new Comments();
this.loadedResponses = false;
@@ -116,7 +118,8 @@
};
DiscussionThreadView.prototype.renderTemplate = function() {
var container, templateData;
var container,
templateData;
this.template = _.template($('#thread-template').html());
container = $('#discussion-container');
if (!container.length) {
@@ -124,6 +127,7 @@
}
templateData = _.extend(this.model.toJSON(), {
readOnly: this.readOnly,
startHeader: this.startHeader + 1, // this is a child so headers should be increased
can_create_comment: container.data('user-create-comment')
});
return this.template(templateData);
@@ -299,7 +303,8 @@
var view;
response.set('thread', this.model);
view = new ThreadResponseView($.extend({
model: response
model: response,
startHeader: this.startHeader + 1 // this is a child so headers should be increased
}, options));
view.on('comment:add', this.addComment);
view.on('comment:endorse', this.endorseThread);
@@ -396,6 +401,7 @@
model: this.model,
mode: this.mode,
context: this.context,
startHeader: this.startHeader,
course_settings: this.options.courseSettings
});
this.editView.bind('thread:updated thread:cancel_edit', this.closeEditView);
@@ -415,7 +421,8 @@
DiscussionThreadView.prototype.createShowView = function() {
this.showView = new DiscussionThreadShowView({
model: this.model,
mode: this.mode
mode: this.mode,
startHeader: this.startHeader
});
this.showView.bind('thread:_delete', this._delete);
return this.showView.bind('thread:edit', this.edit);

View File

@@ -36,6 +36,7 @@
NewPostView.prototype.initialize = function(options) {
var _ref;
this.mode = options.mode || 'inline';
this.startHeader = options.startHeader;
if ((_ref = this.mode) !== 'tab' && _ref !== 'inline') {
throw new Error('invalid mode: ' + this.mode);
}
@@ -45,12 +46,14 @@
};
NewPostView.prototype.render = function() {
var context, threadTypeTemplate;
var context,
threadTypeTemplate;
context = _.clone(this.course_settings.attributes);
_.extend(context, {
cohort_options: this.getCohortOptions(),
is_commentable_cohorted: this.is_commentable_cohorted,
mode: this.mode,
startHeader: this.startHeader,
form_id: this.mode + (this.topicId ? '-' + this.topicId : '')
});
this.$el.html(_.template($('#new-post-template').html())(context));

View File

@@ -22,7 +22,8 @@
this.ResponseCommentEditView = (function(_super) {
__extends(ResponseCommentEditView, _super);
function ResponseCommentEditView() {
function ResponseCommentEditView(options) {
this.options = options;
return ResponseCommentEditView.__super__.constructor.apply(this, arguments);
}
@@ -40,8 +41,10 @@
};
ResponseCommentEditView.prototype.render = function() {
var context = $.extend({mode: this.options.mode, startHeader: this.options.startHeader},
this.model.attributes);
this.template = _.template($('#response-comment-edit-template').html());
this.$el.html(this.template(this.model.toJSON()));
this.$el.html(this.template(context));
this.delegateEvents();
DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), 'edit-comment-body');
return this;

View File

@@ -45,7 +45,8 @@
return this.$el.find(selector);
};
ResponseCommentView.prototype.initialize = function() {
ResponseCommentView.prototype.initialize = function(options) {
this.startHeader = options.startHeader;
return ResponseCommentView.__super__.initialize.call(this);
};
@@ -84,7 +85,8 @@
this.showView = null;
}
this.editView = new ResponseCommentEditView({
model: this.model
model: this.model,
startHeader: this.startHeader
});
this.editView.bind('comment:update', this.update);
this.editView.bind('comment:cancel_edit', this.cancelEdit);

View File

@@ -35,13 +35,16 @@
return this.$el.find(selector);
};
ThreadResponseEditView.prototype.initialize = function() {
ThreadResponseEditView.prototype.initialize = function(options) {
this.options = options;
return ThreadResponseEditView.__super__.initialize.call(this);
};
ThreadResponseEditView.prototype.render = function() {
var context = $.extend({mode: this.options.mode, startHeader: this.options.startHeader},
this.model.attributes);
this.template = _.template($('#thread-response-edit-template').html());
this.$el.html(this.template(this.model.toJSON()));
this.$el.html(this.template(context));
this.delegateEvents();
DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), 'edit-post-body');
return this;

View File

@@ -59,6 +59,7 @@
};
ThreadResponseView.prototype.initialize = function(options) {
this.startHeader = options.startHeader;
this.collapseComments = options.collapseComments;
this.createShowView();
this.readOnly = $('.discussion-module').data('read-only');
@@ -155,7 +156,8 @@
self = this;
comment.set('thread', this.model.get('thread'));
view = new ResponseCommentView({
model: comment
model: comment,
startHeader: this.startHeader
});
view.render();
if (this.readOnly) {
@@ -246,7 +248,8 @@
this.editView.model = this.model;
} else {
this.editView = new ThreadResponseEditView({
model: this.model
model: this.model,
startHeader: this.startHeader
});
this.editView.bind('response:update', this.update);
return this.editView.bind('response:cancel_edit', this.cancelEdit);

View File

@@ -1,5 +1,5 @@
<% if (!_.isUndefined(srInfo)) { %>
<h2 class="sr" id="<%- srInfo.id %>"><%- srInfo.text %></h2>
<h3 class="sr" id="<%- srInfo.id %>"><%- srInfo.text %></h3>
<% } %>
<div class="search-tools listing-tools">
<span class="search-count listing-count">

View File

@@ -1,5 +1,6 @@
<form class="forum-new-post-form">
<h2 class="thread-title"><%- gettext("Add a Post") %></h2>
<h<%- startHeader %> class="thread-title"><%- gettext("Add a Post") %></h<%- startHeader %>>
<% if (mode === 'inline') { %>
<button class="btn-default add-post-cancel">
<span class="sr"><%- gettext('Cancel') %></span>

View File

@@ -1,5 +1,5 @@
<div class="edit-post-form" id="comment_<%- id %>">
<h1><%- gettext("Editing comment") %></h1>
<h<%- startHeader %> ><%- gettext("Editing comment") %></h<%- startHeader%>>
<ul class="edit-comment-form-errors"></ul>
<div class="form-row">
<div class="edit-comment-body" name="body" data-id="<%- id %>"><%- body %></div>

View File

@@ -1,4 +1,4 @@
<h2 class="thread-title"><%- gettext("Editing post") %></h2>
<h<%- startHeader%> class="thread-title"><%- gettext("Editing post") %></h<%- startHeader%>>
<ul class="post-errors"></ul>
<div class="forum-edit-post-form-wrapper"></div>
<div class="post-field">

View File

@@ -1,5 +1,5 @@
<div class="edit-post-form">
<h1><%- gettext("Editing response") %></h1>
<h<%- startHeader %>><%- gettext("Editing response") %></h<%- startHeader %>>
<ul class="edit-post-form-errors"></ul>
<div class="form-row">
<div class="edit-post-body" name="body" data-id="<%- id %>"><%- body %></div>

View File

@@ -17,7 +17,7 @@
</div>
<% } %>
<div class="post-header-content">
<h2 class="post-title"><%- title %></h2>
<h<%- startHeader %> class="post-title"><%- title %></h<%- startHeader%>>
<p class="posted-details">
<%
var timeAgoHtml = interpolate(

View File

@@ -24,7 +24,8 @@
</div>
<% if (can_create_comment && !readOnly) { %>
<form class="discussion-reply-new" data-id="<%- id %>">
<h3><%- gettext("Add a response:") %></h3>
<h<%- startHeader %> class="add-response"><%- gettext("Add a response:") %></h<%- startHeader %> >
<ul class="discussion-errors"></ul>
<div class="reply-body" data-id="<%- id %>"></div>
<div class="reply-post-control">

View File

@@ -192,7 +192,7 @@ class FieldsMixin(object):
return self.q(css='.u-field-{} .u-field-value .u-field-value-readonly'.format(field_id)).text[0]
def value_for_dropdown_field(self, field_id, value=None):
def value_for_dropdown_field(self, field_id, value=None, focus_out=False):
"""
Get or set the value in a dropdown field.
"""
@@ -205,7 +205,7 @@ class FieldsMixin(object):
return None
if value is not None:
select_option_by_text(query, value)
select_option_by_text(query, value, focus_out)
if self.mode_for_field(field_id) == 'edit':
return get_selected_option_text(query)

View File

@@ -8,10 +8,12 @@ from bok_choy.page_object import PageObject
from common.test.acceptance.pages.lms.fields import FieldsMixin
from bok_choy.promise import EmptyPromise
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
from common.test.acceptance.tests.helpers import select_option_by_value
from selenium.webdriver import ActionChains
PROFILE_VISIBILITY_SELECTOR = '#u-field-select-account_privacy option[value="{}"]'
PROFILE_VISIBILITY_INPUT = '#u-field-select-account_privacy'
FIELD_ICONS = {
'country': 'fa-map-marker',
'language_proficiencies': 'fa-comment',
@@ -146,7 +148,8 @@ class LearnerProfilePage(FieldsMixin, PageObject):
self.wait_for_element_visibility('select#u-field-select-account_privacy', 'Privacy dropdown is visible')
if privacy != self.privacy:
self.q(css=PROFILE_VISIBILITY_SELECTOR.format(privacy)).first.click()
query = self.q(css=PROFILE_VISIBILITY_INPUT)
select_option_by_value(query, privacy, focus_out=True)
EmptyPromise(lambda: privacy == self.privacy, 'Privacy is set to {}'.format(privacy)).fulfill()
self.wait_for_ajax()

View File

@@ -26,6 +26,7 @@ from openedx.core.lib.tests.assertions.events import assert_event_matches, is_ma
from xmodule.partitions.partitions import UserPartition
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
from selenium.common.exceptions import StaleElementReferenceException
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.select import Select
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@@ -213,7 +214,7 @@ def enable_css_animations(page):
""")
def select_option_by_text(select_browser_query, option_text):
def select_option_by_text(select_browser_query, option_text, focus_out=False):
"""
Chooses an option within a select by text (helper method for Select's select_by_visible_text method).
@@ -225,6 +226,8 @@ def select_option_by_text(select_browser_query, option_text):
try:
select = Select(query.first.results[0])
select.select_by_visible_text(value)
if focus_out:
query.first.results[0].send_keys(Keys.TAB)
return True
except StaleElementReferenceException:
return False
@@ -267,7 +270,7 @@ def generate_course_key(org, number, run):
return CourseLocator(org, number, run, deprecated=(default_store == 'draft'))
def select_option_by_value(browser_query, value):
def select_option_by_value(browser_query, value, focus_out=False):
"""
Selects a html select element by matching value attribute
"""
@@ -288,9 +291,10 @@ def select_option_by_value(browser_query, value):
if not opt.is_selected():
all_options_selected = False
opt.click()
# if value is not an option choice then it should return false
if all_options_selected and not has_option:
all_options_selected = False
if focus_out:
browser_query.first.results[0].send_keys(Keys.TAB)
return all_options_selected
# Make sure specified option is actually selected

View File

@@ -226,10 +226,13 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest):
Test behaviour of a dropdown field.
"""
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id), initial_value)
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, focus_out=True), initial_value)
for new_value in new_values:
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, new_value), new_value)
self.assertEqual(
self.account_settings_page.value_for_dropdown_field(field_id, new_value, focus_out=True),
new_value
)
# An XHR request is made when changing the field
self.account_settings_page.wait_for_ajax()
if reloads_on_save:
@@ -237,7 +240,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest):
else:
self.browser.refresh()
self.account_settings_page.wait_for_page()
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id), new_value)
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, focus_out=True), new_value)
def _test_link_field(self, field_id, title, link_title, field_type, success_message):
"""
@@ -387,7 +390,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest):
Test behaviour of "Year of Birth" field.
"""
# Note that when we clear the year_of_birth here we're firing an event.
self.assertEqual(self.account_settings_page.value_for_dropdown_field('year_of_birth', ''), '')
self.assertEqual(self.account_settings_page.value_for_dropdown_field('year_of_birth', '', focus_out=True), '')
expected_events = [
self.expected_settings_changed_event('year_of_birth', None, 1980),

View File

@@ -44,8 +44,8 @@ class LearnerProfileTestMixin(EventsTestMixin):
"""
Fill in the public profile fields of a user.
"""
profile_page.value_for_dropdown_field('language_proficiencies', 'English')
profile_page.value_for_dropdown_field('country', 'United Arab Emirates')
profile_page.value_for_dropdown_field('language_proficiencies', 'English', focus_out=True)
profile_page.value_for_dropdown_field('country', 'United Arab Emirates', focus_out=True)
profile_page.set_value_for_textarea_field('bio', 'Nothing Special')
# Waits here for text to appear/save on bio field
profile_page.wait_for_ajax()
@@ -91,7 +91,7 @@ class LearnerProfileTestMixin(EventsTestMixin):
account_settings_page.visit()
account_settings_page.wait_for_page()
self.assertEqual(
account_settings_page.value_for_dropdown_field('year_of_birth', str(birth_year)),
account_settings_page.value_for_dropdown_field('year_of_birth', str(birth_year), focus_out=True),
str(birth_year)
)
@@ -322,7 +322,7 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, AcceptanceTest):
"""
Test behaviour of a dropdown field.
"""
profile_page.value_for_dropdown_field(field_id, new_value)
profile_page.value_for_dropdown_field(field_id, new_value, focus_out=True)
self.assertEqual(profile_page.get_non_editable_mode_value(field_id), displayed_value)
self.assertTrue(profile_page.mode_for_field(field_id), mode)

View File

@@ -3,7 +3,6 @@ Test Help links in LMS
"""
import json
from common.test.acceptance.tests.lms.test_lms_instructor_dashboard import BaseInstructorDashboardTest
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
from common.test.acceptance.tests.studio.base_studio_test import ContainerBase
@@ -15,6 +14,8 @@ from common.test.acceptance.tests.helpers import (
assert_opened_help_link_is_correct
)
from openedx.core.release import doc_version
class TestCohortHelp(ContainerBase):
"""
@@ -27,16 +28,6 @@ class TestCohortHelp(ContainerBase):
self.instructor_dashboard_page.visit()
self.cohort_management = self.instructor_dashboard_page.select_cohort_management()
def get_url_with_changed_domain(self, url):
"""
Replaces .org with .io in the url
Arguments:
url (str): The url to perform replace operation on.
Returns:
str: The updated url
"""
return url.replace('.org/', '.io/')
def verify_help_link(self, href):
"""
Verifies that help link is correct
@@ -50,7 +41,7 @@ class TestCohortHelp(ContainerBase):
actual_link = self.cohort_management.get_cohort_help_element_and_click_help()
assert_link(self, expected_link, actual_link)
assert_opened_help_link_is_correct(self, self.get_url_with_changed_domain(href))
assert_opened_help_link_is_correct(self, href)
def test_manual_cohort_help(self):
"""
@@ -66,8 +57,10 @@ class TestCohortHelp(ContainerBase):
"""
self.cohort_management.add_cohort('cohort_name')
href = 'http://edx.readthedocs.org/projects/edx-partner-course-staff/en/latest/' \
'course_features/cohorts/cohort_config.html#assign-learners-to-cohorts-manually'
href = (
'http://edx.readthedocs.io/projects/edx-partner-course-staff/en/{}/'
'course_features/cohorts/cohort_config.html#assign-learners-to-cohorts-manually'
).format(doc_version())
self.verify_help_link(href)
@@ -86,8 +79,10 @@ class TestCohortHelp(ContainerBase):
self.cohort_management.add_cohort('cohort_name', assignment_type='random')
href = 'http://edx.readthedocs.org/projects/edx-partner-course-staff/en/latest/' \
'course_features/cohorts/cohorts_overview.html#all-automated-assignment'
href = (
'http://edx.readthedocs.io/projects/edx-partner-course-staff/en/{}/'
'course_features/cohorts/cohorts_overview.html#all-automated-assignment'
).format(doc_version())
self.verify_help_link(href)
@@ -119,6 +114,8 @@ class InstructorDashboardHelp(BaseInstructorDashboardTest):
When I click "Help"
Then I see help about the instructor dashboard in a new tab
"""
href = 'http://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_instructor_dash_help.html'
href = (
'http://edx.readthedocs.io/projects/edx-guide-for-students/en/{}/SFD_instructor_dash_help.html'
).format(doc_version())
self.instructor_dashboard_page.click_help()
assert_opened_help_link_is_correct(self, href)

View File

@@ -34,6 +34,8 @@ from common.test.acceptance.tests.helpers import (
from common.test.acceptance.pages.studio.import_export import ExportLibraryPage, ImportLibraryPage
from common.test.acceptance.pages.studio.auto_auth import AutoAuthPage
from openedx.core.release import doc_version
@attr(shard=10)
class StudioHelpTest(StudioCourseTest):
@@ -96,8 +98,10 @@ class SignInHelpTest(AcceptanceTest):
"""
sign_in_page = self.index_page.click_sign_in()
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -129,8 +133,10 @@ class SignUpHelpTest(AcceptanceTest):
"""
sign_up_page = self.index_page.click_sign_up()
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -161,8 +167,10 @@ class HomeHelpTest(StudioCourseTest):
And help url should end with 'getting_started/get_started.html'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -181,8 +189,10 @@ class HomeHelpTest(StudioCourseTest):
And help url should end with 'getting_started/get_started.html'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -218,8 +228,10 @@ class NewCourseHelpTest(AcceptanceTest):
And help url should end with 'getting_started/get_started.html'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -238,8 +250,10 @@ class NewCourseHelpTest(AcceptanceTest):
And help url should end with 'getting_started/get_started.html'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -275,8 +289,10 @@ class NewLibraryHelpTest(AcceptanceTest):
And help url should end with 'getting_started/get_started.html'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -295,8 +311,10 @@ class NewLibraryHelpTest(AcceptanceTest):
And help url should end with 'getting_started/get_started.html'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -332,8 +350,10 @@ class LibraryTabHelpTest(AcceptanceTest):
self.assertTrue(self.dashboard_page.has_new_library_button)
click_css(self.dashboard_page, '#course-index-tabs .libraries-tab', 0, False)
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -365,8 +385,10 @@ class LibraryHelpTest(StudioLibraryTest):
"""
self.library_page.visit()
# The href we want to see in anchor help element.
href = "http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/" \
"en/latest/course_components/libraries.html"
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_components/libraries.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -387,8 +409,10 @@ class LibraryHelpTest(StudioLibraryTest):
"""
self.library_page.visit()
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/course_components/libraries.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_components/libraries.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -411,8 +435,10 @@ class LibraryHelpTest(StudioLibraryTest):
"""
self.library_user_page.visit()
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/course_components/libraries.html#give-other-users-access-to-your-library'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_components/libraries.html#give-other-users-access-to-your-library'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -442,8 +468,10 @@ class LibraryImportHelpTest(StudioLibraryTest):
And help url should end with 'creating_content/libraries.html#import-a-library'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/course_components/libraries.html#import-a-library'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/course_components/libraries.html#import-a-library'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -462,8 +490,10 @@ class LibraryImportHelpTest(StudioLibraryTest):
And help url should end with 'creating_content/libraries.html#import-a-library'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/course_components/libraries.html#import-a-library'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/course_components/libraries.html#import-a-library'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -494,8 +524,10 @@ class LibraryExportHelpTest(StudioLibraryTest):
And help url should end with 'creating_content/libraries.html#export-a-library'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/course_components/libraries.html#export-a-library'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/course_components/libraries.html#export-a-library'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -514,8 +546,10 @@ class LibraryExportHelpTest(StudioLibraryTest):
And help url should end with 'creating_content/libraries.html#export-a-library'
"""
# The href we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/course_components/libraries.html#export-a-library'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/course_components/libraries.html#export-a-library'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -551,8 +585,10 @@ class CourseOutlineHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'developing_course/course_outline.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/developing_course/course_outline.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/developing_course/course_outline.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -570,8 +606,10 @@ class CourseOutlineHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'developing_course/course_outline.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/developing_course/course_outline.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/developing_course/course_outline.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -607,8 +645,10 @@ class CourseUpdateHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'course_assets/handouts_updates.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/course_assets/handouts_updates.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_assets/handouts_updates.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -642,8 +682,10 @@ class AssetIndexHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'course_assets/course_files.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/course_assets/course_files.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_assets/course_files.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -661,8 +703,10 @@ class AssetIndexHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'course_assets/course_files.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/course_assets/course_files.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_assets/course_files.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -697,8 +741,10 @@ class CoursePagesHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'course_assets/pages.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/course_assets/pages.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_assets/pages.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -732,8 +778,10 @@ class UploadTextbookHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'course_assets/textbooks.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/course_assets/textbooks.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/course_assets/textbooks.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -751,8 +799,10 @@ class UploadTextbookHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'course_assets/textbooks.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/course_assets/textbooks.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/course_assets/textbooks.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -802,8 +852,10 @@ class StudioUnitHelpTest(ContainerBase):
And help url should end with 'developing_course/course_units.html'
"""
unit_page = self.go_to_unit_page()
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/developing_course/course_units.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/developing_course/course_units.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -839,8 +891,10 @@ class SettingsHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'set_up_course/setting_up_student_view.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/set_up_course/setting_up_student_view.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/set_up_course/setting_up_student_view.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -876,8 +930,10 @@ class GradingPageHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'grading/index.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/grading/index.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/grading/index.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -913,8 +969,10 @@ class CourseTeamSettingsHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'set_up_course/course_staffing.html#add-course-team-members'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/set_up_course/course_staffing.html#add-course-team-members'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/set_up_course/course_staffing.html#add-course-team-members'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -951,8 +1009,10 @@ class CourseGroupConfigurationHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'index.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/index.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/index.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -971,8 +1031,10 @@ class CourseGroupConfigurationHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'course_features/cohorts/cohorted_courseware.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/course_features/cohorts/cohorted_courseware.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/course_features/cohorts/cohorted_courseware.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -1009,8 +1071,10 @@ class AdvancedSettingHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'index.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/index.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/index.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -1046,8 +1110,10 @@ class CertificatePageHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'set_up_course/creating_course_certificates.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/set_up_course/creating_course_certificates.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/set_up_course/creating_course_certificates.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -1065,8 +1131,10 @@ class CertificatePageHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'set_up_course/creating_course_certificates.html'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/set_up_course/creating_course_certificates.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course'
'/en/{}/set_up_course/creating_course_certificates.html'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -1117,8 +1185,11 @@ class GroupExperimentConfigurationHelpTest(ContainerBase):
And help url should end with
'content_experiments_configure.html#set-up-group-configurations-in-edx-studio'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_features' \
'/content_experiments/content_experiments_configure.html#set-up-group-configurations-in-edx-studio'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/{}/course_features'
'/content_experiments/content_experiments_configure.html#set-up-group-configurations-in-edx-studio'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
test=self,
@@ -1154,8 +1225,10 @@ class ToolsImportHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'releasing_course/export_import_course.html#import-a-course'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/releasing_course/export_import_course.html#import-a-course'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/releasing_course/export_import_course.html#import-a-course'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -1173,8 +1246,10 @@ class ToolsImportHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'releasing_course/export_import_course.html#import-a-course'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/releasing_course/export_import_course.html#import-a-course'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/releasing_course/export_import_course.html#import-a-course'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -1211,8 +1286,10 @@ class ToolsExportHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'releasing_course/export_import_course.html#export-a-course'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/releasing_course/export_import_course.html#export-a-course'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/releasing_course/export_import_course.html#export-a-course'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(
@@ -1230,8 +1307,10 @@ class ToolsExportHelpTest(StudioCourseTest):
Then Help link should open.
And help url should end with 'releasing_course/export_import_course.html#export-a-course'
"""
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/' \
'latest/releasing_course/export_import_course.html#export-a-course'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/'
'{}/releasing_course/export_import_course.html#export-a-course'
).format(doc_version())
# Assert that help link is correct.
assert_side_bar_help_link(
@@ -1262,8 +1341,10 @@ class StudioWelcomeHelpTest(AcceptanceTest):
And help url should contain 'getting_started/get_started.html'
"""
# The url we want to see in anchor help element.
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/' \
'en/latest/getting_started/get_started.html'
href = (
'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/'
'en/{}/getting_started/get_started.html'
).format(doc_version())
# Assert that help link is correct.
assert_nav_help_link(

View File

@@ -0,0 +1,33 @@
[
{
"pk": 2,
"model": "auth.user",
"fields": {
"date_joined": "2015-06-12 11:02:13.007790+00:00",
"username": "enterprise_worker",
"first_name": "enterprise",
"last_name": "worker",
"email":"enterprise_worker@example.com",
"password": "enterpriseworker",
"is_staff": false,
"is_active": true
}
},
{
"pk": 2,
"model": "student.userprofile",
"fields": {
"user": 2,
"name": "enterprise worker",
"courseware": "course.xml"
}
},
{
"pk": 2,
"model": "student.registration",
"fields": {
"user": 2,
"activation_key": "52bfac10384d49219385dcd4cc17177h"
}
}
]

View File

@@ -2,7 +2,6 @@
[help_settings]
# The optional DOC_LINK_BASE_URL configuration property will override url_base
url_base = http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course
version = latest
# below are the pdf settings for the pdf file

View File

@@ -2,7 +2,6 @@
[help_settings]
# The optional DOC_LINK_BASE_URL configuration property will override url_base
url_base = http://edx.readthedocs.io/projects/open-edx-learner-guide
version = latest
# below are the pdf settings for the pdf file

View File

@@ -165,5 +165,5 @@ class TestGetBlocksQueryCounts(SharedModuleStoreTestCase):
self._get_blocks(
course,
expected_mongo_queries,
expected_sql_queries=9 if with_storage_backing else 3,
expected_sql_queries=11 if with_storage_backing else 3,
)

View File

@@ -52,7 +52,8 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'H', 'I', 'TimedExam', 'J', 'K'
)
# The special exams (proctored, practice, timed) should never be visible to students
# The special exams (proctored, practice, timed) are not visible to
# students via the Courses API.
ALL_BLOCKS_EXCEPT_SPECIAL = ('course', 'A', 'B', 'C', 'H', 'I')
def get_course_hierarchy(self):
@@ -133,18 +134,16 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
(
'H',
'A',
'B',
('course', 'A', 'B', 'C',)
),
(
'H',
'ProctoredExam',
'D',
('course', 'A', 'B', 'C'),
),
)
@ddt.unpack
def test_gated(self, gated_block_ref, gating_block_ref, gating_block_child, expected_blocks_before_completion):
def test_gated(self, gated_block_ref, gating_block_ref, expected_blocks_before_completion):
"""
First, checks that a student cannot see the gated block when it is gated by the gating block and no
attempt has been made to complete the gating block.
@@ -164,17 +163,15 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
# clear the request cache to simulate a new request
self.clear_caches()
# mock the api that the lms gating api calls to get the score for each block to always return 1 (ie 100%)
with patch('gating.api.get_module_score', Mock(return_value=1)):
# this call triggers reevaluation of prerequisites fulfilled by the parent of the
# block passed in, so we pass in a child of the gating block
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
with patch('gating.api._get_subsection_percentage', Mock(return_value=100)):
lms_gating_api.evaluate_prerequisite(
self.course,
self.blocks[gating_block_child],
self.user.id,
Mock(location=self.blocks[gating_block_ref].location),
self.user,
)
with self.assertNumQueries(5):
with self.assertNumQueries(6):
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL)
def test_staff_access(self):

View File

@@ -43,118 +43,16 @@ def user_can_skip_entrance_exam(user, course):
return False
def user_has_passed_entrance_exam(request, course):
def user_has_passed_entrance_exam(user, course):
"""
Checks to see if the user has attained a sufficient score to pass the exam
Begin by short-circuiting if the course does not have an entrance exam
"""
if not course_has_entrance_exam(course):
return True
if not request.user.is_authenticated():
if not user.is_authenticated():
return False
entrance_exam_score = get_entrance_exam_score(request, course)
if entrance_exam_score >= course.entrance_exam_minimum_score_pct:
return True
return False
# pylint: disable=invalid-name
def user_must_complete_entrance_exam(request, user, course):
"""
Some courses can be gated on an Entrance Exam, which is a specially-configured chapter module which
presents users with a problem set which they must complete. This particular workflow determines
whether or not the user is allowed to clear the Entrance Exam gate and access the rest of the course.
"""
# First, let's see if the user is allowed to skip
if user_can_skip_entrance_exam(user, course):
return False
# If they can't actually skip the exam, we'll need to see if they've already passed it
if user_has_passed_entrance_exam(request, course):
return False
# Can't skip, haven't passed, must take the exam
return True
def _calculate_entrance_exam_score(user, course_descriptor, exam_modules):
"""
Calculates the score (percent) of the entrance exam using the provided modules
"""
student_module_dict = {}
scores_client = ScoresClient(course_descriptor.id, user.id)
# removing branch and version from exam modules locator
# otherwise student module would not return scores since module usage keys would not match
locations = [
BlockUsageLocator(
course_key=course_descriptor.id,
block_type=exam_module.location.block_type,
block_id=exam_module.location.block_id
)
if isinstance(exam_module.location, BlockUsageLocator) and exam_module.location.version
else exam_module.location
for exam_module in exam_modules
]
scores_client.fetch_scores(locations)
# Iterate over all of the exam modules to get score of user for each of them
for index, exam_module in enumerate(exam_modules):
exam_module_score = scores_client.get(locations[index])
if exam_module_score:
student_module_dict[unicode(locations[index])] = {
'grade': exam_module_score.correct,
'max_grade': exam_module_score.total
}
exam_percentage = 0
module_percentages = []
ignore_categories = ['course', 'chapter', 'sequential', 'vertical']
for index, module in enumerate(exam_modules):
if module.graded and module.category not in ignore_categories:
module_percentage = 0
module_location = unicode(locations[index])
if module_location in student_module_dict and student_module_dict[module_location]['max_grade']:
student_module = student_module_dict[module_location]
module_percentage = student_module['grade'] / student_module['max_grade']
module_percentages.append(module_percentage)
if module_percentages:
exam_percentage = sum(module_percentages) / float(len(module_percentages))
return exam_percentage
def get_entrance_exam_score(request, course):
"""
Gather the set of modules which comprise the entrance exam
Note that 'request' may not actually be a genuine request, due to the
circular nature of module_render calling entrance_exams and get_module_for_descriptor
being used here. In some use cases, the caller is actually mocking a request, although
in these scenarios the 'user' child object can be trusted and used as expected.
It's a much larger refactoring job to break this legacy mess apart, unfortunately.
"""
exam_key = UsageKey.from_string(course.entrance_exam_id)
exam_descriptor = modulestore().get_item(exam_key)
def inner_get_module(descriptor):
"""
Delegate to get_module_for_descriptor (imported here to avoid circular reference)
"""
from courseware.module_render import get_module_for_descriptor
field_data_cache = FieldDataCache([descriptor], course.id, request.user)
return get_module_for_descriptor(
request.user,
request,
descriptor,
field_data_cache,
course.id,
course=course
)
exam_module_generators = yield_dynamic_descriptor_descendants(
exam_descriptor,
request.user.id,
inner_get_module
)
exam_modules = [module for module in exam_module_generators]
return _calculate_entrance_exam_score(request.user, course, exam_modules)
return get_entrance_exam_content(user, course) is None
def get_entrance_exam_content(user, course):

View File

@@ -8,7 +8,6 @@ import logging
from collections import OrderedDict
from functools import partial
import newrelic.agent
from capa.xqueue_interface import XQueueInterface
from django.conf import settings
from django.contrib.auth.models import User
@@ -33,7 +32,7 @@ from xblock.reference.plugins import FSService
import static_replace
from courseware.access import has_access, get_user_role
from courseware.entrance_exams import (
user_must_complete_entrance_exam,
user_can_skip_entrance_exam,
user_has_passed_entrance_exam
)
from courseware.masquerade import (
@@ -82,6 +81,10 @@ from .field_overrides import OverrideFieldData
log = logging.getLogger(__name__)
try:
import newrelic.agent
except ImportError:
newrelic = None # pylint: disable=invalid-name
if settings.XQUEUE_INTERFACE.get('basic_auth') is not None:
REQUESTS_AUTH = HTTPBasicAuth(*settings.XQUEUE_INTERFACE['basic_auth'])
@@ -164,7 +167,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
required_content = milestones_helpers.get_required_content(course, user)
# The user may not actually have to complete the entrance exam, if one is required
if not user_must_complete_entrance_exam(request, user, course):
if user_can_skip_entrance_exam(user, course):
required_content = [content for content in required_content if not content == course.entrance_exam_id]
previous_of_active_section, next_of_active_section = None, None
@@ -967,9 +970,10 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
except InvalidKeyError:
raise Http404
# Gather metrics for New Relic so we can slice data in New Relic Insights
newrelic.agent.add_custom_parameter('course_id', unicode(course_key))
newrelic.agent.add_custom_parameter('org', unicode(course_key.org))
if newrelic:
# Gather metrics for New Relic so we can slice data in New Relic Insights
newrelic.agent.add_custom_parameter('course_id', unicode(course_key))
newrelic.agent.add_custom_parameter('org', unicode(course_key.org))
with modulestore().bulk_operations(course_key):
instance, tracking_context = get_module_by_usage_id(request, course_id, usage_id, course=course)
@@ -979,7 +983,8 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
# "handler" in those cases is always just "xmodule_handler".
nr_tx_name = "{}.{}".format(instance.__class__.__name__, handler)
nr_tx_name += "/{}".format(suffix) if (suffix and handler == "xmodule_handler") else ""
newrelic.agent.set_transaction_name(nr_tx_name, group="Python/XBlock/Handler")
if newrelic:
newrelic.agent.set_transaction_name(nr_tx_name, group="Python/XBlock/Handler")
tracking_context_name = 'module_callback_handler'
req = django_to_webob_request(request)
@@ -990,7 +995,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
and course \
and getattr(course, 'entrance_exam_enabled', False) \
and getattr(instance, 'in_entrance_exam', False):
ee_data = {'entrance_exam_passed': user_has_passed_entrance_exam(request, course)}
ee_data = {'entrance_exam_passed': user_has_passed_entrance_exam(request.user, course)}
resp = append_data_to_webob_response(resp, ee_data)
except NoSuchHandlerError:

View File

@@ -8,7 +8,7 @@ from django.conf import settings
from django.utils.translation import ugettext as _, ugettext_noop
from courseware.access import has_access
from courseware.entrance_exams import user_must_complete_entrance_exam
from courseware.entrance_exams import user_can_skip_entrance_exam
from openedx.core.lib.course_tabs import CourseTabPluginManager
from request_cache.middleware import RequestCache
from student.models import CourseEnrollment
@@ -316,7 +316,7 @@ def get_course_tab_list(request, course):
# If the user has to take an entrance exam, we'll need to hide away all but the
# "Courseware" tab. The tab is then renamed as "Entrance Exam".
course_tab_list = []
must_complete_ee = user_must_complete_entrance_exam(request, user, course)
must_complete_ee = not user_can_skip_entrance_exam(user, course)
for tab in xmodule_tab_list:
if must_complete_ee:
# Hide all of the tabs except for 'Courseware'

View File

@@ -17,7 +17,6 @@ from courseware.tests.helpers import (
from courseware.entrance_exams import (
course_has_entrance_exam,
get_entrance_exam_content,
get_entrance_exam_score,
user_can_skip_entrance_exam,
user_has_passed_entrance_exam,
)
@@ -281,32 +280,14 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
"""
exam_chapter = get_entrance_exam_content(self.request.user, self.course)
self.assertEqual(exam_chapter.url_name, self.entrance_exam.url_name)
self.assertFalse(user_has_passed_entrance_exam(self.request, self.course))
self.assertFalse(user_has_passed_entrance_exam(self.request.user, self.course))
answer_entrance_exam_problem(self.course, self.request, self.problem_1)
answer_entrance_exam_problem(self.course, self.request, self.problem_2)
exam_chapter = get_entrance_exam_content(self.request.user, self.course)
self.assertEqual(exam_chapter, None)
self.assertTrue(user_has_passed_entrance_exam(self.request, self.course))
def test_entrance_exam_score(self):
"""
test entrance exam score. we will hit the method get_entrance_exam_score to verify exam score.
"""
# One query is for getting the list of disabled XBlocks (which is
# then stored in the request).
with self.assertNumQueries(1):
exam_score = get_entrance_exam_score(self.request, self.course)
self.assertEqual(exam_score, 0)
answer_entrance_exam_problem(self.course, self.request, self.problem_1)
answer_entrance_exam_problem(self.course, self.request, self.problem_2)
with self.assertNumQueries(1):
exam_score = get_entrance_exam_score(self.request, self.course)
# 50 percent exam score should be achieved.
self.assertGreater(exam_score * 100, 50)
self.assertTrue(user_has_passed_entrance_exam(self.request.user, self.course))
def test_entrance_exam_requirement_message(self):
"""
@@ -332,6 +313,10 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
minimum_score_pct = 29
self.course.entrance_exam_minimum_score_pct = float(minimum_score_pct) / 100
modulestore().update_item(self.course, self.request.user.id) # pylint: disable=no-member
# answer the problem so it results in only 20% correct.
answer_entrance_exam_problem(self.course, self.request, self.problem_1, value=1, max_value=5)
url = reverse(
'courseware_section',
kwargs={
@@ -342,9 +327,11 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
)
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn('To access course materials, you must score {required_score}% or higher'.format(
required_score=minimum_score_pct
), resp.content)
self.assertIn(
'To access course materials, you must score {}% or higher'.format(minimum_score_pct),
resp.content
)
self.assertIn('Your current score is 20%.', resp.content)
def test_entrance_exam_requirement_message_hidden(self):
"""
@@ -388,7 +375,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
resp = self.client.get(url)
self.assertNotIn('To access course materials, you must score', resp.content)
self.assertIn('You have passed the entrance exam.', resp.content)
self.assertIn('Your score is 100%. You have passed the entrance exam.', resp.content)
self.assertIn('Lesson 1', resp.content)
def test_entrance_exam_gating(self):
@@ -450,7 +437,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
for toc_section in self.expected_unlocked_toc:
self.assertIn(toc_section, unlocked_toc)
@patch('courseware.entrance_exams.user_has_passed_entrance_exam', Mock(return_value=False))
def test_courseware_page_access_without_passing_entrance_exam(self):
"""
Test courseware access page without passing entrance exam
@@ -468,7 +454,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
})
self.assertRedirects(response, expected_url, status_code=302, target_status_code=200)
@patch('courseware.entrance_exams.user_has_passed_entrance_exam', Mock(return_value=False))
def test_courseinfo_page_access_without_passing_entrance_exam(self):
"""
Test courseware access page without passing entrance exam
@@ -481,12 +466,11 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
exam_url = response.get('Location')
self.assertRedirects(response, exam_url)
@patch('courseware.entrance_exams.user_has_passed_entrance_exam', Mock(return_value=True))
@patch('courseware.entrance_exams.get_entrance_exam_content', Mock(return_value=None))
def test_courseware_page_access_after_passing_entrance_exam(self):
"""
Test courseware access page after passing entrance exam
"""
# Mocking get_required_content with empty list to assume user has passed entrance exam
self._assert_chapter_loaded(self.course, self.chapter)
@patch('util.milestones_helpers.get_required_content', Mock(return_value=['a value']))
@@ -528,7 +512,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
Test has_passed_entrance_exam method with anonymous user
"""
self.request.user = self.anonymous_user
self.assertFalse(user_has_passed_entrance_exam(self.request, self.course))
self.assertFalse(user_has_passed_entrance_exam(self.request.user, self.course))
def test_course_has_entrance_exam_missing_exam_id(self):
course = CourseFactory.create(
@@ -541,7 +525,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
def test_user_has_passed_entrance_exam_short_circuit_missing_exam(self):
course = CourseFactory.create(
)
self.assertTrue(user_has_passed_entrance_exam(self.request, course))
self.assertTrue(user_has_passed_entrance_exam(self.request.user, course))
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_MASQUERADE': False})
def test_entrance_exam_xblock_response(self):
@@ -599,7 +583,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
return toc['chapters']
def answer_entrance_exam_problem(course, request, problem, user=None):
def answer_entrance_exam_problem(course, request, problem, user=None, value=1, max_value=1):
"""
Takes a required milestone `problem` in a `course` and fulfills it.
@@ -608,11 +592,13 @@ def answer_entrance_exam_problem(course, request, problem, user=None):
request (Request): request Object
problem (xblock): xblock object, the problem to be fulfilled
user (User): User object in case it is different from request.user
value (int): raw_earned value of the problem
max_value (int): raw_possible value of the problem
"""
if not user:
user = request.user
grade_dict = {'value': 1, 'max_value': 1, 'user_id': user.id}
grade_dict = {'value': value, 'max_value': max_value, 'user_id': user.id}
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course.id,
user,

View File

@@ -296,13 +296,11 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase, Probl
"""
Returns SubsectionGrade for given url.
"""
# list of grade summaries for each section
sections_list = []
for chapter in self.get_course_grade().chapter_grades:
sections_list.extend(chapter['sections'])
# get the first section that matches the url (there should only be one)
return next(section for section in sections_list if section.url_name == hw_url_name)
for chapter in self.get_course_grade().chapter_grades.itervalues():
for section in chapter['sections']:
if section.url_name == hw_url_name:
return section
return None
def score_for_hw(self, hw_url_name):
"""
@@ -818,7 +816,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems):
self.assertEqual(name, "post")
self.assertEqual(len(args), 1)
self.assertTrue(args[0].endswith("/submit/"))
self.assertItemsEqual(kwargs.keys(), ["files", "data"])
self.assertItemsEqual(kwargs.keys(), ["files", "data", "timeout"])
self.assertItemsEqual(kwargs['files'].keys(), filenames.split())

View File

@@ -19,11 +19,19 @@ from django.shortcuts import redirect
from courseware.url_helpers import get_redirect_url_for_global_staff
from edxmako.shortcuts import render_to_response, render_to_string
import logging
import newrelic.agent
log = logging.getLogger("edx.courseware.views.index")
try:
import newrelic.agent
except ImportError:
newrelic = None # pylint: disable=invalid-name
import urllib
import waffle
from xblock.fragment import Fragment
from lms.djangoapps.gating.api import get_entrance_exam_score_ratio, get_entrance_exam_usage_key
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
@@ -33,6 +41,7 @@ from shoppingcart.models import CourseRegistrationCode
from student.models import CourseEnrollment
from student.views import is_course_blocked
from student.roles import GlobalStaff
from survey.utils import must_answer_survey
from util.enterprise_helpers import get_enterprise_consent_url
from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore
@@ -46,9 +55,8 @@ from ..courses import get_studio_url, get_course_with_access
from ..entrance_exams import (
course_has_entrance_exam,
get_entrance_exam_content,
get_entrance_exam_score,
user_has_passed_entrance_exam,
user_must_complete_entrance_exam,
user_can_skip_entrance_exam,
)
from ..exceptions import Redirect
from ..masquerade import setup_masquerade
@@ -57,7 +65,6 @@ from ..module_render import toc_for_course, get_module_for_descriptor
from .views import get_current_child, registered_for_course
log = logging.getLogger("edx.courseware.views.index")
TEMPLATE_IMPORTS = {'urllib': urllib}
CONTENT_DEPTH = 2
@@ -177,6 +184,8 @@ class CoursewareIndex(View):
"""
Initialize metrics for New Relic so we can slice data in New Relic Insights
"""
if not newrelic:
return
newrelic.agent.add_custom_parameter('course_id', unicode(self.course_key))
newrelic.agent.add_custom_parameter('org', unicode(self.course_key.org))
@@ -279,10 +288,7 @@ class CoursewareIndex(View):
"""
Check to see if an Entrance Exam is required for the user.
"""
if (
course_has_entrance_exam(self.course) and
user_must_complete_entrance_exam(self.request, self.effective_user, self.course)
):
if not user_can_skip_entrance_exam(self.effective_user, self.course):
exam_chapter = get_entrance_exam_content(self.effective_user, self.course)
if exam_chapter and exam_chapter.get_children():
exam_section = exam_chapter.get_children()[0]
@@ -432,10 +438,7 @@ class CoursewareIndex(View):
)
# entrance exam data
if course_has_entrance_exam(self.course):
if getattr(self.chapter, 'is_entrance_exam', False):
courseware_context['entrance_exam_current_score'] = get_entrance_exam_score(self.request, self.course)
courseware_context['entrance_exam_passed'] = user_has_passed_entrance_exam(self.request, self.course)
self._add_entrance_exam_to_context(courseware_context)
# staff masquerading data
now = datetime.now(UTC())
@@ -473,6 +476,17 @@ class CoursewareIndex(View):
return courseware_context
def _add_entrance_exam_to_context(self, courseware_context):
"""
Adds entrance exam related information to the given context.
"""
if course_has_entrance_exam(self.course) and getattr(self.chapter, 'is_entrance_exam', False):
courseware_context['entrance_exam_passed'] = user_has_passed_entrance_exam(self.effective_user, self.course)
courseware_context['entrance_exam_current_score'] = get_entrance_exam_score_ratio(
CourseGradeFactory().create(self.effective_user, self.course),
get_entrance_exam_usage_key(self.course),
)
def _create_section_context(self, previous_of_active_section, next_of_active_section):
"""
Returns and creates the rendering context for the section.

View File

@@ -101,7 +101,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.tabs import CourseTabList
from xmodule.x_module import STUDENT_VIEW
from ..entrance_exams import user_must_complete_entrance_exam
from ..entrance_exams import user_can_skip_entrance_exam
from ..module_render import get_module_for_descriptor, get_module, get_module_by_usage_id
from web_fragments.fragment import Fragment
@@ -336,7 +336,7 @@ def course_info(request, course_id):
# If the user needs to take an entrance exam to access this course, then we'll need
# to send them to that specific course module before allowing them into other areas
if user_must_complete_entrance_exam(request, user, course):
if not user_can_skip_entrance_exam(user, course):
return redirect(reverse('courseware', args=[unicode(course.id)]))
# check to see if there is a required survey that must be taken before
@@ -857,7 +857,7 @@ def _progress(request, course_key, student_id):
student = User.objects.prefetch_related("groups").get(id=student.id)
course_grade = CourseGradeFactory().create(student, course)
courseware_summary = course_grade.chapter_grades
courseware_summary = course_grade.chapter_grades.values()
grade_summary = course_grade.summary
studio_url = get_studio_url(course, 'settings/grading')

View File

@@ -55,7 +55,8 @@
el: $('.new-post-article'),
collection: discussion,
course_settings: courseSettings,
mode: 'tab'
mode: 'tab',
startHeader: 2
});
newPostView.render();

View File

@@ -23,6 +23,12 @@
this.courseSettings = options.courseSettings;
this.discussionBoardView = options.discussionBoardView;
this.newPostView = options.newPostView;
if (options.startHeader !== undefined) {
this.startHeader = options.startHeader;
} else {
this.startHeader = 2; // Start the header levels at H<startHeader>
}
},
start: function() {
@@ -95,6 +101,7 @@
el: $('.forum-content'),
model: this.thread,
mode: 'tab',
startHeader: this.startHeader,
courseSettings: this.courseSettings
});
this.main.render();

View File

@@ -17,7 +17,13 @@ from django.shortcuts import render_to_response
from django.template.loader import render_to_string
from django.utils.translation import get_language_bidi
from django.views.decorators.http import require_GET
import newrelic.agent
log = logging.getLogger("edx.discussions")
try:
import newrelic.agent
except ImportError:
newrelic = None # pylint: disable=invalid-name
from rest_framework import status
from web_fragments.fragment import Fragment
@@ -51,13 +57,27 @@ from util.enterprise_helpers import data_sharing_consent_required
from opaque_keys.edx.keys import CourseKey
from contextlib import contextmanager
THREADS_PER_PAGE = 20
INLINE_THREADS_PER_PAGE = 20
PAGES_NEARBY_DELTA = 2
log = logging.getLogger("edx.discussions")
@newrelic.agent.function_trace()
@contextmanager
def newrelic_function_trace(function_name):
"""
A wrapper context manager newrelic.agent.FunctionTrace to no-op if the
newrelic package is not installed
"""
if newrelic:
nr_transaction = newrelic.agent.current_transaction()
with newrelic.agent.FunctionTrace(nr_transaction, function_name):
yield
else:
yield
def make_course_settings(course, user):
"""
Generate a JSON-serializable model for course settings, which will be used to initialize a
@@ -72,7 +92,6 @@ def make_course_settings(course, user):
}
@newrelic.agent.function_trace()
def get_threads(request, course, user_info, discussion_id=None, per_page=THREADS_PER_PAGE):
"""
This may raise an appropriate subclass of cc.utils.CommentClientError
@@ -186,7 +205,6 @@ def inline_discussion(request, course_key, discussion_id):
"""
Renders JSON for DiscussionModules
"""
nr_transaction = newrelic.agent.current_transaction()
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
cc_user = cc.User.from_django_user(request.user)
@@ -197,12 +215,14 @@ def inline_discussion(request, course_key, discussion_id):
except ValueError:
return HttpResponseServerError("Invalid group_id")
with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"):
with newrelic_function_trace("get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
is_staff = has_permission(request.user, 'openclose_thread', course.id)
threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads]
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
with newrelic_function_trace("add_courseware_context"):
add_courseware_context(threads, course, request.user)
return utils.JsonResponse({
'is_commentable_cohorted': is_commentable_cohorted(course_key, discussion_id),
'discussion_data': threads,
@@ -222,8 +242,6 @@ def forum_form_discussion(request, course_key):
"""
Renders the main Discussion page, potentially filtered by a search query
"""
nr_transaction = newrelic.agent.current_transaction()
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
if request.is_ajax():
user = cc.User.from_django_user(request.user)
@@ -238,10 +256,10 @@ def forum_form_discussion(request, course_key):
except ValueError:
return HttpResponseServerError("Invalid group_id")
with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"):
with newrelic_function_trace("get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
with newrelic_function_trace("add_courseware_context"):
add_courseware_context(threads, course, request.user)
return utils.JsonResponse({
@@ -269,8 +287,6 @@ def single_thread(request, course_key, discussion_id, thread_id):
Depending on the HTTP headers, we'll adjust our response accordingly.
"""
nr_transaction = newrelic.agent.current_transaction()
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
if request.is_ajax():
@@ -282,7 +298,7 @@ def single_thread(request, course_key, discussion_id, thread_id):
if not thread:
raise Http404
with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"):
with newrelic_function_trace("get_annotated_content_infos"):
annotated_content_info = utils.get_annotated_content_infos(
course_key,
thread,
@@ -291,7 +307,7 @@ def single_thread(request, course_key, discussion_id, thread_id):
)
content = utils.prepare_content(thread.to_dict(), course_key, is_staff)
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
with newrelic_function_trace("add_courseware_context"):
add_courseware_context([content], course, request.user)
return utils.JsonResponse({
@@ -376,7 +392,6 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th
"""
Returns the template context for rendering the discussion board.
"""
nr_transaction = newrelic.agent.current_transaction()
context = _create_base_discussion_view_context(request, course_key)
course = context['course']
course_settings = context['course_settings']
@@ -405,13 +420,13 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th
is_staff = has_permission(user, 'openclose_thread', course.id)
threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads]
with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"):
with newrelic_function_trace("get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, user, user_info)
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
with newrelic_function_trace("add_courseware_context"):
add_courseware_context(threads, course, user)
with newrelic.agent.FunctionTrace(nr_transaction, "get_cohort_info"):
with newrelic_function_trace("get_cohort_info"):
user_cohort_id = get_cohort_id(user, course_key)
context.update({
@@ -439,9 +454,6 @@ def user_profile(request, course_key, user_id):
Renders a response to display the user profile page (shown after clicking
on a post author's username).
"""
nr_transaction = newrelic.agent.current_transaction()
user = cc.User.from_django_user(request.user)
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
@@ -470,13 +482,13 @@ def user_profile(request, course_key, user_id):
query_params['page'] = page
query_params['num_pages'] = num_pages
with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"):
with newrelic_function_trace("get_metadata_for_threads"):
user_info = cc.User.from_django_user(request.user).to_dict()
annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info)
is_staff = has_permission(request.user, 'openclose_thread', course.id)
threads = [utils.prepare_content(thread, course_key, is_staff) for thread in threads]
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
with newrelic_function_trace("add_courseware_context"):
add_courseware_context(threads, course, request.user)
if request.is_ajax():
return utils.JsonResponse({
@@ -490,7 +502,7 @@ def user_profile(request, course_key, user_id):
course_id=course.id
).order_by("name").values_list("name", flat=True).distinct()
with newrelic.agent.FunctionTrace(nr_transaction, "get_cohort_info"):
with newrelic_function_trace("get_cohort_info"):
user_cohort_id = get_cohort_id(request.user, course_key)
context = _create_base_discussion_view_context(request, course_key)
@@ -518,9 +530,6 @@ def followed_threads(request, course_key, user_id):
"""
Ajax-only endpoint retrieving the threads followed by a specific user.
"""
nr_transaction = newrelic.agent.current_transaction()
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
try:
profiled_user = cc.User(id=user_id, course_id=course_key)
@@ -561,7 +570,7 @@ def followed_threads(request, course_key, user_id):
query_params['num_pages'] = paginated_results.num_pages
user_info = cc.User.from_django_user(request.user).to_dict()
with newrelic.agent.FunctionTrace(nr_transaction, "get_metadata_for_threads"):
with newrelic_function_trace("get_metadata_for_threads"):
annotated_content_info = utils.get_metadata_for_threads(
course_key,
paginated_results.collection,

View File

@@ -2,115 +2,115 @@
API for the gating djangoapp
"""
from collections import defaultdict
from django.contrib.auth.models import User
from django.test.client import RequestFactory
import json
import logging
from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_content
from openedx.core.lib.gating import api as gating_api
from opaque_keys.edx.keys import UsageKey
from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_score
from lms.djangoapps.grades.module_grades import get_module_score
from util import milestones_helpers
log = logging.getLogger(__name__)
def _get_xblock_parent(xblock, category=None):
"""
Returns the parent of the given XBlock. If an optional category is supplied,
traverses the ancestors of the XBlock and returns the first with the
given category.
Arguments:
xblock (XBlock): Get the parent of this XBlock
category (str): Find an ancestor with this category (e.g. sequential)
"""
parent = xblock.get_parent()
if parent and category:
if parent.category == category:
return parent
else:
return _get_xblock_parent(parent, category)
return parent
@gating_api.gating_enabled(default=False)
def evaluate_prerequisite(course, block, user_id):
def evaluate_prerequisite(course, subsection_grade, user):
"""
Finds the parent subsection of the content in the course and evaluates
any milestone relationships attached to that subsection. If the calculated
grade of the prerequisite subsection meets the minimum score required by
dependent subsections, the related milestone will be fulfilled for the user.
Arguments:
course (CourseModule): The course
prereq_content_key (UsageKey): The prerequisite content usage key
user_id (int): ID of User for which evaluation should occur
Returns:
None
Evaluates any gating milestone relationships attached to the given
subsection. If the subsection_grade meets the minimum score required
by dependent subsections, the related milestone will be marked
fulfilled for the user.
"""
sequential = _get_xblock_parent(block, 'sequential')
if sequential:
prereq_milestone = gating_api.get_gating_milestone(
course.id,
sequential.location.for_branch(None),
'fulfills'
)
if prereq_milestone:
gated_content_milestones = defaultdict(list)
for milestone in gating_api.find_gating_milestones(course.id, None, 'requires'):
gated_content_milestones[milestone['id']].append(milestone)
prereq_milestone = gating_api.get_gating_milestone(course.id, subsection_grade.location, 'fulfills')
if prereq_milestone:
gated_content_milestones = defaultdict(list)
for milestone in gating_api.find_gating_milestones(course.id, content_key=None, relationship='requires'):
gated_content_milestones[milestone['id']].append(milestone)
gated_content = gated_content_milestones.get(prereq_milestone['id'])
if gated_content:
user = User.objects.get(id=user_id)
score = get_module_score(user, course, sequential) * 100
for milestone in gated_content:
# Default minimum score to 100
min_score = 100
requirements = milestone.get('requirements')
if requirements:
try:
min_score = int(requirements.get('min_score'))
except (ValueError, TypeError):
log.warning(
'Failed to find minimum score for gating milestone %s, defaulting to 100',
json.dumps(milestone)
)
if score >= min_score:
milestones_helpers.add_user_milestone({'id': user_id}, prereq_milestone)
else:
milestones_helpers.remove_user_milestone({'id': user_id}, prereq_milestone)
gated_content = gated_content_milestones.get(prereq_milestone['id'])
if gated_content:
for milestone in gated_content:
min_percentage = _get_minimum_required_percentage(milestone)
subsection_percentage = _get_subsection_percentage(subsection_grade)
if subsection_percentage >= min_percentage:
milestones_helpers.add_user_milestone({'id': user.id}, prereq_milestone)
else:
milestones_helpers.remove_user_milestone({'id': user.id}, prereq_milestone)
def evaluate_entrance_exam(course, block, user_id):
def _get_minimum_required_percentage(milestone):
"""
Update milestone fulfillments for the specified content module
Returns the minimum percentage requirement for the given milestone.
"""
# Fulfillment Use Case: Entrance Exam
# If this module is part of an entrance exam, we'll need to see if the student
# has reached the point at which they can collect the associated milestone
if milestones_helpers.is_entrance_exams_enabled():
entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', False)
in_entrance_exam = getattr(block, 'in_entrance_exam', False)
if entrance_exam_enabled and in_entrance_exam:
# We don't have access to the true request object in this context, but we can use a mock
request = RequestFactory().request()
request.user = User.objects.get(id=user_id)
exam_pct = get_entrance_exam_score(request, course)
if exam_pct >= course.entrance_exam_minimum_score_pct:
exam_key = UsageKey.from_string(course.entrance_exam_id)
# Default minimum score to 100
min_score = 100
requirements = milestone.get('requirements')
if requirements:
try:
min_score = int(requirements.get('min_score'))
except (ValueError, TypeError):
log.warning(
u'Gating: Failed to find minimum score for gating milestone %s, defaulting to 100',
json.dumps(milestone)
)
return min_score
def _get_subsection_percentage(subsection_grade):
"""
Returns the percentage value of the given subsection_grade.
"""
return _calculate_ratio(subsection_grade.graded_total.earned, subsection_grade.graded_total.possible) * 100.0
def _calculate_ratio(earned, possible):
"""
Returns the percentage of the given earned and possible values.
"""
return float(earned) / float(possible) if possible else 0.0
def evaluate_entrance_exam(course_grade, user):
"""
Evaluates any entrance exam milestone relationships attached
to the given course. If the course_grade meets the
minimum score required, the dependent milestones will be marked
fulfilled for the user.
"""
course = course_grade.course
if milestones_helpers.is_entrance_exams_enabled() and getattr(course, 'entrance_exam_enabled', False):
if get_entrance_exam_content(user, course):
exam_chapter_key = get_entrance_exam_usage_key(course)
exam_score_ratio = get_entrance_exam_score_ratio(course_grade, exam_chapter_key)
if exam_score_ratio >= course.entrance_exam_minimum_score_pct:
relationship_types = milestones_helpers.get_milestone_relationship_types()
content_milestones = milestones_helpers.get_course_content_milestones(
course.id,
exam_key,
exam_chapter_key,
relationship=relationship_types['FULFILLS']
)
# Add each milestone to the user's set...
user = {'id': request.user.id}
# Mark each entrance exam dependent milestone as fulfilled by the user.
for milestone in content_milestones:
milestones_helpers.add_user_milestone(user, milestone)
milestones_helpers.add_user_milestone({'id': user.id}, milestone)
def get_entrance_exam_usage_key(course):
"""
Returns the UsageKey of the entrance exam for the course.
"""
return UsageKey.from_string(course.entrance_exam_id).replace(course_key=course.id)
def get_entrance_exam_score_ratio(course_grade, exam_chapter_key):
"""
Returns the score for the given chapter as a ratio of the
aggregated earned over the possible points, resulting in a
decimal value less than 1.
"""
try:
earned, possible = course_grade.score_for_chapter(exam_chapter_key)
except KeyError:
earned, possible = 0.0, 0.0
log.warning(u'Gating: Unexpectedly failed to find chapter_grade for %s.', exam_chapter_key)
return _calculate_ratio(earned, possible)

View File

@@ -4,25 +4,36 @@ Signal handlers for the gating djangoapp
from django.dispatch import receiver
from gating import api as gating_api
from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED
from opaque_keys.edx.keys import CourseKey, UsageKey
from xmodule.modulestore.django import modulestore
from lms.djangoapps.grades.signals.signals import SUBSECTION_SCORE_CHANGED
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED
@receiver(PROBLEM_WEIGHTED_SCORE_CHANGED)
def handle_score_changed(**kwargs):
@receiver(SUBSECTION_SCORE_CHANGED)
def evaluate_subsection_gated_milestones(**kwargs):
"""
Receives the PROBLEM_WEIGHTED_SCORE_CHANGED signal sent by LMS when a student's score has changed
for a given component and triggers the evaluation of any milestone relationships
which are attached to the updated content.
Receives the SUBSECTION_SCORE_CHANGED signal and triggers the
evaluation of any milestone relationships which are attached
to the subsection.
Arguments:
kwargs (dict): Contains user ID, course key, and content usage key
kwargs (dict): Contains user, course, course_structure, subsection_grade
Returns:
None
"""
course = modulestore().get_course(CourseKey.from_string(kwargs.get('course_id')))
block = modulestore().get_item(UsageKey.from_string(kwargs.get('usage_id')))
gating_api.evaluate_prerequisite(course, block, kwargs.get('user_id'))
gating_api.evaluate_entrance_exam(course, block, kwargs.get('user_id'))
subsection_grade = kwargs['subsection_grade']
gating_api.evaluate_prerequisite(kwargs['course'], subsection_grade, kwargs.get('user'))
@receiver(COURSE_GRADE_CHANGED)
def evaluate_course_gated_milestones(**kwargs):
"""
Receives the COURSE_GRADE_CHANGED signal and triggers the
evaluation of any milestone relationships which are attached
to the course grade.
Arguments:
kwargs (dict): Contains user, course_grade
Returns:
None
"""
gating_api.evaluate_entrance_exam(kwargs['course_grade'], kwargs.get('user'))

View File

@@ -1,7 +1,7 @@
"""
Unit tests for gating.signals module
"""
from mock import patch
from mock import patch, Mock
from nose.plugins.attrib import attr
from ddt import ddt, data, unpack
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
@@ -11,7 +11,7 @@ from courseware.tests.helpers import LoginEnrollmentTestCase
from milestones import api as milestones_api
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.lib.gating import api as gating_api
from gating.api import _get_xblock_parent, evaluate_prerequisite
from gating.api import evaluate_prerequisite
class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
@@ -48,60 +48,14 @@ class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
self.seq1 = ItemFactory.create(
parent_location=self.chapter1.location,
category='sequential',
display_name='untitled sequential 1'
display_name='gating sequential'
)
self.seq2 = ItemFactory.create(
parent_location=self.chapter1.location,
category='sequential',
display_name='untitled sequential 2'
display_name='gated sequential'
)
# create vertical
self.vert1 = ItemFactory.create(
parent_location=self.seq1.location,
category='vertical',
display_name='untitled vertical 1'
)
# create problem
self.prob1 = ItemFactory.create(
parent_location=self.vert1.location,
category='problem',
display_name='untitled problem 1'
)
# create orphan
self.prob2 = ItemFactory.create(
parent_location=self.course.location,
category='problem',
display_name='untitled problem 2'
)
class TestGetXBlockParent(GatingTestCase):
"""
Tests for the get_xblock_parent function
"""
def test_get_direct_parent(self):
""" Test test_get_direct_parent """
result = _get_xblock_parent(self.vert1)
self.assertEqual(result.location, self.seq1.location)
def test_get_parent_with_category(self):
""" Test test_get_parent_of_category """
result = _get_xblock_parent(self.vert1, 'sequential')
self.assertEqual(result.location, self.seq1.location)
result = _get_xblock_parent(self.vert1, 'chapter')
self.assertEqual(result.location, self.chapter1.location)
def test_get_parent_none(self):
""" Test test_get_parent_none """
result = _get_xblock_parent(self.vert1, 'unit')
self.assertIsNone(result)
@attr(shard=3)
@ddt
@@ -114,62 +68,46 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
super(TestEvaluatePrerequisite, self).setUp()
self.user_dict = {'id': self.user.id}
self.prereq_milestone = None
self.subsection_grade = Mock(location=self.seq1.location)
def _setup_gating_milestone(self, min_score):
"""
Setup a gating milestone for testing
"""
gating_api.add_prerequisite(self.course.id, self.seq1.location)
gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, min_score)
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
@patch('gating.api.get_module_score')
@data((.5, True), (1, True), (0, False))
@patch('gating.api._get_subsection_percentage')
@data((50, True), (100, True), (0, False))
@unpack
def test_min_score_achieved(self, module_score, result, mock_module_score):
""" Test test_min_score_achieved """
def test_min_score_achieved(self, module_score, result, mock_score):
self._setup_gating_milestone(50)
mock_score.return_value = module_score
mock_module_score.return_value = module_score
evaluate_prerequisite(self.course, self.prob1, self.user.id)
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
@patch('gating.api.log.warning')
@patch('gating.api.get_module_score')
@data((.5, False), (1, True))
@patch('gating.api._get_subsection_percentage')
@data((50, False), (100, True))
@unpack
def test_invalid_min_score(self, module_score, result, mock_module_score, mock_log):
""" Test test_invalid_min_score """
def test_invalid_min_score(self, module_score, result, mock_score, mock_log):
self._setup_gating_milestone(None)
mock_score.return_value = module_score
mock_module_score.return_value = module_score
evaluate_prerequisite(self.course, self.prob1, self.user.id)
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
self.assertTrue(mock_log.called)
@patch('gating.api.get_module_score')
def test_orphaned_xblock(self, mock_module_score):
""" Test test_orphaned_xblock """
@patch('gating.api._get_subsection_percentage')
def test_no_prerequisites(self, mock_score):
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertFalse(mock_score.called)
evaluate_prerequisite(self.course, self.prob2, self.user.id)
self.assertFalse(mock_module_score.called)
@patch('gating.api.get_module_score')
def test_no_prerequisites(self, mock_module_score):
""" Test test_no_prerequisites """
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertFalse(mock_module_score.called)
@patch('gating.api.get_module_score')
def test_no_gated_content(self, mock_module_score):
""" Test test_no_gated_content """
# Setup gating milestones data
@patch('gating.api._get_subsection_percentage')
def test_no_gated_content(self, mock_score):
gating_api.add_prerequisite(self.course.id, self.seq1.location)
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertFalse(mock_module_score.called)
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertFalse(mock_score.called)

View File

@@ -0,0 +1,192 @@
"""
Integration tests for gated content.
"""
import ddt
from nose.plugins.attrib import attr
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.grades.tests.utils import answer_problem
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from milestones import api as milestones_api
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.djangolib.testing.utils import get_mock_request
from openedx.core.lib.gating import api as gating_api
from request_cache.middleware import RequestCache
from student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
@attr(shard=3)
@ddt.ddt
class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
"""
Base TestCase class for setting up a basic course structure
and testing the gating feature
"""
@classmethod
def setUpClass(cls):
super(TestGatedContent, cls).setUpClass()
cls.set_up_course()
def setUp(self):
super(TestGatedContent, self).setUp()
self.setup_gating_milestone(50)
self.non_staff_user = UserFactory()
self.staff_user = UserFactory(is_staff=True, is_superuser=True)
self.request = get_mock_request(self.non_staff_user)
@classmethod
def set_up_course(cls):
"""
Set up a course for testing gated content.
"""
cls.course = CourseFactory.create(
org='edX',
number='EDX101',
run='EDX101_RUN1',
display_name='edX 101'
)
with modulestore().bulk_operations(cls.course.id):
cls.course.enable_subsection_gating = True
grading_policy = {
"GRADER": [{
"type": "Homework",
"min_count": 3,
"drop_count": 0,
"short_label": "HW",
"weight": 1.0
}]
}
cls.course.grading_policy = grading_policy
cls.course.save()
cls.store.update_item(cls.course, 0)
# create chapter
cls.chapter1 = ItemFactory.create(
parent_location=cls.course.location,
category='chapter',
display_name='chapter 1'
)
# create sequentials
cls.seq1 = ItemFactory.create(
parent_location=cls.chapter1.location,
category='sequential',
display_name='gating sequential 1',
graded=True,
format='Homework',
)
cls.seq2 = ItemFactory.create(
parent_location=cls.chapter1.location,
category='sequential',
display_name='gated sequential 2',
graded=True,
format='Homework',
)
cls.seq3 = ItemFactory.create(
parent_location=cls.chapter1.location,
category='sequential',
display_name='sequential 3',
graded=True,
format='Homework',
)
# create problem
cls.gating_prob1 = ItemFactory.create(
parent_location=cls.seq1.location,
category='problem',
display_name='gating problem 1',
)
cls.gated_prob2 = ItemFactory.create(
parent_location=cls.seq2.location,
category='problem',
display_name='gated problem 2',
)
cls.prob3 = ItemFactory.create(
parent_location=cls.seq3.location,
category='problem',
display_name='problem 3',
)
def setup_gating_milestone(self, min_score):
"""
Setup a gating milestone for testing.
Gating content: seq1 (must be fulfilled before access to seq2)
Gated content: seq2 (requires completion of seq1 before access)
"""
gating_api.add_prerequisite(self.course.id, str(self.seq1.location))
gating_api.set_required_content(self.course.id, str(self.seq2.location), str(self.seq1.location), min_score)
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
def assert_access_to_gated_content(self, user, expected_access):
"""
Verifies access to gated content for the given user is as expected.
"""
# clear the request cache to flush any cached access results
RequestCache.clear_request_cache()
# access to gating content (seq1) remains constant
self.assertTrue(bool(has_access(user, 'load', self.seq1, self.course.id)))
# access to gated content (seq2) is as expected
self.assertEquals(bool(has_access(user, 'load', self.seq2, self.course.id)), expected_access)
def assert_user_has_prereq_milestone(self, user, expected_has_milestone):
"""
Verifies whether or not the user has the prereq milestone
"""
self.assertEquals(
milestones_api.user_has_milestone({'id': user.id}, self.prereq_milestone),
expected_has_milestone,
)
def assert_course_grade(self, user, expected_percent):
"""
Verifies the given user's course grade is the expected percentage.
Also verifies the user's grade information contains values for
all problems in the course, whether or not they are currently
gated.
"""
course_grade = CourseGradeFactory().create(user, self.course)
for prob in [self.gating_prob1, self.gated_prob2, self.prob3]:
self.assertIn(prob.location, course_grade.locations_to_scores)
self.assertEquals(course_grade.percent, expected_percent)
def test_gated_for_nonstaff(self):
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
self.assert_access_to_gated_content(self.non_staff_user, expected_access=False)
def test_not_gated_for_staff(self):
self.assert_user_has_prereq_milestone(self.staff_user, expected_has_milestone=False)
self.assert_access_to_gated_content(self.staff_user, expected_access=True)
def test_gated_content_always_in_grades(self):
# start with a grade from a non-gated subsection
answer_problem(self.course, self.request, self.prob3, 10, 10)
# verify gated status and overall course grade percentage
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
self.assert_access_to_gated_content(self.non_staff_user, expected_access=False)
self.assert_course_grade(self.non_staff_user, .33)
# fulfill the gated requirements
answer_problem(self.course, self.request, self.gating_prob1, 10, 10)
# verify gated status and overall course grade percentage
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=True)
self.assert_access_to_gated_content(self.non_staff_user, expected_access=True)
self.assert_course_grade(self.non_staff_user, .67)
@ddt.data((1, 1, True), (1, 2, True), (1, 3, False), (0, 1, False))
@ddt.unpack
def test_ungating_when_fulfilled(self, earned, max_possible, result):
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
self.assert_access_to_gated_content(self.non_staff_user, expected_access=False)
answer_problem(self.course, self.request, self.gating_prob1, earned, max_possible)
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=result)
self.assert_access_to_gated_content(self.non_staff_user, expected_access=result)

View File

@@ -1,14 +1,14 @@
"""
Unit tests for gating.signals module
"""
from mock import patch
from mock import patch, Mock
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore
from gating.signals import handle_score_changed
from gating.signals import evaluate_subsection_gated_milestones
class TestHandleScoreChanged(ModuleStoreTestCase):
@@ -19,32 +19,26 @@ class TestHandleScoreChanged(ModuleStoreTestCase):
super(TestHandleScoreChanged, self).setUp()
self.course = CourseFactory.create(org='TestX', number='TS01', run='2016_Q1')
self.user = UserFactory.create()
self.test_usage_key = self.course.location
self.subsection_grade = Mock()
@patch('gating.signals.gating_api.evaluate_prerequisite')
def test_gating_enabled(self, mock_evaluate):
""" Test evaluate_prerequisite is called when course.enable_subsection_gating is True """
@patch('lms.djangoapps.gating.api.gating_api.get_gating_milestone')
def test_gating_enabled(self, mock_gating_milestone):
self.course.enable_subsection_gating = True
modulestore().update_item(self.course, 0)
handle_score_changed(
evaluate_subsection_gated_milestones(
sender=None,
points_possible=1,
points_earned=1,
user_id=self.user.id,
course_id=unicode(self.course.id),
usage_id=unicode(self.test_usage_key)
user=self.user,
course=self.course,
subsection_grade=self.subsection_grade,
)
mock_evaluate.assert_called_with(self.course, self.course, self.user.id) # pylint: disable=no-member
self.assertTrue(mock_gating_milestone.called)
@patch('gating.signals.gating_api.evaluate_prerequisite')
def test_gating_disabled(self, mock_evaluate):
""" Test evaluate_prerequisite is not called when course.enable_subsection_gating is False """
handle_score_changed(
@patch('lms.djangoapps.gating.api.gating_api.get_gating_milestone')
def test_gating_disabled(self, mock_gating_milestone):
evaluate_subsection_gated_milestones(
sender=None,
points_possible=1,
points_earned=1,
user_id=self.user.id,
course_id=unicode(self.course.id),
usage_id=unicode(self.test_usage_key)
user=self.user,
course=self.course,
subsection_grade=self.subsection_grade,
)
mock_evaluate.assert_not_called()
self.assertFalse(mock_gating_milestone.called)

View File

@@ -0,0 +1,122 @@
"""
Command to compute all grades for specified courses.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
from django.core.management.base import BaseCommand
import six
from openedx.core.lib.command_utils import (
get_mutually_exclusive_required_option,
parse_course_keys,
)
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
from ... import tasks
log = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Example usage:
$ ./manage.py lms compute_grades --all_courses --settings=devstack
$ ./manage.py lms compute_grades 'edX/DemoX/Demo_Course' --settings=devstack
"""
args = '<course_id course_id ...>'
help = 'Computes grade values for all learners in specified courses.'
def add_arguments(self, parser):
"""
Entry point for subclassed commands to add custom arguments.
"""
parser.add_argument(
'--courses',
dest='courses',
nargs='+',
help='List of (space separated) courses that need grades computed.',
)
parser.add_argument(
'--all_courses',
help='Compute grades for all courses.',
action='store_true',
default=False,
)
parser.add_argument(
'--routing_key',
dest='routing_key',
help='Celery routing key to use.',
)
parser.add_argument(
'--batch_size',
help='Maximum number of students to calculate grades for, per celery task.',
default=100,
type=int,
)
parser.add_argument(
'--start_index',
help='Offset from which to start processing enrollments.',
default=0,
type=int,
)
def handle(self, *args, **options):
self._set_log_level(options)
for course_key in self._get_course_keys(options):
self.enqueue_compute_grades_for_course_tasks(course_key, options)
def enqueue_compute_grades_for_course_tasks(self, course_key, options):
"""
Enqueue celery tasks to compute and persist all grades for the
specified course, in batches.
"""
enrollment_count = CourseEnrollment.objects.filter(course_id=course_key).count()
if enrollment_count == 0:
log.warning("No enrollments found for {}".format(course_key))
for offset in six.moves.range(options['start_index'], enrollment_count, options['batch_size']):
# If the number of enrollments increases after the tasks are
# created, the most recent enrollments may not get processed.
# This is an acceptable limitation for our known use cases.
task_options = {'routing_key': options['routing_key']} if options.get('routing_key') else {}
kwargs = {
'course_key': six.text_type(course_key),
'offset': offset,
'batch_size': options['batch_size'],
}
result = tasks.compute_grades_for_course.apply_async(
kwargs=kwargs,
options=task_options,
)
log.info("Persistent grades: Created {task_name}[{task_id}] with arguments {kwargs}".format(
task_name=tasks.compute_grades_for_course.name,
task_id=result.task_id,
kwargs=kwargs,
))
def _get_course_keys(self, options):
"""
Return a list of courses that need scores computed.
"""
courses_mode = get_mutually_exclusive_required_option(options, 'courses', 'all_courses')
if courses_mode == 'all_courses':
course_keys = [course.id for course in modulestore().get_course_summaries()]
else:
course_keys = parse_course_keys(options['courses'])
return course_keys
def _set_log_level(self, options):
"""
Sets logging levels for this module and the block structure
cache module, based on the given the options.
"""
if options.get('verbosity') == 0:
log_level = logging.WARNING
elif options.get('verbosity') >= 1:
log_level = logging.INFO
log.setLevel(log_level)

View File

@@ -0,0 +1,93 @@
"""
Tests for compute_grades management command.
"""
# pylint: disable=protected-access
from __future__ import absolute_import, division, print_function, unicode_literals
import ddt
from django.contrib.auth import get_user_model
from django.core.management import CommandError, call_command
from mock import patch
import six
from student.models import CourseEnrollment
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from lms.djangoapps.grades.management.commands import compute_grades
@ddt.ddt
class TestComputeGrades(SharedModuleStoreTestCase):
"""
Tests compute_grades management command.
"""
num_users = 3
num_courses = 5
@classmethod
def setUpClass(cls):
super(TestComputeGrades, cls).setUpClass()
User = get_user_model() # pylint: disable=invalid-name
cls.command = compute_grades.Command()
cls.courses = [CourseFactory.create() for _ in range(cls.num_courses)]
cls.course_keys = [six.text_type(course.id) for course in cls.courses]
cls.users = [User.objects.create(username='user{}'.format(idx)) for idx in range(cls.num_users)]
for user in cls.users:
for course in cls.courses:
CourseEnrollment.enroll(user, course.id)
def test_select_all_courses(self):
courses = self.command._get_course_keys({'all_courses': True})
self.assertEqual(
sorted(six.text_type(course) for course in courses),
self.course_keys,
)
def test_specify_courses(self):
courses = self.command._get_course_keys({'courses': [self.course_keys[0], self.course_keys[1], 'd/n/e']})
self.assertEqual(
[six.text_type(course) for course in courses],
[self.course_keys[0], self.course_keys[1], 'd/n/e'],
)
def test_selecting_invalid_course(self):
with self.assertRaises(CommandError):
self.command._get_course_keys({'courses': [self.course_keys[0], self.course_keys[1], 'badcoursekey']})
@patch('lms.djangoapps.grades.tasks.compute_grades_for_course')
def test_tasks_fired(self, mock_task):
call_command(
'compute_grades',
'--routing_key=key',
'--batch_size=2',
'--courses',
self.course_keys[0],
self.course_keys[3],
'd/n/e' # No tasks created for nonexistent course, because it has no enrollments
)
self.assertEqual(
mock_task.apply_async.call_args_list,
[
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[0], 'batch_size': 2, 'offset': 0}
},),
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[0], 'batch_size': 2, 'offset': 2}
},),
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[3], 'batch_size': 2, 'offset': 0}
},),
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[3], 'batch_size': 2, 'offset': 2}
},),
],
)

View File

@@ -302,17 +302,17 @@ class TestResetGrades(TestCase):
self.command.handle(delete=True, all_courses=True, db_table="not course or subsection")
def test_no_run_mode(self):
with self.assertRaisesMessage(CommandError, 'Either --delete or --dry_run must be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --delete, --dry_run'):
self.command.handle(all_courses=True)
def test_both_run_modes(self):
with self.assertRaisesMessage(CommandError, 'Both --delete and --dry_run cannot be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --delete, --dry_run'):
self.command.handle(all_courses=True, dry_run=True, delete=True)
def test_no_course_mode(self):
with self.assertRaisesMessage(CommandError, 'Either --courses or --all_courses must be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
self.command.handle(dry_run=True)
def test_both_course_modes(self):
with self.assertRaisesMessage(CommandError, 'Both --courses and --all_courses cannot be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
self.command.handle(dry_run=True, all_courses=True, courses=['some/course/key'])

View File

@@ -1,96 +0,0 @@
"""
Functionality for module-level grades.
"""
# TODO The score computation in this file is not accurate
# since it is summing percentages instead of computing a
# final percentage of the individual sums.
# Regardless, this file and its code should be removed soon
# as part of TNL-5062.
from django.test.client import RequestFactory
from courseware.model_data import FieldDataCache, ScoresClient
from courseware.module_render import get_module_for_descriptor
from opaque_keys.edx.locator import BlockUsageLocator
from util.module_utils import yield_dynamic_descriptor_descendants
def _get_mock_request(student):
"""
Make a fake request because grading code expects to be able to look at
the request. We have to attach the correct user to the request before
grading that student.
"""
request = RequestFactory().get('/')
request.user = student
return request
def _calculate_score_for_modules(user_id, course, modules):
"""
Calculates the cumulative score (percent) of the given modules
"""
# removing branch and version from exam modules locator
# otherwise student module would not return scores since module usage keys would not match
modules = [m for m in modules]
locations = [
BlockUsageLocator(
course_key=course.id,
block_type=module.location.block_type,
block_id=module.location.block_id
)
if isinstance(module.location, BlockUsageLocator) and module.location.version
else module.location
for module in modules
]
scores_client = ScoresClient(course.id, user_id)
scores_client.fetch_scores(locations)
# Iterate over all of the exam modules to get score percentage of user for each of them
module_percentages = []
ignore_categories = ['course', 'chapter', 'sequential', 'vertical', 'randomize', 'library_content']
for index, module in enumerate(modules):
if module.category not in ignore_categories and (module.graded or module.has_score):
module_score = scores_client.get(locations[index])
if module_score:
correct = module_score.correct or 0
total = module_score.total or 1
module_percentages.append(correct / total)
return sum(module_percentages) / float(len(module_percentages)) if module_percentages else 0
def get_module_score(user, course, module):
"""
Collects all children of the given module and calculates the cumulative
score for this set of modules for the given user.
Arguments:
user (User): The user
course (CourseModule): The course
module (XBlock): The module
Returns:
float: The cumulative score
"""
def inner_get_module(descriptor):
"""
Delegate to get_module_for_descriptor
"""
field_data_cache = FieldDataCache([descriptor], course.id, user)
return get_module_for_descriptor(
user,
_get_mock_request(user),
descriptor,
field_data_cache,
course.id,
course=course
)
modules = yield_dynamic_descriptor_descendants(
module,
user.id,
inner_get_module
)
return _calculate_score_for_modules(user.id, course, modules)

View File

@@ -49,7 +49,7 @@ class CourseGrade(object):
a dict keyed by subsection format types.
"""
subsections_by_format = defaultdict(OrderedDict)
for chapter in self.chapter_grades:
for chapter in self.chapter_grades.itervalues():
for subsection_grade in chapter['sections']:
if subsection_grade.graded:
graded_total = subsection_grade.graded_total
@@ -63,7 +63,7 @@ class CourseGrade(object):
Returns a dict of problem scores keyed by their locations.
"""
locations_to_scores = {}
for chapter in self.chapter_grades:
for chapter in self.chapter_grades.itervalues():
for subsection_grade in chapter['sections']:
locations_to_scores.update(subsection_grade.locations_to_scores)
return locations_to_scores
@@ -88,10 +88,12 @@ class CourseGrade(object):
@lazy
def chapter_grades(self):
"""
Returns a list of chapters, each containing its subsection grades,
display name, and url name.
Returns a dictionary of dictionaries.
The primary dictionary is keyed by the chapter's usage_key.
The secondary dictionary contains the chapter's
subsection grades, display name, and url name.
"""
chapter_grades = []
chapter_grades = OrderedDict()
for chapter_key in self.course_structure.get_children(self.course.location):
chapter = self.course_structure[chapter_key]
chapter_subsection_grades = []
@@ -101,11 +103,11 @@ class CourseGrade(object):
self._subsection_grade_factory.create(self.course_structure[subsection_key], read_only=True)
)
chapter_grades.append({
chapter_grades[chapter_key] = {
'display_name': block_metadata_utils.display_name_with_default_escaped(chapter),
'url_name': block_metadata_utils.url_name_for_block(chapter),
'sections': chapter_subsection_grades
})
}
return chapter_grades
@property
@@ -152,7 +154,7 @@ class CourseGrade(object):
If read_only is True, doesn't save any updates to the grades.
"""
subsections_total = sum(len(chapter['sections']) for chapter in self.chapter_grades)
subsections_total = sum(len(chapter['sections']) for chapter in self.chapter_grades.itervalues())
total_graded_subsections = sum(len(x) for x in self.graded_subsections_by_format.itervalues())
subsections_created = len(self._subsection_grade_factory._unsaved_subsection_grades) # pylint: disable=protected-access
@@ -187,6 +189,19 @@ class CourseGrade(object):
)
)
def score_for_chapter(self, chapter_key):
"""
Returns the aggregate weighted score for the given chapter.
Raises:
KeyError if the chapter is not found.
"""
earned, possible = 0.0, 0.0
chapter_grade = self.chapter_grades[chapter_key]
for section in chapter_grade['sections']:
earned += section.graded_total.earned
possible += section.graded_total.possible
return earned, possible
def score_for_module(self, location):
"""
Calculate the aggregate weighted score for any location in the course.
@@ -201,8 +216,7 @@ class CourseGrade(object):
score = self.locations_to_scores[location]
return score.earned, score.possible
children = self.course_structure.get_children(location)
earned = 0.0
possible = 0.0
earned, possible = 0.0, 0.0
for child in children:
child_earned, child_possible = self.score_for_module(child)
earned += child_earned

View File

@@ -3,13 +3,17 @@ This module contains tasks for asynchronous execution of grade updates.
"""
from celery import task
from celery.exceptions import Retry
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.utils import DatabaseError
from logging import getLogger
import newrelic.agent
log = getLogger(__name__)
try:
import newrelic.agent
except ImportError:
newrelic = None # pylint: disable=invalid-name
from celery_utils.logged_task import LoggedTask
from celery_utils.persist_on_failure import PersistOnFailureTask
@@ -30,8 +34,6 @@ from .new.subsection_grade import SubsectionGradeFactory
from .signals.signals import SUBSECTION_SCORE_CHANGED
from .transformer import GradesTransformer
log = getLogger(__name__)
class DatabaseNotReadyError(IOError):
"""
@@ -56,6 +58,14 @@ class _BaseTask(PersistOnFailureTask, LoggedTask): # pylint: disable=abstract-m
abstract = True
@task
def compute_grades_for_course(course_key, offset, batch_size): # pylint: disable=unused-argument
"""
TODO: TNL-6690: Fill this task in and remove pylint disables
"""
pass
@task(bind=True, base=_BaseTask, default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY)
def recalculate_subsection_grade_v3(self, **kwargs):
"""
@@ -92,8 +102,9 @@ def _recalculate_subsection_grade(self, **kwargs):
course_key = CourseLocator.from_string(kwargs['course_id'])
scored_block_usage_key = UsageKey.from_string(kwargs['usage_id']).replace(course_key=course_key)
newrelic.agent.add_custom_parameter('course_id', unicode(course_key))
newrelic.agent.add_custom_parameter('usage_id', unicode(scored_block_usage_key))
if newrelic:
newrelic.agent.add_custom_parameter('course_id', unicode(course_key))
newrelic.agent.add_custom_parameter('usage_id', unicode(scored_block_usage_key))
# The request cache is not maintained on celery workers,
# where this code runs. So we take the values from the

View File

@@ -8,9 +8,6 @@ from mock import patch
from nose.plugins.attrib import attr
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from courseware.model_data import set_score
from courseware.tests.helpers import LoginEnrollmentTestCase
from lms.djangoapps.course_blocks.api import get_course_blocks
from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory
from openedx.core.djangolib.testing.utils import get_mock_request
@@ -22,7 +19,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from .utils import answer_problem
from ..module_grades import get_module_score
from ..new.course_grade import CourseGradeFactory
from ..new.subsection_grade import SubsectionGradeFactory
@@ -334,195 +330,3 @@ class TestScoreForModule(SharedModuleStoreTestCase):
earned, possible = self.course_grade.score_for_module(self.m.location)
self.assertEqual(earned, 0)
self.assertEqual(possible, 0)
class TestGetModuleScore(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
"""
Test get_module_score
"""
@classmethod
def setUpClass(cls):
super(TestGetModuleScore, cls).setUpClass()
cls.course = CourseFactory.create()
with cls.store.bulk_operations(cls.course.id):
cls.chapter = ItemFactory.create(
parent=cls.course,
category="chapter",
display_name="Test Chapter"
)
cls.seq1 = ItemFactory.create(
parent=cls.chapter,
category='sequential',
display_name="Test Sequential 1",
graded=True
)
cls.seq2 = ItemFactory.create(
parent=cls.chapter,
category='sequential',
display_name="Test Sequential 2",
graded=True
)
cls.seq3 = ItemFactory.create(
parent=cls.chapter,
category='sequential',
display_name="Test Sequential 3",
graded=True
)
cls.vert1 = ItemFactory.create(
parent=cls.seq1,
category='vertical',
display_name='Test Vertical 1'
)
cls.vert2 = ItemFactory.create(
parent=cls.seq2,
category='vertical',
display_name='Test Vertical 2'
)
cls.vert3 = ItemFactory.create(
parent=cls.seq3,
category='vertical',
display_name='Test Vertical 3'
)
cls.randomize = ItemFactory.create(
parent=cls.vert2,
category='randomize',
display_name='Test Randomize'
)
cls.library_content = ItemFactory.create(
parent=cls.vert3,
category='library_content',
display_name='Test Library Content'
)
problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
question_text='The correct answer is Choice 3',
choices=[False, False, True, False],
choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
)
cls.problem1 = ItemFactory.create(
parent=cls.vert1,
category="problem",
display_name="Test Problem 1",
data=problem_xml
)
cls.problem2 = ItemFactory.create(
parent=cls.vert1,
category="problem",
display_name="Test Problem 2",
data=problem_xml
)
cls.problem3 = ItemFactory.create(
parent=cls.randomize,
category="problem",
display_name="Test Problem 3",
data=problem_xml
)
cls.problem4 = ItemFactory.create(
parent=cls.randomize,
category="problem",
display_name="Test Problem 4",
data=problem_xml
)
cls.problem5 = ItemFactory.create(
parent=cls.library_content,
category="problem",
display_name="Test Problem 5",
data=problem_xml
)
cls.problem6 = ItemFactory.create(
parent=cls.library_content,
category="problem",
display_name="Test Problem 6",
data=problem_xml
)
def setUp(self):
"""
Set up test course
"""
super(TestGetModuleScore, self).setUp()
self.request = get_mock_request(UserFactory())
self.client.login(username=self.request.user.username, password="test")
CourseEnrollment.enroll(self.request.user, self.course.id)
self.course_structure = get_course_blocks(self.request.user, self.course.location)
# warm up the score cache to allow accurate query counts, even if tests are run in random order
get_module_score(self.request.user, self.course, self.seq1)
def test_subsection_scores(self):
"""
Test test_get_module_score
"""
# One query is for getting the list of disabled XBlocks (which is
# then stored in the request).
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
self.assertEqual(score, 0)
self.assertEqual(new_score.all_total.earned, 0)
answer_problem(self.course, self.request, self.problem1)
answer_problem(self.course, self.request, self.problem2)
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
self.assertEqual(score, 1.0)
self.assertEqual(new_score.all_total.earned, 2.0)
# These differ because get_module_score normalizes the subsection score
# to 1, which can cause incorrect aggregation behavior that will be
# fixed by TNL-5062.
answer_problem(self.course, self.request, self.problem1)
answer_problem(self.course, self.request, self.problem2, 0)
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
self.assertEqual(score, .5)
self.assertEqual(new_score.all_total.earned, 1.0)
def test_get_module_score_with_empty_score(self):
"""
Test test_get_module_score_with_empty_score
"""
set_score(self.request.user.id, self.problem1.location, None, None) # pylint: disable=no-member
set_score(self.request.user.id, self.problem2.location, None, None) # pylint: disable=no-member
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
self.assertEqual(score, 0)
answer_problem(self.course, self.request, self.problem1)
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
self.assertEqual(score, 0.5)
answer_problem(self.course, self.request, self.problem2)
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
self.assertEqual(score, 1.0)
def test_get_module_score_with_randomize(self):
"""
Test test_get_module_score_with_randomize
"""
answer_problem(self.course, self.request, self.problem3)
answer_problem(self.course, self.request, self.problem4)
score = get_module_score(self.request.user, self.course, self.seq2)
self.assertEqual(score, 1.0)
def test_get_module_score_with_library_content(self):
"""
Test test_get_module_score_with_library_content
"""
answer_problem(self.course, self.request, self.problem5)
answer_problem(self.course, self.request, self.problem6)
score = get_module_score(self.request.user, self.course, self.seq3)
self.assertEqual(score, 1.0)

View File

@@ -1,55 +0,0 @@
#!/usr/bin/python
"""
django management command: dump grades to csv files
for use by batch processes
"""
from django.http import Http404
from django.core.management.base import BaseCommand
from courseware.courses import get_course_by_id
from lms.djangoapps.instructor.offline_gradecalc import offline_grade_calculation
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
class Command(BaseCommand):
help = "Compute grades for all students in a course, and store result in DB.\n"
help += "Usage: compute_grades course_id_or_dir \n"
help += " course_id_or_dir: space separated list of either course_ids or course_dirs\n"
help += 'Example course_id: MITx/8.01rq_MW/Classical_Mechanics_Reading_Questions_Fall_2012_MW_Section'
def add_arguments(self, parser):
parser.add_argument('course_id_or_dir', nargs='+')
def handle(self, *args, **options):
print "options = ", options
try:
course_ids = options['course_id_or_dir']
except KeyError:
print self.help
return
course_key = None
# parse out the course id into a coursekey
for course_id in course_ids:
try:
course_key = CourseKey.from_string(course_id)
# if it's not a new-style course key, parse it from an old-style
# course key
except InvalidKeyError:
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
try:
get_course_by_id(course_key)
except Http404 as err:
print "-----------------------------------------------------------------------------"
print "Sorry, cannot find course with id {}".format(course_id)
print "Got exception {}".format(err)
print "Please provide a course ID or course data directory name, eg content-mit-801rq"
return
print "-----------------------------------------------------------------------------"
print "Computing grades for {}".format(course_id)
offline_grade_calculation(course_key)

View File

@@ -1,34 +0,0 @@
# coding=utf-8
"""Tests for Django instructor management commands"""
from unittest import TestCase
from django.core.management import call_command
from mock import Mock
from lms.djangoapps.instructor.offline_gradecalc import offline_grade_calculation # pylint: disable=unused-import
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
class InstructorCommandsTest(TestCase):
"""Unittest subclass for instructor module management commands."""
def test_compute_grades_command(self):
course_id = 'MITx/0.0001/2016_Fall'
offline_grade_calculation = Mock() # pylint: disable=redefined-outer-name
CourseKey.from_string = Mock(return_value=CourseLocator(*course_id.split('/')))
call_command('compute_grades', )
self.asertEqual(offline_grade_calculation.call_count, 1) # pylint: disable=no-member
offline_grade_calculation.assert_called_with(CourseKey.from_string('MITx/0.0001/2016_Fall'))
def test_compute_grades_command_multiple_courses(self):
course_id1 = 'MITx/0.0001/2016_Fall'
course_id2 = 'MITx/0.0002/2016_Fall'
CourseKey.from_string = Mock()
offline_grade_calculation = Mock() # pylint: disable=redefined-outer-name
call_command('compute_grades', '{0} {1}'.format(course_id1, course_id1))
self.asertEqual(offline_grade_calculation.call_count, 2) # pylint: disable=no-member
CourseKey.from_string.assert_called_with(course_id1)
CourseKey.from_string.assert_called_with(course_id2)

View File

@@ -1,4 +1,5 @@
"""Learner dashboard views"""
import waffle
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http import Http404
@@ -73,4 +74,7 @@ def program_details(request, program_uuid):
'user_preferences': get_user_preferences(request.user)
}
return render_to_response('learner_dashboard/program_details.html', context)
if waffle.switch_is_active('new_program_progress'):
return render_to_response('learner_dashboard/program_details_2017.html', context)
else:
return render_to_response('learner_dashboard/program_details.html', context)

View File

@@ -11,7 +11,6 @@ from util.milestones_helpers import (
add_prerequisite_course,
fulfill_course_milestone,
)
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
@@ -85,28 +84,36 @@ class MobileAPIMilestonesMixin(object):
def _add_entrance_exam(self):
""" Sets up entrance exam """
self.course.entrance_exam_enabled = True
with self.store.bulk_operations(self.course.id):
self.course.entrance_exam_enabled = True
self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=self.course,
category="chapter",
display_name="Entrance Exam Chapter",
is_entrance_exam=True,
in_entrance_exam=True
)
self.problem_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=self.entrance_exam,
category='problem',
display_name="The Only Exam Problem",
graded=True,
in_entrance_exam=True
)
self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=self.course,
category="chapter",
display_name="Entrance Exam Chapter",
is_entrance_exam=True,
in_entrance_exam=True,
)
self.subsection_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=self.entrance_exam,
category='sequential',
display_name="The Only Exam Sequential",
graded=True,
in_entrance_exam=True,
)
self.problem_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=self.subsection_1,
category='problem',
display_name="The Only Exam Problem",
graded=True,
in_entrance_exam=True,
)
add_entrance_exam_milestone(self.course, self.entrance_exam)
add_entrance_exam_milestone(self.course, self.entrance_exam)
self.course.entrance_exam_minimum_score_pct = 0.50
self.course.entrance_exam_id = unicode(self.entrance_exam.location)
modulestore().update_item(self.course, self.user.id)
self.course.entrance_exam_minimum_score_pct = 0.50
self.course.entrance_exam_id = unicode(self.entrance_exam.location)
self.store.update_item(self.course, self.user.id)
def _add_prerequisite_course(self):
""" Helper method to set up the prerequisite course """

View File

@@ -15,7 +15,8 @@
var discussionInlineView = new DiscussionInlineView({
el: this.$el,
showByDefault: true,
readOnly: this.readOnly
readOnly: this.readOnly,
startHeader: 3
});
discussionInlineView.render();
return this;

View File

@@ -8,7 +8,7 @@
</div>
<div class="page-content-secondary">
<h4 class="team-detail-header"><%- gettext("Team Details") %></h4>
<h3 class="team-detail-header"><%- gettext("Team Details") %></h3>
<% if (isMember) { %>
<div class="team-user-membership-status">
<p><%- gettext("You are a member of this team.") %></p>

View File

@@ -904,7 +904,15 @@ DOC_LINK_BASE_URL = ENV_TOKENS.get('DOC_LINK_BASE_URL', DOC_LINK_BASE_URL)
############## Settings for the Enterprise App ######################
ENTERPRISE_ENROLLMENT_API_URL = ENV_TOKENS.get(
'ENTERPRISE_ENROLLMENT_API_URL',
# Publicly-accessible enrollment URL, for use on the client side.
ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = ENV_TOKENS.get(
'ENTERPRISE_PUBLIC_ENROLLMENT_API_URL',
(LMS_ROOT_URL or '') + '/api/enrollment/v1/'
)
# Enrollment URL used on the server-side.
# If not overridden in ENV_TOKENS, then fallback to the value set in env/common.py
ENTERPRISE_ENROLLMENT_API_URL = ENV_TOKENS.get(
'ENTERPRISE_ENROLLMENT_API_URL',
ENTERPRISE_ENROLLMENT_API_URL
)

View File

@@ -3066,3 +3066,5 @@ DOC_LINK_BASE_URL = None
############## Settings for the Enterprise App ######################
ENTERPRISE_ENROLLMENT_API_URL = LMS_ROOT_URL + "/api/enrollment/v1/"
ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = ENTERPRISE_ENROLLMENT_API_URL
ENTERPRISE_API_CACHE_TIMEOUT = 3600 # Value is in seconds

View File

@@ -0,0 +1,13 @@
(function(define) {
'use strict';
define([
'js/learner_dashboard/views/program_details_view_2017'
],
function(ProgramDetailsView) {
return function(options) {
var ProgramDetails = new ProgramDetailsView(options);
return ProgramDetails;
};
});
}).call(this, define || RequireJS.define);

View File

@@ -54,11 +54,6 @@
},
postRender: function() {
// Add describedby to parent only if progess is present
if (this.progressModel) {
this.$el.attr('aria-describedby', 'status-' + this.model.get('uuid'));
}
if (navigator.userAgent.indexOf('MSIE') !== -1 ||
navigator.appVersion.indexOf('Trident/') > 0) {
/* Microsoft Internet Explorer detected in. */

View File

@@ -0,0 +1,47 @@
(function(define) {
'use strict';
define(['backbone',
'jquery',
'underscore',
'gettext',
'js/learner_dashboard/views/explore_new_programs_view',
'js/learner_dashboard/views/certificate_view',
'text!../../../templates/learner_dashboard/sidebar.underscore'
],
function(
Backbone,
$,
_,
gettext,
NewProgramsView,
CertificateView,
sidebarTpl
) {
return Backbone.View.extend({
el: '.sidebar',
tpl: _.template(sidebarTpl),
initialize: function(data) {
this.context = data.context;
},
render: function() {
this.$el.html(this.tpl(this.context));
this.postRender();
},
postRender: function() {
this.newProgramsView = new NewProgramsView({
context: this.context
});
this.newCertificateView = new CertificateView({
context: this.context
});
}
});
}
);
}).call(this, define || RequireJS.define);

View File

@@ -0,0 +1,72 @@
(function(define) {
'use strict';
define(['backbone',
'jquery',
'underscore',
'gettext',
'edx-ui-toolkit/js/utils/html-utils',
'js/learner_dashboard/collections/course_card_collection',
'js/learner_dashboard/views/program_header_view',
'js/learner_dashboard/views/collection_list_view',
'js/learner_dashboard/views/course_card_view',
'js/learner_dashboard/views/program_details_sidebar_view',
'text!../../../templates/learner_dashboard/program_details_view.underscore'
],
function(
Backbone,
$,
_,
gettext,
HtmlUtils,
CourseCardCollection,
HeaderView,
CollectionListView,
CourseCardView,
SidebarView,
pageTpl
) {
return Backbone.View.extend({
el: '.js-program-details-wrapper',
tpl: HtmlUtils.template(pageTpl),
initialize: function(options) {
this.options = options;
this.programModel = new Backbone.Model(this.options.programData);
this.courseCardCollection = new CourseCardCollection(
this.programModel.get('courses'),
this.options.userPreferences
);
this.render();
},
render: function() {
HtmlUtils.setHtml(this.$el, this.tpl());
this.postRender();
},
postRender: function() {
this.headerView = new HeaderView({
model: new Backbone.Model(this.options)
});
new CollectionListView({
el: '.js-course-list',
childView: CourseCardView,
collection: this.courseCardCollection,
context: this.options,
titleContext: {
el: 'h2',
title: 'Course List'
}
}).render();
new SidebarView({
el: '.sidebar',
context: this.options
}).render();
}
});
}
);
}).call(this, define || RequireJS.define);

View File

@@ -114,18 +114,13 @@ define([
expect(view.reLoadBannerImage).not.toThrow(message);
});
it('should calculate the correct percentages for progress bars', function() {
expect(view.$('.complete').css('width')).toEqual('40%');
expect(view.$('.in-progress').css('width')).toEqual('20%');
it('should show the right number of progress bar segments', function() {
expect(view.$('.progress-bar .completed').length).toEqual(4);
expect(view.$('.progress-bar .enrolled').length).toEqual(2);
});
it('should display the correct completed courses message', function() {
var programProgress = _.findWhere(userProgress, {uuid: 'a87e5eac-3c93-45a1-a8e1-4c79ca8401c8'}),
completed = programProgress.completed,
total = completed + programProgress.in_progress + programProgress.not_started;
expect(view.$('.certificate-status .status-text').not('.secondary').html())
.toEqual('You have earned certificates in ' + completed + ' of the ' + total + ' courses so far.');
it('should display the correct course status numbers', function() {
expect(view.$('.number-circle').text()).toEqual('424');
});
it('should render cards if there is no progressData', function() {

View File

@@ -86,6 +86,7 @@ define(['backbone',
// change country
countryView.$(baseSelector).val(countryChange[countryData.valueAttribute]).change();
countryView.$(baseSelector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, countryChange);
AjaxHelpers.respondWithJson(requests, {success: 'true'});
@@ -106,6 +107,7 @@ define(['backbone',
// select time zone option from option
timeZoneView.$(baseSelector).val(timeZoneChange[timeZoneData.valueAttribute]).change();
timeZoneView.$(baseSelector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, timeZoneChange);
AjaxHelpers.respondWithJson(requests, {success: 'true'});
timeZoneView.render();
@@ -130,6 +132,7 @@ define(['backbone',
var data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]};
view.$(selector).val(data[fieldData.valueAttribute]).change();
view.$(selector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
AjaxHelpers.respondWithNoContent(requests);
@@ -144,6 +147,7 @@ define(['backbone',
data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]};
view.$(selector).val(data[fieldData.valueAttribute]).change();
view.$(selector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
AjaxHelpers.respondWithNoContent(requests);
@@ -177,6 +181,7 @@ define(['backbone',
var data = {'language_proficiencies': [{'code': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]};
view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change();
view.$(selector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
AjaxHelpers.respondWithNoContent(requests);
});

View File

@@ -184,6 +184,7 @@ define(['backbone',
}
view.$(data.valueInputSelector).val(data.validValue).change();
view.$(data.valueInputSelector).focusout();
// When the value in the field is changed
expect(view.fieldValue()).toBe(data.validValue);
expectMessageContains(view, view.indicators.inProgress);
@@ -203,6 +204,7 @@ define(['backbone',
}
view.$(data.valueInputSelector).val(data.invalidValue1).change();
view.$(data.valueInputSelector).focusout();
request_data[data.valueAttribute] = data.invalidValue1;
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', url, request_data
@@ -214,6 +216,7 @@ define(['backbone',
expect(view.el).toHaveClass('mode-edit');
view.$(data.valueInputSelector).val(data.invalidValue2).change();
view.$(data.valueInputSelector).focusout();
request_data[data.valueAttribute] = data.invalidValue2;
AjaxHelpers.expectJsonRequest(
requests, 'PATCH', url, request_data
@@ -225,6 +228,7 @@ define(['backbone',
expect(view.el).toHaveClass('mode-edit');
view.$(data.valueInputSelector).val('').change();
view.$(data.valueInputSelector).focusout();
// When the value in the field is changed
expect(view.fieldValue()).toBe(data.defaultValue);
request_data[data.valueAttribute] = data.defaultValue;

View File

@@ -231,6 +231,7 @@ define(['backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helper
}
expect(view.$(dropdownSelectClass).length).toBe(1);
view.$(dropdownSelectClass).val(FieldViewsSpecHelpers.SELECT_OPTIONS[0]).change();
view.$(dropdownSelectClass).focusout();
expect(view.fieldValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]);
AjaxHelpers.respondWithNoContent(requests);

View File

@@ -364,7 +364,6 @@
events: {
'click': 'startEditing',
'change select': 'finishEditing',
'focusout select': 'finishEditing'
},

View File

@@ -115,17 +115,12 @@ $headings-base-color: $gray-d2;
.xblock .xblock {
h3 {
h2 {
@extend %hd-2;
font-weight: $headings-font-weight-bold;
// override external modules and xblocks that use inline CSS
text-transform: initial;
&.unit-title {
margin-bottom: 0;
font-size: 1.5em;
}
&.discussion-module-title {
margin-bottom: 0;
display: inline-block;

View File

@@ -446,8 +446,6 @@ html.video-fullscreen {
}
h2 {
@include float(right);
@include text-align(right);
@include border-right(0);
@include padding-right(0);
margin: 12px 0 0;

View File

@@ -128,8 +128,4 @@ li[class*=forum-nav-thread-label-] {
margin-bottom: 0 !important;
padding-bottom: 0 !important;
}
p {
margin-bottom: 0 !important;
}
}

View File

@@ -105,55 +105,81 @@
.hd-3 {
color: palette(grayscale, dark);
min-height: ($baseline*3);
line-height: 1;
line-height: 1.15;
}
.certificate-status {
margin-bottom: 0;
.status-text {
display: flex;
margin-bottom: 5px;
.status-text {
font-size: font-size(x-small);
color: palette(grayscale, dark);
line-height: 1;
.number-status {
text-align: center;
width: 100%;
float: left;
padding: {
left: 5px;
right: 5px;
bottom: 8px;
}
margin-top: -8px;
font-size: 1em;
font-family: $f-sans-serif;
}
.secondary {
@extend %hide-until-focus;
.number-circle {
padding-top: 1px;
border-radius: 50%;
margin-left: auto;
margin-right: auto;
width: 23px;
height: 23px;
color: white;
text-align: center;
font-size: 0.9375em;
font-family: $f-sans-serif;
font-weight: bold;
}
.status-text {
&:focus,
&:active {
position: relative;
top: 4px;
width: auto;
height: auto;
margin: 0;
text-decoration: none;
.completed {
background: $blue;
}
& ~ .status-text {
@extend %hide-until-focus;
.enrolled {
background: $green;
}
.not-enrolled {
background: palette(grayscale, dark);
}
}
.progress-container {
max-width: inherit;
.progress-bar {
height: 5px;
display: flex;
width: 100%;
.item {
width: 100%;
margin-right: 2px;
height: 5px;
&.completed {
background: $blue;
}
&.enrolled {
background: $green;
}
&.not-enrolled {
background: lightgray;
}
&.not-enrolled:last-of-type {
margin-right: 0;
}
}
}
}
.progress {
height: 5px;
background: palette(grayscale, back);
.bar {
@include float(left);
height: 100%;
position: relative;
&.complete {
background: palette(success, accent);
}
&.in-progress {
background: palette(warning, accent);
}
}
}
}

View File

@@ -9,7 +9,7 @@
<% }) %>
</nav>
<% } %>
<h3 class="hd hd-2 page-title"><%- title %></h3>
<h2 class="hd hd-2 page-title"><%- title %></h2>
<p class="page-description"><%- description %></p>
</div>
<div class="page-header-secondary"></div>

View File

@@ -73,7 +73,13 @@ from openedx.core.djangolib.markup import HTML, Text
<article class="register-choose content-main">
<header class="page-header content-main">
<h3 class="title">
${_("Congratulations! You are now enrolled in {course_name}").format(course_name=course_name)}
% if show_enterprise_context:
${_("Welcome, {username}! You are about to enroll in {course_name}, from "
"{partner_names}, sponsored by {enterprise_name}. Please select your enrollment"
" information below.").format(username=username, course_name=course_name, partner_names=partner_names, enterprise_name=enterprise_name)}
% else:
${_("Congratulations! You are now enrolled in {course_name}").format(course_name=course_name)}
% endif
</h3>
</header>

View File

@@ -78,10 +78,7 @@ from openedx.core.djangolib.markup import HTML, Text
<main id="main" aria-label="Content" tabindex="-1">
<div class="container dashboard" id="dashboard-main">
<div class="my-courses" id="my-courses">
<header class="wrapper-header-courses">
<h2 class="header-courses">${_("My Courses")}</h2>
</header>
<%include file="learner_dashboard/_dashboard_navigation_courses.html"/>
% if len(course_enrollments) > 0:
<ul class="listing-courses">

View File

@@ -14,7 +14,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
data-user-create-subcomment="${json_dumps(can_create_subcomment)}"
data-read-only="${'false' if can_create_thread else 'true'}">
<div class="discussion-module-header">
<h3 class="discussion-module-title">${_(display_name)}</h3>
<h3 class="hd hd-3 discussion-module-title">${_(display_name)}</h3>
<div class="inline-discussion-topic"><span class="inline-discussion-topic-title">${_("Topic:")}</span> ${discussion_category} / ${discussion_target}</div>
</div>
<button class="discussion-show btn" data-discussion-id="${discussion_id}">

Some files were not shown because too many files have changed in this diff Show More