Merge branch 'master' into release-mergeback-to-master
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
44
common/djangoapps/terrain/stubs/tests/test_video.py
Normal file
44
common/djangoapps/terrain/stubs/tests/test_video.py
Normal 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'], '*')
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=_(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
33
common/test/db_fixtures/enterprise.json
Normal file
33
common/test/db_fixtures/enterprise.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
el: $('.new-post-article'),
|
||||
collection: discussion,
|
||||
course_settings: courseSettings,
|
||||
mode: 'tab'
|
||||
mode: 'tab',
|
||||
startHeader: 2
|
||||
});
|
||||
newPostView.render();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
192
lms/djangoapps/gating/tests/test_integration.py
Normal file
192
lms/djangoapps/gating/tests/test_integration.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
122
lms/djangoapps/grades/management/commands/compute_grades.py
Normal file
122
lms/djangoapps/grades/management/commands/compute_grades.py
Normal 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)
|
||||
@@ -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}
|
||||
},),
|
||||
],
|
||||
)
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -364,7 +364,6 @@
|
||||
|
||||
events: {
|
||||
'click': 'startEditing',
|
||||
'change select': 'finishEditing',
|
||||
'focusout select': 'finishEditing'
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -128,8 +128,4 @@ li[class*=forum-nav-thread-label-] {
|
||||
margin-bottom: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user