${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
${_("Next, select an action to perform for the given user and problem:")}
@@ -141,7 +141,7 @@
## Translators: A location (string of text) follows this sentence.
${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
${_("Then select an action")}:
From f475200a16144f3ca066d8ca5cd46240042729ca Mon Sep 17 00:00:00 2001
From: Alison Hodges
Date: Mon, 9 Nov 2015 17:22:00 -0500
Subject: [PATCH 015/115] Revert "Update example location to split style"
This reverts commit 69b261775eccf5aa4d419fcd865e9bc74f1fea33.
---
.../instructor/instructor_dashboard_2/student_admin.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lms/templates/instructor/instructor_dashboard_2/student_admin.html b/lms/templates/instructor/instructor_dashboard_2/student_admin.html
index 3c15715237..a7d3b92279 100644
--- a/lms/templates/instructor/instructor_dashboard_2/student_admin.html
+++ b/lms/templates/instructor/instructor_dashboard_2/student_admin.html
@@ -54,7 +54,7 @@
## Translators: A location (string of text) follows this sentence.
${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378
${_("Next, select an action to perform for the given user and problem:")}
@@ -141,7 +141,7 @@
## Translators: A location (string of text) follows this sentence.
${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378
${_("Then select an action")}:
From 056225e3ac9269c0aa6cf34246d6a8b36953de13 Mon Sep 17 00:00:00 2001
From: Alison Hodges
Date: Mon, 9 Nov 2015 17:26:28 -0500
Subject: [PATCH 016/115] Updates the example locations to split format
---
.../instructor/instructor_dashboard_2/student_admin.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lms/templates/instructor/instructor_dashboard_2/student_admin.html b/lms/templates/instructor/instructor_dashboard_2/student_admin.html
index a7d3b92279..3c15715237 100644
--- a/lms/templates/instructor/instructor_dashboard_2/student_admin.html
+++ b/lms/templates/instructor/instructor_dashboard_2/student_admin.html
@@ -54,7 +54,7 @@
## Translators: A location (string of text) follows this sentence.
${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
${_("Next, select an action to perform for the given user and problem:")}
@@ -141,7 +141,7 @@
## Translators: A location (string of text) follows this sentence.
${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
${_("Then select an action")}:
From ed6e46453c665c7a71d0eba41981c94ebc2867bb Mon Sep 17 00:00:00 2001
From: Afeef Janjua
Date: Wed, 4 Nov 2015 18:55:57 +0500
Subject: [PATCH 017/115] allow reverification for pending status
('must_retry', 'submitted')
---
AUTHORS | 1 +
lms/djangoapps/verify_student/tests/test_views.py | 14 +++++++++++---
lms/djangoapps/verify_student/views.py | 6 +++++-
3 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/AUTHORS b/AUTHORS
index 28ee38920c..c04dc2c40e 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -255,3 +255,4 @@ Douglas Hall
Awais Jibran
Muhammad Rehan
Shawn Milochik
+Afeef Janjua
diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py
index 82127df3ce..588690dd74 100644
--- a/lms/djangoapps/verify_student/tests/test_views.py
+++ b/lms/djangoapps/verify_student/tests/test_views.py
@@ -1870,14 +1870,22 @@ class TestReverifyView(TestCase):
# Allow the student to reverify
self._assert_can_reverify()
- def test_reverify_view_cannot_reverify_pending(self):
+ def test_reverify_view_can_reverify_pending(self):
+ """ Test that the user can still re-verify even if the previous photo
+ verification is in pending state.
+
+ A photo verification is considered in pending state when the user has
+ either submitted the photo verification (status in database: 'submitted')
+ or photo verification submission failed (status in database: 'must_retry').
+ """
+
# User has submitted a verification attempt, but Software Secure has not yet responded
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
attempt.mark_ready()
attempt.submit()
- # Cannot reverify because an attempt has already been submitted.
- self._assert_cannot_reverify()
+ # Can re-verify because an attempt has already been submitted.
+ self._assert_can_reverify()
def test_reverify_view_cannot_reverify_approved(self):
# Submitted attempt has been approved
diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py
index 2710944365..46d831d523 100644
--- a/lms/djangoapps/verify_student/views.py
+++ b/lms/djangoapps/verify_student/views.py
@@ -1350,7 +1350,11 @@ class ReverifyView(View):
Backbone views used in the initial verification flow.
"""
status, _ = SoftwareSecurePhotoVerification.user_status(request.user)
- if status in ["must_reverify", "expired"]:
+
+ # If the verification process is still ongoing i.e. the status for photo
+ # verification is either 'submitted' or 'must_retry' then its marked as
+ # 'pending'
+ if status in ["must_reverify", "expired", "pending"]:
context = {
"user_full_name": request.user.profile.name,
"platform_name": settings.PLATFORM_NAME,
From 094ed32176ca41cb4c200cf5c4efaaabfae16b3c Mon Sep 17 00:00:00 2001
From: Saleem Latif
Date: Wed, 4 Nov 2015 18:18:48 +0500
Subject: [PATCH 018/115] Added ability to regenerate certificates from
Instructor Dashboard
---
lms/djangoapps/certificates/models.py | 26 ++++
.../instructor/tests/test_certificates.py | 78 +++++++++-
lms/djangoapps/instructor/views/api.py | 39 ++++-
lms/djangoapps/instructor/views/api_urls.py | 4 +
.../instructor/views/instructor_dashboard.py | 7 +-
lms/djangoapps/instructor_task/api.py | 24 +++
.../instructor_task/tasks_helper.py | 37 +++--
.../instructor_task/tests/test_api.py | 17 ++
.../tests/test_tasks_helper.py | 146 ++++++++++++++++++
.../js/instructor_dashboard/certificates.js | 50 +++++-
.../instructor_dashboard/certificates_spec.js | 116 ++++++++++++++
lms/static/js/spec/main.js | 5 +
.../sass/course/instructor/_instructor_2.scss | 20 ++-
.../instructor_dashboard_2/certificates.html | 19 +++
14 files changed, 567 insertions(+), 21 deletions(-)
create mode 100644 lms/static/js/spec/instructor_dashboard/certificates_spec.js
diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py
index 5e34632c81..a9b516b4a3 100644
--- a/lms/djangoapps/certificates/models.py
+++ b/lms/djangoapps/certificates/models.py
@@ -54,6 +54,7 @@ import os
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction
+from django.db.models import Count
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
@@ -187,6 +188,31 @@ class GeneratedCertificate(models.Model):
return None
+ @classmethod
+ def get_unique_statuses(cls, course_key=None, flat=False):
+ """
+ 1 - Return unique statuses as a list of dictionaries containing the following key value pairs
+ [
+ {'status': 'status value from db', 'count': 'occurrence count of the status'},
+ {...},
+ ..., ]
+
+ 2 - if flat is 'True' then return unique statuses as a list
+ 3 - if course_key is given then return unique statuses associated with the given course
+
+ :param course_key: Course Key identifier
+ :param flat: boolean showing whether to return statuses as a list of values or a list of dictionaries.
+ """
+ query = cls.objects
+
+ if course_key:
+ query = query.filter(course_id=course_key)
+
+ if flat:
+ return query.values_list('status', flat=True).distinct()
+ else:
+ return query.values('status').annotate(count=Count('status'))
+
@receiver(post_save, sender=GeneratedCertificate)
def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=no-self-argument, unused-argument
diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py
index d54773c6c9..a10efa03f0 100644
--- a/lms/djangoapps/instructor/tests/test_certificates.py
+++ b/lms/djangoapps/instructor/tests/test_certificates.py
@@ -12,7 +12,8 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from config_models.models import cache
from courseware.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
-from certificates.models import CertificateGenerationConfiguration
+from certificates.tests.factories import GeneratedCertificateFactory
+from certificates.models import CertificateGenerationConfiguration, CertificateStatuses
from certificates import api as certs_api
@@ -486,3 +487,78 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertTrue(res_json['success'])
+
+ def test_certificate_regeneration_success(self):
+ """
+ Test certificate regeneration is successful when accessed with 'certificate_statuses'
+ present in GeneratedCertificate table.
+ """
+
+ # Create a generated Certificate of some user with status 'downloadable'
+ GeneratedCertificateFactory.create(
+ user=self.user,
+ course_id=self.course.id,
+ status=CertificateStatuses.downloadable,
+ mode='honor'
+ )
+
+ # Login the client and access the url with 'certificate_statuses'
+ self.client.login(username=self.global_staff.username, password='test')
+ url = reverse('start_certificate_regeneration', kwargs={'course_id': unicode(self.course.id)})
+ response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.downloadable]})
+
+ # Assert 200 status code in response
+ self.assertEqual(response.status_code, 200)
+ res_json = json.loads(response.content)
+
+ # Assert request is successful
+ self.assertTrue(res_json['success'])
+
+ # Assert success message
+ self.assertEqual(
+ res_json['message'],
+ u'Certificate regeneration task has been started. You can view the status of the generation task in '
+ u'the "Pending Tasks" section.'
+ )
+
+ def test_certificate_regeneration_error(self):
+ """
+ Test certificate regeneration errors out when accessed with either empty list of 'certificate_statuses' or
+ the 'certificate_statuses' that are not present in GeneratedCertificate table.
+ """
+ # Create a dummy course and GeneratedCertificate with the same status as the one we will use to access
+ # 'start_certificate_regeneration' but their error message should be displayed as GeneratedCertificate
+ # belongs to a different course
+ dummy_course = CourseFactory.create()
+ GeneratedCertificateFactory.create(
+ user=self.user,
+ course_id=dummy_course.id,
+ status=CertificateStatuses.generating,
+ mode='honor'
+ )
+
+ # Login the client and access the url without 'certificate_statuses'
+ self.client.login(username=self.global_staff.username, password='test')
+ url = reverse('start_certificate_regeneration', kwargs={'course_id': unicode(self.course.id)})
+ response = self.client.post(url)
+
+ # Assert 400 status code in response
+ self.assertEqual(response.status_code, 400)
+ res_json = json.loads(response.content)
+
+ # Assert Error Message
+ self.assertEqual(
+ res_json['message'],
+ u'Please select one or more certificate statuses that require certificate regeneration.'
+ )
+
+ # Access the url passing 'certificate_statuses' that are not present in db
+ url = reverse('start_certificate_regeneration', kwargs={'course_id': unicode(self.course.id)})
+ response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.generating]})
+
+ # Assert 400 status code in response
+ self.assertEqual(response.status_code, 400)
+ res_json = json.loads(response.content)
+
+ # Assert Error Message
+ self.assertEqual(res_json['message'], u'Please select certificate statuses from the list only.')
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index d626a45ed0..f9d2aee23f 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -92,7 +92,7 @@ from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository
from certificates import api as certs_api
-from certificates.models import CertificateWhitelist
+from certificates.models import CertificateWhitelist, GeneratedCertificate
from bulk_email.models import CourseEmail
from student.models import get_user_by_username_or_email
@@ -2708,6 +2708,43 @@ def start_certificate_generation(request, course_id):
return JsonResponse(response_payload)
+@ensure_csrf_cookie
+@cache_control(no_cache=True, no_store=True, must_revalidate=True)
+@require_global_staff
+@require_POST
+def start_certificate_regeneration(request, course_id):
+ """
+ Start regenerating certificates for students whose certificate statuses lie with in 'certificate_statuses'
+ entry in POST data.
+ """
+ course_key = CourseKey.from_string(course_id)
+ certificates_statuses = request.POST.getlist('certificate_statuses', [])
+ if not certificates_statuses:
+ return JsonResponse(
+ {'message': _('Please select one or more certificate statuses that require certificate regeneration.')},
+ status=400
+ )
+
+ # Check if the selected statuses are allowed
+ allowed_statuses = GeneratedCertificate.get_unique_statuses(course_key=course_key, flat=True)
+ if not set(certificates_statuses).issubset(allowed_statuses):
+ return JsonResponse(
+ {'message': _('Please select certificate statuses from the list only.')},
+ status=400
+ )
+ try:
+ instructor_task.api.regenerate_certificates(request, course_key, certificates_statuses)
+ except AlreadyRunningError as error:
+ return JsonResponse({'message': error.message}, status=400)
+
+ response_payload = {
+ 'message': _('Certificate regeneration task has been started. '
+ 'You can view the status of the generation task in the "Pending Tasks" section.'),
+ 'success': True
+ }
+ return JsonResponse(response_payload)
+
+
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_global_staff
diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py
index be99ef4600..7565c4e671 100644
--- a/lms/djangoapps/instructor/views/api_urls.py
+++ b/lms/djangoapps/instructor/views/api_urls.py
@@ -143,6 +143,10 @@ urlpatterns = patterns(
'instructor.views.api.start_certificate_generation',
name='start_certificate_generation'),
+ url(r'^start_certificate_regeneration',
+ 'instructor.views.api.start_certificate_regeneration',
+ name='start_certificate_regeneration'),
+
url(r'^create_certificate_exception/(?P[^/]*)',
'instructor.views.api.create_certificate_exception',
name='create_certificate_exception'),
diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py
index c94dc09820..8034d3d0cf 100644
--- a/lms/djangoapps/instructor/views/instructor_dashboard.py
+++ b/lms/djangoapps/instructor/views/instructor_dashboard.py
@@ -37,7 +37,7 @@ from student.models import CourseEnrollment
from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegCodeItem
from course_modes.models import CourseMode, CourseModesArchive
from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole
-from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist
+from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist, GeneratedCertificate
from certificates import api as certs_api
from util.date_utils import get_default_time_display
@@ -299,6 +299,7 @@ def _section_certificates(course):
'enabled_for_course': certs_api.cert_generation_enabled(course.id),
'instructor_generation_enabled': instructor_generation_enabled,
'html_cert_enabled': html_cert_enabled,
+ 'certificate_statuses': GeneratedCertificate.get_unique_statuses(course_key=course.id),
'urls': {
'generate_example_certificates': reverse(
'generate_example_certificates',
@@ -312,6 +313,10 @@ def _section_certificates(course):
'start_certificate_generation',
kwargs={'course_id': course.id}
),
+ 'start_certificate_regeneration': reverse(
+ 'start_certificate_regeneration',
+ kwargs={'course_id': course.id}
+ ),
'list_instructor_tasks_url': reverse(
'list_instructor_tasks',
kwargs={'course_id': course.id}
diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py
index 602a8ab64a..703c54b4c1 100644
--- a/lms/djangoapps/instructor_task/api.py
+++ b/lms/djangoapps/instructor_task/api.py
@@ -512,3 +512,27 @@ def generate_certificates_for_students(request, course_key, students=None): # p
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
+
+
+def regenerate_certificates(request, course_key, statuses_to_regenerate, students=None):
+ """
+ Submits a task to regenerate certificates for given students enrolled in the course or
+ all students if argument 'students' is None.
+ Regenerate Certificate only if the status of the existing generated certificate is in 'statuses_to_regenerate'
+ list passed in the arguments.
+
+ Raises AlreadyRunningError if certificates are currently being generated.
+ """
+ if students:
+ task_type = 'regenerate_certificates_certain_student'
+ students = [student.id for student in students]
+ task_input = {'students': students}
+ else:
+ task_type = 'regenerate_certificates_all_student'
+ task_input = {}
+
+ task_input.update({"statuses_to_regenerate": statuses_to_regenerate})
+ task_class = generate_certificates
+ task_key = ""
+
+ return submit_task(request, task_type, task_class, course_key, task_input, task_key)
diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py
index c823abd22e..19e9ef4586 100644
--- a/lms/djangoapps/instructor_task/tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tasks_helper.py
@@ -1414,7 +1414,8 @@ def generate_students_certificates(
current_step = {'step': 'Calculating students already have certificates'}
task_progress.update_task_state(extra_meta=current_step)
- students_require_certs = students_require_certificate(course_id, enrolled_students)
+ statuses_to_regenerate = task_input.get('statuses_to_regenerate', [])
+ students_require_certs = students_require_certificate(course_id, enrolled_students, statuses_to_regenerate)
task_progress.skipped = task_progress.total - len(students_require_certs)
@@ -1523,15 +1524,31 @@ def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, tas
return task_progress.update_task_state(extra_meta=current_step)
-def students_require_certificate(course_id, enrolled_students):
- """ Returns list of students where certificates needs to be generated.
- Removing those students who have their certificate already generated
- from total enrolled students for given course.
+def students_require_certificate(course_id, enrolled_students, statuses_to_regenerate=None):
+ """
+ Returns list of students where certificates needs to be generated.
+ if 'statuses_to_regenerate' is given then return students that have Generated Certificates
+ and the generated certificate status lies in 'statuses_to_regenerate'
+
+ if 'statuses_to_regenerate' is not given then return all the enrolled student skipping the ones
+ whose certificates have already been generated.
+
:param course_id:
:param enrolled_students:
+ :param statuses_to_regenerate:
"""
- # compute those students where certificates already generated
- students_already_have_certs = User.objects.filter(
- ~Q(generatedcertificate__status=CertificateStatuses.unavailable),
- generatedcertificate__course_id=course_id)
- return list(set(enrolled_students) - set(students_already_have_certs))
+ if statuses_to_regenerate:
+ # Return Students that have Generated Certificates and the generated certificate status
+ # lies in 'statuses_to_regenerate'
+ return User.objects.filter(
+ generatedcertificate__course_id=course_id,
+ generatedcertificate__status__in=statuses_to_regenerate
+ )
+ else:
+ # compute those students whose certificates are already generated
+ students_already_have_certs = User.objects.filter(
+ ~Q(generatedcertificate__status=CertificateStatuses.unavailable),
+ generatedcertificate__course_id=course_id)
+
+ # Return all the enrolled student skipping the ones whose certificates have already been generated
+ return list(set(enrolled_students) - set(students_already_have_certs))
diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py
index 6ac8a7bb94..712b30cc0b 100644
--- a/lms/djangoapps/instructor_task/tests/test_api.py
+++ b/lms/djangoapps/instructor_task/tests/test_api.py
@@ -22,6 +22,7 @@ from instructor_task.api import (
submit_executive_summary_report,
submit_course_survey_report,
generate_certificates_for_all_students,
+ regenerate_certificates
)
from instructor_task.api_helper import AlreadyRunningError
@@ -31,6 +32,7 @@ from instructor_task.tests.test_base import (InstructorTaskTestCase,
InstructorTaskModuleTestCase,
TestReportMixin,
TEST_COURSE_KEY)
+from certificates.models import CertificateStatuses
class InstructorTaskReportTest(InstructorTaskTestCase):
@@ -263,3 +265,18 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
self.course.id
)
self._test_resubmission(api_call)
+
+ def test_regenerate_certificates(self):
+ """
+ Tests certificates regeneration task submission api
+ """
+ def api_call():
+ """
+ wrapper method for regenerate_certificates
+ """
+ return regenerate_certificates(
+ self.create_task_request(self.instructor),
+ self.course.id,
+ [CertificateStatuses.downloadable, CertificateStatuses.generating]
+ )
+ self._test_resubmission(api_call)
diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
index 9cc34e4827..51a81887e5 100644
--- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
@@ -1635,3 +1635,149 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
},
result
)
+
+ def test_certificate_regeneration_for_students(self):
+ """
+ Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
+ certificate statuses lies in the list 'statuses_to_regenerate' given in task_input.
+ """
+ # create 10 students
+ students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
+ for i in xrange(1, 11)]
+
+ # mark 2 students to have certificates generated already
+ for student in students[:2]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.downloadable,
+ mode='honor'
+ )
+
+ # mark 3 students to have certificates generated with status 'error'
+ for student in students[2:5]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.error,
+ mode='honor'
+ )
+
+ # mark 6th students to have certificates generated with status 'deleted'
+ for student in students[5:6]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.deleted,
+ mode='honor'
+ )
+
+ # white-list 7 students
+ for student in students[:7]:
+ CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
+
+ current_task = Mock()
+ current_task.update_state = Mock()
+
+ # Certificates should be regenerated for students having generated certificates with status
+ # 'downloadable' or 'error' which are total of 5 students in this test case
+ task_input = {'statuses_to_regenerate': [CertificateStatuses.downloadable, CertificateStatuses.error]}
+
+ with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
+ mock_current_task.return_value = current_task
+ with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
+ mock_queue.return_value = (0, "Successfully queued")
+ result = generate_students_certificates(
+ None, None, self.course.id, task_input, 'certificates generated'
+ )
+
+ self.assertDictContainsSubset(
+ {
+ 'action_name': 'certificates generated',
+ 'total': 10,
+ 'attempted': 5,
+ 'succeeded': 5,
+ 'failed': 0,
+ 'skipped': 5
+ },
+ result
+ )
+
+ def test_certificate_regeneration_with_expected_failures(self):
+ """
+ Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
+ certificate statuses lies in the list 'statuses_to_regenerate' given in task_input.
+ """
+ # create 10 students
+ students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
+ for i in xrange(1, 11)]
+
+ # mark 2 students to have certificates generated already
+ for student in students[:2]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.downloadable,
+ mode='honor'
+ )
+
+ # mark 3 students to have certificates generated with status 'error'
+ for student in students[2:5]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.error,
+ mode='honor'
+ )
+
+ # mark 6th students to have certificates generated with status 'deleted'
+ for student in students[5:6]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.deleted,
+ mode='honor'
+ )
+
+ # mark rest of the 4 students with having generated certificates with status 'generating'
+ # These students are not added in white-list and they have not completed grades so certificate generation
+ # for these students should fail other than the one student that has been added to white-list
+ # so from these students 3 failures and 1 success
+ for student in students[6:]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.generating,
+ mode='honor'
+ )
+
+ # white-list 7 students
+ for student in students[:7]:
+ CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
+
+ current_task = Mock()
+ current_task.update_state = Mock()
+
+ # Regenerated certificates for students having generated certificates with status
+ # 'deleted' or 'generating'
+ task_input = {'statuses_to_regenerate': [CertificateStatuses.deleted, CertificateStatuses.generating]}
+
+ with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
+ mock_current_task.return_value = current_task
+ with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
+ mock_queue.return_value = (0, "Successfully queued")
+ result = generate_students_certificates(
+ None, None, self.course.id, task_input, 'certificates generated'
+ )
+
+ self.assertDictContainsSubset(
+ {
+ 'action_name': 'certificates generated',
+ 'total': 10,
+ 'attempted': 5,
+ 'succeeded': 2,
+ 'failed': 3,
+ 'skipped': 5
+ },
+ result
+ )
diff --git a/lms/static/js/instructor_dashboard/certificates.js b/lms/static/js/instructor_dashboard/certificates.js
index a8712b697b..df5404f158 100644
--- a/lms/static/js/instructor_dashboard/certificates.js
+++ b/lms/static/js/instructor_dashboard/certificates.js
@@ -1,4 +1,5 @@
var edx = edx || {};
+var onCertificatesReady = null;
(function($, gettext, _) {
'use strict';
@@ -6,7 +7,7 @@ var edx = edx || {};
edx.instructor_dashboard = edx.instructor_dashboard || {};
edx.instructor_dashboard.certificates = {};
- $(function() {
+ onCertificatesReady = function() {
/**
* Show a confirmation message before letting staff members
* enable/disable self-generated certificates for a course.
@@ -59,7 +60,52 @@ var edx = edx || {};
}
});
});
- });
+
+ /**
+ * Start regenerating certificates for students.
+ */
+ $section.on('click', '#btn-start-regenerating-certificates', function(event) {
+ if ( !confirm( gettext('Start regenerating certificates for students in this course?') ) ) {
+ event.preventDefault();
+ return;
+ }
+
+ var $btn_regenerating_certs = $(this),
+ $certificate_regeneration_status = $('.certificate-regeneration-status'),
+ url = $btn_regenerating_certs.data('endpoint');
+
+ $.ajax({
+ type: "POST",
+ data: $("#certificate-regenerating-form").serializeArray(),
+ url: url,
+ success: function (data) {
+ $btn_regenerating_certs.attr('disabled','disabled');
+ if(data.success){
+ $certificate_regeneration_status.text(data.message).
+ removeClass('msg-error').addClass('msg-success');
+ }
+ else{
+ $certificate_regeneration_status.text(data.message).
+ removeClass('msg-success').addClass("msg-error");
+ }
+ },
+ error: function(jqXHR) {
+ try{
+ var response = JSON.parse(jqXHR.responseText);
+ $certificate_regeneration_status.text(gettext(response.message)).
+ removeClass('msg-success').addClass("msg-error");
+ }catch(error){
+ $certificate_regeneration_status.
+ text(gettext('Error while regenerating certificates. Please try again.')).
+ removeClass('msg-success').addClass("msg-error");
+ }
+ }
+ });
+ });
+ };
+
+ // Call onCertificatesReady on document.ready event
+ $(onCertificatesReady);
var Certificates = (function() {
function Certificates($section) {
diff --git a/lms/static/js/spec/instructor_dashboard/certificates_spec.js b/lms/static/js/spec/instructor_dashboard/certificates_spec.js
new file mode 100644
index 0000000000..9dd500fa1e
--- /dev/null
+++ b/lms/static/js/spec/instructor_dashboard/certificates_spec.js
@@ -0,0 +1,116 @@
+/*global define, onCertificatesReady */
+define([
+ 'jquery',
+ 'common/js/spec_helpers/ajax_helpers',
+ 'js/instructor_dashboard/certificates'
+ ],
+ function($, AjaxHelpers) {
+ 'use strict';
+ describe("edx.instructor_dashboard.certificates.regenerate_certificates", function() {
+ var $regenerate_certificates_button = null,
+ $certificate_regeneration_status = null,
+ requests = null;
+ var MESSAGES = {
+ success_message: 'Certificate regeneration task has been started. ' +
+ 'You can view the status of the generation task in the "Pending Tasks" section.',
+ error_message: 'Please select one or more certificate statuses that require certificate regeneration.',
+ server_error_message: "Error while regenerating certificates. Please try again."
+ };
+ var expected = {
+ error_class: 'msg-error',
+ success_class: 'msg-success',
+ url: 'test/url/',
+ postData : [],
+ selected_statuses: ['downloadable', 'error'],
+ body: 'certificate_statuses=downloadable&certificate_statuses=error'
+ };
+
+ var select_options = function(option_values){
+ $.each(option_values, function(index, element){
+ $("#certificate-statuses option[value=" + element + "]").attr('selected', 'selected');
+ });
+ };
+
+ beforeEach(function() {
+ var fixture = '
From 44b409ebffc7f55e4479d1cd95d8651620d292a6 Mon Sep 17 00:00:00 2001
From: Amir Qayyum Khan
Date: Thu, 1 Oct 2015 19:39:04 +0500
Subject: [PATCH 019/115] Added pagination on grade book.
---
lms/djangoapps/ccx/tests/test_views.py | 65 ++++++----
lms/djangoapps/ccx/urls.py | 5 +
lms/djangoapps/ccx/views.py | 21 +--
.../tests/views/test_instructor_dashboard.py | 39 ++++++
lms/djangoapps/instructor/views/api.py | 41 ------
lms/djangoapps/instructor/views/api_urls.py | 5 +-
.../instructor/views/gradebook_api.py | 120 ++++++++++++++++++
lms/static/sass/course/_gradebook.scss | 10 ++
lms/templates/courseware/gradebook.html | 16 +++
9 files changed, 240 insertions(+), 82 deletions(-)
create mode 100644 lms/djangoapps/instructor/views/gradebook_api.py
diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py
index 1839cfd200..6dcda9e291 100644
--- a/lms/djangoapps/ccx/tests/test_views.py
+++ b/lms/djangoapps/ccx/tests/test_views.py
@@ -80,6 +80,40 @@ def ccx_dummy_request():
return request
+def setup_students_and_grades(context):
+ """
+ Create students and set their grades.
+ :param context: class reference
+ """
+ if context.course:
+ context.student = student = UserFactory.create()
+ CourseEnrollmentFactory.create(user=student, course_id=context.course.id)
+
+ context.student2 = student2 = UserFactory.create()
+ CourseEnrollmentFactory.create(user=student2, course_id=context.course.id)
+
+ # create grades for self.student as if they'd submitted the ccx
+ for chapter in context.course.get_children():
+ for i, section in enumerate(chapter.get_children()):
+ for j, problem in enumerate(section.get_children()):
+ # if not problem.visible_to_staff_only:
+ StudentModuleFactory.create(
+ grade=1 if i < j else 0,
+ max_grade=1,
+ student=context.student,
+ course_id=context.course.id,
+ module_state_key=problem.location
+ )
+
+ StudentModuleFactory.create(
+ grade=1 if i > j else 0,
+ max_grade=1,
+ student=context.student2,
+ course_id=context.course.id,
+ module_state_key=problem.location
+ )
+
+
@attr('shard_1')
@ddt.ddt
class TestCoachDashboard(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
@@ -696,28 +730,12 @@ class TestCCXGrades(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
# which emulates how a student would get access.
self.ccx_key = CCXLocator.from_course_locator(self._course.id, ccx.id)
self.course = get_course_by_id(self.ccx_key, depth=None)
-
- self.student = student = UserFactory.create()
- CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
-
- # create grades for self.student as if they'd submitted the ccx
- for chapter in self.course.get_children():
- for i, section in enumerate(chapter.get_children()):
- for j, problem in enumerate(section.get_children()):
- # if not problem.visible_to_staff_only:
- StudentModuleFactory.create(
- grade=1 if i < j else 0,
- max_grade=1,
- student=self.student,
- course_id=self.course.id,
- module_state_key=problem.location
- )
-
+ setup_students_and_grades(self)
self.client.login(username=coach.username, password="test")
-
self.addCleanup(RequestCache.clear_request_cache)
@patch('ccx.views.render_to_response', intercept_renderer)
+ @patch('instructor.views.gradebook_api.MAX_STUDENTS_PER_PAGE_GRADE_BOOK', 1)
def test_gradebook(self):
self.course.enable_ccx = True
RequestCache.clear_request_cache()
@@ -728,6 +746,8 @@ class TestCCXGrades(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
+ # Max number of student per page is one. Patched setting MAX_STUDENTS_PER_PAGE_GRADE_BOOK = 1
+ self.assertEqual(len(response.mako_context['students']), 1) # pylint: disable=no-member
student_info = response.mako_context['students'][0] # pylint: disable=no-member
self.assertEqual(student_info['grade_summary']['percent'], 0.5)
self.assertEqual(
@@ -751,12 +771,11 @@ class TestCCXGrades(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
response['content-disposition'],
'attachment'
)
+ rows = response.content.strip().split('\r')
+ headers = rows[0]
- headers, row = (
- row.strip().split(',') for row in
- response.content.strip().split('\n')
- )
- data = dict(zip(headers, row))
+ # picking first student records
+ data = dict(zip(headers.strip().split(','), rows[1].strip().split(',')))
self.assertNotIn('HW 04', data)
self.assertEqual(data['HW 01'], '0.75')
self.assertEqual(data['HW 02'], '0.5')
diff --git a/lms/djangoapps/ccx/urls.py b/lms/djangoapps/ccx/urls.py
index 9a2be83e7e..f670749087 100644
--- a/lms/djangoapps/ccx/urls.py
+++ b/lms/djangoapps/ccx/urls.py
@@ -18,8 +18,13 @@ urlpatterns = patterns(
'ccx.views.ccx_schedule', name='ccx_schedule'),
url(r'^ccx_manage_student$',
'ccx.views.ccx_student_management', name='ccx_manage_student'),
+
+ # Grade book
url(r'^ccx_gradebook$',
'ccx.views.ccx_gradebook', name='ccx_gradebook'),
+ url(r'^ccx_gradebook/(?P[0-9]+)$',
+ 'ccx.views.ccx_gradebook', name='ccx_gradebook'),
+
url(r'^ccx_grades.csv$',
'ccx.views.ccx_grades_csv', name='ccx_grades_csv'),
url(r'^ccx_set_grading_policy$',
diff --git a/lms/djangoapps/ccx/views.py b/lms/djangoapps/ccx/views.py
index cabb554b63..e88fa0b623 100644
--- a/lms/djangoapps/ccx/views.py
+++ b/lms/djangoapps/ccx/views.py
@@ -39,8 +39,8 @@ from ccx_keys.locator import CCXLocator
from student.roles import CourseCcxCoachRole
from student.models import CourseEnrollment
-from instructor.offline_gradecalc import student_grades
from instructor.views.api import _split_input_list
+from instructor.views.gradebook_api import get_grade_book_page
from instructor.views.tools import get_student_from_identifier
from instructor.enrollment import (
enroll_email,
@@ -551,24 +551,11 @@ def ccx_gradebook(request, course, ccx=None):
ccx_key = CCXLocator.from_course_locator(course.id, ccx.id)
with ccx_course(ccx_key) as course:
prep_course_for_grading(course, request)
-
- enrolled_students = User.objects.filter(
- courseenrollment__course_id=ccx_key,
- courseenrollment__is_active=1
- ).order_by('username').select_related("profile")
-
- student_info = [
- {
- 'username': student.username,
- 'id': student.id,
- 'email': student.email,
- 'grade_summary': student_grades(student, request, course),
- 'realname': student.profile.name,
- }
- for student in enrolled_students
- ]
+ student_info, page = get_grade_book_page(request, course, course_key=ccx_key)
return render_to_response('courseware/gradebook.html', {
+ 'page': page,
+ 'page_url': reverse('ccx_gradebook', kwargs={'course_id': ccx_key}),
'students': student_info,
'course': course,
'course_id': course.id,
diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
index 4fed6a075e..37ea9c4e7a 100644
--- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
+++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
@@ -8,10 +8,13 @@ from django.conf import settings
from django.core.urlresolvers import reverse
from django.test.client import RequestFactory
from django.test.utils import override_settings
+from edxmako.shortcuts import render_to_response
+from ccx.tests.test_views import setup_students_and_grades
from courseware.tabs import get_course_tab_list
from courseware.tests.factories import UserFactory
from courseware.tests.helpers import LoginEnrollmentTestCase
+from instructor.views.gradebook_api import calculate_page_info
from common.test.utils import XssTestMixin
from student.tests.factories import AdminFactory
@@ -23,6 +26,20 @@ from student.roles import CourseFinanceAdminRole
from student.models import CourseEnrollment
+def intercept_renderer(path, context):
+ """
+ Intercept calls to `render_to_response` and attach the context dict to the
+ response for examination in unit tests.
+ """
+ # I think Django already does this for you in their TestClient, except
+ # we're bypassing that by using edxmako. Probably edxmako should be
+ # integrated better with Django's rendering and event system.
+ response = render_to_response(path, context)
+ response.mako_context = context
+ response.mako_template = path
+ return response
+
+
@ddt.ddt
class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssTestMixin):
"""
@@ -252,3 +269,25 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
"""
response = self.client.get(self.url)
self.assertIn('D: 0.5, C: 0.57, B: 0.63, A: 0.75', response.content)
+
+ @patch('instructor.views.gradebook_api.MAX_STUDENTS_PER_PAGE_GRADE_BOOK', 2)
+ def test_calculate_page_info(self):
+ page = calculate_page_info(offset=0, total_students=2)
+ self.assertEqual(page["offset"], 0)
+ self.assertEqual(page["page_num"], 1)
+ self.assertEqual(page["next_offset"], None)
+ self.assertEqual(page["previous_offset"], None)
+ self.assertEqual(page["total_pages"], 1)
+
+ @patch('instructor.views.gradebook_api.render_to_response', intercept_renderer)
+ @patch('instructor.views.gradebook_api.MAX_STUDENTS_PER_PAGE_GRADE_BOOK', 1)
+ def test_spoc_gradebook_pages(self):
+ setup_students_and_grades(self)
+ url = reverse(
+ 'spoc_gradebook',
+ kwargs={'course_id': self.course.id}
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ # Max number of student per page is one. Patched setting MAX_STUDENTS_PER_PAGE_GRADE_BOOK = 1
+ self.assertEqual(len(response.mako_context['students']), 1) # pylint: disable=no-member
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index d626a45ed0..f394e4166a 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -81,7 +81,6 @@ from instructor.enrollment import (
unenroll_email,
)
from instructor.access import list_with_level, allow_access, revoke_access, ROLES, update_forum_role
-from instructor.offline_gradecalc import student_grades
import instructor_analytics.basic
import instructor_analytics.distributions
import instructor_analytics.csvs
@@ -2625,46 +2624,6 @@ def enable_certificate_generation(request, course_id=None):
return redirect(_instructor_dash_url(course_key, section='certificates'))
-#---- Gradebook (shown to small courses only) ----
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-@require_level('staff')
-def spoc_gradebook(request, course_id):
- """
- Show the gradebook for this course:
- - Only shown for courses with enrollment < settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
- - Only displayed to course staff
- """
- course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
- course = get_course_with_access(request.user, 'staff', course_key, depth=None)
-
- enrolled_students = User.objects.filter(
- courseenrollment__course_id=course_key,
- courseenrollment__is_active=1
- ).order_by('username').select_related("profile")
-
- # possible extension: implement pagination to show to large courses
-
- student_info = [
- {
- 'username': student.username,
- 'id': student.id,
- 'email': student.email,
- 'grade_summary': student_grades(student, request, course),
- 'realname': student.profile.name,
- }
- for student in enrolled_students
- ]
-
- return render_to_response('courseware/gradebook.html', {
- 'students': student_info,
- 'course': course,
- 'course_id': course_key,
- # Checked above
- 'staff_access': True,
- 'ordered_grades': sorted(course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True),
- })
-
-
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py
index be99ef4600..700e99639c 100644
--- a/lms/djangoapps/instructor/views/api_urls.py
+++ b/lms/djangoapps/instructor/views/api_urls.py
@@ -124,7 +124,10 @@ urlpatterns = patterns(
# spoc gradebook
url(r'^gradebook$',
- 'instructor.views.api.spoc_gradebook', name='spoc_gradebook'),
+ 'instructor.views.gradebook_api.spoc_gradebook', name='spoc_gradebook'),
+
+ url(r'^gradebook/(?P[0-9]+)$',
+ 'instructor.views.gradebook_api.spoc_gradebook', name='spoc_gradebook'),
# Cohort management
url(r'add_users_to_cohorts$',
diff --git a/lms/djangoapps/instructor/views/gradebook_api.py b/lms/djangoapps/instructor/views/gradebook_api.py
new file mode 100644
index 0000000000..2d9e5fd0a2
--- /dev/null
+++ b/lms/djangoapps/instructor/views/gradebook_api.py
@@ -0,0 +1,120 @@
+"""
+Grade book view for instructor and pagination work (for grade book)
+which is currently use by ccx and instructor apps.
+"""
+import math
+
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.views.decorators.cache import cache_control
+
+from opaque_keys.edx.keys import CourseKey
+
+from edxmako.shortcuts import render_to_response
+from courseware.courses import get_course_with_access
+from instructor.offline_gradecalc import student_grades
+from instructor.views.api import require_level
+
+
+# Grade book: max students per page
+MAX_STUDENTS_PER_PAGE_GRADE_BOOK = 20
+
+
+def calculate_page_info(offset, total_students):
+ """
+ Takes care of sanitizing the offset of current page also calculates offsets for next and previous page
+ and information like total number of pages and current page number.
+
+ :param offset: offset for database query
+ :return: tuple consist of page number, query offset for next and previous pages and valid offset
+ """
+
+ # validate offset.
+ if not (isinstance(offset, int) or offset.isdigit()) or int(offset) < 0 or int(offset) >= total_students:
+ offset = 0
+ else:
+ offset = int(offset)
+
+ # calculate offsets for next and previous pages.
+ next_offset = offset + MAX_STUDENTS_PER_PAGE_GRADE_BOOK
+ previous_offset = offset - MAX_STUDENTS_PER_PAGE_GRADE_BOOK
+
+ # calculate current page number.
+ page_num = ((offset / MAX_STUDENTS_PER_PAGE_GRADE_BOOK) + 1)
+
+ # calculate total number of pages.
+ total_pages = int(math.ceil(float(total_students) / MAX_STUDENTS_PER_PAGE_GRADE_BOOK)) or 1
+
+ if previous_offset < 0 or offset == 0:
+ # We are at first page, so there's no previous page.
+ previous_offset = None
+
+ if next_offset >= total_students:
+ # We've reached the last page, so there's no next page.
+ next_offset = None
+
+ return {
+ "previous_offset": previous_offset,
+ "next_offset": next_offset,
+ "page_num": page_num,
+ "offset": offset,
+ "total_pages": total_pages
+ }
+
+
+def get_grade_book_page(request, course, course_key):
+ """
+ Get student records per page along with page information i.e current page, total pages and
+ offset information.
+ """
+ # Unsanitized offset
+ current_offset = request.GET.get('offset', 0)
+ enrolled_students = User.objects.filter(
+ courseenrollment__course_id=course_key,
+ courseenrollment__is_active=1
+ ).order_by('username').select_related("profile")
+
+ total_students = enrolled_students.count()
+ page = calculate_page_info(current_offset, total_students)
+ offset = page["offset"]
+ total_pages = page["total_pages"]
+
+ if total_pages > 1:
+ # Apply limit on queryset only if total number of students are greater then MAX_STUDENTS_PER_PAGE_GRADE_BOOK.
+ enrolled_students = enrolled_students[offset: offset + MAX_STUDENTS_PER_PAGE_GRADE_BOOK]
+
+ student_info = [
+ {
+ 'username': student.username,
+ 'id': student.id,
+ 'email': student.email,
+ 'grade_summary': student_grades(student, request, course),
+ 'realname': student.profile.name,
+ }
+ for student in enrolled_students
+ ]
+ return student_info, page
+
+
+@cache_control(no_cache=True, no_store=True, must_revalidate=True)
+@require_level('staff')
+def spoc_gradebook(request, course_id):
+ """
+ Show the gradebook for this course:
+ - Only shown for courses with enrollment < settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
+ - Only displayed to course staff
+ """
+ course_key = CourseKey.from_string(course_id)
+ course = get_course_with_access(request.user, 'staff', course_key, depth=None)
+ student_info, page = get_grade_book_page(request, course, course_key)
+
+ return render_to_response('courseware/gradebook.html', {
+ 'page': page,
+ 'page_url': reverse('spoc_gradebook', kwargs={'course_id': unicode(course_key)}),
+ 'students': student_info,
+ 'course': course,
+ 'course_id': course_key,
+ # Checked above
+ 'staff_access': True,
+ 'ordered_grades': sorted(course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True),
+ })
diff --git a/lms/static/sass/course/_gradebook.scss b/lms/static/sass/course/_gradebook.scss
index 28bb5a87b4..1085195087 100644
--- a/lms/static/sass/course/_gradebook.scss
+++ b/lms/static/sass/course/_gradebook.scss
@@ -80,6 +80,16 @@ div.gradebook-wrapper {
}
}
+ .grade-book-footer {
+ position: relative;
+ top: 15px;
+ width: 100%;
+ border: 0;
+ box-shadow: 0;
+ text-align: center;
+ display: inline-block;
+ }
+
.grades {
position: relative;
float: left;
diff --git a/lms/templates/courseware/gradebook.html b/lms/templates/courseware/gradebook.html
index 2ec8dee0b4..ebc6bac37e 100644
--- a/lms/templates/courseware/gradebook.html
+++ b/lms/templates/courseware/gradebook.html
@@ -118,7 +118,23 @@ from django.core.urlresolvers import reverse
+
+ %if page["previous_offset"] is not None:
+
+ ${_('previous page')}
+
+ %endif
+ ${_('Page')} ${page["page_num"]} ${_('of')} ${page["total_pages"]}
+
+ %if page["next_offset"] is not None:
+
+ ${_('next page')}
+
+ %endif
+
%endif
From cbc57aae6b3de70017e0bf4a2a0f26678ff61a36 Mon Sep 17 00:00:00 2001
From: Zia Fazal
Date: Thu, 29 Oct 2015 13:43:39 +0500
Subject: [PATCH 020/115] enabled web certs by default
changing default value instead of setting it via fields
fixed broken test
fixed quality violation
following another approach
---
.../tests/test_course_create_rerun.py | 23 +++++++++++++++++++
cms/djangoapps/contentstore/views/course.py | 7 ++++--
2 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py
index a7c5df8bc7..4db95d3bed 100644
--- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py
+++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py
@@ -1,11 +1,15 @@
"""
Test view handler for rerun (and eventually create)
"""
+import ddt
+
from django.test.client import RequestFactory
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
from student.roles import CourseInstructorRole, CourseStaffRole
from student.tests.factories import UserFactory
from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
@@ -13,6 +17,7 @@ from datetime import datetime
from xmodule.course_module import CourseFields
+@ddt.ddt
class TestCourseListing(ModuleStoreTestCase):
"""
Unit tests for getting the list of courses for a logged in user
@@ -64,3 +69,21 @@ class TestCourseListing(ModuleStoreTestCase):
self.assertEqual(dest_course_key.run, 'copy')
dest_course = self.store.get_course(dest_course_key)
self.assertEqual(dest_course.start, CourseFields.start.default)
+
+ @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
+ def test_newly_created_course_has_web_certs_enabled(self, store):
+ """
+ Tests newly created course has web certs enabled by default.
+ """
+ with modulestore().default_store(store):
+ response = self.client.ajax_post('/course/', {
+ 'org': 'orgX',
+ 'number': 'CS101',
+ 'display_name': 'Course with web certs enabled',
+ 'run': '2015_T2'
+ })
+ self.assertEqual(response.status_code, 200)
+ data = parse_json(response)
+ new_course_key = CourseKey.from_string(data['course_key'])
+ course = self.store.get_course(new_course_key)
+ self.assertTrue(course.cert_html_view_enabled)
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 4ff5151e16..4e8fc5a027 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -741,8 +741,11 @@ def create_new_course_in_store(store, user, org, number, run, fields):
Separated out b/c command line course creation uses this as well as the web interface.
"""
- # Set default language from settings
- fields.update({'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en')})
+ # Set default language from settings and enable web certs
+ fields.update({
+ 'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en'),
+ 'cert_html_view_enabled': True,
+ })
with modulestore().default_store(store):
# Creating the course raises DuplicateCourseError if an existing course with this org/name is found
From fc2594e37be5b66c49890903263f7d3ae529cb4b Mon Sep 17 00:00:00 2001
From: Matjaz Gregoric
Date: Wed, 11 Nov 2015 09:02:22 +0100
Subject: [PATCH 021/115] Remove stray quotation mark from courseware-error
text.
---
lms/templates/courseware/courseware-error.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/templates/courseware/courseware-error.html b/lms/templates/courseware/courseware-error.html
index 8829ba3811..47400365e9 100644
--- a/lms/templates/courseware/courseware-error.html
+++ b/lms/templates/courseware/courseware-error.html
@@ -20,7 +20,7 @@
${_("We're sorry, this module is temporarily unavailable. Our staff is working to fix "
- "it as soon as possible. Please email us at {tech_support_email}' to report any problems or downtime.").format(
+ "it as soon as possible. Please email us at {tech_support_email} to report any problems or downtime.").format(
tech_support_email=u'{0}'.format(settings.TECH_SUPPORT_EMAIL)
)}
From a62e403ed847753f14262dd0b32710c2514d390b Mon Sep 17 00:00:00 2001
From: Davorin Sego
Date: Mon, 21 Sep 2015 10:06:47 +0200
Subject: [PATCH 022/115] Search optimization
Remove filtering by partition, course and user
Rewrite the LMS result processor to use the course blocks api
---
.../acceptance/pages/lms/courseware_search.py | 2 +-
.../test_lms_cohorted_courseware_search.py | 21 +-
.../test_lms_split_test_courseware_search.py | 81 ++---
.../courseware_search/lms_filter_generator.py | 67 ----
.../courseware_search/lms_result_processor.py | 30 +-
.../test/test_lms_filter_generator.py | 287 +-----------------
.../test/test_lms_search_initializer.py | 151 ---------
7 files changed, 54 insertions(+), 585 deletions(-)
delete mode 100644 lms/lib/courseware_search/test/test_lms_search_initializer.py
diff --git a/common/test/acceptance/pages/lms/courseware_search.py b/common/test/acceptance/pages/lms/courseware_search.py
index 8f4a57515a..640f17935c 100644
--- a/common/test/acceptance/pages/lms/courseware_search.py
+++ b/common/test/acceptance/pages/lms/courseware_search.py
@@ -29,7 +29,7 @@ class CoursewareSearchPage(CoursePage):
def search(self):
""" execute the search """
self.q(css=self.search_bar_selector + ' [type="submit"]').click()
- self.wait_for_element_visibility('.search-info', 'Search results are shown')
+ self.wait_for_ajax()
def search_for_term(self, text):
"""
diff --git a/common/test/acceptance/tests/lms/test_lms_cohorted_courseware_search.py b/common/test/acceptance/tests/lms/test_lms_cohorted_courseware_search.py
index e0739b4385..76560d9d11 100644
--- a/common/test/acceptance/tests/lms/test_lms_cohorted_courseware_search.py
+++ b/common/test/acceptance/tests/lms/test_lms_cohorted_courseware_search.py
@@ -30,8 +30,6 @@ class CoursewareSearchCohortTest(ContainerBase):
"""
Test courseware search.
"""
- USERNAME = 'STUDENT_TESTER'
- EMAIL = 'student101@example.com'
TEST_INDEX_FILENAME = "test_root/index_file.dat"
@@ -71,6 +69,14 @@ class CoursewareSearchCohortTest(ContainerBase):
self.browser, username=self.cohort_b_student_username, email=self.cohort_b_student_email, no_login=True
).visit()
+ # Create a student who will end up in the default cohort group
+ self.cohort_default_student_username = "cohort_default_student"
+ self.cohort_default_student_email = "cohort_default_student@example.com"
+ StudioAutoAuthPage(
+ self.browser, username=self.cohort_default_student_username,
+ email=self.cohort_default_student_email, no_login=True
+ ).visit()
+
self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
# Enable Cohorting and assign cohorts and content groups
@@ -183,6 +189,7 @@ class CoursewareSearchCohortTest(ContainerBase):
set_visibility(1, self.content_group_a)
set_visibility(2, self.content_group_b)
set_visibility(3, self.content_group_a, self.content_group_b)
+ set_visibility(4, 'All Students and Staff') # Does not work without this
container_page.publish_action.click()
@@ -213,7 +220,7 @@ class CoursewareSearchCohortTest(ContainerBase):
"""
Make sure that the page is accessible.
"""
- self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self._auto_auth(self.cohort_default_student_username, self.cohort_default_student_email, False)
self.courseware_search_page.visit()
def test_cohorted_search_user_a_a_content(self):
@@ -234,20 +241,20 @@ class CoursewareSearchCohortTest(ContainerBase):
self.courseware_search_page.search_for_term(self.group_a_html)
assert self.group_a_html not in self.courseware_search_page.search_results.html[0]
- def test_cohorted_search_user_c_ab_content(self):
+ def test_cohorted_search_user_default_ab_content(self):
"""
Test user not enrolled in any cohorts can't see any of restricted content.
"""
- self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self._auto_auth(self.cohort_default_student_username, self.cohort_default_student_email, False)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term(self.group_a_and_b_html)
assert self.group_a_and_b_html not in self.courseware_search_page.search_results.html[0]
- def test_cohorted_search_user_c_all_content(self):
+ def test_cohorted_search_user_default_all_content(self):
"""
Test user can search public content if cohorts used on course.
"""
- self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self._auto_auth(self.cohort_default_student_username, self.cohort_default_student_email, False)
self.courseware_search_page.visit()
self.courseware_search_page.search_for_term(self.visible_to_all_html)
assert self.visible_to_all_html in self.courseware_search_page.search_results.html[0]
diff --git a/common/test/acceptance/tests/lms/test_lms_split_test_courseware_search.py b/common/test/acceptance/tests/lms/test_lms_split_test_courseware_search.py
index 094152d16a..e3ba27cce1 100644
--- a/common/test/acceptance/tests/lms/test_lms_split_test_courseware_search.py
+++ b/common/test/acceptance/tests/lms/test_lms_split_test_courseware_search.py
@@ -52,7 +52,7 @@ class SplitTestCoursewareSearchTest(ContainerBase):
self.course_info['run']
)
- self._add_and_configure_split_test()
+ self._create_group_configuration()
self._studio_reindex()
def _auto_auth(self, username, email, staff):
@@ -72,41 +72,24 @@ class SplitTestCoursewareSearchTest(ContainerBase):
self.course_outline.start_reindex()
self.course_outline.wait_for_ajax()
- def _add_and_configure_split_test(self):
+ def _create_group_configuration(self):
"""
- Add a split test and a configuration to a test course fixture
+ Create a group configuration for course
"""
- # Create a new group configurations
- # pylint: disable=W0212
+ # pylint: disable=protected-access
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
create_user_partition_json(
0,
- "Name",
- "Description.",
+ "Configuration A/B",
+ "Content Group Partition.",
[Group("0", "Group A"), Group("1", "Group B")]
- ),
- create_user_partition_json(
- 456,
- "Name 2",
- "Description 2.",
- [Group("2", "Group C"), Group("3", "Group D")]
- ),
- ],
- },
+ )
+ ]
+ }
})
- # Add a split test module to the 'Test Unit' vertical in the course tree
- split_test_1 = XBlockFixtureDesc('split_test', 'Test Content Experiment 1', metadata={'user_partition_id': 0})
- split_test_1_parent_vertical = self.course_fixture.get_nested_xblocks(category="vertical")[1]
- self.course_fixture.create_xblock(split_test_1_parent_vertical.locator, split_test_1)
-
- # Add a split test module to the 'Test 2 Unit' vertical in the course tree
- split_test_2 = XBlockFixtureDesc('split_test', 'Test Content Experiment 2', metadata={'user_partition_id': 456})
- split_test_2_parent_vertical = self.course_fixture.get_nested_xblocks(category="vertical")[2]
- self.course_fixture.create_xblock(split_test_2_parent_vertical.locator, split_test_2)
-
def populate_course_fixture(self, course_fixture):
"""
Populate the children of the test course fixture.
@@ -116,28 +99,26 @@ class SplitTestCoursewareSearchTest(ContainerBase):
})
course_fixture.add_children(
- XBlockFixtureDesc('chapter', 'Content Section').add_children(
- XBlockFixtureDesc('sequential', 'Content Subsection').add_children(
- XBlockFixtureDesc('vertical', 'Content Unit').add_children(
- XBlockFixtureDesc('html', 'VISIBLETOALLCONTENT', data='VISIBLETOALLCONTENT')
- )
- )
- ),
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
- XBlockFixtureDesc('vertical', 'Test Unit')
+ XBlockFixtureDesc('vertical', 'Test Unit').add_children(
+ XBlockFixtureDesc(
+ 'html',
+ 'VISIBLE TO A',
+ data='VISIBLE TO A',
+ metadata={"group_access": {0: [0]}}
+ ),
+ XBlockFixtureDesc(
+ 'html',
+ 'VISIBLE TO B',
+ data='VISIBLE TO B',
+ metadata={"group_access": {0: [1]}}
+ )
+ )
)
- ),
- XBlockFixtureDesc('chapter', 'X Section').add_children(
- XBlockFixtureDesc('sequential', 'X Subsection').add_children(
- XBlockFixtureDesc('vertical', 'X Unit')
- )
- ),
+ )
)
- self.test_1_breadcrumb = "Test Section \xe2\x96\xb8 Test Subsection \xe2\x96\xb8 Test Unit".decode("utf-8")
- self.test_2_breadcrumb = "X Section \xe2\x96\xb8 X Subsection \xe2\x96\xb8 X Unit".decode("utf-8")
-
def test_page_existence(self):
"""
Make sure that the page is accessible.
@@ -145,15 +126,6 @@ class SplitTestCoursewareSearchTest(ContainerBase):
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.courseware_search_page.visit()
- def test_search_for_experiment_content_user_not_assigned(self):
- """
- Test user can't search for experiment content if not assigned to a group.
- """
- self._auto_auth(self.USERNAME, self.EMAIL, False)
- self.courseware_search_page.visit()
- self.courseware_search_page.search_for_term("Group")
- assert "Sorry, no results were found." in self.courseware_search_page.search_results.html[0]
-
def test_search_for_experiment_content_user_assigned_to_one_group(self):
"""
Test user can search for experiment content restricted to his group
@@ -161,8 +133,5 @@ class SplitTestCoursewareSearchTest(ContainerBase):
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.courseware_search_page.visit()
- self.course_navigation_page.go_to_section("Test Section", "Test Subsection")
- self.courseware_search_page.search_for_term("Group")
+ self.courseware_search_page.search_for_term("VISIBLE TO")
assert "1 result" in self.courseware_search_page.search_results.html[0]
- assert self.test_1_breadcrumb in self.courseware_search_page.search_results.html[0]
- assert self.test_2_breadcrumb not in self.courseware_search_page.search_results.html[0]
diff --git a/lms/lib/courseware_search/lms_filter_generator.py b/lms/lib/courseware_search/lms_filter_generator.py
index 8bc40ecced..bee00302a1 100644
--- a/lms/lib/courseware_search/lms_filter_generator.py
+++ b/lms/lib/courseware_search/lms_filter_generator.py
@@ -5,15 +5,9 @@ This file contains implementation override of SearchFilterGenerator which will a
from microsite_configuration import microsite
from student.models import CourseEnrollment
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import CourseKey
-from opaque_keys.edx.locations import SlashSeparatedCourseKey
-from xmodule.modulestore.django import modulestore
-
from search.filter_generator import SearchFilterGenerator
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme
from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartitionScheme
-from courseware.access import get_user_role
INCLUDE_SCHEMES = [CohortPartitionScheme, RandomUserPartitionScheme, ]
@@ -31,67 +25,6 @@ class LmsSearchFilterGenerator(SearchFilterGenerator):
self._user_enrollments[user] = CourseEnrollment.enrollments_for_user(user)
return self._user_enrollments[user]
- def filter_dictionary(self, **kwargs):
- """ LMS implementation, adds filtering by user partition, course id and user """
-
- def get_group_for_user_partition(user_partition, course_key, user):
- """ Returns the specified user's group for user partition """
- if user_partition.scheme in SCHEME_SUPPORTS_ASSIGNMENT:
- return user_partition.scheme.get_group_for_user(
- course_key,
- user,
- user_partition,
- assign=False,
- )
- else:
- return user_partition.scheme.get_group_for_user(
- course_key,
- user,
- user_partition,
- )
-
- def get_group_ids_for_user(course, user):
- """ Collect user partition group ids for user for this course """
- partition_groups = []
- for user_partition in course.user_partitions:
- if user_partition.scheme in INCLUDE_SCHEMES:
- group = get_group_for_user_partition(user_partition, course.id, user)
- if group:
- partition_groups.append(group)
- partition_group_ids = [unicode(partition_group.id) for partition_group in partition_groups]
- return partition_group_ids if partition_group_ids else None
-
- filter_dictionary = super(LmsSearchFilterGenerator, self).filter_dictionary(**kwargs)
- if 'user' in kwargs:
- user = kwargs['user']
-
- if 'course_id' in kwargs and kwargs['course_id']:
- try:
- course_key = CourseKey.from_string(kwargs['course_id'])
- except InvalidKeyError:
- course_key = SlashSeparatedCourseKey.from_deprecated_string(kwargs['course_id'])
-
- # Staff user looking at course as staff user
- if get_user_role(user, course_key) in ('instructor', 'staff'):
- return filter_dictionary
- # Need to check course exist (if course gets deleted enrollments don't get cleaned up)
- course = modulestore().get_course(course_key)
- if course:
- filter_dictionary['content_groups'] = get_group_ids_for_user(course, user)
- else:
- user_enrollments = self._enrollments_for_user(user)
- content_groups = []
- for enrollment in user_enrollments:
- course = modulestore().get_course(enrollment.course_id)
- if course:
- enrollment_group_ids = get_group_ids_for_user(course, user)
- if enrollment_group_ids:
- content_groups.extend(enrollment_group_ids)
-
- filter_dictionary['content_groups'] = content_groups if content_groups else None
-
- return filter_dictionary
-
def field_dictionary(self, **kwargs):
""" add course if provided otherwise add courses in which the user is enrolled in """
field_dictionary = super(LmsSearchFilterGenerator, self).field_dictionary(**kwargs)
diff --git a/lms/lib/courseware_search/lms_result_processor.py b/lms/lib/courseware_search/lms_result_processor.py
index 5102038178..73d6df73da 100644
--- a/lms/lib/courseware_search/lms_result_processor.py
+++ b/lms/lib/courseware_search/lms_result_processor.py
@@ -8,18 +8,16 @@ from django.core.urlresolvers import reverse
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from search.result_processor import SearchResultProcessor
from xmodule.modulestore.django import modulestore
-
-from courseware.access import has_access
+from lms.djangoapps.course_blocks.api import get_course_blocks
+from lms.djangoapps.courseware.access import has_access
class LmsSearchResultProcessor(SearchResultProcessor):
-
""" SearchResultProcessor for LMS Search """
_course_key = None
- _course_name = None
_usage_key = None
_module_store = None
- _module_temp_dictionary = {}
+ _course_blocks = {}
def get_course_key(self):
""" fetch course key object from string representation - retain result for subsequent uses """
@@ -39,11 +37,13 @@ class LmsSearchResultProcessor(SearchResultProcessor):
self._module_store = modulestore()
return self._module_store
- def get_item(self, usage_key):
- """ fetch item from the modulestore - don't refetch if we've already retrieved it beforehand """
- if usage_key not in self._module_temp_dictionary:
- self._module_temp_dictionary[usage_key] = self.get_module_store().get_item(usage_key)
- return self._module_temp_dictionary[usage_key]
+ def get_course_blocks(self, user):
+ """ fetch cached blocks for course - retain for subsequent use """
+ course_key = self.get_course_key()
+ if course_key not in self._course_blocks:
+ root_block_usage_key = self.get_module_store().make_course_usage_key(course_key)
+ self._course_blocks[course_key] = get_course_blocks(user, root_block_usage_key)
+ return self._course_blocks[course_key]
@property
def url(self):
@@ -60,10 +60,6 @@ class LmsSearchResultProcessor(SearchResultProcessor):
def should_remove(self, user):
""" Test to see if this result should be removed due to access restriction """
- user_has_access = has_access(
- user,
- "load",
- self.get_item(self.get_usage_key()),
- self.get_course_key()
- )
- return not user_has_access
+ if has_access(user, 'staff', self.get_course_key()):
+ return False
+ return self.get_usage_key() not in self.get_course_blocks(user).get_block_keys()
diff --git a/lms/lib/courseware_search/test/test_lms_filter_generator.py b/lms/lib/courseware_search/test/test_lms_filter_generator.py
index 479b1350f7..e56bff22b2 100644
--- a/lms/lib/courseware_search/test/test_lms_filter_generator.py
+++ b/lms/lib/courseware_search/test/test_lms_filter_generator.py
@@ -3,20 +3,10 @@ Tests for the lms_filter_generator
"""
from mock import patch, Mock
-from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
-
-from xmodule.partitions.partitions import Group, UserPartition
-from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartitionScheme
-from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme
-from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory, config_course_cohorts
-from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
-from openedx.core.djangoapps.course_groups.views import link_cohort_to_partition_group
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import CourseKey
from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator
@@ -58,10 +48,6 @@ class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase):
publish_item=True,
)
- self.groups = [Group(1, 'Group 1'), Group(2, 'Group 2')]
-
- self.content_groups = [1, 2]
-
def setUp(self):
super(LmsSearchFilterGeneratorTestCase, self).setUp()
self.build_courses()
@@ -150,274 +136,3 @@ class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase):
self.assertNotIn('org', exclude_dictionary)
self.assertIn('org', field_dictionary)
self.assertEqual('TestMicrosite3', field_dictionary['org'])
-
-
-class LmsSearchFilterGeneratorGroupsTestCase(LmsSearchFilterGeneratorTestCase):
- """
- Test case class to test search result processor
- with content and user groups present within the course
- """
-
- def setUp(self):
- super(LmsSearchFilterGeneratorGroupsTestCase, self).setUp()
- self.user_partition = None
- self.split_test_user_partition = None
- self.first_cohort = None
- self.second_cohort = None
-
- def add_seq_with_content_groups(self, groups=None):
- """
- Adds sequential and two content groups to first course in courses list.
- """
- config_course_cohorts(self.courses[0], is_cohorted=True)
-
- if groups is None:
- groups = self.groups
-
- self.user_partition = UserPartition(
- id=0,
- name='Partition 1',
- description='This is partition 1',
- groups=groups,
- scheme=CohortPartitionScheme
- )
-
- self.user_partition.scheme.name = "cohort"
-
- ItemFactory.create(
- parent_location=self.chapter.location,
- category='sequential',
- display_name="Lesson 1",
- publish_item=True,
- metadata={u"user_partitions": [self.user_partition.to_json()]}
- )
-
- self.first_cohort, self.second_cohort = [
- CohortFactory(course_id=self.courses[0].id) for _ in range(2)
- ]
-
- self.courses[0].user_partitions = [self.user_partition]
- self.courses[0].save()
- modulestore().update_item(self.courses[0], self.user.id)
-
- def add_user_to_cohort_group(self):
- """
- adds user to cohort and links cohort to content group
- """
- add_user_to_cohort(self.first_cohort, self.user.username)
-
- link_cohort_to_partition_group(
- self.first_cohort,
- self.user_partition.id,
- self.groups[0].id,
- )
-
- self.courses[0].save()
- modulestore().update_item(self.courses[0], self.user.id)
-
- def add_split_test(self, groups=None):
- """
- Adds split test and two content groups to second course in courses list.
- """
- if groups is None:
- groups = self.groups
-
- self.split_test_user_partition = UserPartition(
- id=0,
- name='Partition 2',
- description='This is partition 2',
- groups=groups,
- scheme=RandomUserPartitionScheme
- )
-
- self.split_test_user_partition.scheme.name = "random"
-
- sequential = ItemFactory.create(
- parent_location=self.chapter.location,
- category='sequential',
- display_name="Lesson 2",
- publish_item=True,
- )
-
- vertical = ItemFactory.create(
- parent_location=sequential.location,
- category='vertical',
- display_name='Subsection 3',
- publish_item=True,
- )
-
- split_test_unit = ItemFactory.create(
- parent_location=vertical.location,
- category='split_test',
- user_partition_id=0,
- display_name="Test Content Experiment 1",
- )
-
- condition_1_vertical = ItemFactory.create(
- parent_location=split_test_unit.location,
- category="vertical",
- display_name="Group ID 1",
- )
-
- condition_2_vertical = ItemFactory.create(
- parent_location=split_test_unit.location,
- category="vertical",
- display_name="Group ID 2",
- )
-
- ItemFactory.create(
- parent_location=condition_1_vertical.location,
- category="html",
- display_name="Group A",
- publish_item=True,
- )
-
- ItemFactory.create(
- parent_location=condition_2_vertical.location,
- category="html",
- display_name="Group B",
- publish_item=True,
- )
-
- self.courses[1].user_partitions = [self.split_test_user_partition]
- self.courses[1].save()
- modulestore().update_item(self.courses[1], self.user.id)
-
- def add_user_to_splittest_group(self):
- """
- adds user to a random split test group
- """
- self.split_test_user_partition.scheme.get_group_for_user(
- CourseKey.from_string(unicode(self.courses[1].id)),
- self.user,
- self.split_test_user_partition,
- assign=True,
- )
-
- self.courses[1].save()
- modulestore().update_item(self.courses[1], self.user.id)
-
- def test_content_group_id_provided(self):
- """
- Tests that we get the content group ID when course is assigned to cohort
- which is assigned content group.
- """
- self.add_seq_with_content_groups()
- self.add_user_to_cohort_group()
- field_dictionary, filter_dictionary, _ = LmsSearchFilterGenerator.generate_field_filters(
- user=self.user,
- course_id=unicode(self.courses[0].id)
- )
-
- self.assertTrue('start_date' in filter_dictionary)
- self.assertEqual(unicode(self.courses[0].id), field_dictionary['course'])
- self.assertEqual([unicode(self.content_groups[0])], filter_dictionary['content_groups'])
-
- def test_content_multiple_groups_id_provided(self):
- """
- Tests that we get content groups IDs when course is assigned to cohort
- which is assigned to multiple content groups.
- """
- self.add_seq_with_content_groups()
- self.add_user_to_cohort_group()
-
- # Second cohort link
- link_cohort_to_partition_group(
- self.second_cohort,
- self.user_partition.id,
- self.groups[0].id,
- )
-
- self.courses[0].save()
- modulestore().update_item(self.courses[0], self.user.id)
-
- field_dictionary, filter_dictionary, _ = LmsSearchFilterGenerator.generate_field_filters(
- user=self.user,
- course_id=unicode(self.courses[0].id)
- )
-
- self.assertTrue('start_date' in filter_dictionary)
- self.assertEqual(unicode(self.courses[0].id), field_dictionary['course'])
- # returns only first group, relevant to current user
- self.assertEqual([unicode(self.content_groups[0])], filter_dictionary['content_groups'])
-
- def test_content_group_id_not_provided(self):
- """
- Tests that we don't get content group ID when course is assigned a cohort
- but cohort is not assigned to content group.
- """
- self.add_seq_with_content_groups(groups=[])
-
- field_dictionary, filter_dictionary, _ = LmsSearchFilterGenerator.generate_field_filters(
- user=self.user,
- course_id=unicode(self.courses[0].id)
- )
-
- self.assertTrue('start_date' in filter_dictionary)
- self.assertEqual(unicode(self.courses[0].id), field_dictionary['course'])
- self.assertEqual(None, filter_dictionary['content_groups'])
-
- def test_content_group_with_cohort_not_provided(self):
- """
- Tests that we don't get content group ID when course has no cohorts
- """
- self.add_seq_with_content_groups()
-
- field_dictionary, filter_dictionary, _ = LmsSearchFilterGenerator.generate_field_filters(
- user=self.user,
- course_id=unicode(self.courses[0].id)
- )
-
- self.assertTrue('start_date' in filter_dictionary)
- self.assertEqual(unicode(self.courses[0].id), field_dictionary['course'])
- self.assertEqual(None, filter_dictionary['content_groups'])
-
- def test_split_test_with_user_groups_user_not_assigned(self):
- """
- Tests that we don't get user group ID when user is not assigned to a split test group
- """
- self.add_split_test()
-
- field_dictionary, filter_dictionary, _ = LmsSearchFilterGenerator.generate_field_filters(
- user=self.user,
- course_id=unicode(self.courses[1].id)
- )
-
- self.assertTrue('start_date' in filter_dictionary)
- self.assertEqual(unicode(self.courses[1].id), field_dictionary['course'])
- self.assertEqual(None, filter_dictionary['content_groups'])
-
- def test_split_test_with_user_groups_user_assigned(self):
- """
- Tests that we get user group ID when user is assigned to a split test group
- """
- self.add_split_test()
- self.add_user_to_splittest_group()
-
- field_dictionary, filter_dictionary, _ = LmsSearchFilterGenerator.generate_field_filters(
- user=self.user,
- course_id=unicode(self.courses[1].id)
- )
-
- partition_group = self.split_test_user_partition.scheme.get_group_for_user(
- CourseKey.from_string(unicode(self.courses[1].id)),
- self.user,
- self.split_test_user_partition,
- assign=False,
- )
-
- self.assertTrue('start_date' in filter_dictionary)
- self.assertEqual(unicode(self.courses[1].id), field_dictionary['course'])
- self.assertEqual([unicode(partition_group.id)], filter_dictionary['content_groups'])
-
- def test_invalid_course_key(self):
- """
- Test system raises an error if no course found.
- """
-
- self.add_seq_with_content_groups()
- with self.assertRaises(InvalidKeyError):
- LmsSearchFilterGenerator.generate_field_filters(
- user=self.user,
- course_id='this_is_false_course_id'
- )
diff --git a/lms/lib/courseware_search/test/test_lms_search_initializer.py b/lms/lib/courseware_search/test/test_lms_search_initializer.py
deleted file mode 100644
index 9cf04c23fa..0000000000
--- a/lms/lib/courseware_search/test/test_lms_search_initializer.py
+++ /dev/null
@@ -1,151 +0,0 @@
-"""
-Tests for the lms_search_initializer
-"""
-
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
-from xmodule.partitions.partitions import Group, UserPartition
-from xmodule.modulestore.django import modulestore
-
-from courseware.tests.factories import UserFactory
-from courseware.tests.test_masquerade import StaffMasqueradeTestCase
-from courseware.masquerade import handle_ajax
-
-from lms.lib.courseware_search.lms_search_initializer import LmsSearchInitializer
-from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator
-
-
-class LmsSearchInitializerTestCase(StaffMasqueradeTestCase):
- """ Test case class to test search initializer """
-
- def build_course(self):
- """
- Build up a course tree with an html control
- """
- self.global_staff = UserFactory(is_staff=True)
-
- self.course = CourseFactory.create(
- org='Elasticsearch',
- course='ES101',
- run='test_run',
- display_name='Elasticsearch test course',
- )
- self.section = ItemFactory.create(
- parent=self.course,
- category='chapter',
- display_name='Test Section',
- )
- self.subsection = ItemFactory.create(
- parent=self.section,
- category='sequential',
- display_name='Test Subsection',
- )
- self.vertical = ItemFactory.create(
- parent=self.subsection,
- category='vertical',
- display_name='Test Unit',
- )
- self.html = ItemFactory.create(
- parent=self.vertical,
- category='html',
- display_name='Test Html control 1',
- )
- self.html = ItemFactory.create(
- parent=self.vertical,
- category='html',
- display_name='Test Html control 2',
- )
-
- def setUp(self):
- super(LmsSearchInitializerTestCase, self).setUp()
- self.build_course()
- self.user_partition = UserPartition(
- id=0,
- name='Test User Partition',
- description='',
- groups=[Group(0, 'Group 1'), Group(1, 'Group 2')],
- scheme_id='cohort'
- )
- self.course.user_partitions.append(self.user_partition)
- modulestore().update_item(self.course, self.global_staff.id) # pylint: disable=no-member
-
- def test_staff_masquerading_added_to_group(self):
- """
- Tests that initializer sets masquerading for a staff user in a group.
- """
- # Verify that there is no masquerading group initially
- _, filter_directory, _ = LmsSearchFilterGenerator.generate_field_filters( # pylint: disable=unused-variable
- user=self.global_staff,
- course_id=unicode(self.course.id)
- )
- # User is staff by default, no content groups filter is set - see all
- self.assertNotIn('content_groups', filter_directory)
-
- # Install a masquerading group
- request = self._create_mock_json_request(
- self.global_staff,
- body='{"role": "student", "user_partition_id": 0, "group_id": 1}'
- )
- handle_ajax(request, unicode(self.course.id))
-
- # Call initializer
- LmsSearchInitializer.set_search_enviroment(
- request=request,
- course_id=unicode(self.course.id)
- )
-
- # Verify that there is masquerading group after masquerade
- _, filter_directory, _ = LmsSearchFilterGenerator.generate_field_filters( # pylint: disable=unused-variable
- user=self.global_staff,
- course_id=unicode(self.course.id)
- )
- self.assertEqual(filter_directory['content_groups'], [unicode(1)])
-
- def test_staff_masquerading_as_a_staff_user(self):
- """
- Tests that initializer sets masquerading for a staff user as staff.
- """
-
- # Install a masquerading group
- request = self._create_mock_json_request(
- self.global_staff,
- body='{"role": "staff"}'
- )
- handle_ajax(request, unicode(self.course.id))
-
- # Call initializer
- LmsSearchInitializer.set_search_enviroment(
- request=request,
- course_id=unicode(self.course.id)
- )
-
- # Verify that there is masquerading group after masquerade
- _, filter_directory, _ = LmsSearchFilterGenerator.generate_field_filters( # pylint: disable=unused-variable
- user=self.global_staff,
- course_id=unicode(self.course.id)
- )
- self.assertNotIn('content_groups', filter_directory)
-
- def test_staff_masquerading_as_a_student_user(self):
- """
- Tests that initializer sets masquerading for a staff user as student.
- """
-
- # Install a masquerading group
- request = self._create_mock_json_request(
- self.global_staff,
- body='{"role": "student"}'
- )
- handle_ajax(request, unicode(self.course.id))
-
- # Call initializer
- LmsSearchInitializer.set_search_enviroment(
- request=request,
- course_id=unicode(self.course.id)
- )
-
- # Verify that there is masquerading group after masquerade
- _, filter_directory, _ = LmsSearchFilterGenerator.generate_field_filters( # pylint: disable=unused-variable
- user=self.global_staff,
- course_id=unicode(self.course.id)
- )
- self.assertEqual(filter_directory['content_groups'], None)
From c068bc95cf02f1309fc8b2bf2a63891c5bacfb58 Mon Sep 17 00:00:00 2001
From: Saleem Latif
Date: Wed, 11 Nov 2015 18:03:45 +0500
Subject: [PATCH 023/115] Custom 500 error message should only be seen for
Preview
---
common/djangoapps/util/views.py | 19 +++++++++++++++----
.../certificates/tests/test_webview_views.py | 6 +++++-
lms/djangoapps/certificates/views/webview.py | 5 ++++-
3 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py
index cfa37c62c9..99083db7fc 100644
--- a/common/djangoapps/util/views.py
+++ b/common/djangoapps/util/views.py
@@ -56,13 +56,18 @@ def jsonable_server_error(request, template_name='500.html'):
return server_error(request, template_name=template_name)
-def handle_500(template_path, context=None):
+def handle_500(template_path, context=None, test_func=None):
"""
Decorator for view specific 500 error handling.
+ Custom handling will be skipped only if test_func is passed and it returns False
- Usage::
+ Usage:
- @handle_500(template_path='certificates/server-error.html', context={'error-info': 'Internal Server Error'})
+ @handle_500(
+ template_path='certificates/server-error.html',
+ context={'error-info': 'Internal Server Error'},
+ test_func=lambda request: request.GET.get('preview', None)
+ )
def my_view(request):
# Any unhandled exception in this view would be handled by the handle_500 decorator
# ...
@@ -83,9 +88,15 @@ def handle_500(template_path, context=None):
if settings.DEBUG:
# In debug mode let django process the 500 errors and display debug info for the developer
raise
- else:
+ elif test_func is None or test_func(request):
+ # Display custom 500 page if either
+ # 1. test_func is None (meaning nothing to test)
+ # 2. or test_func(request) returns True
log.exception("Error in django view.")
return render_to_response(template_path, context)
+ else:
+ # Do not show custom 500 error when test fails
+ raise
return inner
return decorator
diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py
index 18727eb04e..362122e276 100644
--- a/lms/djangoapps/certificates/tests/test_webview_views.py
+++ b/lms/djangoapps/certificates/tests/test_webview_views.py
@@ -454,9 +454,13 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
user_id=self.user.id,
course_id=unicode(self.course.id)
)
- response = self.client.get(test_url)
+ response = self.client.get(test_url + "?preview=honor")
self.assertIn("Invalid Certificate Configuration", response.content)
+ # Verify that Exception is raised when certificate is not in the preview mode
+ with self.assertRaises(Exception):
+ self.client.get(test_url)
+
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_certificate_evidence_event_emitted(self):
self.client.logout()
diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py
index 11465db16b..3d4fbb4118 100644
--- a/lms/djangoapps/certificates/views/webview.py
+++ b/lms/djangoapps/certificates/views/webview.py
@@ -281,7 +281,10 @@ def _update_certificate_context(context, course, user, user_certificate):
)
-@handle_500(template_path="certificates/server-error.html")
+@handle_500(
+ template_path="certificates/server-error.html",
+ test_func=lambda request: request.GET.get('preview', None)
+)
def render_html_view(request, user_id, course_id):
"""
This public view generates an HTML representation of the specified student's certificate
From 78c6a38ca642efb5fc40db6ec3ee6945157f4f79 Mon Sep 17 00:00:00 2001
From: Chris Rodriguez
Date: Tue, 29 Sep 2015 13:54:45 -0400
Subject: [PATCH 024/115] LMS: new UI for video player + AFontGarde iconfonts
---
.../xmodule/xmodule/css/video/display.scss | 696 +++---
.../spec/video/video_accessible_menu_spec.js | 4 +-
.../js/spec/video/video_caption_spec.js | 184 +-
.../js/spec/video/video_context_menu_spec.js | 16 +-
.../js/spec/video/video_full_screen_spec.js | 6 -
.../video/video_play_pause_control_spec.js | 6 -
.../video/video_play_skip_control_spec.js | 2 -
.../js/spec/video/video_player_spec.js | 10 -
.../spec/video/video_quality_control_spec.js | 4 +-
.../js/spec/video/video_skip_control_spec.js | 2 -
.../js/spec/video/video_speed_control_spec.js | 45 +-
.../spec/video/video_volume_control_spec.js | 44 +-
.../js/src/video/04_video_full_screen.js | 28 +-
.../js/src/video/05_video_quality_control.js | 32 +-
.../js/src/video/07_video_volume_control.js | 76 +-
.../js/src/video/08_video_speed_control.js | 50 +-
.../js/src/video/09_play_pause_control.js | 34 +-
.../js/src/video/09_play_skip_control.js | 21 +-
.../xmodule/js/src/video/09_skip_control.js | 14 +-
.../xmodule/js/src/video/09_video_caption.js | 1937 +++++++++--------
.../static/images/fontawesome/arrows-alt.svg | 6 +
.../static/images/fontawesome/caret-left.svg | 6 +
.../static/images/fontawesome/caret-right.svg | 6 +
common/static/images/fontawesome/caret-up.svg | 6 +
common/static/images/fontawesome/cc.svg | 6 +
common/static/images/fontawesome/compress.svg | 6 +
common/static/images/fontawesome/list-alt.svg | 6 +
common/static/images/fontawesome/pause.svg | 6 +
common/static/images/fontawesome/play.svg | 6 +
.../static/images/fontawesome/quote-left.svg | 6 +
.../images/fontawesome/step-forward.svg | 6 +
.../static/images/fontawesome/volume-down.svg | 6 +
.../static/images/fontawesome/volume-off.svg | 6 +
.../static/images/fontawesome/volume-up.svg | 6 +
.../js/vendor/afontgarde/afontgarde.css | 60 +
.../static/js/vendor/afontgarde/afontgarde.js | 287 +++
.../static/js/vendor/afontgarde/edx-icons.js | 3 +
.../modernizr.fontface-generatedcontent.js | 4 +
.../test/acceptance/pages/lms/video/video.py | 29 +-
.../tests/video/test_studio_video_module.py | 4 +-
.../tests/video/test_video_module.py | 8 +-
lms/envs/common.py | 4 +
42 files changed, 2223 insertions(+), 1471 deletions(-)
create mode 100755 common/static/images/fontawesome/arrows-alt.svg
create mode 100755 common/static/images/fontawesome/caret-left.svg
create mode 100755 common/static/images/fontawesome/caret-right.svg
create mode 100755 common/static/images/fontawesome/caret-up.svg
create mode 100755 common/static/images/fontawesome/cc.svg
create mode 100755 common/static/images/fontawesome/compress.svg
create mode 100755 common/static/images/fontawesome/list-alt.svg
create mode 100755 common/static/images/fontawesome/pause.svg
create mode 100755 common/static/images/fontawesome/play.svg
create mode 100755 common/static/images/fontawesome/quote-left.svg
create mode 100755 common/static/images/fontawesome/step-forward.svg
create mode 100755 common/static/images/fontawesome/volume-down.svg
create mode 100755 common/static/images/fontawesome/volume-off.svg
create mode 100755 common/static/images/fontawesome/volume-up.svg
create mode 100644 common/static/js/vendor/afontgarde/afontgarde.css
create mode 100644 common/static/js/vendor/afontgarde/afontgarde.js
create mode 100644 common/static/js/vendor/afontgarde/edx-icons.js
create mode 100644 common/static/js/vendor/afontgarde/modernizr.fontface-generatedcontent.js
diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss
index 5f531435bf..c54591a7ff 100644
--- a/common/lib/xmodule/xmodule/css/video/display.scss
+++ b/common/lib/xmodule/xmodule/css/video/display.scss
@@ -1,3 +1,66 @@
+// This is for A Font Garde
+// It loads the icon font only when it's available.
+// ---
+// It is scoped to the video player for now, but we
+// will eventually want to move this to the main font
+// sheet, globally, so it applies to all use cases.
+
+// --------
+// Defaults: what displays if the icon font doesn't load.
+// --------
+
+// the html target is necessary for xblocks and xmodules, but works across the board
+html:not('.afontgarde') .icon-fallback-img {
+
+ .fa-play {
+ background: url('#{$static-path}/images/fontawesome/play.svg') center center no-repeat;
+ }
+
+ .fa-pause {
+ background: url('#{$static-path}/images/fontawesome/pause.svg') center center no-repeat;
+ }
+
+ .fa-step-forward {
+ background: url('#{$static-path}/images/fontawesome/step-forward.svg') center center no-repeat;
+ }
+
+ .fa-arrows-alt {
+ background: url('#{$static-path}/images/fontawesome/arrows-alt.svg') center center no-repeat;
+ }
+
+ .fa-caret-right {
+ background: url('#{$static-path}/images/fontawesome/caret-right.svg') center center no-repeat;
+ }
+
+ .fa-caret-left {
+ background: url('#{$static-path}/images/fontawesome/caret-left.svg') center center no-repeat;
+ }
+
+ .fa-caret-up {
+ background: url('#{$static-path}/images/fontawesome/caret-up.svg') center center no-repeat;
+ }
+
+ .fa-compress {
+ background: url('#{$static-path}/images/fontawesome/compress.svg') center center no-repeat;
+ }
+
+ .fa-quote-left {
+ background: url('#{$static-path}/images/fontawesome/quote-left.svg') center center no-repeat;
+ }
+
+ .fa-volume-up {
+ background: url('#{$static-path}/images/fontawesome/volume-up.svg') center center no-repeat;
+ }
+
+ .fa-volume-down {
+ background: url('#{$static-path}/images/fontawesome/volume-down.svg') center center no-repeat;
+ }
+
+ .fa-volume-off {
+ background: url('#{$static-path}/images/fontawesome/volume-off.svg') center center no-repeat;
+ }
+}
+
& {
margin-bottom: ($baseline*1.5);
}
@@ -6,21 +69,25 @@
display: none;
}
-div.video {
+.video {
@include clearfix();
- background: #f3f3f3;
+ background: rgb(240, 243, 245); // UXPL grayscale-cool xx-light;
display: block;
margin: 0 -12px;
padding: 12px;
border-radius: 5px;
outline: none;
- &:focus, &:active, &:hover {
+ &:focus,
+ &:active,
+ &:hover {
border: 0;
}
&.is-initialized {
- article.video-wrapper {
+
+ .video-wrapper {
+
.spinner {
display: none;
}
@@ -29,12 +96,14 @@ div.video {
// CASE: video pre-roll state
&.is-pre-roll {
+
.slider {
visibility: hidden;
}
.video-player {
position: relative;
+
&:before {
display: block;
content: "";
@@ -44,12 +113,12 @@ div.video {
}
}
- div.tc-wrapper {
+ .tc-wrapper {
@include clearfix();
position: relative;
}
- div.focus_grabber {
+ .focus_grabber {
position: relative;
display: inline;
width: 0px;
@@ -60,7 +129,7 @@ div.video {
margin: 0;
padding: 0;
- .video-download-button{
+ .video-download-button {
display: inline-block;
vertical-align: top;
margin: ($baseline*0.75) ($baseline/2) 0 0;
@@ -75,16 +144,20 @@ div.video {
padding: ($baseline*0.75);
color: $lighter-base-font-color;
- &:hover, &:focus {
+ &:hover,
+ &:focus {
background-color: $action-primary-active-bg;
color: $very-light-text;
}
}
}
+
.video-tracks {
+
> a {
border-radius: 3px 0 0 3px;
}
+
> a.external-track {
border-radius: 3px;
}
@@ -116,16 +189,17 @@ div.video {
}
}
- article.video-wrapper {
- float: left;
- margin-right: flex-gutter(9);
+ .video-wrapper {
+ @include float(left);
+ @include margin-right(flex-gutter(9));
width: flex-grid(6, 9);
background-color: black;
position: relative;
- div.video-player-pre, div.video-player-post {
+ .video-player-pre,
+ .video-player-post {
height: 50px;
- background-color: black;
+ background-color: rgb(17, 16, 16) // UXPL grayscale black;
}
.spinner {
@@ -173,7 +247,7 @@ div.video {
}
}
- section.video-player {
+ .video-player {
overflow: hidden;
min-height: 300px;
@@ -185,7 +259,9 @@ div.video {
}
}
- object, iframe, video {
+ object,
+ iframe,
+ video {
display: block;
border: none;
width: 100%;
@@ -201,285 +277,272 @@ div.video {
}
}
- section.video-controls {
+ .video-controls {
@include clearfix();
- background: #333;
- border: 1px solid $black;
- border-top: 0;
- color: $gray-l3;
position: relative;
+ border: 0;
+ background: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
+ color: rgb(240, 243, 245); // UXPL grayscale-cool xx-light
- &:hover, &:focus {
- ul, div {
+ &:hover,
+ &:focus {
+
+ ul,
+ div {
opacity: 1;
}
}
- %video-button {
- @extend %ui-fake-link;
- @include transition(none);
- display: block;
- font-weight: 700;
- line-height: 46px;
+ %video-control {
+ @extend %t-strong;
+ @extend %t-title7;
+ display: inline-block;
+ vertical-align: middle;
margin: 0;
- padding: 0 0 0 15px;
- overflow: hidden;
- text-indent: -9999px;
- -webkit-font-smoothing: antialiased;
- box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
- color: $white;
- border-width: 0 1px;
- border-style: solid;
- border-color: $black;
+ border: 0;
+ border-radius: 0;
+ padding: ($baseline / 2) ($baseline / 1.5);
+ background: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
+ box-shadow: none;
+ text-shadow: none;
+ color: rgb(207, 216, 220); // UXPL grayscale-cool light
- &:hover, &:focus {
- background-color: #444;
- color: $white;
- text-decoration: none;
+ &:hover,
+ &:focus {
+ background: darken(rgb(40, 44, 46), 7%); // UXPL secondary
}
&:active,
- &:focus {
- color: $white;
- background-color: #444;
- text-decoration: none;
+ &.is-active,
+ &.active {
+ color: rgb(14, 166, 236); // UXPL primary accent
}
}
- div.slider {
+ .control {
+ @extend %video-control;
+
+ .icon-fallback-img {
+
+ .icon {
+ // if the icon font doesn't render, we need to provide dimensions for the svg's
+ width: 1em;
+ height: 1em;
+
+ &.icon-hd {
+ // except when it's text, like with HD
+ // otherwise it's shifted to the right because "HD" is wider than 1em
+ width: auto;
+ }
+ }
+ }
+ }
+
+ .slider {
@include clearfix();
- @include transform(scaleY(0.5) translate3d(0, 50%, 0));
- background: #c2c2c2;
- border: 1px solid $black;
- border-radius: 0;
- border-top: 1px solid $black;
- box-shadow: inset 0 1px 0 #eee, 0 1px 0 #555;
+ @include transform-origin(bottom left);
+ @include transition(height .7s ease-in-out 0s);
position: absolute;
- z-index: 1;
bottom: 100%;
left: 0;
right: 0;
- height: 14px;
- margin-left: -1px;
- margin-right: -1px;
- -webkit-transition: -webkit-transform 0.7s ease-in-out;
- -moz-transition: -moz-transform 0.7s ease-in-out;
- -ms-transition: -ms-transform 0.7s ease-in-out;
- transition: transform 0.7s ease-in-out;
+ z-index: 1;
+ height: ($baseline / 4);
+ margin-left: 0;
+ border: 0;
+ border-radius: 0;
+ background: rgb(79, 89, 93); // UXPL grayscale-cool dark
- div.ui-widget-header {
- background: #777;
- box-shadow: inset 0 1px 0 #999;
+ .ui-widget-header {
+ background: rgb(142, 62, 99); // UXPL secondary dark
+ box-shadow: none;
}
- div.ui-corner-all.slider-range {
- background-color: #1e91d3;
+ .ui-corner-all.slider-range {
opacity: 0.3;
+ background-color: #1e91d3;
}
- a.ui-slider-handle {
+ .ui-slider-handle {
@extend %ui-fake-link;
- @include transform(scale(.7, 1.3) translate3d(-80%, -15%, 0));
- background: $pink url('#{$static-path}/images/slider-handle.png') center center no-repeat;
- background-size: 50%;
- border: 1px solid darken($pink, 20%);
- border-radius: 50%;
- box-shadow: inset 0 1px 0 lighten($pink, 10%);
- height: 20px;
- margin-left: 0;
+ @include transform-origin(bottom left);
+ @include transition(all .7s ease-in-out 0s);
top: 0;
- -webkit-transition: -webkit-transform 0.7s ease-in-out;
- -moz-transition: -moz-transform 0.7s ease-in-out;
- -ms-transition: -ms-transform 0.7s ease-in-out;
- transition: transform 0.7s ease-in-out;
- width: 20px;
+ height: ($baseline / 4);
+ width: ($baseline / 4);
+ margin-left: -($baseline / 8); // center-center causes the control to be beyond the end of the sider
+ border: 0;
+ border-radius: ($baseline / 5);
+ background: rgb(203, 89, 141); // UXPL secondary base
+ box-shadow: none;
- &:focus, &:hover {
- background-color: lighten($pink, 10%);
+ &:focus,
+ &:hover {
+ background-color: rgb(219, 139, 175); // UXPL secondary light
}
}
}
.vcr {
- float: left;
+ @include float(left);
list-style: none;
- margin: 0 lh() 0 0;
+ @include border-right(1px solid rgb(40, 44, 46)); // UXPL grayscale-cool x-dark
padding: 0;
@media (max-width: 1120px) {
- margin-right: lh(0.5);
+ @include margin-right(lh(0.5));
font-size: em(14);
}
.video_control {
- @extend %video-button;
- float: left;
- background-image: url('#{$static-path}/images/vcr.png');
- background-position: 15px 15px ;
- background-repeat: no-repeat;
- border-left: none;
- padding: 0 lh(.75);
- width: 14px;
&:focus {
- @extend %ui-depth4;
position: relative;
- outline: $white dotted thin;
- outline-offset: -2px;
- }
-
- &:empty {
- height: 46px;
- background-position: 15px 15px;
- }
-
- &.play {
- background-position: 17px -114px;
- }
-
- &.pause {
- background-position: 16px -50px;
}
&.skip {
- background-image: none;
- text-indent: 0;
- width: initial;
white-space: nowrap;
}
}
- div.vidtime {
+ .vidtime {
@extend %t-strong;
- float: left;
- line-height: 46px; //height of play pause buttons
- -webkit-font-smoothing: antialiased;
- padding-left: lh(.75);
+ @extend %t-title7;
+ @include padding-left(lh(.75));
+ display: inline-block;
+ color: rgb(207, 216, 220); // UXPL grayscale-cool light
+ -webkit-font-smoothing: antialiased;;
+
@media (max-width: 1120px) {
- padding-left: lh(0.5);
+ @include padding-left(lh(0.5));
}
}
}
- div.secondary-controls {
- float: right;
+ .secondary-controls {
+ @include float(right);
+ @include border-left(1px dotted rgb(79, 89, 93)); // UXPL grayscale-cool x-dark
+
+ .volume,
+ .add-fullscreen,
+ .grouped-controls,
+ .quality-control {
+ @include border-left(1px dotted rgb(79, 89, 93)); // UXPL grayscale-cool x-dark
+ }
+
+ .speed-button,
+ .volume > .control,
+ .add-fullscreen,
+ .quality-control,
+ .toggle-transcript {
- a.speed-button,
- div.volume > a,
- a.add-fullscreen,
- a.quality-control,
- a.hide-subtitles {
- // overflow is used to bypass Firefox CSS :focus outline bug
- // http://johndoesdesign.com/blog/2012/css/firefox-and-its-css-focus-outline-bug/
&:focus {
- @extend %ui-depth5;
position: relative;
- outline: $white dotted thin;
- outline-offset: -2px;
- overflow: auto;
}
}
.menu-container {
- float: left;
position: relative;
- &.is-opened {
- .menu {
- display: block;
- opacity: 1;
- padding: 0;
- margin: 0;
- list-style: none;
- }
- }
-
.menu {
@include transition(none);
@extend %ui-depth1;
- box-shadow: inset 1px 0 0 #555, 0 1px 0 #444;
- background-color: #444;
- border: 1px solid $black;
- bottom: 46px;
- display: none;
- opacity: 0;
position: absolute;
+ display: none;
+ bottom: ($baseline * 2);
+ @include right(0); // right-align menus since this whole collection is on the right
+ width: 120px;
+ margin: 0;
+ border: none;
+ padding: 0;
+ box-shadow: none;
+ background-color: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
+ list-style: none;
li {
@extend %ui-fake-link;
- box-shadow: 0 1px 0 #555;
- border-bottom: 1px solid $black;
- color: $white;
+ color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
- a {
- border: 0;
- color: $white;
+ .speed-option,
+ .control-lang {
+ @include text-align(left);
display: block;
+ width: 100%;
+ border: 0;
+ border-radius: 0;
padding: lh(0.5);
+ background: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
+ box-shadow: none;
+ color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
overflow: hidden;
+ text-shadow: none;
text-overflow: ellipsis;
white-space: nowrap;
- &:hover, &:focus {
- background-color: #666;
- color: #aaa;
- outline-offset: -4px;
+ &:hover,
+ &:focus {
+ background-color: rgb(79, 89, 93); // UXPL grayscale-cool dark
+ color: rgb(252, 252, 252); // UXPL grayscale white
}
}
- &.is-active{
- a {
- font-weight: bold;
+ &.is-active {
+
+ .speed-option,
+ .control-lang {
+ color: rgb(14, 166, 236); // UXPL primary accent
}
}
+ }
+ }
- &:last-child {
- box-shadow: none;
- border-bottom: 0;
- margin-top: 0;
- }
+ &.is-opened {
+
+ .menu {
+ display: block;
}
}
}
- div.speeds {
- &.is-opened {
- .speed-button {
- background-image: url('#{$static-path}/images/open-arrow.png');
+ .speeds,
+ .lang,
+ .grouped-controls {
+ display: inline-block;
+
+ .control {
+
+ .icon-fallback-img {
+ @include float(left);
+ @include transform-origin(center center);
}
}
+ }
- .menu{
- width: 131px;
+ .speeds {
- @media (max-width: 1120px) {
- width: 80px;
+ &.is-opened {
+
+ .control {
+
+ .icon {
+
+ @include ltr {
+ @include transform(rotate(-90deg));
+ }
+
+ @include rtl {
+ @include transform(rotate(90deg));
+ }
+ }
}
}
.speed-button {
- @extend %video-button;
- @include clearfix();
- background-image: url('#{$static-path}/images/closed-arrow.png');
- background-position: 10px center;
- background-repeat: no-repeat;
- min-width: 116px;
- text-indent: 0;
-
- @media (max-width: 1120px) {
- min-width: 0;
- width: 60px;
- }
.label {
- float: left;
- font-size: em(14);
- font-weight: normal;
- letter-spacing: 1px;
- padding: 0 lh(0.25) 0 lh(0.5);
- line-height: 46px;
- text-transform: uppercase;
- color: #999;
+ @include padding(0 ($baseline/3) 0 0);
+ font-family: $body-font-family;
+ color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
@media (max-width: 1120px) {
display: none;
@@ -487,117 +550,115 @@ div.video {
}
.value {
- float: left;
+ @include padding(0, lh(0.5), 0, 0);
+ color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
font-weight: bold;
- margin-bottom: 0;
- padding: 0 lh(0.5) 0 0;
@media (max-width: 1120px) {
- padding: 0 lh(0.5) 0 lh(0.5);
+ padding: 0 lh(0.5);
}
-
- line-height: 46px;
- color: $white;
}
}
}
- div.volume {
- float: left;
+ .lang {
+
+ .language-menu {
+ width: $baseline;
+ padding: ($baseline / 2) 0;
+ }
+
+ .control {
+
+ .icon {
+
+ @include rtl {
+ @include transform(rotate(-180deg));
+ }
+ }
+ }
+
+ &.is-opened {
+
+ .control {
+
+ .icon {
+
+ @include ltr {
+ @include transform(rotate(90deg));
+ }
+
+ @include rtl {
+ @include transform(rotate(90deg));
+ }
+ }
+ }
+ }
+ }
+
+ .volume {
+ display: inline-block;
position: relative;
&.is-opened {
+
.volume-slider-container {
display: block;
opacity: 1;
}
}
- &.is-muted {
- & > a {
- background-image: url('#{$static-path}/images/mute.png');
- }
- }
-
- & > a {
- @extend %video-button;
- @include clearfix();
- background-image: url('#{$static-path}/images/volume.png');
- background-position: 10px center;
- background-repeat: no-repeat;
- width: 30px;
- height: 46px;
- }
-
&:not(:first-child) > a {
- border-left: none;
+ @include border-left(none);
}
.volume-slider-container {
@include transition(none);
@extend %ui-depth1;
- box-shadow: inset 1px 0 0 #555, 0 3px 0 #444;
- background-color: #444;
- border: 1px solid $black;
- bottom: 46px;
display: none;
- opacity: 0;
position: absolute;
- width: 45px;
- height: 125px;
- margin-left: -1px;
+ bottom: ($baseline * 2);
+ @include right(0);
+ width: 41px;
+ height: 120px;
+ background-color: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
.volume-slider {
height: 100px;
- border: 0;
- width: 5px;
+ width: ($baseline / 4);
margin: 14px auto;
- background: #666;
- border: 1px solid $black;
- box-shadow: 0 1px 0 #333;
+ border: 0;
+ background: rgb(79, 89, 93); // UXPL grayscale-cool dark
- a.ui-slider-handle {
+ .ui-slider-handle {
@extend %ui-fake-link;
@include transition(height $tmg-s2 ease-in-out 0s, width $tmg-s2 ease-in-out 0s);
- background: $pink url('#{$static-path}/images/slider-handle.png') center center no-repeat;
- background-size: 50%;
- border: 1px solid darken($pink, 20%);
- border-radius: 15px;
- box-shadow: inset 0 1px 0 lighten($pink, 10%);
+ @include left(-5px);
height: 15px;
- left: -6px;
width: 15px;
+ background: rgb(203, 89, 141); // UXPL secondary base
+ border: 0;
+ border-radius: ($baseline / 5);
+
+ &:hover,
+ &:focus {
+ background: rgb(219, 139, 175); // UXPL secondary light
+ }
}
.ui-slider-range {
- background: #ddd;
+ background: rgb(142, 62, 99); // UXPL secondary dark
}
}
}
}
- a.add-fullscreen {
- @extend %video-button;
- background: url('#{$static-path}/images/fullscreen.png') center no-repeat;
- border-left: none;
- float: left;
- padding: 0 11px;
- width: 30px;
- }
-
-
- a.quality-control {
- @extend %video-button;
- background: url('#{$static-path}/images/hd.png') center no-repeat;
- border-left: none;
- float: left;
- padding: 0 11px;
- width: 30px;
+ .quality-control {
+ font-weight: 700;
+ letter-spacing: -1px;
&.active {
- background-color: #F44;
- color: #0ff;
- text-decoration: none;
+ color: rgb(14, 166, 236); // UXPL primary accent
}
&.is-hidden {
@@ -605,62 +666,55 @@ div.video {
}
}
- div.lang {
- & > a.hide-subtitles {
- @extend %video-button;
- @include transition(none);
- box-shadow: inset 1px 0 0 #555;
- background: url('#{$static-path}/images/cc.png') center no-repeat;
- border-left: none;
- border-right: none;
- padding: 0 11px;
- width: 30px;
+ .toggle-transcript {
- &.off {
- opacity: 0.7;
+ &.is-active {
+ color: rgb(14, 166, 236); // UXPL primary accent
}
- }
+ }
- .menu.langs-list {
- right: -1px;
- width: 150px;
+ .lang {
+
+ & > .hide-subtitles {
+ @include transition(none);
}
}
}
}
- &:hover section.video-controls {
- ul, div {
- opacity: 1;
- }
+ &:hover {
- div.slider {
- @include transform(scaleY(1) translate3d(0, 0, 0));
+ .video-controls {
- a.ui-slider-handle {
- @include transform(scale(1) translate3d(-50%, -15%, 0));
+ .slider {
+ height: ($baseline / 1.5);
+
+ .ui-slider-handle {
+ height: ($baseline / 1.5);
+ width: ($baseline / 1.5);
+ }
}
}
}
}
- ol.subtitles {
- padding-left: 0;
- float: left;
- max-height: 460px;
+ .subtitles {
+ @include float(left);
overflow: auto;
- width: flex-grid(3, 9);
margin: 0;
+ max-height: 460px;
+ width: flex-grid(3, 9);
+ padding: 0;
font-size: 14px;
list-style: none;
visibility: visible;
li {
@extend %ui-fake-link;
- border: 0;
- color: rgb(29,157,217);
margin-bottom: 8px;
+ border: 0;
padding: 0;
+ color: #0074b5; // AA compliant
line-height: lh();
&.current {
@@ -673,7 +727,8 @@ div.video {
outline-offset: -1px;
}
- &:hover, &:focus {
+ &:hover,
+ &:focus {
text-decoration: underline;
}
@@ -685,13 +740,12 @@ div.video {
&.closed {
- article.video-wrapper {
+ .video-wrapper {
width: flex-grid(9,9);
-
background-color: inherit;
}
- article.video-wrapper section.video-controls.html5 {
+ .video-wrapper .video-controls.html5 {
bottom: 0;
left: 0;
right: 0;
@@ -699,21 +753,22 @@ div.video {
z-index: 1;
}
- article.video-wrapper div.video-player-pre, article.video-wrapper div.video-player-post {
+ .video-wrapper .video-player-pre,
+ .video-wrapper .video-player-post {
height: 0;
}
- article.video-wrapper section.video-player {
+ .video-wrapper .video-player {
h3 {
color: black;
}
}
- ol.subtitles {
+ .subtitles {
@extend .is-hidden;
}
- ol.subtitles.html5 {
+ .subtitles.html5 {
@extend %ui-depth0;
background-color: rgba(243, 243, 243, 0.8);
height: 100%;
@@ -743,63 +798,66 @@ div.video {
border-radius: 0;
&.closed {
- div.tc-wrapper {
- article.video-wrapper {
+ .tc-wrapper {
+ .video-wrapper {
width: 100%;
}
}
}
- article.video-wrapper div.video-player-pre, article.video-wrapper div.video-player-post {
+ .video-wrapper .video-player-pre,
+ .video-wrapper .video-player-post {
height: 0;
}
- article.video-wrapper {
+ .video-wrapper {
position: static;
}
- article.video-wrapper section.video-player {
+ .video-wrapper .video-player {
h3 {
color: white;
}
}
- div.tc-wrapper {
+ .tc-wrapper {
@include clearfix();
width: 100%;
height: 100%;
position: static;
- article.video-wrapper {
+ .video-wrapper {
height: 100%;
width: 75%;
+ @include margin-right(0);
vertical-align: middle;
- margin-right: 0;
- object, iframe, video{
+ object,
+ iframe,
+ video{
position: absolute;
width: auto;
height: auto;
}
}
- section.video-controls {
+ .video-controls {
@extend %ui-depth4;
+ position: absolute;
bottom: 0;
left: 0;
- position: absolute;
width: 100%;
}
}
- ol.subtitles {
- @include box-sizing(border-box);
- @include transition(none);
- background: $black;
+ .subtitles {
height: 100%;
width: 25%;
padding: lh();
+ @include box-sizing(border-box);
+ @include transition(none);
+ background: $black;
visibility: visible;
li {
@@ -813,9 +871,11 @@ div.video {
}
&.is-touch {
- div.tc-wrapper {
- article.video-wrapper {
- object, iframe, video {
+ .tc-wrapper {
+ .video-wrapper {
+ object,
+ iframe,
+ video {
width: 100%;
height: 100%;
}
@@ -864,5 +924,3 @@ div.video {
}
}
}
-
-
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js
index a790f8ecae..feae0e58f3 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js
@@ -260,7 +260,7 @@
state.videoSpeedControl.setSpeed(1.0);
spyOn(state.videoPlayer, 'onSpeedChange').andCallThrough();
- $('li[data-speed="0.75"] a').click();
+ $('li[data-speed="0.75"] .speed-link').click();
});
it('trigger speedChange event', function () {
@@ -274,7 +274,7 @@
xdescribe('onSpeedChange', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
- $('li[data-speed="1.0"] a').addClass('active');
+ $('li[data-speed="1.0"] .speed-link').addClass('active');
state.videoSpeedControl.setSpeed(0.75);
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
index b8d489d861..9d822bd8c1 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
@@ -23,39 +23,39 @@
});
describe('constructor', function () {
+
describe('always', function () {
+
beforeEach(function () {
spyOn($, 'ajaxWithPrefix').andCallThrough();
});
- it('create the caption element', function () {
+ it('create the transcript element', function () {
state = jasmine.initializePlayer();
- expect($('.video')).toContain('ol.subtitles');
+ expect($('.video')).toContain('.subtitles');
});
- it('add caption control to video player', function () {
+ it('add transcript control to video player', function () {
state = jasmine.initializePlayer();
- expect($('.video')).toContain('a.hide-subtitles');
+ expect($('.video')).toContain('.toggle-transcript');
});
- it('add ARIA attributes to caption control', function () {
+ it('add ARIA attributes to transcript control', function () {
state = jasmine.initializePlayer();
- var captionControl = $('a.hide-subtitles');
+ var captionControl = $('.toggle-transcript');
expect(captionControl).toHaveAttrs({
- 'role': 'button',
- 'title': 'Turn off captions',
'aria-disabled': 'false'
});
});
- it('fetch the caption in HTML5 mode', function () {
+ it('fetch the transcript in HTML5 mode', function () {
runs(function () {
state = jasmine.initializePlayer();
});
waitsFor(function () {
return state.videoCaption.loaded;
- }, 'Expect captions to be loaded.', WAIT_TIMEOUT);
+ }, 'Expect transcript to be loaded.', WAIT_TIMEOUT);
runs(function () {
expect($.ajaxWithPrefix).toHaveBeenCalledWith({
@@ -70,7 +70,7 @@
});
});
- it('fetch the caption in Flash mode', function () {
+ it('fetch the transcript in Flash mode', function () {
runs(function () {
state = jasmine.initializePlayerYouTube();
spyOn(state, 'isFlashMode').andReturn(true);
@@ -79,7 +79,7 @@
waitsFor(function () {
return state.videoCaption.loaded;
- }, 'Expect captions to be loaded.', WAIT_TIMEOUT);
+ }, 'Expect transcript to be loaded.', WAIT_TIMEOUT);
runs(function () {
expect($.ajaxWithPrefix).toHaveBeenCalledWith({
@@ -96,14 +96,14 @@
});
});
- it('fetch the caption in Youtube mode', function () {
+ it('fetch the transcript in Youtube mode', function () {
runs(function () {
state = jasmine.initializePlayerYouTube();
});
waitsFor(function () {
return state.videoCaption.loaded;
- }, 'Expect captions to be loaded.', WAIT_TIMEOUT);
+ }, 'Expect transcript to be loaded.', WAIT_TIMEOUT);
runs(function () {
expect($.ajaxWithPrefix).toHaveBeenCalledWith({
@@ -159,7 +159,14 @@
});
describe('renderLanguageMenu', function () {
+
describe('is rendered', function () {
+ var KEY = $.ui.keyCode,
+
+ keyPressEvent = function(key) {
+ return $.Event('keydown', { keyCode: key });
+ };
+
it('if languages more than 1', function () {
state = jasmine.initializePlayer();
var transcripts = state.config.transcriptLanguages,
@@ -172,7 +179,7 @@
$('.langs-list li').each(function(index) {
var code = $(this).data('lang-code'),
- link = $(this).find('a'),
+ link = $(this).find('.control'),
label = link.text();
expect(code).toBeInArray(langCodes);
@@ -183,7 +190,7 @@
it('when clicking on link with new language', function () {
state = jasmine.initializePlayer();
var Caption = state.videoCaption,
- link = $('.langs-list li[data-lang-code="de"] a');
+ link = $('.langs-list li[data-lang-code="de"] .control-lang');
spyOn(Caption, 'fetchCaption');
spyOn(state.storage, 'setItem');
@@ -201,7 +208,7 @@
it('when clicking on link with current language', function () {
state = jasmine.initializePlayer();
var Caption = state.videoCaption,
- link = $('.langs-list li[data-lang-code="en"] a');
+ link = $('.langs-list li[data-lang-code="en"] .control-lang');
spyOn(Caption, 'fetchCaption');
spyOn(state.storage, 'setItem');
@@ -223,6 +230,23 @@
$('.lang').mouseleave();
expect($('.lang')).not.toHaveClass('is-opened');
});
+
+ it('opens the language menu on arrow up', function() {
+ state = jasmine.initializePlayer();
+ $('.language-menu').focus();
+ $('.language-menu').trigger(keyPressEvent(KEY.UP));
+ expect($('.lang')).toHaveClass('is-opened');
+ expect($('.langs-list').find('li').last().find('.control-lang')).toBeFocused();
+ });
+
+ it('closes the language menu on ESC', function() {
+ state = jasmine.initializePlayer();
+ $('.language-menu').trigger(keyPressEvent(KEY.UP));
+ expect($('.lang')).toHaveClass('is-opened');
+ $('.language-menu').trigger(keyPressEvent(KEY.ESCAPE));
+ expect($('.lang')).not.toHaveClass('is-opened');
+ expect($('.language-menu')).toBeFocused();
+ });
});
describe('is not rendered', function () {
@@ -246,10 +270,10 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
});
- it('render the caption', function () {
+ it('render the transcript', function () {
runs(function () {
var captionsData = jasmine.stubbedCaption,
items = $('.subtitles li[data-index]');
@@ -267,7 +291,7 @@
});
});
- it('add a padding element to caption', function () {
+ it('add a padding element to transcript', function () {
runs(function () {
expect($('.subtitles li:first').hasClass('spacing'))
.toBe(true);
@@ -277,7 +301,7 @@
});
- it('bind all the caption link', function () {
+ it('bind all the transcript link', function () {
runs(function () {
var handlerList = ['captionMouseOverOut', 'captionClick',
'captionMouseDown', 'captionFocus', 'captionBlur',
@@ -323,7 +347,7 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
runs(function () {
expect(state.videoCaption.rendered).toBeTruthy();
@@ -346,14 +370,14 @@
);
});
- it('show captions on play', function () {
+ it('show transcript on play', function () {
runs(function () {
state.el.trigger('play');
});
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
runs(function () {
var captionsData = jasmine.stubbedCaption,
@@ -377,7 +401,7 @@
});
});
- describe('when no captions file was specified', function () {
+ describe('when no transcripts file was specified', function () {
beforeEach(function () {
state = jasmine.initializePlayer('video_all.html', {
'sub': '',
@@ -385,8 +409,8 @@
});
});
- it('captions panel is not shown', function () {
- expect(state.videoCaption.hideSubtitlesEl).toBeHidden();
+ it('transcript panel is not shown', function () {
+ expect(state.videoCaption.languageChooserEl).toBeHidden();
});
});
});
@@ -403,10 +427,10 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
});
- describe('when cursor is outside of the caption box', function () {
+ describe('when cursor is outside of the transcript box', function () {
it('does not set freezing timeout', function () {
runs(function () {
expect(state.videoCaption.frozen).toBeFalsy();
@@ -414,7 +438,7 @@
});
});
- describe('when cursor is in the caption box', function () {
+ describe('when cursor is in the transcript box', function () {
beforeEach(function () {
spyOn(state.videoCaption, 'onMouseLeave');
runs(function () {
@@ -452,7 +476,7 @@
});
describe(
- 'when cursor is moving out of the caption box',
+ 'when cursor is moving out of the transcript box',
function () {
beforeEach(function () {
@@ -469,7 +493,7 @@
expect(window.clearTimeout).toHaveBeenCalledWith(100);
});
- it('unfreeze the caption', function () {
+ it('unfreeze the transcript', function () {
expect(state.videoCaption.frozen).toBeNull();
});
});
@@ -482,7 +506,7 @@
$('.subtitles').trigger(jQuery.Event('mouseout'));
});
- it('scroll the caption', function () {
+ it('scroll the transcript', function () {
expect($.fn.scrollTo).toHaveBeenCalled();
});
});
@@ -493,7 +517,7 @@
$('.subtitles').trigger(jQuery.Event('mouseout'));
});
- it('does not scroll the caption', function () {
+ it('does not scroll the transcript', function () {
expect($.fn.scrollTo).not.toHaveBeenCalled();
});
});
@@ -514,7 +538,7 @@
spyOn(state, 'youtubeId').andReturn('Z5KLxerq05Y');
});
- it('show caption on language change', function () {
+ it('show transcript on language change', function () {
Caption.loaded = true;
Caption.fetchCaption();
@@ -522,7 +546,7 @@
expect(Caption.hideCaptions).toHaveBeenCalledWith(false);
});
- msg = 'use cookie to show/hide captions if they have not been ' +
+ msg = 'use cookie to show/hide transcripts if they have not been ' +
'loaded yet';
it(msg, function () {
Caption.loaded = false;
@@ -554,7 +578,7 @@
});
msg = 'on success: change language on touch devices when ' +
- 'captions have not been rendered yet';
+ 'transcripts have not been rendered yet';
it(msg, function () {
state.isTouch = true;
Caption.loaded = true;
@@ -604,7 +628,7 @@
expect(Caption.loaded).toBeTruthy();
});
- msg = 'on error: captions are hidden if there are no transcripts';
+ msg = 'on error: transcripts are hidden if there are no transcripts';
it(msg, function () {
spyOn(Caption, 'fetchAvailableTranslations');
$.ajax.andCallFake(function (settings) {
@@ -619,7 +643,6 @@
expect(Caption.fetchAvailableTranslations).not.toHaveBeenCalled();
expect(Caption.hideCaptions.mostRecentCall.args)
.toEqual([true, false]);
- expect(Caption.hideSubtitlesEl).toBeHidden();
});
msg = 'on error: for Html5 player an attempt to fetch transcript ' +
@@ -667,7 +690,7 @@
msg = 'on error: fetch available translations if there are ' +
'additional transcripts';
- xit(msg, function () {
+ it(msg, function () {
$.ajax
.andCallFake(function (settings) {
_.result(settings, 'error');
@@ -683,7 +706,6 @@
expect($.ajaxWithPrefix).toHaveBeenCalled();
expect(Caption.fetchAvailableTranslations).toHaveBeenCalled();
- expect(Caption.hideCaptions).not.toHaveBeenCalled();
});
});
@@ -745,7 +767,7 @@
expect(Caption.renderLanguageMenu).not.toHaveBeenCalled();
});
- msg = 'on error: captions are hidden if there are no transcript';
+ msg = 'on error: transcripts are hidden if there are no transcript';
it(msg, function () {
$.ajax.andCallFake(function (settings) {
_.result(settings, 'error');
@@ -754,12 +776,12 @@
expect($.ajaxWithPrefix).toHaveBeenCalled();
expect(Caption.hideCaptions).toHaveBeenCalledWith(true, false);
- expect(Caption.hideSubtitlesEl).toBeHidden();
+ expect(Caption.subtitlesEl).toBeHidden();
});
});
describe('play', function () {
- describe('when the caption was not rendered', function () {
+ describe('when the transcript was not rendered', function () {
beforeEach(function () {
window.onTouchBasedDevice.andReturn(['iPad']);
@@ -770,10 +792,10 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
});
- it('render the caption', function () {
+ it('render the transcript', function () {
runs(function () {
var captionsData;
@@ -792,7 +814,7 @@
});
- it('add a padding element to caption', function () {
+ it('add a padding element to transcript', function () {
runs(function () {
expect($('.subtitles li:first')).toBe('.spacing');
expect($('.subtitles li:last')).toBe('.spacing');
@@ -833,7 +855,7 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
});
describe('when the video speed is 1.0x', function () {
@@ -852,7 +874,7 @@
});
describe('when the video speed is not 1.0x', function () {
- it('search the caption based on 1.0x speed', function () {
+ it('search the transcript based on 1.0x speed', function () {
runs(function () {
state.videoCaption.updatePlayTime(25.000);
expect(state.videoCaption.currentIndex).toEqual(5);
@@ -882,14 +904,14 @@
});
});
- it('deactivate the previous caption', function () {
+ it('deactivate the previous transcript', function () {
runs(function () {
expect($('.subtitles li[data-index=1]'))
.not.toHaveClass('current');
});
});
- it('activate new caption', function () {
+ it('activate new transcript', function () {
runs(function () {
expect($('.subtitles li[data-index=5]'))
.toHaveClass('current');
@@ -902,7 +924,7 @@
});
});
- it('scroll caption to new position', function () {
+ it('scroll transcript to new position', function () {
runs(function () {
expect($.fn.scrollTo).toHaveBeenCalled();
});
@@ -930,7 +952,7 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
runs(function () {
videoControl = state.videoControl;
@@ -939,8 +961,8 @@
});
});
- describe('set the height of caption container', function () {
- it('when CC button is enabled', function () {
+ describe('set the height of transcript container', function () {
+ it('when transcript button is enabled', function () {
runs(function () {
var realHeight = parseInt(
$('.subtitles').css('maxHeight'), 10
@@ -953,7 +975,7 @@
});
});
- it('when CC button is disabled ', function () {
+ it('when transcript button is disabled ', function () {
runs(function () {
var realHeight, videoWrapperHeight, progressSliderHeight,
controlHeight, shouldBeHeight;
@@ -976,7 +998,7 @@
});
});
- it('set the height of caption spacing', function () {
+ it('set the height of transcript spacing', function () {
runs(function () {
var firstSpacing, lastSpacing;
@@ -994,7 +1016,7 @@
});
});
- it('scroll caption to new position', function () {
+ it('scroll transcript to new position', function () {
runs(function () {
expect($.fn.scrollTo).toHaveBeenCalled();
});
@@ -1009,11 +1031,11 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
});
describe('when frozen', function () {
- it('does not scroll the caption', function () {
+ it('does not scroll the transcript', function () {
runs(function () {
state.videoCaption.frozen = true;
$('.subtitles li[data-index=1]').addClass('current');
@@ -1030,8 +1052,8 @@
});
});
- describe('when there is no current caption', function () {
- it('does not scroll the caption', function () {
+ describe('when there is no current transcript', function () {
+ it('does not scroll the transcript', function () {
runs(function () {
state.videoCaption.scrollCaption();
expect($.fn.scrollTo).not.toHaveBeenCalled();
@@ -1039,8 +1061,8 @@
});
});
- describe('when there is a current caption', function () {
- it('scroll to current caption', function () {
+ describe('when there is a current transcript', function () {
+ it('scroll to current transcript', function () {
runs(function () {
$('.subtitles li[data-index=1]').addClass('current');
state.videoCaption.scrollCaption();
@@ -1062,7 +1084,7 @@
isRendered = state.videoCaption.rendered;
return isRendered && duration;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
});
describe('when the video speed is 1.0x', function () {
@@ -1104,40 +1126,30 @@
$('.subtitles li[data-index=1]').addClass('current');
});
- describe('when the caption is visible', function () {
+ describe('when the transcript is visible', function () {
beforeEach(function () {
state.el.removeClass('closed');
state.videoCaption.toggle(jQuery.Event('click'));
});
- it('hide the caption', function () {
+ it('hide the transcript', function () {
expect(state.el).toHaveClass('closed');
});
-
- it('changes ARIA attribute of caption control', function () {
- expect($('a.hide-subtitles'))
- .toHaveAttr('title', 'Turn on captions');
- });
});
- describe('when the caption is hidden', function () {
+ describe('when the transcript is hidden', function () {
beforeEach(function () {
state.el.addClass('closed');
state.videoCaption.toggle(jQuery.Event('click'));
jasmine.Clock.useMock();
});
- it('show the caption', function () {
+ it('show the transcript', function () {
expect(state.el).not.toHaveClass('closed');
});
- it('changes ARIA attribute of caption control', function () {
- expect($('a.hide-subtitles'))
- .toHaveAttr('title', 'Turn off captions');
- });
-
// Test turned off due to flakiness (11/25/13)
- xit('scroll the caption', function () {
+ xit('scroll the transcript', function () {
// After transcripts are shown, and the video plays for a
// bit.
jasmine.Clock.tick(1000);
@@ -1153,7 +1165,7 @@
});
});
- describe('caption accessibility', function () {
+ describe('transcript accessibility', function () {
beforeEach(function () {
runs(function () {
state = jasmine.initializePlayer();
@@ -1161,7 +1173,7 @@
waitsFor(function () {
return state.videoCaption.rendered;
- }, 'Captions are not rendered', WAIT_TIMEOUT);
+ }, 'Transcripts are not rendered', WAIT_TIMEOUT);
});
describe('when getting focus through TAB key', function () {
@@ -1174,7 +1186,7 @@
});
});
- it('shows an outline around the caption', function () {
+ it('shows an outline around the transcript', function () {
runs(function () {
expect($('.subtitles li[data-index=0]'))
.toHaveClass('focused');
@@ -1197,7 +1209,7 @@
});
});
- it('does not show an outline around the caption', function () {
+ it('does not show an outline around the transcript', function () {
runs(function () {
expect($('.subtitles li[data-index=0]'))
.not.toHaveClass('focused');
@@ -1212,7 +1224,7 @@
});
describe(
- 'when same caption gets the focus through mouse after ' +
+ 'when same transcript gets the focus through mouse after ' +
'having focus through TAB key',
function () {
@@ -1241,7 +1253,7 @@
});
describe(
- 'when a second caption gets focus through mouse after ' +
+ 'when a second transcript gets focus through mouse after ' +
'first had focus through TAB key',
function () {
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_context_menu_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_context_menu_spec.js
index 295b151a4f..96b854340b 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_context_menu_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_context_menu_spec.js
@@ -5,16 +5,16 @@
closeSubmenuKeyboard, menu, menuItems, menuSubmenuItem, submenu, submenuItems, overlay, playButton;
openMenu = function () {
- var container = $('div.video');
+ var container = $('.video');
jasmine.Clock.useMock();
container.find('video').trigger('contextmenu');
- menu = container.children('ol.contextmenu');
- menuItems = menu.children('li.menu-item').not('.submenu-item');
- menuSubmenuItem = menu.children('li.menu-item.submenu-item');
- submenu = menuSubmenuItem.children('ol.submenu');
- submenuItems = submenu.children('li.menu-item');
- overlay = container.children('div.overlay');
- playButton = $('a.video_control.play');
+ menu = container.children('.contextmenu');
+ menuItems = menu.children('.menu-item').not('.submenu-item');
+ menuSubmenuItem = menu.children('.menu-item.submenu-item');
+ submenu = menuSubmenuItem.children('.submenu');
+ submenuItems = submenu.children('.menu-item');
+ overlay = container.children('.overlay');
+ playButton = $('.video_control.play');
};
keyPressEvent = function(key) {
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js
index 215b891f41..6aa858b73f 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js
@@ -30,8 +30,6 @@
var fullScreenControl = $('.add-fullscreen');
expect(fullScreenControl).toHaveAttrs({
- 'role': 'button',
- 'title': 'Fill browser',
'aria-disabled': 'false'
});
});
@@ -53,14 +51,10 @@
var fullScreenControl = $('.add-fullscreen');
fullScreenControl.click();
expect(fullScreenControl).toHaveAttrs({
- 'role': 'button',
- 'title': 'Exit full browser',
'aria-disabled': 'false'
});
fullScreenControl.click();
expect(fullScreenControl).toHaveAttrs({
- 'role': 'button',
- 'title': 'Fill browser',
'aria-disabled': 'false'
});
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_play_pause_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_play_pause_control_spec.js
index 877dc9861e..0cbb23c1f1 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_play_pause_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_play_pause_control_spec.js
@@ -25,8 +25,6 @@
it('add ARIA attributes to play control', function () {
expect($('.video_control.play')).toHaveAttrs({
- 'role': 'button',
- 'title': 'Play',
'aria-disabled': 'false'
});
});
@@ -34,8 +32,6 @@
it('can update ARIA state on play', function () {
state.el.trigger('play');
expect($('.video_control.pause')).toHaveAttrs({
- 'role': 'button',
- 'title': 'Pause',
'aria-disabled': 'false'
});
});
@@ -44,8 +40,6 @@
state.el.trigger('play');
state.el.trigger('ended');
expect($('.video_control.play')).toHaveAttrs({
- 'role': 'button',
- 'title': 'Play',
'aria-disabled': 'false'
});
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_play_skip_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_play_skip_control_spec.js
index 9ccea6a0ab..725bdd5c8a 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_play_skip_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_play_skip_control_spec.js
@@ -27,8 +27,6 @@
it('add ARIA attributes to play control', function () {
expect($('.video_control.play')).toHaveAttrs({
- 'role': 'button',
- 'title': 'Play',
'aria-disabled': 'false'
});
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
index 843b6c3f65..e521282ec8 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
@@ -745,11 +745,6 @@ function (VideoPlayer) {
$('.add-fullscreen').click();
});
- it('replace the full screen button tooltip', function () {
- expect($('.add-fullscreen'))
- .toHaveAttr('title', 'Exit full browser');
- });
-
it('add the video-fullscreen class', function () {
expect(state.el).toHaveClass('video-fullscreen');
});
@@ -773,11 +768,6 @@ function (VideoPlayer) {
$('.add-fullscreen').click();
});
- it('replace the full screen button tooltip', function () {
- expect($('.add-fullscreen'))
- .toHaveAttr('title', 'Fill browser');
- });
-
it('remove the video-fullscreen class', function () {
expect(state.el).not.toHaveClass('video-fullscreen');
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js
index 0bf3722a4c..5ce7375aee 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_quality_control_spec.js
@@ -33,8 +33,6 @@
it('add ARIA attributes to quality control', function () {
expect(qualityControl.el).toHaveAttrs({
- 'role': 'button',
- 'title': 'HD off',
'aria-disabled': 'false'
});
});
@@ -117,7 +115,7 @@
it('does not contain the quality control', function () {
state = jasmine.initializePlayer();
- expect(state.el.find('a.quality-control').length).toBe(0);
+ expect(state.el.find('.quality-control').length).toBe(0);
});
});
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_skip_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_skip_control_spec.js
index da3a87845b..d3c0b47d52 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_skip_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_skip_control_spec.js
@@ -33,8 +33,6 @@
it('add ARIA attributes to play control', function () {
state.el.trigger('play');
expect($('.skip-control')).toHaveAttrs({
- 'role': 'button',
- 'title': 'Do not show again',
'aria-disabled': 'false'
});
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js
index d5b14e6b2d..2ea4a7e365 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js
@@ -1,4 +1,5 @@
(function (undefined) {
+ 'use strict';
describe('VideoSpeedControl', function () {
var state, oldOTBD;
@@ -38,21 +39,11 @@
expect($(link)).toHaveData(
'speed', state.speeds[index]
);
- expect($(link).find('a').text()).toBe(
+ expect($(link).find('.speed-option').text()).toBe(
state.speeds[index] + 'x'
);
});
});
-
- it('add ARIA attributes to speed control', function () {
- var speedControl = $('div.speeds>a');
-
- expect(speedControl).toHaveAttrs({
- 'role': 'button',
- 'title': 'Speeds',
- 'aria-disabled': 'false'
- });
- });
});
describe('when running on touch based device', function () {
@@ -61,33 +52,17 @@
window.onTouchBasedDevice.andReturn([device]);
state = jasmine.initializePlayer();
- expect(state.el.find('div.speeds')).not.toExist();
+ expect(state.el.find('.speeds')).not.toExist();
});
});
});
describe('when running on non-touch based device', function () {
- var speedControl, speedEntries, speedButton,
+ var speedControl, speedEntries, speedButton, speedsContainer,
KEY = $.ui.keyCode,
keyPressEvent = function(key) {
return $.Event('keydown', {keyCode: key});
- },
-
- // Get previous element in array or cyles back to the last
- // if it is the first.
- previousSpeed = function(index) {
- return speedEntries.eq(index < 1 ?
- speedEntries.length - 1 :
- index - 1);
- },
-
- // Get next element in array or cyles back to the first if
- // it is the last.
- nextSpeed = function(index) {
- return speedEntries.eq(index >= speedEntries.length-1 ?
- 0 :
- index + 1);
};
beforeEach(function () {
@@ -95,7 +70,7 @@
speedControl = $('.speeds');
speedButton = $('.speed-button');
speedsContainer = $('.video-speeds');
- speedEntries = speedsContainer.find('a');
+ speedEntries = speedsContainer.find('.speed-option');
});
it('open/close the speed menu on mouseenter/mouseleave',
@@ -114,11 +89,6 @@
expect(speedControl).toHaveClass('is-opened');
});
- it('close the speed menu on click', function () {
- speedControl.mouseenter().click();
- expect(speedControl).not.toHaveClass('is-opened');
- });
-
it('close the speed menu on outside click', function () {
speedControl.trigger(keyPressEvent(KEY.ENTER));
$(window).click();
@@ -150,8 +120,7 @@
it('UP and DOWN keydown function as expected on speed entries',
function () {
- var lastEntry = speedEntries.length-1,
- speed_0_75 = speedEntries.filter(':contains("0.75x")'),
+ var speed_0_75 = speedEntries.filter(':contains("0.75x")'),
speed_1_0 = speedEntries.filter(':contains("1.0x")');
// First open menu
@@ -226,7 +195,7 @@
it('trigger speedChange event', function () {
spyOnEvent(state.el, 'speedchange');
- $('li[data-speed="0.75"] a').click();
+ $('li[data-speed="0.75"] .speed-option').click();
expect('speedchange').toHaveBeenTriggeredOn(state.el);
expect(state.videoSpeedControl.currentSpeed).toEqual('0.75');
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js
index e1edb571d3..5c3f782e85 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js
@@ -3,6 +3,12 @@
describe('VideoVolumeControl', function () {
var state, oldOTBD, volumeControl;
+ var KEY = $.ui.keyCode,
+
+ keyPressEvent = function(key) {
+ return $.Event('keydown', { keyCode: key });
+ };
+
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice')
@@ -56,24 +62,20 @@ describe('VideoVolumeControl', function () {
var liveRegion = $('.video-live-region');
expect(liveRegion).toHaveAttrs({
- 'role': 'status',
- 'aria-live': 'polite',
- 'aria-atomic': 'false'
+ 'aria-live': 'polite'
});
});
it('add ARIA attributes to volume control', function () {
- var button = $('.volume > a');
+ var button = $('.volume .control');
expect(button).toHaveAttrs({
- 'role': 'button',
- 'title': 'Volume',
'aria-disabled': 'false'
});
});
it('bind the volume control', function () {
- var button = $('.volume > a');
+ var button = $('.volume .control');
expect(button).toHandle('keydown');
expect(button).toHandle('mousedown');
@@ -185,16 +187,19 @@ describe('VideoVolumeControl', function () {
});
describe('increaseVolume', function () {
+
beforeEach(function () {
state = jasmine.initializePlayer();
volumeControl = state.videoVolumeControl;
});
it('volume is increased correctly', function () {
+ var button = $('.volume .control');
volumeControl.volume = 60;
- state.el.trigger(jQuery.Event("keydown", {
- keyCode: $.ui.keyCode.UP
- }));
+
+ // adjust the volume
+ button.focus();
+ button.trigger(keyPressEvent(KEY.UP));
expect(volumeControl.volume).toEqual(80);
});
@@ -206,16 +211,19 @@ describe('VideoVolumeControl', function () {
});
describe('decreaseVolume', function () {
+
beforeEach(function () {
state = jasmine.initializePlayer();
volumeControl = state.videoVolumeControl;
});
it('volume is decreased correctly', function () {
+ var button = $('.volume .control');
volumeControl.volume = 60;
- state.el.trigger(jQuery.Event("keydown", {
- keyCode: $.ui.keyCode.DOWN
- }));
+
+ // adjust the volume
+ button.focus();
+ button.trigger(keyPressEvent(KEY.DOWN));
expect(volumeControl.volume).toEqual(40);
});
@@ -274,21 +282,21 @@ describe('VideoVolumeControl', function () {
it('nothing happens if ALT+keyUp are pushed down', function () {
assertVolumeIsNotChanged({
- keyCode: $.ui.keyCode.UP,
+ keyCode: KEY.UP,
altKey: true
});
});
it('nothing happens if SHIFT+keyUp are pushed down', function () {
assertVolumeIsNotChanged({
- keyCode: $.ui.keyCode.UP,
+ keyCode: KEY.UP,
shiftKey: true
});
});
it('nothing happens if SHIFT+keyDown are pushed down', function () {
assertVolumeIsNotChanged({
- keyCode: $.ui.keyCode.DOWN,
+ keyCode: KEY.DOWN,
shiftKey: true
});
});
@@ -302,8 +310,8 @@ describe('VideoVolumeControl', function () {
it('nothing happens if ALT+ENTER are pushed down', function () {
var isMuted = volumeControl.getMuteStatus();
- $('.volume > a').trigger(jQuery.Event("keydown", {
- keyCode: $.ui.keyCode.ENTER,
+ $('.volume .control').trigger(jQuery.Event("keydown", {
+ keyCode: KEY.ENTER,
altKey: true
}));
expect(volumeControl.getMuteStatus()).toEqual(isMuted);
diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js b/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js
index e561852057..916f840a9f 100644
--- a/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js
+++ b/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js
@@ -2,10 +2,14 @@
'use strict';
define('video/04_video_full_screen.js', [], function () {
var template = [
- '',
- gettext('Fill browser'),
- ''
+ ''
].join('');
// VideoControl() function - what this module "exports".
@@ -133,8 +137,12 @@ define('video/04_video_full_screen.js', [], function () {
fullScreenClassNameEl.removeClass('video-fullscreen');
$(window).scrollTop(this.scrollPos);
this.videoFullScreen.fullScreenEl
- .attr('title', gettext('Fill browser'))
- .text(gettext('Fill browser'));
+ .find('.icon')
+ .removeClass('fa-compress')
+ .addClass('fa-arrows-alt')
+ .find('.control-text')
+ .text(gettext('Fill browser'));
+
this.el.trigger('fullscreen', [this.isFullScreen]);
}
@@ -146,8 +154,12 @@ define('video/04_video_full_screen.js', [], function () {
this.videoFullScreen.fullScreenState = this.isFullScreen = true;
fullScreenClassNameEl.addClass('video-fullscreen');
this.videoFullScreen.fullScreenEl
- .attr('title', gettext('Exit full browser'))
- .text(gettext('Exit full browser'));
+ .find('.icon')
+ .removeClass('fa-arrows-alt')
+ .addClass('fa-compress')
+ .find('.control-text')
+ .text(gettext('Exit full browser'));
+
this.el.trigger('fullscreen', [this.isFullScreen]);
}
diff --git a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js
index 11965fe31e..10e119b1ad 100644
--- a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js
@@ -1,15 +1,27 @@
(function (requirejs, require, define) {
// VideoQualityControl module.
+'use strict';
define(
'video/05_video_quality_control.js',
[],
function () {
var template = [
- '',
- gettext('HD off'),
- ''
+ ''
].join('');
// VideoQualityControl() function - what this module "exports".
@@ -134,17 +146,17 @@ function () {
var controlStateStr;
this.videoQualityControl.quality = value;
if (_.contains(this.config.availableHDQualities, value)) {
- controlStateStr = gettext('HD on');
+ controlStateStr = gettext('on');
this.videoQualityControl.el
.addClass('active')
- .attr('title', controlStateStr)
- .text(controlStateStr);
+ .find('.control-text')
+ .text(controlStateStr);
} else {
- controlStateStr = gettext('HD off');
+ controlStateStr = gettext('off');
this.videoQualityControl.el
.removeClass('active')
- .attr('title', controlStateStr)
- .text(controlStateStr);
+ .find('.control-text')
+ .text(controlStateStr);
}
}
diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
index 7177ee9215..aff29a4459 100644
--- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js
@@ -38,13 +38,25 @@ function() {
step: 20,
template: [
- '
'
- ].join(''),
-
- template: [
- '',
- '',
- ''
- ].join(''),
-
- destroy: function () {
- this.state.el
- .off({
- 'caption:fetch': this.fetchCaption,
- 'caption:resize': this.onResize,
- 'caption:update': this.onCaptionUpdate,
- 'ended': this.pause,
- 'fullscreen': this.onResize,
- 'pause': this.pause,
- 'play': this.play,
- 'destroy': this.destroy
- })
- .removeClass('is-captions-rendered');
- if (this.fetchXHR && this.fetchXHR.abort) {
- this.fetchXHR.abort();
- }
- if (this.availableTranslationsXHR && this.availableTranslationsXHR.abort) {
- this.availableTranslationsXHR.abort();
- }
- this.subtitlesEl.remove();
- this.container.remove();
- delete this.state.videoCaption;
- },
+ define(
+ 'video/09_video_caption.js',
+ ['video/00_sjson.js', 'video/00_async_process.js'],
+ function (Sjson, AsyncProcess) {
/**
- * @desc Initiate rendering of elements, and set their initial configuration.
- *
- */
- renderElements: function () {
- var languages = this.state.config.transcriptLanguages;
-
- this.loaded = false;
- this.subtitlesEl = $(this.template);
- this.container = $(this.langTemplate);
- this.hideSubtitlesEl = this.container.find('a.hide-subtitles');
-
- if (_.keys(languages).length) {
- this.renderLanguageMenu(languages);
- this.fetchCaption();
- }
- },
-
- /**
- * @desc Bind any necessary function callbacks to DOM events (click,
- * mousemove, etc.).
- *
- */
- bindHandlers: function () {
- var state = this.state,
- events = [
- 'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur',
- 'keydown'
- ].join(' ');
-
- this.hideSubtitlesEl.on('click', this.toggle);
- this.subtitlesEl
- .on({
- mouseenter: this.onMouseEnter,
- mouseleave: this.onMouseLeave,
- mousemove: this.onMovement,
- mousewheel: this.onMovement,
- DOMMouseScroll: this.onMovement
- })
- .on(events, 'li[data-index]', this.onCaptionHandler);
-
- if (this.showLanguageMenu) {
- this.container.on({
- mouseenter: this.onContainerMouseEnter,
- mouseleave: this.onContainerMouseLeave
- });
+ * @desc VideoCaption module exports a function.
+ *
+ * @type {function}
+ * @access public
+ *
+ * @param {object} state - The object containing the state of the video
+ * player. All other modules, their parameters, public variables, etc.
+ * are available via this object.
+ *
+ * @this {object} The global window object.
+ *
+ * @returns {jquery Promise}
+ */
+ var VideoCaption = function (state) {
+ if (!(this instanceof VideoCaption)) {
+ return new VideoCaption(state);
}
- state.el
- .on({
- 'caption:fetch': this.fetchCaption,
- 'caption:resize': this.onResize,
- 'caption:update': this.onCaptionUpdate,
- 'ended': this.pause,
- 'fullscreen': this.onResize,
- 'pause': this.pause,
- 'play': this.play,
- 'destroy': this.destroy
- });
-
- if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
- this.subtitlesEl.on('scroll', state.videoControl.showControls);
- }
- },
-
- onCaptionUpdate: function (event, time) {
- this.updatePlayTime(time);
- },
-
- onCaptionHandler: function (event) {
- switch (event.type) {
- case 'mouseover':
- case 'mouseout':
- this.captionMouseOverOut(event);
- break;
- case 'mousedown':
- this.captionMouseDown(event);
- break;
- case 'click':
- this.captionClick(event);
- break;
- case 'focusin':
- this.captionFocus(event);
- break;
- case 'focusout':
- this.captionBlur(event);
- break;
- case 'keydown':
- this.captionKeyDown(event);
- break;
- }
- },
-
- /**
- * @desc Opens language menu.
- *
- * @param {jquery Event} event
- */
- onContainerMouseEnter: function (event) {
- event.preventDefault();
- $(event.currentTarget).addClass('is-opened');
- this.state.el.trigger('language_menu:show');
- },
-
- /**
- * @desc Closes language menu.
- *
- * @param {jquery Event} event
- */
- onContainerMouseLeave: function (event) {
- event.preventDefault();
- $(event.currentTarget).removeClass('is-opened');
- this.state.el.trigger('language_menu:hide');
- },
-
- /**
- * @desc Freezes moving of captions when mouse is over them.
- *
- * @param {jquery Event} event
- */
- onMouseEnter: function (event) {
- if (this.frozen) {
- clearTimeout(this.frozen);
- }
-
- this.frozen = setTimeout(
- this.onMouseLeave,
- this.state.config.captionsFreezeTime
+ _.bindAll(this, 'toggle', 'onMouseEnter', 'onMouseLeave', 'onMovement',
+ 'onContainerMouseEnter', 'onContainerMouseLeave', 'fetchCaption',
+ 'onResize', 'pause', 'play', 'onCaptionUpdate', 'onCaptionHandler', 'destroy',
+ 'handleKeypress', 'handleKeypressLink', 'openLanguageMenu', 'closeLanguageMenu',
+ 'previousLanguageMenuItem', 'nextLanguageMenuItem'
);
- },
+ this.state = state;
+ this.state.videoCaption = this;
+ this.renderElements();
- /**
- * @desc Unfreezes moving of captions when mouse go out.
- *
- * @param {jquery Event} event
- */
- onMouseLeave: function (event) {
- if (this.frozen) {
- clearTimeout(this.frozen);
- }
+ return $.Deferred().resolve().promise();
+ };
- this.frozen = null;
+ VideoCaption.prototype = {
+ langTemplate: [
+ '
',
+ '',
+ '
',
+ '',
+ '
',
+ '
'
+ ].join(''),
- if (this.playing) {
- this.scrollCaption();
- }
- },
+ template: [
+ '',
+ '',
+ ''
+ ].join(''),
- /**
- * @desc Freezes moving of captions when mouse is moving over them.
- *
- * @param {jquery Event} event
- */
- onMovement: function (event) {
- this.onMouseEnter();
- },
+ destroy: function () {
+ this.state.el
+ .off({
+ 'caption:fetch': this.fetchCaption,
+ 'caption:resize': this.onResize,
+ 'caption:update': this.onCaptionUpdate,
+ 'ended': this.pause,
+ 'fullscreen': this.onResize,
+ 'pause': this.pause,
+ 'play': this.play,
+ 'destroy': this.destroy
+ })
+ .removeClass('is-captions-rendered');
+ if (this.fetchXHR && this.fetchXHR.abort) {
+ this.fetchXHR.abort();
+ }
+ if (this.availableTranslationsXHR && this.availableTranslationsXHR.abort) {
+ this.availableTranslationsXHR.abort();
+ }
+ this.subtitlesEl.remove();
+ this.container.remove();
+ delete this.state.videoCaption;
+ },
+ /**
+ * @desc Initiate rendering of elements, and set their initial configuration.
+ *
+ */
+ renderElements: function () {
+ var languages = this.state.config.transcriptLanguages;
- /**
- * @desc Gets the correct start and end times from the state configuration
- *
- * @returns {array} if [startTime, endTime] are defined
- */
- getStartEndTimes: function () {
- // due to the way config.startTime/endTime are
- // processed in 03_video_player.js, we assume
- // endTime can be an integer or null,
- // and startTime is an integer > 0
- var config = this.state.config;
- var startTime = config.startTime * 1000;
- var endTime = (config.endTime !== null) ? config.endTime * 1000 : null;
- return [startTime, endTime];
- },
+ this.loaded = false;
+ this.subtitlesEl = $(this.template);
+ this.container = $(this.langTemplate);
+ this.transcriptControlEl = this.container.find('.toggle-transcript');
+ this.languageChooserEl = this.container.find('.lang');
+ this.menuChooserEl = this.languageChooserEl.parent();
- /**
- * @desc Gets captions within the start / end times stored within this.state.config
- *
- * @returns {object} {start, captions} parallel arrays of
- * start times and corresponding captions
- */
- getBoundedCaptions: function () {
- // get start and caption. If startTime and endTime
- // are specified, filter by that range.
- var times = this.getStartEndTimes();
- var results = this.sjson.filter.apply(this.sjson, times);
- var start = results.start;
- var captions = results.captions;
+ if (_.keys(languages).length) {
+ this.renderLanguageMenu(languages);
+ this.fetchCaption();
+ }
+ },
- return {
- 'start': start,
- 'captions': captions
- };
- },
+ /**
+ * @desc Bind any necessary function callbacks to DOM events (click,
+ * mousemove, etc.).
+ *
+ */
+ bindHandlers: function () {
+ var state = this.state,
+ events = [
+ 'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur',
+ 'keydown'
+ ].join(' ');
- /**
- * @desc Fetch the caption file specified by the user. Upon successful
- * receipt of the file, the captions will be rendered.
- * @param {boolean} [fetchWithYoutubeId] Fetch youtube captions if true.
- * @returns {boolean}
- * true: The user specified a caption file. NOTE: if an error happens
- * while the specified file is being retrieved (for example the
- * file is missing on the server), this function will still return
- * true.
- * false: No caption file was specified, or an empty string was
- * specified for the Youtube type player.
- */
- fetchCaption: function (fetchWithYoutubeId) {
- var self = this,
- state = this.state,
- language = state.getCurrentLanguage(),
- url = state.config.transcriptTranslationUrl.replace('__lang__', language),
- data, youtubeId;
+ this.transcriptControlEl.on('click', this.toggle);
+ this.subtitlesEl
+ .on({
+ mouseenter: this.onMouseEnter,
+ mouseleave: this.onMouseLeave,
+ mousemove: this.onMovement,
+ mousewheel: this.onMovement,
+ DOMMouseScroll: this.onMovement
+ })
+ .on(events, 'li[data-index]', this.onCaptionHandler);
- if (this.loaded) {
- this.hideCaptions(false);
- }
+ if (this.showLanguageMenu) {
+ this.languageChooserEl.on({
+ keydown: this.handleKeypress
+ }, '.language-menu');
- if (this.fetchXHR && this.fetchXHR.abort) {
- this.fetchXHR.abort();
- }
+ this.languageChooserEl.on({
+ keydown: this.handleKeypressLink
+ }, '.control-lang');
- if (state.videoType === 'youtube' || fetchWithYoutubeId) {
- try {
- youtubeId = state.youtubeId('1.0');
- } catch (err) {
- youtubeId = null;
+ this.container.on({
+ mouseenter: this.onContainerMouseEnter,
+ mouseleave: this.onContainerMouseLeave
+ });
}
- if (!youtubeId) {
+ state.el
+ .on({
+ 'caption:fetch': this.fetchCaption,
+ 'caption:resize': this.onResize,
+ 'caption:update': this.onCaptionUpdate,
+ 'ended': this.pause,
+ 'fullscreen': this.onResize,
+ 'pause': this.pause,
+ 'play': this.play,
+ 'destroy': this.destroy
+ });
+
+ if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
+ this.subtitlesEl.on('scroll', state.videoControl.showControls);
+ }
+ },
+
+ onCaptionUpdate: function (event, time) {
+ this.updatePlayTime(time);
+ },
+
+ handleKeypressLink: function(event) {
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode,
+ focused, index, total;
+
+ switch(keyCode) {
+ case KEY.UP:
+ event.preventDefault();
+ focused = $(':focus').parent();
+ index = this.languageChooserEl.find('li').index(focused);
+ total = this.languageChooserEl.find('li').size() - 1;
+
+ this.previousLanguageMenuItem(event, index, total);
+ break;
+
+ case KEY.DOWN:
+ event.preventDefault();
+ focused = $(':focus').parent();
+ index = this.languageChooserEl.find('li').index(focused);
+ total = this.languageChooserEl.find('li').size() - 1;
+
+ this.nextLanguageMenuItem(event, index, total);
+ break;
+
+ case KEY.ESCAPE:
+ this.closeLanguageMenu(event);
+ break;
+
+ case KEY.ENTER:
+ case KEY.SPACE:
+ return true;
+ }
+ },
+
+ handleKeypress: function(event) {
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode;
+
+ switch(keyCode) {
+ // Handle keypresses
+ case KEY.ENTER:
+ case KEY.SPACE:
+ case KEY.UP:
+ event.preventDefault();
+ this.openLanguageMenu(event);
+ break;
+
+ case KEY.ESCAPE:
+ this.closeLanguageMenu(event);
+ break;
+ }
+
+ return event.keyCode === KEY.TAB;
+ },
+
+ nextLanguageMenuItem: function(event, index, total) {
+ event.preventDefault();
+
+ if (event.altKey || event.shiftKey) {
+ return true;
+ }
+
+ if (index === total) {
+ this.languageChooserEl
+ .find('.control-lang').first()
+ .focus();
+ } else {
+ this.languageChooserEl
+ .find('li:eq(' + index + ')')
+ .next()
+ .find('.control-lang')
+ .focus();
+ }
+
+ return false;
+ },
+
+ previousLanguageMenuItem: function(event, index) {
+ event.preventDefault();
+
+ if (event.altKey) {
+ return true;
+ }
+
+ if (event.shiftKey) {
+ return true;
+ }
+
+ if (index === 0) {
+ this.languageChooserEl
+ .find('.control-lang').last()
+ .focus();
+ } else {
+ this.languageChooserEl
+ .find('li:eq(' + index + ')')
+ .prev()
+ .find('.control-lang')
+ .focus();
+ }
+
+ return false;
+ },
+
+ openLanguageMenu: function(event) {
+ event.preventDefault();
+
+ var button = this.languageChooserEl,
+ menu = button.parent().find('.menu');
+
+
+ this.state.el.trigger('language_menu:show');
+ button
+ .addClass('is-opened');
+ menu
+ .find('.control-lang').last()
+ .focus();
+ },
+
+ closeLanguageMenu: function(event) {
+ event.preventDefault();
+
+ var button = this.languageChooserEl;
+
+ this.state.el.trigger('language_menu:hide');
+ button
+ .removeClass('is-opened')
+ .find('.language-menu')
+ .focus();
+ },
+
+ onCaptionHandler: function (event) {
+ switch (event.type) {
+ case 'mouseover':
+ case 'mouseout':
+ this.captionMouseOverOut(event);
+ break;
+ case 'mousedown':
+ this.captionMouseDown(event);
+ break;
+ case 'click':
+ this.captionClick(event);
+ break;
+ case 'focusin':
+ this.captionFocus(event);
+ break;
+ case 'focusout':
+ this.captionBlur(event);
+ break;
+ case 'keydown':
+ this.captionKeyDown(event);
+ break;
+ }
+ },
+
+ /**
+ * @desc Opens language menu.
+ *
+ * @param {jquery Event} event
+ */
+ onContainerMouseEnter: function (event) {
+ event.preventDefault();
+ $(event.currentTarget).find('.lang').addClass('is-opened');
+ this.state.el.trigger('language_menu:show');
+ },
+
+ /**
+ * @desc Closes language menu.
+ *
+ * @param {jquery Event} event
+ */
+ onContainerMouseLeave: function (event) {
+ event.preventDefault();
+ $(event.currentTarget).find('.lang').removeClass('is-opened');
+ this.state.el.trigger('language_menu:hide');
+ },
+
+ /**
+ * @desc Freezes moving of captions when mouse is over them.
+ *
+ * @param {jquery Event} event
+ */
+ onMouseEnter: function () {
+ if (this.frozen) {
+ clearTimeout(this.frozen);
+ }
+
+ this.frozen = setTimeout(
+ this.onMouseLeave,
+ this.state.config.captionsFreezeTime
+ );
+ },
+
+ /**
+ * @desc Unfreezes moving of captions when mouse go out.
+ *
+ * @param {jquery Event} event
+ */
+ onMouseLeave: function () {
+ if (this.frozen) {
+ clearTimeout(this.frozen);
+ }
+
+ this.frozen = null;
+
+ if (this.playing) {
+ this.scrollCaption();
+ }
+ },
+
+ /**
+ * @desc Freezes moving of captions when mouse is moving over them.
+ *
+ * @param {jquery Event} event
+ */
+ onMovement: function () {
+ this.onMouseEnter();
+ },
+
+ /**
+ * @desc Gets the correct start and end times from the state configuration
+ *
+ * @returns {array} if [startTime, endTime] are defined
+ */
+ getStartEndTimes: function () {
+ // due to the way config.startTime/endTime are
+ // processed in 03_video_player.js, we assume
+ // endTime can be an integer or null,
+ // and startTime is an integer > 0
+ var config = this.state.config;
+ var startTime = config.startTime * 1000;
+ var endTime = (config.endTime !== null) ? config.endTime * 1000 : null;
+ return [startTime, endTime];
+ },
+
+ /**
+ * @desc Gets captions within the start / end times stored within this.state.config
+ *
+ * @returns {object} {start, captions} parallel arrays of
+ * start times and corresponding captions
+ */
+ getBoundedCaptions: function () {
+ // get start and caption. If startTime and endTime
+ // are specified, filter by that range.
+ var times = this.getStartEndTimes();
+ var results = this.sjson.filter.apply(this.sjson, times);
+ var start = results.start;
+ var captions = results.captions;
+
+ return {
+ 'start': start,
+ 'captions': captions
+ };
+ },
+
+ /**
+ * @desc Fetch the caption file specified by the user. Upon successful
+ * receipt of the file, the captions will be rendered.
+ * @param {boolean} [fetchWithYoutubeId] Fetch youtube captions if true.
+ * @returns {boolean}
+ * true: The user specified a caption file. NOTE: if an error happens
+ * while the specified file is being retrieved (for example the
+ * file is missing on the server), this function will still return
+ * true.
+ * false: No caption file was specified, or an empty string was
+ * specified for the Youtube type player.
+ */
+ fetchCaption: function (fetchWithYoutubeId) {
+ var self = this,
+ state = this.state,
+ language = state.getCurrentLanguage(),
+ url = state.config.transcriptTranslationUrl.replace('__lang__', language),
+ data, youtubeId;
+
+ if (this.loaded) {
+ this.hideCaptions(false);
+ }
+
+ if (this.fetchXHR && this.fetchXHR.abort) {
+ this.fetchXHR.abort();
+ }
+
+ if (state.videoType === 'youtube' || fetchWithYoutubeId) {
+ try {
+ youtubeId = state.youtubeId('1.0');
+ } catch (err) {
+ youtubeId = null;
+ }
+
+ if (!youtubeId) {
+ return false;
+ }
+
+ data = {videoId: youtubeId};
+ }
+
+ state.el.removeClass('is-captions-rendered');
+ // Fetch the captions file. If no file was specified, or if an error
+ // occurred, then we hide the captions panel, and the "CC" button
+ this.fetchXHR = $.ajaxWithPrefix({
+ url: url,
+ notifyOnError: false,
+ data: data,
+ success: function (sjson) {
+ self.sjson = new Sjson(sjson);
+ var results = self.getBoundedCaptions();
+ var start = results.start;
+ var captions = results.captions;
+
+ if (self.loaded) {
+ if (self.rendered) {
+ self.renderCaption(start, captions);
+ self.updatePlayTime(state.videoPlayer.currentTime);
+ }
+ } else {
+ if (state.isTouch) {
+ self.subtitlesEl.find('li').html(
+ gettext(
+ 'Caption will be displayed when ' +
+ 'you start playing the video.'
+ )
+ );
+ } else {
+ self.renderCaption(start, captions);
+ }
+ self.hideCaptions(state.hide_captions, false);
+ self.state.el.find('.video-wrapper').after(self.subtitlesEl);
+ self.state.el.find('.secondary-controls').append(self.container);
+ self.bindHandlers();
+ }
+
+ self.loaded = true;
+ },
+ error: function (jqXHR, textStatus, errorThrown) {
+ console.log('[Video info]: ERROR while fetching captions.');
+ console.log(
+ '[Video info]: STATUS:', textStatus +
+ ', MESSAGE:', '' + errorThrown
+ );
+ // If initial list of languages has more than 1 item, check
+ // for availability other transcripts.
+ // If player mode is html5 and there are no initial languages
+ // then try to fetch youtube version of transcript with
+ // youtubeId.
+ if (_.keys(state.config.transcriptLanguages).length > 1) {
+ self.fetchAvailableTranslations();
+ } else if (!fetchWithYoutubeId && state.videoType === 'html5') {
+ console.log('[Video info]: Html5 mode fetching caption with youtubeId.');
+ self.fetchCaption(true);
+ } else {
+ self.hideCaptions(true, false);
+ self.state.el.find('.lang').hide();
+ self.state.el.find('.transcript-control').hide();
+ self.subtitlesEl.hide();
+ }
+ }
+ });
+
+ return true;
+ },
+
+ /**
+ * @desc Fetch the list of available translations. Upon successful receipt,
+ * the list of available translations will be updated.
+ *
+ * @returns {jquery Promise}
+ */
+ fetchAvailableTranslations: function () {
+ var self = this,
+ state = this.state;
+
+ this.availableTranslationsXHR = $.ajaxWithPrefix({
+ url: state.config.transcriptAvailableTranslationsUrl,
+ notifyOnError: false,
+ success: function (response) {
+ var currentLanguages = state.config.transcriptLanguages,
+ newLanguages = _.pick(currentLanguages, response);
+
+ // Update property with available currently translations.
+ state.config.transcriptLanguages = newLanguages;
+ // Remove an old language menu.
+ self.container.find('.langs-list').remove();
+
+ if (_.keys(newLanguages).length) {
+ // And try again to fetch transcript.
+ self.fetchCaption();
+ self.renderLanguageMenu(newLanguages);
+ }
+ },
+ error: function () {
+ self.hideCaptions(true, false);
+ self.state.el.find('.lang').hide();
+ self.state.el.find('.transcript-control').hide();
+ self.subtitlesEl.hide();
+ }
+ });
+
+ return this.availableTranslationsXHR;
+ },
+
+ /**
+ * @desc Recalculates and updates the height of the container of captions.
+ *
+ */
+ onResize: function () {
+ this.subtitlesEl
+ .find('.spacing').first()
+ .height(this.topSpacingHeight()).end()
+ .find('.spacing').last()
+ .height(this.bottomSpacingHeight());
+
+ this.scrollCaption();
+ this.setSubtitlesHeight();
+ },
+
+ /**
+ * @desc Create any necessary DOM elements, attach them, and set their
+ * initial configuration for the Language menu.
+ *
+ * @param {object} languages Dictionary where key is language code,
+ * value - language label
+ *
+ */
+ renderLanguageMenu: function (languages) {
+ var self = this,
+ state = this.state,
+ menu = $(''),
+ currentLang = state.getCurrentLanguage();
+
+ if (_.keys(languages).length < 2) {
+ // Remove the menu toggle button
+ self.container.find('.lang').remove();
return false;
}
- data = {videoId: youtubeId};
- }
+ this.showLanguageMenu = true;
- state.el.removeClass('is-captions-rendered');
- // Fetch the captions file. If no file was specified, or if an error
- // occurred, then we hide the captions panel, and the "CC" button
- this.fetchXHR = $.ajaxWithPrefix({
- url: url,
- notifyOnError: false,
- data: data,
- success: function (sjson) {
- self.sjson = new Sjson(sjson);
- var results = self.getBoundedCaptions();
- var start = results.start;
- var captions = results.captions;
+ $.each(languages, function(code, label) {
+ var li = $(''),
+ link = $('');
- if (self.loaded) {
- if (self.rendered) {
- self.renderCaption(start, captions);
- self.updatePlayTime(state.videoPlayer.currentTime);
- }
- } else {
- if (state.isTouch) {
- self.subtitlesEl.find('li').html(
- gettext(
- 'Caption will be displayed when ' +
- 'you start playing the video.'
- )
- );
- } else {
- self.renderCaption(start, captions);
- }
- self.hideCaptions(state.hide_captions, false);
- self.state.el.find('.video-wrapper').after(self.subtitlesEl);
- self.state.el.find('.secondary-controls').append(self.container);
- self.bindHandlers();
+ if (currentLang === code) {
+ li.addClass('is-active');
}
- self.loaded = true;
- },
- error: function (jqXHR, textStatus, errorThrown) {
- console.log('[Video info]: ERROR while fetching captions.');
- console.log(
- '[Video info]: STATUS:', textStatus +
- ', MESSAGE:', '' + errorThrown
- );
- // If initial list of languages has more than 1 item, check
- // for availability other transcripts.
- // If player mode is html5 and there are no initial languages
- // then try to fetch youtube version of transcript with
- // youtubeId.
- if (_.keys(state.config.transcriptLanguages).length > 1) {
- self.fetchAvailableTranslations();
- } else if (!fetchWithYoutubeId && state.videoType === 'html5') {
- console.log('[Video info]: Html5 mode fetching caption with youtubeId.');
- self.fetchCaption(true);
- } else {
- self.hideCaptions(true, false);
- self.hideSubtitlesEl.hide();
- }
- }
- });
+ li.append(link);
+ menu.append(li);
+ });
- return true;
- },
+ this.languageChooserEl.append(menu);
- /**
- * @desc Fetch the list of available translations. Upon successful receipt,
- * the list of available translations will be updated.
- *
- * @returns {jquery Promise}
- */
- fetchAvailableTranslations: function () {
- var self = this,
- state = this.state;
+ menu.on('click', '.control-lang', function (e) {
+ var el = $(e.currentTarget).parent(),
+ state = self.state,
+ langCode = el.data('lang-code');
- this.availableTranslationsXHR = $.ajaxWithPrefix({
- url: state.config.transcriptAvailableTranslationsUrl,
- notifyOnError: false,
- success: function (response) {
- var currentLanguages = state.config.transcriptLanguages,
- newLanguages = _.pick(currentLanguages, response);
+ if (state.lang !== langCode) {
+ state.lang = langCode;
+ el .addClass('is-active')
+ .siblings('li')
+ .removeClass('is-active');
- // Update property with available currently translations.
- state.config.transcriptLanguages = newLanguages;
- // Remove an old language menu.
- self.container.find('.langs-list').remove();
-
- if (_.keys(newLanguages).length) {
- // And try again to fetch transcript.
+ state.el.trigger('language_menu:change', [langCode]);
self.fetchCaption();
- self.renderLanguageMenu(newLanguages);
}
- },
- error: function (jqXHR, textStatus, errorThrown) {
- self.hideCaptions(true, false);
- self.hideSubtitlesEl.hide();
- }
- });
+ });
+ },
- return this.availableTranslationsXHR;
- },
+ /**
+ * @desc Create any necessary DOM elements, attach them, and set their
+ * initial configuration.
+ *
+ * @param {jQuery element} container Element in which captions will be
+ * inserted.
+ * @param {array} start List of start times for the video.
+ * @param {array} captions List of captions for the video.
+ * @returns {object} jQuery's Promise object
+ *
+ */
+ buildCaptions: function (container, start, captions) {
+ var process = function(text, index) {
+ var liEl = $('
', {
+ 'role': 'link',
+ 'data-index': index,
+ 'data-start': start[index],
+ 'tabindex': 0
+ }).html(text);
- /**
- * @desc Recalculates and updates the height of the container of captions.
- *
- */
- onResize: function () {
- this.subtitlesEl
- .find('.spacing').first()
- .height(this.topSpacingHeight()).end()
- .find('.spacing').last()
- .height(this.bottomSpacingHeight());
+ return liEl[0];
+ };
- this.scrollCaption();
- this.setSubtitlesHeight();
- },
+ return AsyncProcess.array(captions, process).done(function (list) {
+ container.append(list);
+ });
+ },
- /**
- * @desc Create any necessary DOM elements, attach them, and set their
- * initial configuration for the Language menu.
- *
- * @param {object} languages Dictionary where key is language code,
- * value - language label
- *
- */
- renderLanguageMenu: function (languages) {
- var self = this,
- state = this.state,
- menu = $(''),
- currentLang = state.getCurrentLanguage();
+ /**
+ * @desc Initiates creating of captions and set their initial configuration.
+ *
+ * @param {array} start List of start times for the video.
+ * @param {array} captions List of captions for the video.
+ *
+ */
+ renderCaption: function (start, captions) {
+ var self = this;
- if (_.keys(languages).length < 2) {
- return false;
- }
-
- this.showLanguageMenu = true;
-
- $.each(languages, function(code, label) {
- var li = $(''),
- link = $('' + label + '');
-
- if (currentLang === code) {
- li.addClass('is-active');
- }
-
- li.append(link);
- menu.append(li);
- });
-
- this.container.append(menu);
-
- menu.on('click', 'a', function (e) {
- var el = $(e.currentTarget).parent(),
- state = self.state,
- langCode = el.data('lang-code');
-
- if (state.lang !== langCode) {
- state.lang = langCode;
- el .addClass('is-active')
- .siblings('li')
- .removeClass('is-active');
-
- state.el.trigger('language_menu:change', [langCode]);
- self.fetchCaption();
- }
- });
- },
-
- /**
- * @desc Create any necessary DOM elements, attach them, and set their
- * initial configuration.
- *
- * @param {jQuery element} container Element in which captions will be
- * inserted.
- * @param {array} start List of start times for the video.
- * @param {array} captions List of captions for the video.
- * @returns {object} jQuery's Promise object
- *
- */
- buildCaptions: function (container, start, captions) {
- var process = function(text, index) {
- var liEl = $('
', {
- 'data-index': index,
- 'data-start': start[index],
- 'tabindex': 0
- }).html(text);
-
- return liEl[0];
+ var onRender = function () {
+ self.addPaddings();
+ // Enables or disables automatic scrolling of the captions when the
+ // video is playing. This feature has to be disabled when tabbing
+ // through them as it interferes with that action. Initially, have
+ // this flag enabled as we assume mouse use. Then, if the first
+ // caption (through forward tabbing) or the last caption (through
+ // backwards tabbing) gets the focus, disable that feature.
+ // Re-enable it if tabbing then cycles out of the the captions.
+ self.autoScrolling = true;
+ // Keeps track of where the focus is situated in the array of
+ // captions. Used to implement the automatic scrolling behavior and
+ // decide if the outline around a caption has to be hidden or shown
+ // on a mouseenter or mouseleave. Initially, no caption has the
+ // focus, set the index to -1.
+ self.currentCaptionIndex = -1;
+ // Used to track if the focus is coming from a click or tabbing. This
+ // has to be known to decide if, when a caption gets the focus, an
+ // outline has to be drawn (tabbing) or not (mouse click).
+ self.isMouseFocus = false;
+ self.rendered = true;
+ self.state.el.addClass('is-captions-rendered');
};
- return AsyncProcess.array(captions, process).done(function (list) {
- container.append(list);
- });
- },
+ this.rendered = false;
+ this.subtitlesEl.empty();
+ this.setSubtitlesHeight();
+ this.buildCaptions(this.subtitlesEl, start, captions).done(onRender);
+ },
- /**
- * @desc Initiates creating of captions and set their initial configuration.
- *
- * @param {array} start List of start times for the video.
- * @param {array} captions List of captions for the video.
- *
- */
- renderCaption: function (start, captions) {
- var self = this;
+ /**
+ * @desc Sets top and bottom spacing height and make sure they are taken
+ * out of the tabbing order.
+ *
+ */
+ addPaddings: function () {
- var onRender = function () {
- self.addPaddings();
- // Enables or disables automatic scrolling of the captions when the
- // video is playing. This feature has to be disabled when tabbing
- // through them as it interferes with that action. Initially, have
- // this flag enabled as we assume mouse use. Then, if the first
- // caption (through forward tabbing) or the last caption (through
- // backwards tabbing) gets the focus, disable that feature.
- // Re-enable it if tabbing then cycles out of the the captions.
- self.autoScrolling = true;
- // Keeps track of where the focus is situated in the array of
- // captions. Used to implement the automatic scrolling behavior and
- // decide if the outline around a caption has to be hidden or shown
- // on a mouseenter or mouseleave. Initially, no caption has the
- // focus, set the index to -1.
- self.currentCaptionIndex = -1;
- // Used to track if the focus is coming from a click or tabbing. This
- // has to be known to decide if, when a caption gets the focus, an
- // outline has to be drawn (tabbing) or not (mouse click).
- self.isMouseFocus = false;
- self.rendered = true;
- self.state.el.addClass('is-captions-rendered');
- };
+ this.subtitlesEl
+ .prepend(
+ $('
')
+ .height(this.bottomSpacingHeight())
+ .attr('tabindex', -1)
+ );
+ },
- this.rendered = false;
- this.subtitlesEl.empty();
- this.setSubtitlesHeight();
- this.buildCaptions(this.subtitlesEl, start, captions).done(onRender);
- },
+ /**
+ * @desc
+ * On mouseOver: Hides the outline of a caption that has been tabbed to.
+ * On mouseOut: Shows the outline of a caption that has been tabbed to.
+ *
+ * @param {jquery Event} event
+ *
+ */
+ captionMouseOverOut: function (event) {
+ var caption = $(event.target),
+ captionIndex = parseInt(caption.attr('data-index'), 10);
- /**
- * @desc Sets top and bottom spacing height and make sure they are taken
- * out of the tabbing order.
- *
- */
- addPaddings: function () {
-
- this.subtitlesEl
- .prepend(
- $('
')
- .height(this.bottomSpacingHeight())
- .attr('tabindex', -1)
- );
- },
-
- /**
- * @desc
- * On mouseOver: Hides the outline of a caption that has been tabbed to.
- * On mouseOut: Shows the outline of a caption that has been tabbed to.
- *
- * @param {jquery Event} event
- *
- */
- captionMouseOverOut: function (event) {
- var caption = $(event.target),
- captionIndex = parseInt(caption.attr('data-index'), 10);
-
- if (captionIndex === this.currentCaptionIndex) {
- if (event.type === 'mouseover') {
- caption.removeClass('focused');
+ if (captionIndex === this.currentCaptionIndex) {
+ if (event.type === 'mouseover') {
+ caption.removeClass('focused');
+ }
+ else { // mouseout
+ caption.addClass('focused');
+ }
}
- else { // mouseout
- caption.addClass('focused');
- }
- }
- },
+ },
- /**
- * @desc Handles mousedown event on concrete caption.
- *
- * @param {jquery Event} event
- *
- */
- captionMouseDown: function (event) {
- var caption = $(event.target);
+ /**
+ * @desc Handles mousedown event on concrete caption.
+ *
+ * @param {jquery Event} event
+ *
+ */
+ captionMouseDown: function (event) {
+ var caption = $(event.target);
- this.isMouseFocus = true;
- this.autoScrolling = true;
- caption.removeClass('focused');
- this.currentCaptionIndex = -1;
- },
-
- /**
- * @desc Handles click event on concrete caption.
- *
- * @param {jquery Event} event
- *
- */
- captionClick: function (event) {
- this.seekPlayer(event);
- },
-
- /**
- * @desc Handles focus event on concrete caption.
- *
- * @param {jquery Event} event
- *
- */
- captionFocus: function (event) {
- var caption = $(event.target),
- captionIndex = parseInt(caption.attr('data-index'), 10);
- // If the focus comes from a mouse click, hide the outline, turn on
- // automatic scrolling and set currentCaptionIndex to point outside of
- // caption list (ie -1) to disable mouseenter, mouseleave behavior.
- if (this.isMouseFocus) {
+ this.isMouseFocus = true;
this.autoScrolling = true;
caption.removeClass('focused');
this.currentCaptionIndex = -1;
- }
- // If the focus comes from tabbing, show the outline and turn off
- // automatic scrolling.
- else {
- this.currentCaptionIndex = captionIndex;
- caption.addClass('focused');
- // The second and second to last elements turn automatic scrolling
- // off again as it may have been enabled in captionBlur.
- if (
- captionIndex <= 1 ||
- captionIndex >= this.sjson.getSize() - 2
- ) {
- this.autoScrolling = false;
- }
- }
- },
+ },
- /**
- * @desc Handles blur event on concrete caption.
- *
- * @param {jquery Event} event
- *
- */
- captionBlur: function (event) {
- var caption = $(event.target),
- captionIndex = parseInt(caption.attr('data-index'), 10);
-
- caption.removeClass('focused');
- // If we are on first or last index, we have to turn automatic scroll
- // on again when losing focus. There is no way to know in what
- // direction we are tabbing. So we could be on the first element and
- // tabbing back out of the captions or on the last element and tabbing
- // forward out of the captions.
- if (captionIndex === 0 ||
- captionIndex === this.sjson.getSize() - 1) {
-
- this.autoScrolling = true;
- }
- },
-
- /**
- * @desc Handles keydown event on concrete caption.
- *
- * @param {jquery Event} event
- *
- */
- captionKeyDown: function (event) {
- this.isMouseFocus = false;
- if (event.which === 13) { //Enter key
+ /**
+ * @desc Handles click event on concrete caption.
+ *
+ * @param {jquery Event} event
+ *
+ */
+ captionClick: function (event) {
this.seekPlayer(event);
- }
- },
+ },
- /**
- * @desc Scrolls caption container to make active caption visible.
- *
- */
- scrollCaption: function () {
- var el = this.subtitlesEl.find('.current:first');
+ /**
+ * @desc Handles focus event on concrete caption.
+ *
+ * @param {jquery Event} event
+ *
+ */
+ captionFocus: function (event) {
+ var caption = $(event.target),
+ captionIndex = parseInt(caption.attr('data-index'), 10);
+ // If the focus comes from a mouse click, hide the outline, turn on
+ // automatic scrolling and set currentCaptionIndex to point outside of
+ // caption list (ie -1) to disable mouseenter, mouseleave behavior.
+ if (this.isMouseFocus) {
+ this.autoScrolling = true;
+ caption.removeClass('focused');
+ this.currentCaptionIndex = -1;
+ }
+ // If the focus comes from tabbing, show the outline and turn off
+ // automatic scrolling.
+ else {
+ this.currentCaptionIndex = captionIndex;
+ caption.addClass('focused');
+ // The second and second to last elements turn automatic scrolling
+ // off again as it may have been enabled in captionBlur.
+ if (
+ captionIndex <= 1 ||
+ captionIndex >= this.sjson.getSize() - 2
+ ) {
+ this.autoScrolling = false;
+ }
+ }
+ },
- // Automatic scrolling gets disabled if one of the captions has
- // received focus through tabbing.
- if (
- !this.frozen &&
- el.length &&
- this.autoScrolling
- ) {
- this.subtitlesEl.scrollTo(
- el,
+ /**
+ * @desc Handles blur event on concrete caption.
+ *
+ * @param {jquery Event} event
+ *
+ */
+ captionBlur: function (event) {
+ var caption = $(event.target),
+ captionIndex = parseInt(caption.attr('data-index'), 10);
+
+ caption.removeClass('focused');
+ // If we are on first or last index, we have to turn automatic scroll
+ // on again when losing focus. There is no way to know in what
+ // direction we are tabbing. So we could be on the first element and
+ // tabbing back out of the captions or on the last element and tabbing
+ // forward out of the captions.
+ if (captionIndex === 0 ||
+ captionIndex === this.sjson.getSize() - 1) {
+
+ this.autoScrolling = true;
+ }
+ },
+
+ /**
+ * @desc Handles keydown event on concrete caption.
+ *
+ * @param {jquery Event} event
+ *
+ */
+ captionKeyDown: function (event) {
+ this.isMouseFocus = false;
+ if (event.which === 13) { //Enter key
+ this.seekPlayer(event);
+ }
+ },
+
+ /**
+ * @desc Scrolls caption container to make active caption visible.
+ *
+ */
+ scrollCaption: function () {
+ var el = this.subtitlesEl.find('.current:first');
+
+ // Automatic scrolling gets disabled if one of the captions has
+ // received focus through tabbing.
+ if (
+ !this.frozen &&
+ el.length &&
+ this.autoScrolling
+ ) {
+ this.subtitlesEl.scrollTo(
+ el,
+ {
+ offset: -1 * this.calculateOffset(el)
+ }
+ );
+ }
+ },
+
+ /**
+ * @desc Updates flags on play
+ *
+ */
+ play: function () {
+ var captions, startAndCaptions, start;
+ if (this.loaded) {
+ if (!this.rendered) {
+ startAndCaptions = this.getBoundedCaptions();
+ start = startAndCaptions.start;
+ captions = startAndCaptions.captions;
+ this.renderCaption(start, captions);
+ }
+
+ this.playing = true;
+ }
+ },
+
+ /**
+ * @desc Updates flags on pause
+ *
+ */
+ pause: function () {
+ if (this.loaded) {
+ this.playing = false;
+ }
+ },
+
+ /**
+ * @desc Updates captions UI on paying.
+ *
+ * @param {number} time Time in seconds.
+ *
+ */
+ updatePlayTime: function (time) {
+ var state = this.state,
+ params, newIndex;
+
+ if (this.loaded) {
+ if (state.isFlashMode()) {
+ time = Time.convert(time, state.speed, '1.0');
+ }
+
+ time = Math.round(time * 1000 + 100);
+ var times = this.getStartEndTimes();
+ // if start and end times are defined, limit search.
+ // else, use the entire list of video captions
+ params = [time].concat(times);
+ newIndex = this.sjson.search.apply(this.sjson, params);
+
+ if (
+ typeof newIndex !== 'undefined' &&
+ newIndex !== -1 &&
+ this.currentIndex !== newIndex
+ ) {
+ if (typeof this.currentIndex !== 'undefined') {
+ this.subtitlesEl
+ .find('li.current')
+ .removeClass('current');
+ }
+
+ this.subtitlesEl
+ .find("li[data-index='" + newIndex + "']")
+ .addClass('current');
+
+ this.currentIndex = newIndex;
+ this.scrollCaption();
+ }
+ }
+ },
+
+ /**
+ * @desc Sends log to the server on caption seek.
+ *
+ * @param {jquery Event} event
+ *
+ */
+ seekPlayer: function (event) {
+ var state = this.state,
+ time = parseInt($(event.target).data('start'), 10);
+
+ if (state.isFlashMode()) {
+ time = Math.round(Time.convert(time, '1.0', state.speed));
+ }
+
+ state.trigger(
+ 'videoPlayer.onCaptionSeek',
{
- offset: -1 * this.calculateOffset(el)
+ 'type': 'onCaptionSeek',
+ 'time': time/1000
}
);
- }
- },
- /**
- * @desc Updates flags on play
- *
- */
- play: function () {
- var captions, startAndCaptions, start;
- if (this.loaded) {
- if (!this.rendered) {
- startAndCaptions = this.getBoundedCaptions();
- start = startAndCaptions.start;
- captions = startAndCaptions.captions;
- this.renderCaption(start, captions);
+ event.preventDefault();
+ },
+
+ /**
+ * @desc Calculates offset for paddings.
+ *
+ * @param {jquery element} element Top or bottom padding element.
+ * @returns {number} Offset for the passed padding element.
+ *
+ */
+ calculateOffset: function (element) {
+ return this.captionHeight() / 2 - element.height() / 2;
+ },
+
+ /**
+ * @desc Calculates offset for the top padding element.
+ *
+ * @returns {number} Offset for the passed top padding element.
+ *
+ */
+ topSpacingHeight: function () {
+ return this.calculateOffset(
+ this.subtitlesEl.find('li:not(.spacing)').first()
+ );
+ },
+
+ /**
+ * @desc Calculates offset for the bottom padding element.
+ *
+ * @returns {number} Offset for the passed bottom padding element.
+ *
+ */
+ bottomSpacingHeight: function () {
+ return this.calculateOffset(
+ this.subtitlesEl.find('li:not(.spacing)').last()
+ );
+ },
+
+ /**
+ * @desc Shows/Hides transcript on click `transcript` button
+ *
+ * @param {jquery Event} event
+ *
+ */
+ toggle: function (event) {
+ event.preventDefault();
+
+ if (this.state.el.hasClass('closed')) {
+ this.hideCaptions(false, true, true);
+ } else {
+ this.hideCaptions(true, true, true);
+ }
+ },
+
+ /**
+ * @desc Shows/Hides captions and updates the cookie.
+ *
+ * @param {boolean} hide_captions if `true` hides the caption,
+ * otherwise - show.
+ * @param {boolean} update_cookie Flag to update or not the cookie.
+ *
+ */
+ hideCaptions: function (hide_captions, update_cookie, trigger_event) {
+ var transcriptControlEl = this.transcriptControlEl,
+ state = this.state, text;
+
+ if (typeof update_cookie === 'undefined') {
+ update_cookie = true;
}
- this.playing = true;
- }
- },
-
- /**
- * @desc Updates flags on pause
- *
- */
- pause: function () {
- if (this.loaded) {
- this.playing = false;
- }
- },
-
- /**
- * @desc Updates captions UI on paying.
- *
- * @param {number} time Time in seconds.
- *
- */
- updatePlayTime: function (time) {
- var state = this.state,
- params, newIndex;
-
- if (this.loaded) {
- if (state.isFlashMode()) {
- time = Time.convert(time, state.speed, '1.0');
- }
-
- time = Math.round(time * 1000 + 100);
- var times = this.getStartEndTimes();
- // if start and end times are defined, limit search.
- // else, use the entire list of video captions
- params = [time].concat(times);
- newIndex = this.sjson.search.apply(this.sjson, params);
-
- if (
- typeof newIndex !== 'undefined' &&
- newIndex !== -1 &&
- this.currentIndex !== newIndex
- ) {
- if (typeof this.currentIndex !== 'undefined') {
- this.subtitlesEl
- .find('li.current')
- .removeClass('current');
+ if (hide_captions) {
+ state.captionsHidden = true;
+ state.el.addClass('closed');
+ text = gettext('Turn on transcripts');
+ if (trigger_event) {
+ this.state.el.trigger('captions:hide');
}
- this.subtitlesEl
- .find("li[data-index='" + newIndex + "']")
- .addClass('current');
-
- this.currentIndex = newIndex;
- this.scrollCaption();
- }
- }
- },
-
- /**
- * @desc Sends log to the server on caption seek.
- *
- * @param {jquery Event} event
- *
- */
- seekPlayer: function (event) {
- var state = this.state,
- time = parseInt($(event.target).data('start'), 10);
-
- if (state.isFlashMode()) {
- time = Math.round(Time.convert(time, '1.0', state.speed));
- }
-
- state.trigger(
- 'videoPlayer.onCaptionSeek',
- {
- 'type': 'onCaptionSeek',
- 'time': time/1000
- }
- );
-
- event.preventDefault();
- },
-
- /**
- * @desc Calculates offset for paddings.
- *
- * @param {jquery element} element Top or bottom padding element.
- * @returns {number} Offset for the passed padding element.
- *
- */
- calculateOffset: function (element) {
- return this.captionHeight() / 2 - element.height() / 2;
- },
-
- /**
- * @desc Calculates offset for the top padding element.
- *
- * @returns {number} Offset for the passed top padding element.
- *
- */
- topSpacingHeight: function () {
- return this.calculateOffset(
- this.subtitlesEl.find('li:not(.spacing)').first()
- );
- },
-
- /**
- * @desc Calculates offset for the bottom padding element.
- *
- * @returns {number} Offset for the passed bottom padding element.
- *
- */
- bottomSpacingHeight: function () {
- return this.calculateOffset(
- this.subtitlesEl.find('li:not(.spacing)').last()
- );
- },
-
- /**
- * @desc Shows/Hides captions on click `CC` button
- *
- * @param {jquery Event} event
- *
- */
- toggle: function (event) {
- event.preventDefault();
-
- if (this.state.el.hasClass('closed')) {
- this.hideCaptions(false, true, true);
- } else {
- this.hideCaptions(true, true, true);
- }
- },
-
- /**
- * @desc Shows/Hides captions and updates the cookie.
- *
- * @param {boolean} hide_captions if `true` hides the caption,
- * otherwise - show.
- * @param {boolean} update_cookie Flag to update or not the cookie.
- *
- */
- hideCaptions: function (hide_captions, update_cookie, trigger_event) {
- var hideSubtitlesEl = this.hideSubtitlesEl,
- state = this.state, text;
-
- if (typeof update_cookie === 'undefined') {
- update_cookie = true;
- }
-
- if (hide_captions) {
- state.captionsHidden = true;
- state.el.addClass('closed');
- text = gettext('Turn on captions');
- if (trigger_event) {
- this.state.el.trigger('captions:hide');
- }
- } else {
- state.captionsHidden = false;
- state.el.removeClass('closed');
- this.scrollCaption();
- text = gettext('Turn off captions');
- if (trigger_event) {
- this.state.el.trigger('captions:show');
- }
- }
-
- hideSubtitlesEl
- .attr('title', text)
- .text(gettext(text));
-
- if (state.resizer) {
- if (state.isFullScreen) {
- state.resizer.setMode('both');
+ transcriptControlEl
+ .removeClass('is-active')
+ .find('.control-text')
+ .text(gettext(text));
} else {
- state.resizer.alignByWidthOnly();
- }
- }
+ state.captionsHidden = false;
+ state.el.removeClass('closed');
+ this.scrollCaption();
+ text = gettext('Turn off transcripts');
+ if (trigger_event) {
+ this.state.el.trigger('captions:show');
+ }
- this.setSubtitlesHeight();
- if (update_cookie) {
- $.cookie('hide_captions', hide_captions, {
- expires: 3650,
- path: '/'
+ transcriptControlEl
+ .addClass('is-active')
+ .find('.control-text')
+ .text(gettext(text));
+ }
+
+ if (state.resizer) {
+ if (state.isFullScreen) {
+ state.resizer.setMode('both');
+ } else {
+ state.resizer.alignByWidthOnly();
+ }
+ }
+
+ this.setSubtitlesHeight();
+ if (update_cookie) {
+ $.cookie('hide_captions', hide_captions, {
+ expires: 3650,
+ path: '/'
+ });
+ }
+ },
+
+ /**
+ * @desc Return the caption container height.
+ *
+ * @returns {number} event Height of the container in pixels.
+ *
+ */
+ captionHeight: function () {
+ var state = this.state;
+ if (state.isFullScreen) {
+ return state.container.height() - state.videoFullScreen.height;
+ } else {
+ return state.container.height();
+ }
+ },
+
+ /**
+ * @desc Sets the height of the caption container element.
+ *
+ */
+ setSubtitlesHeight: function () {
+ var height = 0,
+ state = this.state;
+ // on page load captionHidden = undefined
+ if ((state.captionsHidden === undefined && state.hide_captions) ||
+ state.captionsHidden === true
+ ) {
+ // In case of html5 autoshowing subtitles, we adjust height of
+ // subs, by height of scrollbar.
+ height = state.el.find('.video-controls').height() +
+ 0.5 * state.el.find('.slider').height();
+ // Height of videoControl does not contain height of slider.
+ // css is set to absolute, to avoid yanking when slider
+ // autochanges its height.
+ }
+
+ this.subtitlesEl.css({
+ maxHeight: this.captionHeight() - height
});
}
- },
+ };
- /**
- * @desc Return the caption container height.
- *
- * @returns {number} event Height of the container in pixels.
- *
- */
- captionHeight: function () {
- var state = this.state;
- if (state.isFullScreen) {
- return state.container.height() - state.videoFullScreen.height;
- } else {
- return state.container.height();
- }
- },
-
- /**
- * @desc Sets the height of the caption container element.
- *
- */
- setSubtitlesHeight: function () {
- var height = 0,
- state = this.state;
- // on page load captionHidden = undefined
- if ((state.captionsHidden === undefined && state.hide_captions) ||
- state.captionsHidden === true
- ) {
- // In case of html5 autoshowing subtitles, we adjust height of
- // subs, by height of scrollbar.
- height = state.el.find('.video-controls').height() +
- 0.5 * state.el.find('.slider').height();
- // Height of videoControl does not contain height of slider.
- // css is set to absolute, to avoid yanking when slider
- // autochanges its height.
- }
-
- this.subtitlesEl.css({
- maxHeight: this.captionHeight() - height
- });
- }
- };
-
- return VideoCaption;
-});
+ return VideoCaption;
+ });
}(RequireJS.define));
diff --git a/common/static/images/fontawesome/arrows-alt.svg b/common/static/images/fontawesome/arrows-alt.svg
new file mode 100755
index 0000000000..8b54dcd918
--- /dev/null
+++ b/common/static/images/fontawesome/arrows-alt.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/caret-left.svg b/common/static/images/fontawesome/caret-left.svg
new file mode 100755
index 0000000000..2872a170a4
--- /dev/null
+++ b/common/static/images/fontawesome/caret-left.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/caret-right.svg b/common/static/images/fontawesome/caret-right.svg
new file mode 100755
index 0000000000..4f04974589
--- /dev/null
+++ b/common/static/images/fontawesome/caret-right.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/caret-up.svg b/common/static/images/fontawesome/caret-up.svg
new file mode 100755
index 0000000000..ff66bd7bfd
--- /dev/null
+++ b/common/static/images/fontawesome/caret-up.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/cc.svg b/common/static/images/fontawesome/cc.svg
new file mode 100755
index 0000000000..d64f66be83
--- /dev/null
+++ b/common/static/images/fontawesome/cc.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/compress.svg b/common/static/images/fontawesome/compress.svg
new file mode 100755
index 0000000000..24ede46b47
--- /dev/null
+++ b/common/static/images/fontawesome/compress.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/list-alt.svg b/common/static/images/fontawesome/list-alt.svg
new file mode 100755
index 0000000000..3576e8f27a
--- /dev/null
+++ b/common/static/images/fontawesome/list-alt.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/pause.svg b/common/static/images/fontawesome/pause.svg
new file mode 100755
index 0000000000..6515bbf34a
--- /dev/null
+++ b/common/static/images/fontawesome/pause.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/play.svg b/common/static/images/fontawesome/play.svg
new file mode 100755
index 0000000000..7a64f1a414
--- /dev/null
+++ b/common/static/images/fontawesome/play.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/quote-left.svg b/common/static/images/fontawesome/quote-left.svg
new file mode 100755
index 0000000000..f2a148656b
--- /dev/null
+++ b/common/static/images/fontawesome/quote-left.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/step-forward.svg b/common/static/images/fontawesome/step-forward.svg
new file mode 100755
index 0000000000..a67349657e
--- /dev/null
+++ b/common/static/images/fontawesome/step-forward.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/volume-down.svg b/common/static/images/fontawesome/volume-down.svg
new file mode 100755
index 0000000000..f3ca5baaec
--- /dev/null
+++ b/common/static/images/fontawesome/volume-down.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/volume-off.svg b/common/static/images/fontawesome/volume-off.svg
new file mode 100755
index 0000000000..8b2560fa1c
--- /dev/null
+++ b/common/static/images/fontawesome/volume-off.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/images/fontawesome/volume-up.svg b/common/static/images/fontawesome/volume-up.svg
new file mode 100755
index 0000000000..9e74383098
--- /dev/null
+++ b/common/static/images/fontawesome/volume-up.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/common/static/js/vendor/afontgarde/afontgarde.css b/common/static/js/vendor/afontgarde/afontgarde.css
new file mode 100644
index 0000000000..89c32ea6b0
--- /dev/null
+++ b/common/static/js/vendor/afontgarde/afontgarde.css
@@ -0,0 +1,60 @@
+/*! afontgarde - v0.1.6 - 2015-03-13
+ * https://github.com/filamentgroup/a-font-garde
+ * Copyright (c) 2015 Filament Group c/o Zach Leatherman
+ * MIT License */
+
+.icon-fallback-text .icon {
+ display: none;
+}
+/*
+ADDED BY afontgarde.js:
+Note: sure .FONT_NAME comes first for adjoining classes bug in IE7.
+
+.FONT_NAME.supports-generatedcontent .icon-fallback-text .icon {
+ display: inline-block;
+}*/
+
+.icon-fallback-img .text,
+.icon-fallback-glyph .text/*,
+ADDED BY afontgarde.js:
+Note: sure .FONT_NAME comes first for adjoining classes bug in IE7.
+
+.FONT_NAME.supports-generatedcontent .icon-fallback-text .text*/ {
+ /* visually hide but accessible (h5bp.com) */
+ clip: rect(0 0 0 0);
+ overflow: hidden;
+ position: absolute;
+ height: 1px;
+ width: 1px;
+}
+
+/* Careful, don’t use adjoining classes here (IE7) */
+.supports-no-generatedcontent .icon-fallback-glyph .text,
+.supports-no-generatedcontent .icon-fallback-img .text {
+ clip: auto;
+ overflow: visible;
+ position: static;
+ height: auto;
+ width: auto;
+}
+/*
+ADDED BY afontgarde.js:
+.FONT_NAME .icon-fallback-glyph .icon:before {
+ // inherit for font-size, line-height was not working on IE8
+ font-size: 1em;
+ font-size: inherit;
+ line-height: 1;
+ line-height: inherit;
+}*/
+.icon-fallback-img .icon {
+ display: inline-block;
+}
+
+// Necessary for xblocks and xmodules, but works across the board
+html:not('.afontgarde') .icon-fallback-img .icon:before {
+ content: "";
+}
+/* The img fallback version is not as reliable since it does not check to make sure the fontloaded font has loaded. If we did add the .fontloaded class, it would unnecessarily request the fallback image. */
+.fontawesome .icon-fallback-img .icon {
+ background-image: none;
+}
\ No newline at end of file
diff --git a/common/static/js/vendor/afontgarde/afontgarde.js b/common/static/js/vendor/afontgarde/afontgarde.js
new file mode 100644
index 0000000000..3bf3465c97
--- /dev/null
+++ b/common/static/js/vendor/afontgarde/afontgarde.js
@@ -0,0 +1,287 @@
+/*! afontgarde - v0.1.6 - 2015-03-13
+ * https://github.com/filamentgroup/a-font-garde
+ * Copyright (c) 2015 Filament Group c/o Zach Leatherman
+ * MIT License */
+
+/*! fontfaceonload - v0.1.6 - 2015-03-13
+ * https://github.com/zachleat/fontfaceonload
+ * Copyright (c) 2015 Zach Leatherman (@zachleat)
+ * MIT License */
+
+;(function( win, doc ) {
+ "use strict";
+
+ var TEST_STRING = 'AxmTYklsjo190QW',
+ SANS_SERIF_FONTS = 'sans-serif',
+ SERIF_FONTS = 'serif',
+
+ // lighter and bolder not supported
+ weightLookup = {
+ normal: '400',
+ bold: '700'
+ },
+
+ defaultOptions = {
+ tolerance: 2, // px
+ delay: 100,
+ glyphs: '',
+ success: function() {},
+ error: function() {},
+ timeout: 5000,
+ weight: '400', // normal
+ style: 'normal'
+ },
+
+ // See https://github.com/typekit/webfontloader/blob/master/src/core/fontruler.js#L41
+ style = [
+ 'display:block',
+ 'position:absolute',
+ 'top:-999px',
+ 'left:-999px',
+ 'font-size:48px',
+ 'width:auto',
+ 'height:auto',
+ 'line-height:normal',
+ 'margin:0',
+ 'padding:0',
+ 'font-variant:normal',
+ 'white-space:nowrap'
+ ],
+ html = '
' + TEST_STRING + '
';
+
+ var FontFaceOnloadInstance = function() {
+ this.fontFamily = '';
+ this.appended = false;
+ this.serif = undefined;
+ this.sansSerif = undefined;
+ this.parent = undefined;
+ this.options = {};
+ };
+
+ FontFaceOnloadInstance.prototype.getMeasurements = function () {
+ return {
+ sansSerif: {
+ width: this.sansSerif.offsetWidth,
+ height: this.sansSerif.offsetHeight
+ },
+ serif: {
+ width: this.serif.offsetWidth,
+ height: this.serif.offsetHeight
+ }
+ };
+ };
+
+ FontFaceOnloadInstance.prototype.load = function () {
+ var startTime = new Date(),
+ that = this,
+ serif = that.serif,
+ sansSerif = that.sansSerif,
+ parent = that.parent,
+ appended = that.appended,
+ dimensions,
+ options = this.options,
+ ref = options.reference;
+
+ function getStyle( family ) {
+ return style
+ .concat( [ 'font-weight:' + options.weight, 'font-style:' + options.style ] )
+ .concat( "font-family:" + family )
+ .join( ";" );
+ }
+
+ var sansSerifHtml = html.replace( /\%s/, getStyle( SANS_SERIF_FONTS ) ),
+ serifHtml = html.replace( /\%s/, getStyle( SERIF_FONTS ) );
+
+ if( !parent ) {
+ parent = that.parent = doc.createElement( "div" );
+ }
+
+ parent.innerHTML = sansSerifHtml + serifHtml;
+ sansSerif = that.sansSerif = parent.firstChild;
+ serif = that.serif = sansSerif.nextSibling;
+
+ if( options.glyphs ) {
+ sansSerif.innerHTML += options.glyphs;
+ serif.innerHTML += options.glyphs;
+ }
+
+ function hasNewDimensions( dims, el, tolerance ) {
+ return Math.abs( dims.width - el.offsetWidth ) > tolerance ||
+ Math.abs( dims.height - el.offsetHeight ) > tolerance;
+ }
+
+ function isTimeout() {
+ return ( new Date() ).getTime() - startTime.getTime() > options.timeout;
+ }
+
+ (function checkDimensions() {
+ if( !ref ) {
+ ref = doc.body;
+ }
+ if( !appended && ref ) {
+ ref.appendChild( parent );
+ appended = that.appended = true;
+
+ dimensions = that.getMeasurements();
+
+ // Make sure we set the new font-family after we take our initial dimensions:
+ // handles the case where FontFaceOnload is called after the font has already
+ // loaded.
+ sansSerif.style.fontFamily = that.fontFamily + ', ' + SANS_SERIF_FONTS;
+ serif.style.fontFamily = that.fontFamily + ', ' + SERIF_FONTS;
+ }
+
+ if( appended && dimensions &&
+ ( hasNewDimensions( dimensions.sansSerif, sansSerif, options.tolerance ) ||
+ hasNewDimensions( dimensions.serif, serif, options.tolerance ) ) ) {
+
+ options.success();
+ } else if( isTimeout() ) {
+ options.error();
+ } else {
+ if( !appended && "requestAnimationFrame" in window ) {
+ win.requestAnimationFrame( checkDimensions );
+ } else {
+ win.setTimeout( checkDimensions, options.delay );
+ }
+ }
+ })();
+ }; // end load()
+
+ FontFaceOnloadInstance.prototype.checkFontFaces = function( timeout ) {
+ var _t = this;
+ doc.fonts.forEach(function( font ) {
+ if( font.family.toLowerCase() === _t.fontFamily.toLowerCase() &&
+ ( weightLookup[ font.weight ] || font.weight ) === ''+_t.options.weight &&
+ font.style === _t.options.style ) {
+ font.load().then(function() {
+ _t.options.success();
+ win.clearTimeout( timeout );
+ });
+ }
+ });
+ };
+
+ FontFaceOnloadInstance.prototype.init = function( fontFamily, options ) {
+ var timeout;
+
+ for( var j in defaultOptions ) {
+ if( !options.hasOwnProperty( j ) ) {
+ options[ j ] = defaultOptions[ j ];
+ }
+ }
+
+ this.options = options;
+ this.fontFamily = fontFamily;
+
+ // For some reason this was failing on afontgarde + icon fonts.
+ if( !options.glyphs && "fonts" in doc ) {
+ if( options.timeout ) {
+ timeout = win.setTimeout(function() {
+ options.error();
+ }, options.timeout );
+ }
+
+ this.checkFontFaces( timeout );
+ } else {
+ this.load();
+ }
+ };
+
+ var FontFaceOnload = function( fontFamily, options ) {
+ var instance = new FontFaceOnloadInstance();
+ instance.init(fontFamily, options);
+
+ return instance;
+ };
+
+ // intentional global
+ win.FontFaceOnload = FontFaceOnload;
+})( this, this.document );
+
+/*
+ * A Font Garde
+ */
+
+;(function( w ) {
+
+ var doc = w.document,
+ ref,
+ css = ['.FONT_NAME.supports-generatedcontent .icon-fallback-text .icon { display: inline-block; }',
+ '.FONT_NAME.supports-generatedcontent .icon-fallback-text .text { clip: rect(0 0 0 0); overflow: hidden; position: absolute; height: 1px; width: 1px; }',
+ '.FONT_NAME .icon-fallback-glyph .icon:before { font-size: 1em; font-size: inherit; line-height: 1; line-height: inherit; }'];
+
+ function addEvent( type, callback ) {
+ if( 'addEventListener' in w ) {
+ return w.addEventListener( type, callback, false );
+ } else if( 'attachEvent' in w ) {
+ return w.attachEvent( 'on' + type, callback );
+ }
+ }
+
+ // options can be a string of glyphs or an options object to pass into FontFaceOnload
+ AFontGarde = function( fontFamily, options ) {
+ var fontFamilyClassName = fontFamily.toLowerCase().replace( /\s/g, '' ),
+ executed = false;
+
+ function init() {
+ if( executed ) {
+ return;
+ }
+ executed = true;
+
+ if( typeof FontFaceOnload === 'undefined' ) {
+ throw 'FontFaceOnload is a prerequisite.';
+ }
+
+ if( !ref ) {
+ ref = doc.getElementsByTagName( 'script' )[ 0 ];
+ }
+ var style = doc.createElement( 'style' ),
+ cssContent = css.join( '\n' ).replace( /FONT_NAME/gi, fontFamilyClassName );
+
+ style.setAttribute( 'type', 'text/css' );
+ if( style.styleSheet ) {
+ style.styleSheet.cssText = cssContent;
+ } else {
+ style.appendChild( doc.createTextNode( cssContent ) );
+ }
+ ref.parentNode.insertBefore( style, ref );
+
+ var opts = {
+ timeout: 5000,
+ success: function() {
+ // If you’re using more than one icon font, change this classname (and in a-font-garde.css)
+ doc.documentElement.className += ' ' + fontFamilyClassName;
+
+ if( options && options.success ) {
+ options.success();
+ }
+ }
+ };
+
+ // These characters are a few of the glyphs from the font above */
+ if( typeof options === "string" ) {
+ opts.glyphs = options;
+ } else {
+ for( var j in options ) {
+ if( options.hasOwnProperty( j ) && j !== "success" ) {
+ opts[ j ] = options[ j ];
+ }
+ }
+ }
+
+ FontFaceOnload( fontFamily, opts );
+ }
+
+ // MIT credit: filamentgroup/shoestring
+ addEvent( "DOMContentLoaded", init );
+ addEvent( "readystatechange", init );
+ addEvent( "load", init );
+
+ if( doc.readyState === "complete" ){
+ init();
+ }
+ };
+
+})( this );
\ No newline at end of file
diff --git a/common/static/js/vendor/afontgarde/edx-icons.js b/common/static/js/vendor/afontgarde/edx-icons.js
new file mode 100644
index 0000000000..a019005f00
--- /dev/null
+++ b/common/static/js/vendor/afontgarde/edx-icons.js
@@ -0,0 +1,3 @@
+AFontGarde('FontAwesome', {
+ glyphs: ''
+});
\ No newline at end of file
diff --git a/common/static/js/vendor/afontgarde/modernizr.fontface-generatedcontent.js b/common/static/js/vendor/afontgarde/modernizr.fontface-generatedcontent.js
new file mode 100644
index 0000000000..58bf650026
--- /dev/null
+++ b/common/static/js/vendor/afontgarde/modernizr.fontface-generatedcontent.js
@@ -0,0 +1,4 @@
+/* Modernizr 2.7.1 (Custom Build) | MIT & BSD
+ * Build: http://modernizr.com/download/#-fontface-generatedcontent-cssclasses-teststyles-cssclassprefix:supports!
+ */
+;window.Modernizr=function(a,b,c){function w(a){j.cssText=a}function x(a,b){return w(prefixes.join(a+";")+(b||""))}function y(a,b){return typeof a===b}function z(a,b){return!!~(""+a).indexOf(b)}function A(a,b,d){for(var e in a){var f=b[a[e]];if(f!==c)return d===!1?a[e]:y(f,"function")?f.bind(d||b):f}return!1}var d="2.7.1",e={},f=!0,g=b.documentElement,h="modernizr",i=b.createElement(h),j=i.style,k,l=":)",m={}.toString,n={},o={},p={},q=[],r=q.slice,s,t=function(a,c,d,e){var f,i,j,k,l=b.createElement("div"),m=b.body,n=m||b.createElement("body");if(parseInt(d,10))while(d--)j=b.createElement("div"),j.id=e?e[d]:h+(d+1),l.appendChild(j);return f=["",'"].join(""),l.id=h,(m?l:n).innerHTML+=f,n.appendChild(l),m||(n.style.background="",n.style.overflow="hidden",k=g.style.overflow,g.style.overflow="hidden",g.appendChild(n)),i=c(l,a),m?l.parentNode.removeChild(l):(n.parentNode.removeChild(n),g.style.overflow=k),!!i},u={}.hasOwnProperty,v;!y(u,"undefined")&&!y(u.call,"undefined")?v=function(a,b){return u.call(a,b)}:v=function(a,b){return b in a&&y(a.constructor.prototype[b],"undefined")},Function.prototype.bind||(Function.prototype.bind=function(b){var c=this;if(typeof c!="function")throw new TypeError;var d=r.call(arguments,1),e=function(){if(this instanceof e){var a=function(){};a.prototype=c.prototype;var f=new a,g=c.apply(f,d.concat(r.call(arguments)));return Object(g)===g?g:f}return c.apply(b,d.concat(r.call(arguments)))};return e}),n.fontface=function(){var a;return t('@font-face {font-family:"font";src:url("https://")}',function(c,d){var e=b.getElementById("smodernizr"),f=e.sheet||e.styleSheet,g=f?f.cssRules&&f.cssRules[0]?f.cssRules[0].cssText:f.cssText||"":"";a=/src/i.test(g)&&g.indexOf(d.split(" ")[0])===0}),a},n.generatedcontent=function(){var a;return t(["#",h,"{font:0/0 a}#",h,':after{content:"',l,'";visibility:hidden;font:3px/1 a}'].join(""),function(b){a=b.offsetHeight>=3}),a};for(var B in n)v(n,B)&&(s=B.toLowerCase(),e[s]=n[B](),q.push((e[s]?"":"no-")+s));return e.addTest=function(a,b){if(typeof a=="object")for(var d in a)v(a,d)&&e.addTest(d,a[d]);else{a=a.toLowerCase();if(e[a]!==c)return e;b=typeof b=="function"?b():b,typeof f!="undefined"&&f&&(g.className+=" supports-"+(b?"":"no-")+a),e[a]=b}return e},w(""),i=k=null,e._version=d,e.testStyles=t,g.className=g.className.replace(/(^|\s)no-js(\s|$)/,"$1$2")+(f?" supports-js supports-"+q.join(" supports-"):""),e}(this,this.document);
\ No newline at end of file
diff --git a/common/test/acceptance/pages/lms/video/video.py b/common/test/acceptance/pages/lms/video/video.py
index c2bb480db2..e2c61a1b88 100644
--- a/common/test/acceptance/pages/lms/video/video.py
+++ b/common/test/acceptance/pages/lms/video/video.py
@@ -14,7 +14,8 @@ import logging
log = logging.getLogger('VideoPage')
VIDEO_BUTTONS = {
- 'CC': '.hide-subtitles',
+ 'transcript': '.lang',
+ 'transcript_button': '.toggle-transcript',
'volume': '.volume',
'play': '.video_control.play',
'pause': '.video_control.pause',
@@ -32,12 +33,12 @@ CSS_CLASS_NAMES = {
'captions': '.subtitles',
'captions_text': '.subtitles > li',
'error_message': '.video .video-player h3',
- 'video_container': 'div.video',
+ 'video_container': '.video',
'video_sources': '.video-player video source',
'video_spinner': '.video-wrapper .spinner',
'video_xmodule': '.xmodule_VideoModule',
'video_init': '.is-initialized',
- 'video_time': 'div.vidtime',
+ 'video_time': '.vidtime',
'video_display_name': '.vert h2',
'captions_lang_list': '.langs-list li',
'video_speed': '.speeds .value',
@@ -45,8 +46,8 @@ CSS_CLASS_NAMES = {
}
VIDEO_MODES = {
- 'html5': 'div.video video',
- 'youtube': 'div.video iframe'
+ 'html5': '.video video',
+ 'youtube': '.video iframe'
}
VIDEO_MENUS = {
@@ -99,7 +100,7 @@ class VideoPage(PageObject):
video_player_buttons.append('play')
for button in video_player_buttons:
- self.wait_for_element_visibility(VIDEO_BUTTONS[button], '{} button is visible'.format(button.title()))
+ self.wait_for_element_visibility(VIDEO_BUTTONS[button], '{} button is visible'.format(button))
def _is_finished_loading():
"""
@@ -126,7 +127,7 @@ class VideoPage(PageObject):
video_player_buttons = ['do_not_show_again', 'skip_bumper', 'volume']
for button in video_player_buttons:
- self.wait_for_element_visibility(VIDEO_BUTTONS[button], '{} button is visible'.format(button.title()))
+ self.wait_for_element_visibility(VIDEO_BUTTONS[button], '{} button is visible'.format(button))
@property
def is_poster_shown(self):
@@ -316,13 +317,13 @@ class VideoPage(PageObject):
states = {True: 'Shown', False: 'Hidden'}
state = states[captions_new_state]
- # Make sure that the CC button is there
- EmptyPromise(lambda: self.is_button_shown('CC'),
- "CC button is shown").fulfill()
+ # Make sure that the transcript button is there
+ EmptyPromise(lambda: self.is_button_shown('transcript_button'),
+ "transcript button is shown").fulfill()
# toggle captions visibility state if needed
if self.is_captions_visible() != captions_new_state:
- self.click_player_button('CC')
+ self.click_player_button('transcript_button')
# Verify that captions state is toggled/changed
EmptyPromise(lambda: self.is_captions_visible() == captions_new_state,
@@ -371,7 +372,7 @@ class VideoPage(PageObject):
hover = ActionChains(self.browser).move_to_element(element_to_hover_over)
hover.perform()
- speed_selector = self.get_element_selector('li[data-speed="{speed}"] a'.format(speed=speed))
+ speed_selector = self.get_element_selector('li[data-speed="{speed}"] .control'.format(speed=speed))
self.q(css=speed_selector).first.click()
def verify_speed_changed(self, expected_speed):
@@ -548,8 +549,8 @@ class VideoPage(PageObject):
"""
self.wait_for_ajax()
- # mouse over to CC button
- cc_button_selector = self.get_element_selector(VIDEO_BUTTONS["CC"])
+ # mouse over to transcript button
+ cc_button_selector = self.get_element_selector(VIDEO_BUTTONS["transcript"])
element_to_hover_over = self.q(css=cc_button_selector).results[0]
ActionChains(self.browser).move_to_element(element_to_hover_over).perform()
diff --git a/common/test/acceptance/tests/video/test_studio_video_module.py b/common/test/acceptance/tests/video/test_studio_video_module.py
index ac9dde7f8e..7a693166bb 100644
--- a/common/test/acceptance/tests/video/test_studio_video_module.py
+++ b/common/test/acceptance/tests/video/test_studio_video_module.py
@@ -267,11 +267,11 @@ class CMSVideoTest(CMSVideoBaseTest):
"""
self._create_course_unit(subtitles=True)
- self.video.click_player_button('CC')
+ self.video.click_player_button('transcript_button')
self.assertFalse(self.video.is_captions_visible())
- self.video.click_player_button('CC')
+ self.video.click_player_button('transcript_button')
self.assertTrue(self.video.is_captions_visible())
diff --git a/common/test/acceptance/tests/video/test_video_module.py b/common/test/acceptance/tests/video/test_video_module.py
index fb2c1fc6e9..db0169685f 100644
--- a/common/test/acceptance/tests/video/test_video_module.py
+++ b/common/test/acceptance/tests/video/test_video_module.py
@@ -254,7 +254,7 @@ class YouTubeVideoTest(VideoBaseTest):
Then the "CC" button is hidden
"""
self.navigate_to_video()
- self.assertFalse(self.video.is_button_shown('CC'))
+ self.assertFalse(self.video.is_button_shown('transcript_button'))
def test_fullscreen_video_alignment_with_transcript_hidden(self):
"""
@@ -351,8 +351,8 @@ class YouTubeVideoTest(VideoBaseTest):
# check if video aligned correctly with enabled transcript
self.assertTrue(self.video.is_aligned(True))
- # click video button "CC"
- self.video.click_player_button('CC')
+ # click video button "transcript"
+ self.video.click_player_button('transcript_button')
# check if video aligned correctly without enabled transcript
self.assertTrue(self.video.is_aligned(False))
@@ -459,7 +459,7 @@ class YouTubeVideoTest(VideoBaseTest):
self.assertTrue(self.video.is_video_rendered('html5'))
# check if caption button is visible
- self.assertTrue(self.video.is_button_shown('CC'))
+ self.assertTrue(self.video.is_button_shown('transcript_button'))
self._verify_caption_text('Welcome to edX.')
def test_download_transcript_button_works_correctly(self):
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 6072e7087e..e8b30cfb78 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1273,6 +1273,9 @@ main_vendor_js = base_vendor_js + [
'js/vendor/jquery-ui.min.js',
'js/vendor/jquery.qtip.min.js',
'js/vendor/jquery.ba-bbq.min.js',
+ 'js/vendor/afontgarde/modernizr.fontface-generatedcontent.js',
+ 'js/vendor/afontgarde/afontgarde.js',
+ 'js/vendor/afontgarde/edx-icons.js',
]
# Common files used by both RequireJS code and non-RequireJS code
@@ -1376,6 +1379,7 @@ credit_web_view_js = [
PIPELINE_CSS = {
'style-vendor': {
'source_filenames': [
+ 'js/vendor/afontgarde/afontgarde.css',
'css/vendor/font-awesome.css',
'css/vendor/jquery.qtip.min.css',
'css/vendor/responsive-carousel/responsive-carousel.css',
From dcc9172e7b6f5698a8c03c9ce4f2fbef67505878 Mon Sep 17 00:00:00 2001
From: Ben Patterson
Date: Thu, 12 Nov 2015 20:34:52 -0500
Subject: [PATCH 025/115] Use upstream nose instead of a fork.
The multiprocessing issues we had seen previously do not appear
when we use our combination of plugin settings for multiprocessing.
This makes it unnecessary to use a fork.
---
requirements/edx/base.txt | 1 +
requirements/edx/github.txt | 3 ---
2 files changed, 1 insertion(+), 3 deletions(-)
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 65769a903f..55e2e0e8f0 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -134,6 +134,7 @@ freezegun==0.1.11
mock-django==0.6.9
mock==1.0.1
moto==0.3.1
+nose==1.3.7
nose-exclude
nose-ignore-docstring
nosexcover==1.0.7
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index cee88b3c54..1f257b3b35 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -33,9 +33,6 @@ git+https://github.com/edx/rfc6266.git@v0.0.5-edx#egg=rfc6266==0.0.5-edx
# Used for testing
-e git+https://github.com/gabrielfalcao/lettuce.git@b18b8fb711eb7a178c58574716032ad8de525912#egg=lettuce=1.8-support
-# nose fork needed for multiprocess support
-git+https://github.com/edx/nose.git@99c2aff0ff51bf228bfa5482e97e612c97a23245#egg=nose==1.3.7.1
-
# Our libraries:
-e git+https://github.com/edx/XBlock.git@a20c70f2e3df1cb716b9c7a25fecf57020543b7f#egg=XBlock
-e git+https://github.com/edx/codejail.git@6b17c33a89bef0ac510926b1d7fea2748b73aadd#egg=codejail
From 15380a00f7e75c1cb668f26c96da78c028c95748 Mon Sep 17 00:00:00 2001
From: Saleem Latif
Date: Thu, 12 Nov 2015 15:33:59 +0500
Subject: [PATCH 026/115] invalidate Generated Certificates before regeneration
---
.../instructor_task/tasks_helper.py | 42 ++++-
.../tests/test_tasks_helper.py | 152 +++++++++++++++++-
2 files changed, 187 insertions(+), 7 deletions(-)
diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py
index b9aa42be56..fa625cd739 100644
--- a/lms/djangoapps/instructor_task/tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tasks_helper.py
@@ -40,7 +40,8 @@ from django.utils.translation import ugettext as _
from certificates.models import (
CertificateWhitelist,
certificate_info_for_user,
- CertificateStatuses
+ CertificateStatuses,
+ GeneratedCertificate
)
from certificates.api import generate_user_certificates
from courseware.courses import get_course_by_id, get_problems_in_section
@@ -1419,6 +1420,12 @@ def generate_students_certificates(
statuses_to_regenerate = task_input.get('statuses_to_regenerate', [])
students_require_certs = students_require_certificate(course_id, enrolled_students, statuses_to_regenerate)
+ if statuses_to_regenerate:
+ # Mark existing generated certificates as 'unavailable' before regenerating
+ # We need to call this method after "students_require_certificate" otherwise "students_require_certificate"
+ # would return no results.
+ invalidate_generated_certificates(course_id, enrolled_students, statuses_to_regenerate)
+
task_progress.skipped = task_progress.total - len(students_require_certs)
current_step = {'step': 'Generating Certificates'}
@@ -1542,10 +1549,12 @@ def students_require_certificate(course_id, enrolled_students, statuses_to_regen
if statuses_to_regenerate:
# Return Students that have Generated Certificates and the generated certificate status
# lies in 'statuses_to_regenerate'
- return User.objects.filter(
+ students_require_certificates = enrolled_students.filter(
generatedcertificate__course_id=course_id,
generatedcertificate__status__in=statuses_to_regenerate
)
+ # Fetch results otherwise subsequent operations on table cause wrong data fetch
+ return list(students_require_certificates)
else:
# compute those students whose certificates are already generated
students_already_have_certs = User.objects.filter(
@@ -1554,3 +1563,32 @@ def students_require_certificate(course_id, enrolled_students, statuses_to_regen
# Return all the enrolled student skipping the ones whose certificates have already been generated
return list(set(enrolled_students) - set(students_already_have_certs))
+
+
+def invalidate_generated_certificates(course_id, enrolled_students, certificate_statuses): # pylint: disable=invalid-name
+ """
+ Invalidate generated certificates for all enrolled students in the given course having status in
+ 'certificate_statuses'.
+
+ Generated Certificates are invalidated by marking its status 'unavailable' and updating verify_uuid, download_uuid,
+ download_url and grade with empty string.
+
+ :param course_id: Course Key for the course whose generated certificates need to be removed
+ :param enrolled_students: (queryset or list) students enrolled in the course
+ :param certificate_statuses: certificates statuses for whom to remove generated certificate
+ """
+ certificates = GeneratedCertificate.objects.filter(
+ user__in=enrolled_students,
+ course_id=course_id,
+ status__in=certificate_statuses,
+ )
+
+ # Mark generated certificates as 'unavailable' and update download_url, download_uui, verify_uuid and
+ # grade with empty string for each row
+ certificates.update(
+ status=CertificateStatuses.unavailable,
+ verify_uuid='',
+ download_uuid='',
+ download_url='',
+ grade='',
+ )
diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
index 51a81887e5..4e7d87e19a 100644
--- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
@@ -16,7 +16,7 @@ from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
-from certificates.models import CertificateStatuses
+from certificates.models import CertificateStatuses, GeneratedCertificate
from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory
from course_modes.models import CourseMode
from courseware.tests.factories import InstructorFactory
@@ -1708,6 +1708,9 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
certificate statuses lies in the list 'statuses_to_regenerate' given in task_input.
"""
+ # Default grade for students
+ default_grade = '-1'
+
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
@@ -1718,7 +1721,8 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
user=student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
- mode='honor'
+ mode='honor',
+ grade=default_grade
)
# mark 3 students to have certificates generated with status 'error'
@@ -1727,7 +1731,8 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
user=student,
course_id=self.course.id,
status=CertificateStatuses.error,
- mode='honor'
+ mode='honor',
+ grade=default_grade
)
# mark 6th students to have certificates generated with status 'deleted'
@@ -1736,7 +1741,8 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
user=student,
course_id=self.course.id,
status=CertificateStatuses.deleted,
- mode='honor'
+ mode='honor',
+ grade=default_grade
)
# mark rest of the 4 students with having generated certificates with status 'generating'
@@ -1748,7 +1754,8 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
user=student,
course_id=self.course.id,
status=CertificateStatuses.generating,
- mode='honor'
+ mode='honor',
+ grade=default_grade
)
# white-list 7 students
@@ -1781,3 +1788,138 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
},
result
)
+ generated_certificates = GeneratedCertificate.objects.filter(
+ user__in=students,
+ course_id=self.course.id,
+ mode='honor'
+ )
+ certificate_statuses = [generated_certificate.status for generated_certificate in generated_certificates]
+ certificate_grades = [generated_certificate.grade for generated_certificate in generated_certificates]
+
+ # Verify from results from database
+ # Certificates are being generated for 2 white-listed students that had statuses in 'deleted'' and 'generating'
+ self.assertEqual(certificate_statuses.count(CertificateStatuses.generating), 2)
+ # 5 students are skipped that had Certificate Status 'downloadable' and 'error'
+ self.assertEqual(certificate_statuses.count(CertificateStatuses.downloadable), 2)
+ self.assertEqual(certificate_statuses.count(CertificateStatuses.error), 3)
+
+ # grades will be '0.0' as students are either white-listed or ending in error
+ self.assertEqual(certificate_grades.count('0.0'), 5)
+ # grades will be '-1' for students that were skipped
+ self.assertEqual(certificate_grades.count(default_grade), 5)
+
+ def test_certificate_regeneration_with_existing_unavailable_status(self):
+ """
+ Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
+ certificate status lies in the list 'statuses_to_regenerate' given in task_input. but the 'unavailable'
+ status is not touched if it is not in the 'statuses_to_regenerate' list.
+ """
+ # Default grade for students
+ default_grade = '-1'
+
+ # create 10 students
+ students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
+ for i in xrange(1, 11)]
+
+ # mark 2 students to have certificates generated already
+ for student in students[:2]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.downloadable,
+ mode='honor',
+ grade=default_grade
+ )
+
+ # mark 3 students to have certificates generated with status 'error'
+ for student in students[2:5]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.error,
+ mode='honor',
+ grade=default_grade
+ )
+
+ # mark 2 students to have generated certificates with status 'unavailable'
+ for student in students[5:7]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.unavailable,
+ mode='honor',
+ grade=default_grade
+ )
+
+ # mark 3 students to have generated certificates with status 'generating'
+ for student in students[7:]:
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=self.course.id,
+ status=CertificateStatuses.generating,
+ mode='honor',
+ grade=default_grade
+ )
+
+ # white-list all students
+ for student in students[:]:
+ CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
+
+ current_task = Mock()
+ current_task.update_state = Mock()
+
+ # Regenerated certificates for students having generated certificates with status
+ # 'downloadable', 'error' or 'generating'
+ task_input = {
+ 'statuses_to_regenerate': [
+ CertificateStatuses.downloadable,
+ CertificateStatuses.error,
+ CertificateStatuses.generating
+ ]
+ }
+
+ with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
+ mock_current_task.return_value = current_task
+ with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
+ mock_queue.return_value = (0, "Successfully queued")
+ result = generate_students_certificates(
+ None, None, self.course.id, task_input, 'certificates generated'
+ )
+
+ self.assertDictContainsSubset(
+ {
+ 'action_name': 'certificates generated',
+ 'total': 10,
+ 'attempted': 8,
+ 'succeeded': 8,
+ 'failed': 0,
+ 'skipped': 2
+ },
+ result
+ )
+
+ generated_certificates = GeneratedCertificate.objects.filter(
+ user__in=students,
+ course_id=self.course.id,
+ mode='honor'
+ )
+ certificate_statuses = [generated_certificate.status for generated_certificate in generated_certificates]
+ certificate_grades = [generated_certificate.grade for generated_certificate in generated_certificates]
+
+ # Verify from results from database
+ # Certificates are being generated for 8 students that had statuses in 'downloadable', 'error' and 'generating'
+ self.assertEqual(certificate_statuses.count(CertificateStatuses.generating), 8)
+ # 2 students are skipped that had Certificate Status 'unavailable'
+ self.assertEqual(certificate_statuses.count(CertificateStatuses.unavailable), 2)
+
+ # grades will be '0.0' as students are white-listed and have not completed any tasks
+ self.assertEqual(certificate_grades.count('0.0'), 8)
+ # grades will be '-1' for students that have not been processed
+ self.assertEqual(certificate_grades.count(default_grade), 2)
+
+ # Verify that students with status 'unavailable were skipped
+ unavailable_certificates = \
+ [cert for cert in generated_certificates
+ if cert.status == CertificateStatuses.unavailable and cert.grade == default_grade]
+
+ self.assertEquals(len(unavailable_certificates), 2)
From 4ea70aeb197470cf06f6ab7654a86adf687d91fc Mon Sep 17 00:00:00 2001
From: wajeeha-khalid
Date: Tue, 10 Nov 2015 18:28:16 +0500
Subject: [PATCH 027/115] MA-862; accept application/merge-patch+json for
comment/thread update
---
common/djangoapps/util/testing.py | 18 ++++++++++++++++++
.../discussion_api/tests/test_views.py | 17 ++++++++++-------
lms/djangoapps/discussion_api/views.py | 17 +++++++++++++++++
3 files changed, 45 insertions(+), 7 deletions(-)
diff --git a/common/djangoapps/util/testing.py b/common/djangoapps/util/testing.py
index 72a94a93cd..a14a4022ed 100644
--- a/common/djangoapps/util/testing.py
+++ b/common/djangoapps/util/testing.py
@@ -1,3 +1,8 @@
+"""
+Utility Mixins for unit tests
+"""
+
+import json
import sys
from mock import patch
@@ -90,3 +95,16 @@ class EventTestMixin(object):
Reset the mock tracker in order to forget about old events.
"""
self.mock_tracker.reset_mock()
+
+
+class PatchMediaTypeMixin(object):
+ """
+ Generic mixin for verifying unsupported media type in PATCH
+ """
+ def test_patch_unsupported_media_type(self):
+ response = self.client.patch( # pylint: disable=no-member
+ self.url,
+ json.dumps({}),
+ content_type=self.unsupported_media_type
+ )
+ self.assertEqual(response.status_code, 415)
diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py
index 8a64d3582f..31737d4ae0 100644
--- a/lms/djangoapps/discussion_api/tests/test_views.py
+++ b/lms/djangoapps/discussion_api/tests/test_views.py
@@ -11,6 +11,7 @@ import mock
from pytz import UTC
from django.core.urlresolvers import reverse
+from rest_framework.parsers import JSONParser
from rest_framework.test import APIClient
from xmodule.modulestore import ModuleStoreEnum
@@ -24,7 +25,7 @@ from discussion_api.tests.utils import (
make_minimal_cs_thread,
)
from student.tests.factories import CourseEnrollmentFactory, UserFactory
-from util.testing import UrlResetMixin
+from util.testing import UrlResetMixin, PatchMediaTypeMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls, ItemFactory
@@ -536,9 +537,10 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
@httpretty.activate
@disable_signal(api, 'thread_edited')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
+class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
"""Tests for ThreadViewSet partial_update"""
def setUp(self):
+ self.unsupported_media_type = JSONParser.media_type
super(ThreadViewSetPartialUpdateTest, self).setUp()
self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"})
@@ -592,7 +594,7 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
response = self.client.patch( # pylint: disable=no-member
self.url,
json.dumps(request_data),
- content_type="application/json"
+ content_type="application/merge-patch+json"
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
@@ -625,7 +627,7 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
response = self.client.patch( # pylint: disable=no-member
self.url,
json.dumps(request_data),
- content_type="application/json"
+ content_type="application/merge-patch+json"
)
expected_response_data = {
"field_errors": {"title": {"developer_message": "This field may not be blank."}}
@@ -930,9 +932,10 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
@disable_signal(api, 'comment_edited')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
+class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
"""Tests for CommentViewSet partial_update"""
def setUp(self):
+ self.unsupported_media_type = JSONParser.media_type
super(CommentViewSetPartialUpdateTest, self).setUp()
httpretty.reset()
httpretty.enable()
@@ -982,7 +985,7 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
response = self.client.patch( # pylint: disable=no-member
self.url,
json.dumps(request_data),
- content_type="application/json"
+ content_type="application/merge-patch+json"
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
@@ -1004,7 +1007,7 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
response = self.client.patch( # pylint: disable=no-member
self.url,
json.dumps(request_data),
- content_type="application/json"
+ content_type="application/merge-patch+json"
)
expected_response_data = {
"field_errors": {"raw_body": {"developer_message": "This field may not be blank."}}
diff --git a/lms/djangoapps/discussion_api/views.py b/lms/djangoapps/discussion_api/views.py
index 612010c448..516f57686f 100644
--- a/lms/djangoapps/discussion_api/views.py
+++ b/lms/djangoapps/discussion_api/views.py
@@ -2,6 +2,8 @@
Discussion API views
"""
from django.core.exceptions import ValidationError
+from rest_framework.exceptions import UnsupportedMediaType
+from rest_framework.parsers import JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -25,6 +27,7 @@ from discussion_api.api import (
update_thread,
)
from discussion_api.forms import CommentListGetForm, ThreadListGetForm, _PaginationForm
+from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
@@ -119,6 +122,7 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
PATCH /api/discussion/v1/threads/thread_id
{"raw_body": "Edited text"}
+ Content Type: "application/merge-patch+json"
DELETE /api/discussion/v1/threads/thread_id
@@ -175,6 +179,9 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
topic_id, type, title, and raw_body are accepted with the same meaning
as in a POST request
+ If "application/merge-patch+json" is not the specified content type,
+ a 415 error is returned.
+
**GET Response Values**:
* results: The list of threads; each item in the list has the same
@@ -232,6 +239,7 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
"""
lookup_field = "thread_id"
+ parser_classes = (JSONParser, MergePatchParser,)
def list(self, request):
"""
@@ -274,6 +282,8 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
Implements the PATCH method for the instance endpoint as described in
the class docstring.
"""
+ if request.content_type != MergePatchParser.media_type:
+ raise UnsupportedMediaType(request.content_type)
return Response(update_thread(request, thread_id, request.data))
def destroy(self, request, thread_id):
@@ -307,6 +317,7 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet):
PATCH /api/discussion/v1/comments/comment_id
{"raw_body": "Edited text"}
+ Content Type: "application/merge-patch+json"
DELETE /api/discussion/v1/comments/comment_id
@@ -347,6 +358,9 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet):
raw_body is accepted with the same meaning as in a POST request
+ If "application/merge-patch+json" is not the specified content type,
+ a 415 error is returned.
+
**GET Response Values**:
* results: The list of comments; each item in the list has the same
@@ -409,6 +423,7 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet):
"""
lookup_field = "comment_id"
+ parser_classes = (JSONParser, MergePatchParser,)
def list(self, request):
"""
@@ -465,4 +480,6 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet):
Implements the PATCH method for the instance endpoint as described in
the class docstring.
"""
+ if request.content_type != MergePatchParser.media_type:
+ raise UnsupportedMediaType(request.content_type)
return Response(update_comment(request, comment_id, request.data))
From 35d04bff89b05864aa4d9d277769b4938485eb4d Mon Sep 17 00:00:00 2001
From: asadiqbal
Date: Thu, 12 Nov 2015 12:16:09 +0500
Subject: [PATCH 028/115] SOL-1385
---
.../test/acceptance/fixtures/certificates.py | 26 +++++++++++++++++
.../pages/lms/instructor_dashboard.py | 7 +++++
.../lms/test_lms_instructor_dashboard.py | 28 ++++++++++++++++++-
.../instructor/views/instructor_dashboard.py | 1 +
.../instructor_dashboard_2/certificates.html | 7 ++++-
5 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/common/test/acceptance/fixtures/certificates.py b/common/test/acceptance/fixtures/certificates.py
index f12573cad9..346037801d 100644
--- a/common/test/acceptance/fixtures/certificates.py
+++ b/common/test/acceptance/fixtures/certificates.py
@@ -15,6 +15,13 @@ class CertificateConfigFixtureError(Exception):
pass
+class CertificateConfigUpdateFixtureError(Exception):
+ """
+ Error occurred while updating certificate config fixture.
+ """
+ pass
+
+
class CertificateConfigFixture(StudioApiFixture):
"""
Fixture to create certificates configuration for a course
@@ -44,3 +51,22 @@ class CertificateConfigFixture(StudioApiFixture):
)
return self
+
+ def update_certificate(self, certificate_id):
+ """
+ Update the certificates config data to certificate endpoint.
+ """
+ response = self.session.put(
+ '{}/certificates/{}/{}'.format(STUDIO_BASE_URL, self.course_id, certificate_id),
+ data=json.dumps(self.certificates),
+ headers=self.headers
+ )
+
+ if not response.ok:
+ raise CertificateConfigUpdateFixtureError(
+ "Could not update certificate {0}. Status was {1}".format(
+ json.dumps(self.certificates), response.status_code
+ )
+ )
+
+ return self
diff --git a/common/test/acceptance/pages/lms/instructor_dashboard.py b/common/test/acceptance/pages/lms/instructor_dashboard.py
index 83e4292896..977d391b87 100644
--- a/common/test/acceptance/pages/lms/instructor_dashboard.py
+++ b/common/test/acceptance/pages/lms/instructor_dashboard.py
@@ -1056,6 +1056,13 @@ class CertificatesPage(PageObject):
"""
return self.get_selector('#btn-start-generating-certificates')
+ @property
+ def generate_certificates_disabled_button(self): # pylint: disable=invalid-name
+ """
+ Returns the disabled state of button
+ """
+ return self.get_selector('#disabled-btn-start-generating-certificates')
+
@property
def certificate_generation_status(self):
"""
diff --git a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
index 93f2043b2d..0b2201c5e6 100644
--- a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
+++ b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
@@ -22,6 +22,7 @@ from ...pages.lms.problem import ProblemPage
from ...pages.lms.track_selection import TrackSelectionPage
from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage
from common.test.acceptance.tests.helpers import disable_animations
+from ...fixtures.certificates import CertificateConfigFixture
class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest):
@@ -589,12 +590,37 @@ class CertificatesTest(BaseInstructorDashboardTest):
def setUp(self):
super(CertificatesTest, self).setUp()
- self.course_fixture = CourseFixture(**self.course_info).install()
+ self.test_certificate_config = {
+ 'id': 1,
+ 'name': 'Certificate name',
+ 'description': 'Certificate description',
+ 'course_title': 'Course title override',
+ 'signatories': [],
+ 'version': 1,
+ 'is_active': True
+ }
+ CourseFixture(**self.course_info).install()
+ self.cert_fixture = CertificateConfigFixture(self.course_id, self.test_certificate_config)
+ self.cert_fixture.install()
self.user_name, self.user_id = self.log_in_as_instructor()
self.instructor_dashboard_page = self.visit_instructor_dashboard()
self.certificates_section = self.instructor_dashboard_page.select_certificates()
disable_animations(self.certificates_section)
+ def test_generate_certificates_buttons_is_disable(self):
+ """
+ Scenario: On the Certificates tab of the Instructor Dashboard, Generate Certificates button is disable.
+ Given that I am on the Certificates tab on the Instructor Dashboard
+ The instructor-generation and cert_html_view_enabled feature flags have been enabled
+ But the certificate is not active in settings.
+ Then I see a 'Generate Certificates' button disabled
+ """
+ self.test_certificate_config['is_active'] = False
+ self.cert_fixture.update_certificate(1)
+ self.browser.refresh()
+ self.assertFalse(self.certificates_section.generate_certificates_button.visible)
+ self.assertTrue(self.certificates_section.generate_certificates_disabled_button.visible)
+
def test_generate_certificates_buttons_is_visible(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, Generate Certificates button is visible.
diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py
index 8034d3d0cf..c967476f2a 100644
--- a/lms/djangoapps/instructor/views/instructor_dashboard.py
+++ b/lms/djangoapps/instructor/views/instructor_dashboard.py
@@ -299,6 +299,7 @@ def _section_certificates(course):
'enabled_for_course': certs_api.cert_generation_enabled(course.id),
'instructor_generation_enabled': instructor_generation_enabled,
'html_cert_enabled': html_cert_enabled,
+ 'active_certificate': certs_api.get_active_web_certificate(course),
'certificate_statuses': GeneratedCertificate.get_unique_statuses(course_key=course.id),
'urls': {
'generate_example_certificates': reverse(
diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html
index 43f039308d..bc3060f060 100644
--- a/lms/templates/instructor/instructor_dashboard_2/certificates.html
+++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html
@@ -73,7 +73,12 @@ import json
${_("Generate Certificates")}
From 195178aa41add48619d454909bedcddc0ab63310 Mon Sep 17 00:00:00 2001
From: Saleem Latif
Date: Wed, 11 Nov 2015 13:18:54 +0500
Subject: [PATCH 029/115] Refactor certificates bok-choy pages to use
PageObject
---
.../pages/studio/settings_certificates.py | 111 +++++++++++++-----
1 file changed, 82 insertions(+), 29 deletions(-)
diff --git a/common/test/acceptance/pages/studio/settings_certificates.py b/common/test/acceptance/pages/studio/settings_certificates.py
index 4b72d74bb6..51a14439ca 100644
--- a/common/test/acceptance/pages/studio/settings_certificates.py
+++ b/common/test/acceptance/pages/studio/settings_certificates.py
@@ -78,7 +78,7 @@ class CertificatesPage(CoursePage):
Return list of the certificates for the course.
"""
css = self.certficate_css + ' .wrapper-collection'
- return [Certificate(self, self.certficate_css, index) for index in xrange(len(self.q(css=css)))]
+ return [CertificateSectionPage(self, self.certficate_css, index) for index in xrange(len(self.q(css=css)))]
@property
def no_certificates_message_shown(self):
@@ -171,16 +171,42 @@ class CertificatesPage(CoursePage):
self.wait_for_ajax()
-class Certificate(object):
+class CertificateSectionPage(CertificatesPage):
"""
- Certificate page object wrapper
+ CertificateSectionPage is the certificate section within Certificates page, There might be multiple certificates
+ in a Certificates Page so this section object can be used to used to identify unique certificate and apply
+ operations on it.
"""
- def __init__(self, page, prefix, index):
- self.page = page
+ def __init__(self, container, prefix, index):
+ """
+ Initialize CertificateSection Page
+
+ :param container: Container Page Object of the certificate section
+ :param prefix: css selector of the container element
+ :param index: index of section in the certificate list on the page
+
+ :return:
+ """
self.selector = prefix + ' .certificates-list-item-{}'.format(index)
self.index = index
+ super(CertificateSectionPage, self).__init__(container.browser, **container.course_info)
+
+ def is_browser_on_page(self):
+ """
+ Verify that the browser is on the page and it is not still loading.
+ """
+ return self.q(css=".certificates").present
+
+ @property
+ def url(self):
+ """
+ Construct a URL to the page section within the certificate page.
+ """
+ # This is a page section and can not be accessed directly
+ return None
+
################
# Helpers
################
@@ -195,7 +221,7 @@ class Certificate(object):
"""
Find elements as defined by css locator.
"""
- return self.page.q(css=self.get_selector(css=css_selector))
+ return self.q(css=self.get_selector(css=css_selector))
def get_text(self, css):
"""
@@ -280,7 +306,7 @@ class Certificate(object):
Return list of the signatories for the certificate.
"""
css = self.selector + ' .signatory-' + self.mode
- return [Signatory(self, self.selector, self.mode, index) for index in xrange(len(self.page.q(css=css)))]
+ return [SignatorySectionPage(self, self.selector, self.mode, index) for index in xrange(len(self.q(css=css)))]
################
# Wait Actions
@@ -312,16 +338,16 @@ class Certificate(object):
"""
Create a new certificate.
"""
- disable_animations(self.page)
+ disable_animations(self)
self.find_css('.action-primary').first.click()
- self.page.wait_for_ajax()
+ self.wait_for_ajax()
def click_save_certificate_button(self):
"""
Save certificate.
"""
self.find_css('.action-primary').first.click()
- self.page.wait_for_ajax()
+ self.wait_for_ajax()
def click_add_signatory_button(self):
"""
@@ -355,16 +381,43 @@ class Certificate(object):
self.find_css('.actions .delete.action-icon').first.click()
-class Signatory(object):
+class SignatorySectionPage(CertificatesPage):
"""
- Signatory page object wrapper
+ SignatorySectionPage is the signatory section within CertificatesSection, There might be multiple signatories
+ in a certificate section so this section object can be used to used to identify unique section and apply
+ operations on it.
"""
- def __init__(self, certificate, prefix, mode, index):
- self.certificate = certificate
+ def __init__(self, container, prefix, mode, index):
+ """
+ Initialize SignatorySection Page
+
+ :param container: Container Section Page Object of the Signatory section
+ :param prefix: css selector of the container element
+ :param index: index of section in the signatory list on the page
+ :param mode: 'details' or 'edit', showing whether signatory is being displayed or edited
+
+ :return:
+ """
self.prefix = prefix
self.index = index
self.mode = mode
+ super(SignatorySectionPage, self).__init__(container.browser, **container.course_info)
+
+ def is_browser_on_page(self):
+ """
+ Verify that the browser is on the page and it is not still loading.
+ """
+ return self.q(css=self.prefix + " .signatory-details-list, .signatory-edit-list").present
+
+ @property
+ def url(self):
+ """
+ Construct a URL to the page section within the certificate section page.
+ """
+ # This is a page section and can not be accessed directly
+ return None
+
################
# Helpers
################
@@ -392,7 +445,7 @@ class Signatory(object):
"""
Find elements as defined by css locator.
"""
- return self.certificate.page.q(css=self.get_selector(css=css_selector))
+ return self.q(css=self.get_selector(css=css_selector))
################
# Properties
@@ -461,24 +514,24 @@ class Signatory(object):
self.click_signatory_delete_icon()
self.wait_for_signatory_delete_prompt()
- self.certificate.page.q(css='#prompt-warning a.button.action-primary').first.click()
- self.certificate.page.wait_for_ajax()
+ self.q(css='#prompt-warning a.button.action-primary').first.click()
+ self.wait_for_ajax()
def save(self):
"""
Save signatory.
"""
# Click on the save button.
- self.certificate.page.q(css='button.signatory-panel-save').click()
+ self.q(css='button.signatory-panel-save').click()
self.mode = 'details'
- self.certificate.page.wait_for_ajax()
+ self.wait_for_ajax()
self.wait_for_signatory_detail_view()
def close(self):
"""
Cancel signatory editing.
"""
- self.certificate.page.q(css='button.signatory-panel-close').click()
+ self.q(css='button.signatory-panel-close').click()
self.mode = 'details'
self.wait_for_signatory_detail_view()
@@ -491,20 +544,20 @@ class Signatory(object):
self.wait_for_signature_image_upload_prompt()
asset_file_path = self.file_path(image_filename)
- self.certificate.page.q(
+ self.q(
css='.assetupload-modal .upload-dialog input[type="file"]'
)[0].send_keys(asset_file_path)
EmptyPromise(
- lambda: not self.certificate.page.q(
+ lambda: not self.q(
css='.assetupload-modal a.action-upload.disabled'
).present,
'Upload button is not disabled anymore'
).fulfill()
- self.certificate.page.q(css='.assetupload-modal a.action-upload').first.click()
+ self.q(css='.assetupload-modal a.action-upload').first.click()
EmptyPromise(
- lambda: not self.certificate.page.q(css='.assetupload-modal .upload-dialog').visible,
+ lambda: not self.q(css='.assetupload-modal .upload-dialog').visible,
'Upload dialog is removed after uploading image'
).fulfill()
@@ -518,7 +571,7 @@ class Signatory(object):
Returns whether or not the delete icon is present.
"""
EmptyPromise(
- lambda: self.certificate.page.q(css='.signatory-panel-delete').present,
+ lambda: self.q(css='.signatory-panel-delete').present,
'Delete icon is displayed'
).fulfill()
@@ -527,7 +580,7 @@ class Signatory(object):
Promise to wait until signatory delete prompt is visible
"""
EmptyPromise(
- lambda: self.certificate.page.q(css='a.button.action-primary').present,
+ lambda: self.q(css='a.button.action-primary').present,
'Delete prompt is displayed'
).fulfill()
@@ -554,7 +607,7 @@ class Signatory(object):
Promise to wait until signatory image upload prompt is visible
"""
EmptyPromise(
- lambda: self.certificate.page.q(css='.assetupload-modal .action-upload').present,
+ lambda: self.q(css='.assetupload-modal .action-upload').present,
'Signature image upload dialog opened'
).fulfill()
@@ -563,7 +616,7 @@ class Signatory(object):
Promise to wait until signatory image upload button is visible
"""
EmptyPromise(
- lambda: self.certificate.page.q(css=".action-upload-signature").first.present,
+ lambda: self.q(css=".action-upload-signature").first.present,
'Signature image upload button available'
).fulfill()
@@ -573,7 +626,7 @@ class Signatory(object):
Promise for the signature image to be displayed
"""
EmptyPromise(
- lambda: self.certificate.page.q(css=".current-signature-image .signature-image").present,
+ lambda: self.q(css=".current-signature-image .signature-image").present,
'Signature image available'
).fulfill()
From de47bcde7e2e49785cf5939e319d4b4347927f46 Mon Sep 17 00:00:00 2001
From: Diana Huang
Date: Thu, 12 Nov 2015 16:51:31 -0500
Subject: [PATCH 030/115] Start running the PDF Textbook test again.
Remove redundant and skipped video test.
---
common/test/acceptance/tests/lms/test_lms.py | 72 --------------------
1 file changed, 72 deletions(-)
diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py
index 45d31630cc..6165a59270 100644
--- a/common/test/acceptance/tests/lms/test_lms.py
+++ b/common/test/acceptance/tests/lms/test_lms.py
@@ -708,7 +708,6 @@ class PDFTextBooksTabTest(UniqueCourseTest):
# Auto-auth register for the course
AutoAuthPage(self.browser, course_id=self.course_id).visit()
- @skip('TODO: fix this, see TNL-2083')
def test_verify_textbook_tabs(self):
"""
Test multiple pdf textbooks loads correctly in lms.
@@ -720,77 +719,6 @@ class PDFTextBooksTabTest(UniqueCourseTest):
self.tab_nav.go_to_tab("PDF Book {}".format(i))
-@attr('shard_1')
-class VideoTest(UniqueCourseTest):
- """
- Navigate to a video in the courseware and play it.
- """
- def setUp(self):
- """
- Initialize pages and install a course fixture.
- """
- super(VideoTest, self).setUp()
-
- self.course_info_page = CourseInfoPage(self.browser, self.course_id)
- self.course_nav = CourseNavPage(self.browser)
- self.tab_nav = TabNavPage(self.browser)
- self.video = VideoPage(self.browser)
-
- # Install a course fixture with a video component
- course_fix = CourseFixture(
- self.course_info['org'], self.course_info['number'],
- self.course_info['run'], self.course_info['display_name']
- )
-
- course_fix.add_children(
- XBlockFixtureDesc('chapter', 'Test Section').add_children(
- XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
- XBlockFixtureDesc('vertical', 'Test Unit').add_children(
- XBlockFixtureDesc('video', 'Video')
- )))).install()
-
- # Auto-auth register for the course
- AutoAuthPage(self.browser, course_id=self.course_id).visit()
-
- @skip("BLD-563: Video Player Stuck on Pause")
- def test_video_player(self):
- """
- Play a video in the courseware.
- """
-
- # Navigate to a video
- self.course_info_page.visit()
- self.tab_nav.go_to_tab('Courseware')
-
- # The video should start off paused
- # Since the video hasn't loaded yet, it's elapsed time is 0
- self.assertFalse(self.video.is_playing)
- self.assertEqual(self.video.elapsed_time, 0)
-
- # Play the video
- self.video.play()
-
- # Now we should be playing
- self.assertTrue(self.video.is_playing)
-
- # Commented the below EmptyPromise, will move to its page once this test is working and stable
- # Also there is should be no Promise check in any test as this should be done in Page Object
- # Wait for the video to load the duration
- # EmptyPromise(
- # lambda: self.video.duration > 0,
- # 'video has duration', timeout=20
- # ).fulfill()
-
- # Pause the video
- self.video.pause()
-
- # Expect that the elapsed time and duration are reasonable
- # Again, we can't expect the video to actually play because of
- # latency through the ssh tunnel
- self.assertGreaterEqual(self.video.elapsed_time, 0)
- self.assertGreaterEqual(self.video.duration, self.video.elapsed_time)
-
-
@attr('shard_1')
class VisibleToStaffOnlyTest(UniqueCourseTest):
"""
From 3ee5acfc00d22bc96dc2837739893ef223917fdb Mon Sep 17 00:00:00 2001
From: Amir Qayyum Khan
Date: Wed, 7 Oct 2015 15:06:32 +0500
Subject: [PATCH 031/115] Added error message incase user specified start or
due date without time and also added start date and due date comparison
validation in ccx coach dashboard validation.
---
lms/static/js/ccx/schedule.js | 70 +++++++++++++++++++------
lms/static/js/spec/ccx/schedule_spec.js | 44 +++++++++++++++-
lms/static/js/spec/main.js | 2 +-
3 files changed, 97 insertions(+), 19 deletions(-)
diff --git a/lms/static/js/ccx/schedule.js b/lms/static/js/ccx/schedule.js
index 20ffdc1c38..2a3b1814f5 100644
--- a/lms/static/js/ccx/schedule.js
+++ b/lms/static/js/ccx/schedule.js
@@ -104,23 +104,41 @@ var edx = edx || {};
// Add unit handler
$('#add-unit-button').on('click', function(event) {
event.preventDefault();
- var chapter = self.chapter_select.val(),
- sequential = self.sequential_select.val(),
- vertical = self.vertical_select.val(),
- units = self.find_lineage(self.schedule,
- chapter,
- sequential === 'all' ? null : sequential,
- vertical === 'all' ? null: vertical),
- start = self.get_datetime('start'),
- due = self.get_datetime('due');
- units.map(self.show);
- var unit = units[units.length - 1];
- if (unit !== undefined && start) { unit.start = start; }
- if (unit !== undefined && due) { unit.due = due; }
- self.schedule_apply([unit], self.show);
- self.schedule_collection.set(self.schedule);
- self.dirty = true;
- self.render();
+ // Default value of time is 00:00.
+ var start, chapter, sequential, vertical, units, due;
+ start = self.get_datetime('start');
+ chapter = self.chapter_select.val();
+ sequential = self.sequential_select.val();
+ vertical = self.vertical_select.val();
+ units = self.find_lineage(
+ self.schedule,
+ chapter,
+ sequential === 'all' ? null : sequential,
+ vertical === 'all' ? null : vertical
+ );
+ due = self.get_datetime('due');
+ var errorMessage = self.valid_dates(start, due);
+ if (_.isUndefined(errorMessage)) {
+ units.map(self.show);
+ var unit = units[units.length - 1];
+ if (!_.isUndefined(unit)) {
+ if (!_.isNull(start)) {
+ unit.start = start;
+ }
+ if (!_.isNull(due)) {
+ unit.due = due;
+ }
+ }
+ self.schedule_apply([unit], self.show);
+ self.schedule_collection.set(self.schedule);
+ self.dirty = true;
+ self.render();
+ } else {
+ self.dirty = false;
+ $('#ccx_schedule_error_message').text(errorMessage);
+ $('#ajax-error').show().focus();
+ $('#dirty-schedule').hide();
+ }
});
// Handle save button
@@ -251,9 +269,27 @@ var edx = edx || {};
}
},
+ valid_dates: function(start, due) {
+ var errorMessage;
+ // Start date is compulsory and due date is optional.
+ if (_.isEmpty(start) && !_.isEmpty(due)) {
+ errorMessage = gettext("Please enter valid start date and time.");
+ } else if (!_.isEmpty(start) && !_.isEmpty(due)) {
+ var requirejs = window.require || RequireJS.require;
+ var moment = requirejs("moment");
+ var parsedDueDate = moment(due, 'YYYY-MM-DD HH:mm');
+ var parsedStartDate = moment(start, 'YYYY-MM-DD HH:mm');
+ if (parsedDueDate.isBefore(parsedStartDate)) {
+ errorMessage = gettext("Due date cannot be before start date.");
+ }
+ }
+ return errorMessage;
+ },
+
get_datetime: function(which) {
var date = $('form#add-unit input[name=' + which + '_date]').val();
var time = $('form#add-unit input[name=' + which + '_time]').val();
+ time = _.isEmpty(time) ? "00:00" : time;
if (date && time) {
return date + ' ' + time; }
return null;
diff --git a/lms/static/js/spec/ccx/schedule_spec.js b/lms/static/js/spec/ccx/schedule_spec.js
index 01364a680f..9dd6e4fa08 100644
--- a/lms/static/js/spec/ccx/schedule_spec.js
+++ b/lms/static/js/spec/ccx/schedule_spec.js
@@ -124,7 +124,7 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/ccx/schedule'],
val = "i4x://edX/DemoX/sequential/edx_introduction";
view.sequential_select.val(val);
view.sequential_select.change();
- val = "i4x://edX/DemoX/vertical/vertical_0270f6de40fc",
+ val = "i4x://edX/DemoX/vertical/vertical_0270f6de40fc";
view.vertical_select.val(val);
view.vertical_select.change();
expect(view.vertical_select.val()).toEqual(val);
@@ -141,11 +141,53 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/ccx/schedule'],
view.vertical_select.val(val);
view.vertical_select.change();
var unit = view.find_unit(view.schedule, 'i4x://edX/DemoX/chapter/d8a6192ade314473a78242dfeedfbf5b');
+ view.set_datetime('start', '2015-12-12 10:00');
+ view.set_datetime('due', '2015-12-12 10:30');
expect(unit.hidden).toBe(true);
$('#add-unit-button').click();
expect(unit.hidden).toBe(false);
});
+ it("add unit when start date is greater the due date", function() {
+ var val = 'i4x://edX/DemoX/chapter/d8a6192ade314473a78242dfeedfbf5b';
+ view.chapter_select.val(val);
+ view.chapter_select.change();
+ val = "i4x://edX/DemoX/sequential/edx_introduction";
+ view.sequential_select.val(val);
+ view.sequential_select.change();
+ val = "i4x://edX/DemoX/vertical/vertical_0270f6de40fc";
+ view.vertical_select.val(val);
+ view.vertical_select.change();
+ var unit = view.find_unit(view.schedule, 'i4x://edX/DemoX/chapter/d8a6192ade314473a78242dfeedfbf5b');
+ // start date is before due date
+ view.set_datetime('start', '2015-11-13 10:45');
+ view.set_datetime('due', '2015-11-12 10:00');
+ expect(unit.hidden).toBe(true);
+ $('#add-unit-button').click();
+ // Assert unit is not added to schedule
+ expect(unit.hidden).toBe(true);
+ });
+
+ it("add unit when start date is missing", function() {
+ var val = 'i4x://edX/DemoX/chapter/d8a6192ade314473a78242dfeedfbf5b';
+ view.chapter_select.val(val);
+ view.chapter_select.change();
+ val = "i4x://edX/DemoX/sequential/edx_introduction";
+ view.sequential_select.val(val);
+ view.sequential_select.change();
+ val = "i4x://edX/DemoX/vertical/vertical_0270f6de40fc";
+ view.vertical_select.val(val);
+ view.vertical_select.change();
+ var unit = view.find_unit(view.schedule, 'i4x://edX/DemoX/chapter/d8a6192ade314473a78242dfeedfbf5b');
+ // start date is missing
+ view.set_datetime('start', null);
+ view.set_datetime('due', '2015-12-12 10:00');
+ expect(unit.hidden).toBe(true);
+ $('#add-unit-button').click();
+ // Assert unit is not added to schedule
+ expect(unit.hidden).toBe(true);
+ });
+
it("gets a datetime string from date and time fields", function() {
view.set_datetime('start', '2015-12-12 10:45');
expect($('form#add-unit input[name=start_date]')).toHaveValue('2015-12-12');
diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js
index bf5ee49a10..86634d607f 100644
--- a/lms/static/js/spec/main.js
+++ b/lms/static/js/spec/main.js
@@ -316,7 +316,7 @@
},
'js/ccx/schedule': {
exports: 'js/ccx/schedule',
- deps: ['jquery', 'underscore', 'backbone', 'gettext']
+ deps: ['jquery', 'underscore', 'backbone', 'gettext', 'moment']
},
// Backbone classes loaded explicitly until they are converted to use RequireJS
From c8fbadb24a139a87b69c4ae92981409008abe532 Mon Sep 17 00:00:00 2001
From: Peter Fogg
Date: Fri, 13 Nov 2015 14:53:24 -0500
Subject: [PATCH 032/115] Show a message when logging in with no internet
connection.
ECOM-2890
---
lms/static/js/spec/student_account/login_spec.js | 15 +++++++++++++++
lms/static/js/student_account/views/LoginView.js | 10 ++++++++--
2 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/lms/static/js/spec/student_account/login_spec.js b/lms/static/js/spec/student_account/login_spec.js
index f66a4d60e1..f95e1a9d00 100644
--- a/lms/static/js/spec/student_account/login_spec.js
+++ b/lms/static/js/spec/student_account/login_spec.js
@@ -265,6 +265,21 @@
expect(view.$errors).toHaveClass('hidden');
expect(authComplete).toBe(true);
});
+
+ it('displays an error if there is no internet connection', function () {
+ createLoginView(this);
+
+ // Submit the form, with successful validation
+ submitForm(true);
+
+ // Simulate an error from the LMS servers
+ AjaxHelpers.respondWithError(requests, 0);
+
+ // Expect that an error is displayed and that auth complete is not triggered
+ expect(view.$errors).not.toHaveClass('hidden');
+ expect(authComplete).toBe(false);
+ expect(view.$errors.text()).toContain('Please check your internet connection and try again.');
+ });
});
});
}).call(this, define || RequireJS.define);
diff --git a/lms/static/js/student_account/views/LoginView.js b/lms/static/js/student_account/views/LoginView.js
index e4509b20eb..68ff04fa1b 100644
--- a/lms/static/js/student_account/views/LoginView.js
+++ b/lms/static/js/student_account/views/LoginView.js
@@ -3,9 +3,10 @@
define([
'jquery',
'underscore',
+ 'gettext',
'js/student_account/views/FormView'
],
- function($, _, FormView) {
+ function($, _, gettext, FormView) {
return FormView.extend({
el: '#login-form',
@@ -102,7 +103,12 @@
},
saveError: function( error ) {
- this.errors = ['
'
);
From 521568937daeaab9e7a5954079f4fd20d8dd5ebf Mon Sep 17 00:00:00 2001
From: Nimisha Asthagiri
Date: Fri, 13 Nov 2015 00:23:29 -0500
Subject: [PATCH 038/115] MA-1624 Unit tests for User Partition Transformer.
---
.../tests/test_user_partitions.py | 219 +++++++++++++++++-
.../transformers/user_partitions.py | 16 +-
2 files changed, 219 insertions(+), 16 deletions(-)
diff --git a/lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py b/lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py
index 799a1c29a0..c10bca4289 100644
--- a/lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py
+++ b/lms/djangoapps/course_blocks/transformers/tests/test_user_partitions.py
@@ -2,6 +2,7 @@
"""
Tests for UserPartitionTransformer.
"""
+from collections import namedtuple
import ddt
from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartitionScheme
@@ -10,10 +11,11 @@ from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
from openedx.core.djangoapps.course_groups.views import link_cohort_to_partition_group
from student.tests.factories import CourseEnrollmentFactory
from xmodule.partitions.partitions import Group, UserPartition
+from xmodule.modulestore.tests.factories import CourseFactory
from ...api import get_course_blocks
from ..user_partitions import UserPartitionTransformer, _MergedGroupAccess
-from .test_helpers import CourseStructureTestCase
+from .test_helpers import CourseStructureTestCase, update_block
class UserPartitionTestMixin(object):
@@ -42,21 +44,23 @@ class UserPartitionTestMixin(object):
user_partition.scheme.name = "cohort"
self.user_partitions.append(user_partition)
- def setup_chorts(self, course):
+ def setup_cohorts(self, course):
"""
Sets up a cohort for each previously created user partition.
"""
+ config_course_cohorts(course, is_cohorted=True)
+ self.partition_cohorts = []
for user_partition in self.user_partitions:
- config_course_cohorts(course, is_cohorted=True)
- self.cohorts = []
+ partition_cohorts = []
for group in self.groups:
cohort = CohortFactory(course_id=course.id)
- self.cohorts.append(cohort)
+ partition_cohorts.append(cohort)
link_cohort_to_partition_group(
cohort,
user_partition.id,
group.id,
)
+ self.partition_cohorts.append(partition_cohorts)
@ddt.ddt
@@ -84,7 +88,7 @@ class UserPartitionTransformerTestCase(UserPartitionTestMixin, CourseStructureTe
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
# Set up cohorts.
- self.setup_chorts(self.course)
+ self.setup_cohorts(self.course)
self.transformer = UserPartitionTransformer()
@@ -194,7 +198,8 @@ class UserPartitionTransformerTestCase(UserPartitionTestMixin, CourseStructureTe
@ddt.unpack
def test_transform(self, group_id, expected_blocks):
if group_id:
- add_user_to_cohort(self.cohorts[group_id - 1], self.user.username)
+ cohort = self.partition_cohorts[self.user_partition.id - 1][group_id - 1]
+ add_user_to_cohort(cohort, self.user.username)
trans_block_structure = get_course_blocks(
self.user,
@@ -208,11 +213,207 @@ class UserPartitionTransformerTestCase(UserPartitionTestMixin, CourseStructureTe
@ddt.ddt
-class MergedGroupAccessTestCase(UserPartitionTestMixin, CourseStructureTestCase):
+class MergedGroupAccessTestData(UserPartitionTestMixin, CourseStructureTestCase):
"""
_MergedGroupAccess Test
"""
- # TODO Test Merged Group Access (MA-1624)
+ def setUp(self):
+ """
+ Setup course structure and create user for user partition
+ transformer test.
+ """
+ super(MergedGroupAccessTestData, self).setUp()
+
+ # Set up multiple user partitions and groups.
+ self.setup_groups_partitions(num_user_partitions=3)
+
+ self.course = CourseFactory.create(user_partitions=self.user_partitions)
+ CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
+
+ # Set up cohorts.
+ self.setup_cohorts(self.course)
+
+ def get_course_hierarchy(self):
+ """
+ Returns a course hierarchy to test with.
+ """
+ # The block tree is as follows, with the numbers in the brackets
+ # specifying the group_id for each of the 3 partitions.
+ # A
+ # / | \
+ # / | \
+ # B C D
+ # [1][3][] [2][2][] [3][1][]
+ # \ /
+ # \ /
+ # E
+ #
+ return [
+ {
+ 'org': 'MergedGroupAccess',
+ 'course': 'MGA101F',
+ 'run': 'test_run',
+ 'user_partitions': self.user_partitions,
+ '#type': 'course',
+ '#ref': 'A',
+ },
+ {
+ '#type': 'vertical',
+ '#ref': 'B',
+ '#parents': ['A'],
+ 'metadata': {'group_access': {1: [1], 2:[3], 3:[]}},
+ },
+ {
+ '#type': 'vertical',
+ '#ref': 'C',
+ '#parents': ['A'],
+ 'metadata': {'group_access': {1: [2], 2:[2], 3:[]}},
+ },
+ {
+ '#type': 'vertical',
+ '#ref': 'D',
+ '#parents': ['A'],
+ 'metadata': {'group_access': {1: [3], 2:[1], 3:[]}},
+ },
+ {
+ '#type': 'vertical',
+ '#ref': 'E',
+ '#parents': ['B', 'C'],
+ },
+ ]
+
+ AccessTestData = namedtuple(
+ 'AccessTestData',
+ ['partition_groups', 'xblock_access', 'merged_parents_list', 'expected_access'],
+ )
+ AccessTestData.__new__.__defaults__ = ({}, None, [], False)
+
+ @ddt.data(
+ # universal access throughout
+ AccessTestData(expected_access=True),
+ AccessTestData(xblock_access={1: None}, expected_access=True),
+ AccessTestData(xblock_access={1: []}, expected_access=True),
+
+ # partition 1 requiring membership in group 1
+ AccessTestData(xblock_access={1: [1]}),
+ AccessTestData(partition_groups={2: 1, 3: 1}, xblock_access={1: [1]}),
+ AccessTestData(partition_groups={1: 1, 2: 1, 3: 1}, xblock_access={1: [1]}, expected_access=True),
+ AccessTestData(partition_groups={1: 1, 2: 1}, xblock_access={1: [1], 2: [], 3: []}, expected_access=True),
+
+ # partitions 1 and 2 requiring membership in group 1
+ AccessTestData(xblock_access={1: [1], 2: [1]}),
+ AccessTestData(partition_groups={2: 1, 3: 1}, xblock_access={1: [1], 2: [1]}),
+ AccessTestData(partition_groups={1: 1, 2: 1}, xblock_access={1: [1], 2: [1]}, expected_access=True),
+
+ # partitions 1 and 2 requiring membership in different groups
+ AccessTestData(xblock_access={1: [1], 2: [2]}),
+ AccessTestData(partition_groups={2: 1, 3: 1}, xblock_access={1: [1], 2: [2]}),
+ AccessTestData(partition_groups={1: 1, 2: 1, 3: 1}, xblock_access={1: [1], 2: [2]}),
+
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={1: [1], 2: [2]}, expected_access=True),
+
+ # partitions 1 and 2 requiring membership in list of groups
+ AccessTestData(partition_groups={1: 3, 2: 3}, xblock_access={1: [1, 2], 2: [1, 2]}),
+
+ AccessTestData(partition_groups={1: 1, 2: 1}, xblock_access={1: [1, 2], 2: [1, 2]}, expected_access=True),
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={1: [1, 2], 2: [1, 2]}, expected_access=True),
+ AccessTestData(partition_groups={1: 2, 2: 1}, xblock_access={1: [1, 2], 2: [1, 2]}, expected_access=True),
+ AccessTestData(partition_groups={1: 2, 2: 2}, xblock_access={1: [1, 2], 2: [1, 2]}, expected_access=True),
+
+ # parent inheritance
+ # 1 parent allows
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {1}}], expected_access=True),
+
+ # 2 parents allow
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {1}}, {1: {1}}], expected_access=True),
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {2}}, {1: {1}}], expected_access=True),
+ AccessTestData(
+ partition_groups={1: 1, 2: 2},
+ merged_parents_list=[{1: {2}, 2: {2}}, {1: {1}, 2: {1}}],
+ expected_access=True,
+ ),
+
+ # 1 parent denies
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {}}]),
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {3}}]),
+
+ # 1 parent denies, 1 parent allows all
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {}}, {}], expected_access=True),
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {}}, {1: {}}, {}], expected_access=True),
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {}}, {}, {1: {}}], expected_access=True),
+
+ # 1 parent denies, 1 parent allows
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {3}}, {1: {1}}], expected_access=True),
+
+ # 2 parents deny
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {}}, {1: {}}]),
+ AccessTestData(partition_groups={1: 1, 2: 2}, merged_parents_list=[{1: {3}}, {1: {3}, 2: {2}}]),
+
+ # intersect with parent
+ # child denies, 1 parent allows
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={1: [3]}, merged_parents_list=[{1: {1}}]),
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={1: [2]}, merged_parents_list=[{1: {1}}]),
+
+ # child denies, 2 parents allow
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={1: [3]}, merged_parents_list=[{1: {1}}, {2: {2}}]),
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={2: [3]}, merged_parents_list=[{1: {1}}, {2: {2}}]),
+
+ # child allows, 1 parent denies
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={2: [2]}, merged_parents_list=[{1: {}}]),
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={1: [1]}, merged_parents_list=[{1: {2}}]),
+ AccessTestData(partition_groups={1: 1, 2: 2}, xblock_access={2: [2]}, merged_parents_list=[{1: {2}}]),
+
+ # child allows, 1 parent allows
+ AccessTestData(
+ partition_groups={1: 1, 2: 2},
+ xblock_access={1: [1]},
+ merged_parents_list=[{}],
+ expected_access=True,
+ ),
+ AccessTestData(
+ partition_groups={1: 1, 2: 2}, xblock_access={2: [2]}, merged_parents_list=[{1: {1}}], expected_access=True
+ ),
+ AccessTestData(
+ partition_groups={1: 1, 2: 2},
+ xblock_access={1: [1, 3], 2: [2, 3]},
+ merged_parents_list=[{1: {1, 2, 3}}, {2: {1, 2, 3}}],
+ expected_access=True,
+ ),
+
+ # child allows, 1 parent allows, 1 parent denies
+ AccessTestData(
+ partition_groups={1: 1, 2: 2},
+ xblock_access={1: [1]},
+ merged_parents_list=[{1: {3}}, {1: {1}}],
+ expected_access=True,
+ ),
+ )
+ @ddt.unpack
+ def test_merged_group_access(self, user_partition_groups, xblock_access, merged_parents_list, expected_access):
+ # use the course as the block to test
+ block = self.course
+
+ # update block access
+ if xblock_access is not None:
+ block.group_access = xblock_access
+ update_block(self.course)
+
+ # convert merged_parents_list to _MergedGroupAccess objects
+ for ind, merged_parent in enumerate(merged_parents_list):
+ converted_object = _MergedGroupAccess([], block, [])
+ converted_object._access = merged_parent
+ merged_parents_list[ind] = converted_object
+
+ merged_group_access = _MergedGroupAccess(self.user_partitions, block, merged_parents_list)
+
+ # convert group_id to groups in user_partition_groups parameter
+ for partition_id, group_id in user_partition_groups.iteritems():
+ user_partition_groups[partition_id] = self.groups[group_id - 1]
+
+ self.assertEquals(
+ merged_group_access.check_group_access(user_partition_groups),
+ expected_access,
+ )
@ddt.data(
([None], None),
diff --git a/lms/djangoapps/course_blocks/transformers/user_partitions.py b/lms/djangoapps/course_blocks/transformers/user_partitions.py
index 337dc0e51a..91ef4f9918 100644
--- a/lms/djangoapps/course_blocks/transformers/user_partitions.py
+++ b/lms/djangoapps/course_blocks/transformers/user_partitions.py
@@ -161,19 +161,21 @@ class _MergedGroupAccess(object):
for merged_parent_access in merged_parent_access_list:
# pylint: disable=protected-access
if partition.id in merged_parent_access._access:
- # Since this parent has group access restrictions,
- # merge it with the running list of
- # parent-introduced restrictions.
+ # Since this parent has group access
+ # restrictions, merge it with the running list
+ # of parent-introduced restrictions.
merged_parent_group_ids.update(merged_parent_access._access[partition.id])
else:
- # Since at least one parent chain has no group
- # access restrictions for this partition, allow
- # unfettered group access or this partition.
+ # Since this parent chain has no group access
+ # restrictions for this partition, allow
+ # unfettered group access for this partition
+ # and don't bother checking the rest of the
+ # parents.
merged_parent_group_ids = None
break
# Group access for this partition as stored on the xblock
- xblock_partition_access = set(xblock_group_access.get(partition.id, [])) or None
+ xblock_partition_access = set(xblock_group_access.get(partition.id) or []) or None
# Compute this block's access by intersecting the block's
# own access with the merged access from its parent chains.
From 90cdb91391ff48278ad5b4a1f56ebbfb2d0191ef Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Wed, 21 Oct 2015 19:39:18 -0700
Subject: [PATCH 039/115] Allow users to login via third_party_auth even if not
activated
---
.../djangoapps/third_party_auth/pipeline.py | 51 +++++++------------
.../djangoapps/third_party_auth/settings.py | 9 ++--
common/djangoapps/third_party_auth/views.py | 9 +++-
3 files changed, 29 insertions(+), 40 deletions(-)
diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py
index 418f0b7167..97e496a775 100644
--- a/common/djangoapps/third_party_auth/pipeline.py
+++ b/common/djangoapps/third_party_auth/pipeline.py
@@ -151,19 +151,6 @@ class AuthEntryError(AuthException):
"""
-class NotActivatedException(AuthException):
- """ Raised when a user tries to login to an unverified account """
- def __init__(self, backend, email):
- self.email = email
- super(NotActivatedException, self).__init__(backend, email)
-
- def __str__(self):
- return (
- _('This account has not yet been activated. An activation email has been re-sent to {email_address}.')
- .format(email_address=self.email)
- )
-
-
class ProviderUserState(object):
"""Object representing the provider state (attached or not) for a user.
@@ -514,26 +501,24 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
# This parameter is used by the auth_exchange app, which always allows users to
# login, whether or not their account is validated.
pass
- # IF the user has just registered a new account as part of this pipeline, that is fine
- # and we allow the login to continue this once, because if we pause again to force the
- # user to activate their account via email, the pipeline may get lost (e.g. email takes
- # too long to arrive, user opens the activation email on a different device, etc.).
- # This is consistent with first party auth and ensures that the pipeline completes
- # fully, which is critical.
- # But if this is an existing account, we refuse to allow them to login again until they
- # check their email and activate the account.
- elif social is not None:
- # This third party account is already linked to a user account. That means that the
- # user's account existed before this pipeline originally began (since the creation
- # of the 'social' link entry occurs in one of the following pipeline steps).
- # Reject this login attempt and tell the user to validate their account first.
-
- # Send them another activation email:
- student.views.reactivation_email_for_user(user)
-
- raise NotActivatedException(backend, user.email)
- # else: The user must have just successfully registered their account, so we proceed.
- # We know they did not just login, because the login process rejects unverified users.
+ elif social is None:
+ # The user has just registered a new account as part of this pipeline. Their account
+ # is inactive but we allow the login to continue, because if we pause again to force
+ # the user to activate their account via email, the pipeline may get lost (e.g.
+ # email takes too long to arrive, user opens the activation email on a different
+ # device, etc.). This is consistent with first party auth and ensures that the
+ # pipeline completes fully, which is critical.
+ pass
+ else:
+ # This is an existing account, linked to a third party provider but not activated.
+ # We now also allow them to login again, because if they had entered their email
+ # incorrectly then there would be no way for them to recover the account, nor
+ # register anew via SSO. See SOL-1324 in JIRA.
+ # However, we will log a warning for this case:
+ logger.warning(
+ 'User "%s" is using third_party_auth to login but has not yet activated their account. ',
+ user.username
+ )
@partial.partial
diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py
index a856aefa4f..fd9fa7abf9 100644
--- a/common/djangoapps/third_party_auth/settings.py
+++ b/common/djangoapps/third_party_auth/settings.py
@@ -73,11 +73,10 @@ def apply_settings(django_settings):
django_settings.SOCIAL_AUTH_RAISE_EXCEPTIONS = False
# Allow users to login using social auth even if their account is not verified yet
- # The 'ensure_user_information' step controls this and only allows brand new users
- # to login without verification. Repeat logins are not permitted until the account
- # gets verified.
- django_settings.INACTIVE_USER_LOGIN = True
- django_settings.INACTIVE_USER_URL = '/auth/inactive'
+ # Otherwise users who use social auth to register with an invalid email address
+ # can become "stuck". We control this in a more fine-grained manner in pipeline.py
+ django_settings.SOCIAL_AUTH_INACTIVE_USER_LOGIN = True
+ django_settings.SOCIAL_AUTH_INACTIVE_USER_URL = '/auth/inactive'
# Context processors required under Django.
django_settings.SOCIAL_AUTH_UUID_LENGTH = 4
diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py
index 58fd17c784..daa104383b 100644
--- a/common/djangoapps/third_party_auth/views.py
+++ b/common/djangoapps/third_party_auth/views.py
@@ -17,8 +17,13 @@ URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'socia
def inactive_user_view(request):
"""
- A newly registered user has completed the social auth pipeline.
- Their account is not yet activated, but we let them login this once.
+ A newly or recently registered user has completed the social auth pipeline.
+ Their account is not yet activated, but we let them login since the third party auth
+ provider is trusted to vouch for them. See details in pipeline.py.
+
+ The reason this view exists is that if we don't define this as the
+ SOCIAL_AUTH_INACTIVE_USER_URL, inactive users will get sent to LOGIN_ERROR_URL, which we
+ don't want.
"""
# 'next' may be set to '/account/finish_auth/.../' if this user needs to be auto-enrolled
# in a course. Otherwise, just redirect them to the dashboard, which displays a message
From f1bfa568844bc4eb43e85a5202eaa89dc5f99d11 Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Wed, 21 Oct 2015 23:35:37 -0700
Subject: [PATCH 040/115] Clean up integration tests, test logging in without
activation
---
.../third_party_auth/tests/specs/base.py | 175 ++++++++++++++++++
.../tests/specs/test_generic.py | 33 ++++
.../tests/specs/test_testshib.py | 162 +++-------------
.../third_party_auth/tests/testutil.py | 26 ++-
4 files changed, 251 insertions(+), 145 deletions(-)
create mode 100644 common/djangoapps/third_party_auth/tests/specs/test_generic.py
diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py
index 3b69b1340c..570906b6cf 100644
--- a/common/djangoapps/third_party_auth/tests/specs/base.py
+++ b/common/djangoapps/third_party_auth/tests/specs/base.py
@@ -10,6 +10,7 @@ from django.contrib import auth
from django.contrib.auth import models as auth_models
from django.contrib.messages.storage import fallback
from django.contrib.sessions.backends import cache
+from django.core.urlresolvers import reverse
from django.test import utils as django_utils
from django.conf import settings as django_settings
from edxmako.tests import mako_middleware_process_request
@@ -18,6 +19,7 @@ from social.apps.django_app import utils as social_utils
from social.apps.django_app import views as social_views
from student import models as student_models
from student import views as student_views
+from student.tests.factories import UserFactory
from student_account.views import account_settings_context
from third_party_auth import middleware, pipeline
@@ -25,6 +27,179 @@ from third_party_auth import settings as auth_settings
from third_party_auth.tests import testutil
+class IntegrationTestMixin(object):
+ """
+ Mixin base class for third_party_auth integration tests.
+ This class is newer and simpler than the 'IntegrationTest' alternative below, but it is
+ currently less comprehensive. Some providers are tested with this, others with
+ IntegrationTest.
+ """
+ # Provider information:
+ PROVIDER_NAME = "override"
+ PROVIDER_BACKEND = "override"
+ PROVIDER_ID = "override"
+ # Information about the user expected from the provider:
+ USER_EMAIL = "override"
+ USER_NAME = "override"
+ USER_USERNAME = "override"
+
+ def setUp(self):
+ super(IntegrationTestMixin, self).setUp()
+ self.login_page_url = reverse('signin_user')
+ self.register_page_url = reverse('register_user')
+ # Set the server name:
+ self.client.defaults['SERVER_NAME'] = 'example.none' # The SAML lib we use doesn't like testserver' as a domain
+ self.url_prefix = 'http://example.none'
+ patcher = testutil.patch_mako_templates()
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ # Override this method in a subclass and enable at least one provider.
+
+ def test_register(self):
+ # The user goes to the register page, and sees a button to register with the provider:
+ provider_register_url = self._check_register_page()
+ # The user clicks on the Dummy button:
+ try_login_response = self.client.get(provider_register_url)
+ # The user should be redirected to the provider's login page:
+ self.assertEqual(try_login_response.status_code, 302)
+ provider_response = self.do_provider_login(try_login_response['Location'])
+ # We should be redirected to the register screen since this account is not linked to an edX account:
+ self.assertEqual(provider_response.status_code, 302)
+ self.assertEqual(provider_response['Location'], self.url_prefix + self.register_page_url)
+ register_response = self.client.get(self.register_page_url)
+ tpa_context = register_response.context["data"]["third_party_auth"]
+ self.assertEqual(tpa_context["errorMessage"], None)
+ # Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown.
+ self.assertEqual(tpa_context["currentProvider"], self.PROVIDER_NAME)
+ # Check that the data (e.g. email) from the provider is displayed in the form:
+ form_data = register_response.context['data']['registration_form_desc']
+ form_fields = {field['name']: field for field in form_data['fields']}
+ self.assertEqual(form_fields['email']['defaultValue'], self.USER_EMAIL)
+ self.assertEqual(form_fields['name']['defaultValue'], self.USER_NAME)
+ self.assertEqual(form_fields['username']['defaultValue'], self.USER_USERNAME)
+ # Now complete the form:
+ ajax_register_response = self.client.post(
+ reverse('user_api_registration'),
+ {
+ 'email': 'email-edited@tpa-test.none',
+ 'name': 'My Customized Name',
+ 'username': 'new_username',
+ 'honor_code': True,
+ }
+ )
+ self.assertEqual(ajax_register_response.status_code, 200)
+ # Then the AJAX will finish the third party auth:
+ continue_response = self.client.get(tpa_context["finishAuthUrl"])
+ # And we should be redirected to the dashboard:
+ self.assertEqual(continue_response.status_code, 302)
+ self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
+
+ # Now check that we can login again, whether or not we have yet verified the account:
+ self.client.logout()
+ self._test_return_login(user_is_activated=False)
+
+ self.client.logout()
+ self.verify_user_email('email-edited@tpa-test.none')
+ self._test_return_login(user_is_activated=True)
+
+ def test_login(self):
+ user = UserFactory.create()
+ # The user goes to the login page, and sees a button to login with TestShib:
+ provider_login_url = self._check_login_page()
+ # The user clicks on the TestShib button:
+ try_login_response = self.client.get(provider_login_url)
+ # The user should be redirected to the provider's login page:
+ self.assertEqual(try_login_response.status_code, 302)
+ complete_response = self.do_provider_login(try_login_response['Location'])
+ # We should be redirected to the login screen since this account is not linked to an edX account:
+ self.assertEqual(complete_response.status_code, 302)
+ self.assertEqual(complete_response['Location'], self.url_prefix + self.login_page_url)
+ login_response = self.client.get(self.login_page_url)
+ tpa_context = login_response.context["data"]["third_party_auth"]
+ self.assertEqual(tpa_context["errorMessage"], None)
+ # Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown.
+ self.assertEqual(tpa_context["currentProvider"], self.PROVIDER_NAME)
+ # Now the user enters their username and password.
+ # The AJAX on the page will log them in:
+ ajax_login_response = self.client.post(
+ reverse('user_api_login_session'),
+ {'email': user.email, 'password': 'test'}
+ )
+ self.assertEqual(ajax_login_response.status_code, 200)
+ # Then the AJAX will finish the third party auth:
+ continue_response = self.client.get(tpa_context["finishAuthUrl"])
+ # And we should be redirected to the dashboard:
+ self.assertEqual(continue_response.status_code, 302)
+ self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
+
+ # Now check that we can login again:
+ self.client.logout()
+ self._test_return_login()
+
+ def do_provider_login(self, provider_redirect_url):
+ """
+ mock logging in to the provider
+ Should end with loading self.complete_url, which should be returned
+ """
+ raise NotImplementedError
+
+ def _test_return_login(self, user_is_activated=True):
+ """ Test logging in to an account that is already linked. """
+ # Make sure we're not logged in:
+ dashboard_response = self.client.get(reverse('dashboard'))
+ self.assertEqual(dashboard_response.status_code, 302)
+ # The user goes to the login page, and sees a button to login with TestShib:
+ provider_login_url = self._check_login_page()
+ # The user clicks on the TestShib button:
+ try_login_response = self.client.get(provider_login_url)
+ # The user should be redirected to the provider:
+ self.assertEqual(try_login_response.status_code, 302)
+ login_response = self.do_provider_login(try_login_response['Location'])
+ # There will be one weird redirect required to set the login cookie:
+ self.assertEqual(login_response.status_code, 302)
+ self.assertEqual(login_response['Location'], self.url_prefix + self.complete_url)
+ # And then we should be redirected to the dashboard:
+ login_response = self.client.get(login_response['Location'])
+ self.assertEqual(login_response.status_code, 302)
+ if user_is_activated:
+ url_expected = reverse('dashboard')
+ else:
+ url_expected = '/auth/inactive?next=/dashboard'
+ self.assertEqual(login_response['Location'], self.url_prefix + url_expected)
+ # Now we are logged in:
+ dashboard_response = self.client.get(reverse('dashboard'))
+ self.assertEqual(dashboard_response.status_code, 200)
+
+ def _check_login_page(self):
+ """
+ Load the login form and check that it contains a button for the provider.
+ Return the URL for logging into that provider.
+ """
+ return self._check_login_or_register_page(self.login_page_url, "loginUrl")
+
+ def _check_register_page(self):
+ """
+ Load the registration form and check that it contains a button for the provider.
+ Return the URL for registering with that provider.
+ """
+ return self._check_login_or_register_page(self.register_page_url, "registerUrl")
+
+ def _check_login_or_register_page(self, url, url_to_return):
+ """ Shared logic for _check_login_page() and _check_register_page() """
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(self.PROVIDER_NAME, response.content)
+ context_data = response.context['data']['third_party_auth']
+ provider_urls = {provider['id']: provider[url_to_return] for provider in context_data['providers']}
+ self.assertIn(self.PROVIDER_ID, provider_urls)
+ return provider_urls[self.PROVIDER_ID]
+
+ @property
+ def complete_url(self):
+ """ Get the auth completion URL for this provider """
+ return reverse('social:complete', kwargs={'backend': self.PROVIDER_BACKEND})
+
+
@unittest.skipUnless(
testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES')
@django_utils.override_settings() # For settings reversion on a method-by-method basis.
diff --git a/common/djangoapps/third_party_auth/tests/specs/test_generic.py b/common/djangoapps/third_party_auth/tests/specs/test_generic.py
new file mode 100644
index 0000000000..56b7fd8333
--- /dev/null
+++ b/common/djangoapps/third_party_auth/tests/specs/test_generic.py
@@ -0,0 +1,33 @@
+"""
+Use the 'Dummy' auth provider for generic integration tests of third_party_auth.
+"""
+import unittest
+from third_party_auth.tests import testutil
+
+from .base import IntegrationTestMixin
+
+
+@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
+class GenericIntegrationTest(IntegrationTestMixin, testutil.TestCase):
+ """
+ Basic integration tests of third_party_auth using Dummy provider
+ """
+ PROVIDER_ID = "oa2-dummy"
+ PROVIDER_NAME = "Dummy"
+ PROVIDER_BACKEND = "dummy"
+
+ USER_EMAIL = "adama@fleet.colonies.gov"
+ USER_NAME = "William Adama"
+ USER_USERNAME = "Galactica1"
+
+ def setUp(self):
+ super(GenericIntegrationTest, self).setUp()
+ self.configure_dummy_provider(enabled=True)
+
+ def do_provider_login(self, provider_redirect_url):
+ """
+ Mock logging in to the Dummy provider
+ """
+ # For the Dummy provider, the provider redirect URL is self.complete_url
+ self.assertEqual(provider_redirect_url, self.url_prefix + self.complete_url)
+ return self.client.get(provider_redirect_url)
diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py
index f9c1870577..defea79520 100644
--- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py
+++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py
@@ -1,38 +1,36 @@
"""
Third_party_auth integration tests using a mock version of the TestShib provider
"""
-
-import json
import unittest
import httpretty
from mock import patch
-from django.core.urlresolvers import reverse
-
-from student.tests.factories import UserFactory
from third_party_auth.tasks import fetch_saml_metadata
from third_party_auth.tests import testutil
-from openedx.core.lib.js_utils import escape_json_dumps
+
+from .base import IntegrationTestMixin
TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth'
TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml'
TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO'
-TPA_TESTSHIB_LOGIN_URL = '/auth/login/tpa-saml/?auth_entry=login&next=%2Fdashboard&idp=testshib'
-TPA_TESTSHIB_REGISTER_URL = '/auth/login/tpa-saml/?auth_entry=register&next=%2Fdashboard&idp=testshib'
-TPA_TESTSHIB_COMPLETE_URL = '/auth/complete/tpa-saml/'
-
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
-class TestShibIntegrationTest(testutil.SAMLTestCase):
+class TestShibIntegrationTest(IntegrationTestMixin, testutil.SAMLTestCase):
"""
TestShib provider Integration Test, to test SAML functionality
"""
+ PROVIDER_ID = "saml-testshib"
+ PROVIDER_NAME = "TestShib"
+ PROVIDER_BACKEND = "tpa-saml"
+
+ USER_EMAIL = "myself@testshib.org"
+ USER_NAME = "Me Myself And I"
+ USER_USERNAME = "myself"
+
def setUp(self):
super(TestShibIntegrationTest, self).setUp()
- self.login_page_url = reverse('signin_user')
- self.register_page_url = reverse('register_user')
self.enable_saml(
private_key=self._get_private_key(),
public_key=self._get_public_key(),
@@ -53,13 +51,14 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID')
uid_patch.start()
self.addCleanup(uid_patch.stop)
+ self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
def test_login_before_metadata_fetched(self):
self._configure_testshib_provider(fetch_metadata=False)
# The user goes to the login page, and sees a button to login with TestShib:
- self._check_login_page()
+ testshib_login_url = self._check_login_page()
# The user clicks on the TestShib button:
- try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
+ try_login_response = self.client.get(testshib_login_url)
# The user should be redirected to back to the login page:
self.assertEqual(try_login_response.status_code, 302)
self.assertEqual(try_login_response['Location'], self.url_prefix + self.login_page_url)
@@ -68,115 +67,15 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
self.assertEqual(response.status_code, 200)
self.assertIn('Authentication with TestShib is currently unavailable.', response.content)
- def test_register(self):
- self._configure_testshib_provider()
- self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
- # The user goes to the register page, and sees a button to register with TestShib:
- self._check_register_page()
- # The user clicks on the TestShib button:
- try_login_response = self.client.get(TPA_TESTSHIB_REGISTER_URL)
- # The user should be redirected to TestShib:
- self.assertEqual(try_login_response.status_code, 302)
- self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
- # Now the user will authenticate with the SAML provider
- testshib_response = self._fake_testshib_login_and_return()
- # We should be redirected to the register screen since this account is not linked to an edX account:
- self.assertEqual(testshib_response.status_code, 302)
- self.assertEqual(testshib_response['Location'], self.url_prefix + self.register_page_url)
- register_response = self.client.get(self.register_page_url)
- # We'd now like to see if the "You've successfully signed into TestShib" message is
- # shown, but it's managed by a JavaScript runtime template, and we can't run JS in this
- # type of test, so we just check for the variable that triggers that message.
- self.assertIn('"currentProvider": "TestShib"', register_response.content)
- self.assertIn('"errorMessage": null', register_response.content)
- # Now do a crude check that the data (e.g. email) from the provider is displayed in the form:
- self.assertIn('"defaultValue": "myself@testshib.org"', register_response.content)
- self.assertIn('"defaultValue": "Me Myself And I"', register_response.content)
- # Now complete the form:
- ajax_register_response = self.client.post(
- reverse('user_api_registration'),
- {
- 'email': 'myself@testshib.org',
- 'name': 'Myself',
- 'username': 'myself',
- 'honor_code': True,
- }
- )
- self.assertEqual(ajax_register_response.status_code, 200)
- # Then the AJAX will finish the third party auth:
- continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
- # And we should be redirected to the dashboard:
- self.assertEqual(continue_response.status_code, 302)
- self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
-
- # Now check that we can login again:
- self.client.logout()
- self.verify_user_email('myself@testshib.org')
- self._test_return_login()
-
def test_login(self):
+ """ Configure TestShib before running the login test """
self._configure_testshib_provider()
- self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
- user = UserFactory.create()
- # The user goes to the login page, and sees a button to login with TestShib:
- self._check_login_page()
- # The user clicks on the TestShib button:
- try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
- # The user should be redirected to TestShib:
- self.assertEqual(try_login_response.status_code, 302)
- self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
- # Now the user will authenticate with the SAML provider
- testshib_response = self._fake_testshib_login_and_return()
- # We should be redirected to the login screen since this account is not linked to an edX account:
- self.assertEqual(testshib_response.status_code, 302)
- self.assertEqual(testshib_response['Location'], self.url_prefix + self.login_page_url)
- login_response = self.client.get(self.login_page_url)
- # We'd now like to see if the "You've successfully signed into TestShib" message is
- # shown, but it's managed by a JavaScript runtime template, and we can't run JS in this
- # type of test, so we just check for the variable that triggers that message.
- self.assertIn('"currentProvider": "TestShib"', login_response.content)
- self.assertIn('"errorMessage": null', login_response.content)
- # Now the user enters their username and password.
- # The AJAX on the page will log them in:
- ajax_login_response = self.client.post(
- reverse('user_api_login_session'),
- {'email': user.email, 'password': 'test'}
- )
- self.assertEqual(ajax_login_response.status_code, 200)
- # Then the AJAX will finish the third party auth:
- continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
- # And we should be redirected to the dashboard:
- self.assertEqual(continue_response.status_code, 302)
- self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
+ super(TestShibIntegrationTest, self).test_login()
- # Now check that we can login again:
- self.client.logout()
- self._test_return_login()
-
- def _test_return_login(self):
- """ Test logging in to an account that is already linked. """
- # Make sure we're not logged in:
- dashboard_response = self.client.get(reverse('dashboard'))
- self.assertEqual(dashboard_response.status_code, 302)
- # The user goes to the login page, and sees a button to login with TestShib:
- self._check_login_page()
- # The user clicks on the TestShib button:
- try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
- # The user should be redirected to TestShib:
- self.assertEqual(try_login_response.status_code, 302)
- self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
- # Now the user will authenticate with the SAML provider
- login_response = self._fake_testshib_login_and_return()
- # There will be one weird redirect required to set the login cookie:
- self.assertEqual(login_response.status_code, 302)
- self.assertEqual(login_response['Location'], self.url_prefix + TPA_TESTSHIB_COMPLETE_URL)
- # And then we should be redirected to the dashboard:
- login_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
- self.assertEqual(login_response.status_code, 302)
- self.assertEqual(login_response['Location'], self.url_prefix + reverse('dashboard'))
- # Now we are logged in:
- dashboard_response = self.client.get(reverse('dashboard'))
- self.assertEqual(dashboard_response.status_code, 200)
+ def test_register(self):
+ """ Configure TestShib before running the register test """
+ self._configure_testshib_provider()
+ super(TestShibIntegrationTest, self).test_register()
def _freeze_time(self, timestamp):
""" Mock the current time for SAML, so we can replay canned requests/responses """
@@ -184,22 +83,6 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
now_patch.start()
self.addCleanup(now_patch.stop)
- def _check_login_page(self):
- """ Load the login form and check that it contains a TestShib button """
- response = self.client.get(self.login_page_url)
- self.assertEqual(response.status_code, 200)
- self.assertIn("TestShib", response.content)
- self.assertIn(escape_json_dumps(TPA_TESTSHIB_LOGIN_URL), response.content)
- return response
-
- def _check_register_page(self):
- """ Load the login form and check that it contains a TestShib button """
- response = self.client.get(self.register_page_url)
- self.assertEqual(response.status_code, 200)
- self.assertIn("TestShib", response.content)
- self.assertIn(escape_json_dumps(TPA_TESTSHIB_REGISTER_URL), response.content)
- return response
-
def _configure_testshib_provider(self, **kwargs):
""" Enable and configure the TestShib SAML IdP as a third_party_auth provider """
fetch_metadata = kwargs.pop('fetch_metadata', True)
@@ -219,11 +102,12 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
self.assertEqual(num_changed, 1)
self.assertEqual(num_total, 1)
- def _fake_testshib_login_and_return(self):
+ def do_provider_login(self, provider_redirect_url):
""" Mocked: the user logs in to TestShib and then gets redirected back """
# The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response:
+ self.assertTrue(provider_redirect_url.startswith(TESTSHIB_SSO_URL))
return self.client.post(
- TPA_TESTSHIB_COMPLETE_URL,
+ self.complete_url,
content_type='application/x-www-form-urlencoded',
data=self.read_data_file('testshib_response.txt'),
)
diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py
index 9f9127171b..baf1ec5ca4 100644
--- a/common/djangoapps/third_party_auth/tests/testutil.py
+++ b/common/djangoapps/third_party_auth/tests/testutil.py
@@ -10,6 +10,7 @@ from django.contrib.auth.models import User
from provider.oauth2.models import Client as OAuth2Client
from provider import constants
import django.test
+from mako.template import Template
import mock
import os.path
@@ -27,6 +28,18 @@ AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
AUTH_FEATURE_ENABLED = AUTH_FEATURES_KEY in settings.FEATURES
+def patch_mako_templates():
+ """ Patch mako so the django test client can access template context """
+ orig_render = Template.render_unicode
+
+ def wrapped_render(*args, **kwargs):
+ """ Render the template and send the context info to any listeners that want it """
+ django.test.signals.template_rendered.send(sender=None, template=None, context=kwargs)
+ return orig_render(*args, **kwargs)
+
+ return mock.patch.multiple(Template, render_unicode=wrapped_render, render=wrapped_render)
+
+
class FakeDjangoSettings(object):
"""A fake for Django settings."""
@@ -109,6 +122,13 @@ class ThirdPartyAuthTestMixin(object):
kwargs.setdefault("secret", "test")
return cls.configure_oauth_provider(**kwargs)
+ @classmethod
+ def configure_dummy_provider(cls, **kwargs):
+ """ Update the settings for the Twitter third party auth provider/backend """
+ kwargs.setdefault("name", "Dummy")
+ kwargs.setdefault("backend_name", "dummy")
+ return cls.configure_oauth_provider(**kwargs)
+
@classmethod
def verify_user_email(cls, email):
""" Mark the user with the given email as verified """
@@ -142,12 +162,6 @@ class SAMLTestCase(TestCase):
"""
Base class for SAML-related third_party_auth tests
"""
-
- def setUp(self):
- super(SAMLTestCase, self).setUp()
- self.client.defaults['SERVER_NAME'] = 'example.none' # The SAML lib we use doesn't like testserver' as a domain
- self.url_prefix = 'http://example.none'
-
@classmethod
def _get_public_key(cls, key_name='saml_key'):
""" Get a public key for use in the test. """
From 40448e44fa17ff97e51d45b70c8c471bd2c927f3 Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Thu, 22 Oct 2015 10:16:03 -0700
Subject: [PATCH 041/115] Address review comments and test failure
---
.../djangoapps/third_party_auth/tests/specs/base.py | 13 +++++--------
.../third_party_auth/tests/specs/test_lti.py | 2 ++
.../djangoapps/third_party_auth/tests/testutil.py | 7 ++++++-
common/djangoapps/third_party_auth/urls.py | 2 +-
4 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py
index 570906b6cf..b510c26106 100644
--- a/common/djangoapps/third_party_auth/tests/specs/base.py
+++ b/common/djangoapps/third_party_auth/tests/specs/base.py
@@ -47,9 +47,6 @@ class IntegrationTestMixin(object):
super(IntegrationTestMixin, self).setUp()
self.login_page_url = reverse('signin_user')
self.register_page_url = reverse('register_user')
- # Set the server name:
- self.client.defaults['SERVER_NAME'] = 'example.none' # The SAML lib we use doesn't like testserver' as a domain
- self.url_prefix = 'http://example.none'
patcher = testutil.patch_mako_templates()
patcher.start()
self.addCleanup(patcher.stop)
@@ -104,9 +101,9 @@ class IntegrationTestMixin(object):
def test_login(self):
user = UserFactory.create()
- # The user goes to the login page, and sees a button to login with TestShib:
+ # The user goes to the login page, and sees a button to login with this provider:
provider_login_url = self._check_login_page()
- # The user clicks on the TestShib button:
+ # The user clicks on the provider's button:
try_login_response = self.client.get(provider_login_url)
# The user should be redirected to the provider's login page:
self.assertEqual(try_login_response.status_code, 302)
@@ -148,9 +145,9 @@ class IntegrationTestMixin(object):
# Make sure we're not logged in:
dashboard_response = self.client.get(reverse('dashboard'))
self.assertEqual(dashboard_response.status_code, 302)
- # The user goes to the login page, and sees a button to login with TestShib:
+ # The user goes to the login page, and sees a button to login with this provider:
provider_login_url = self._check_login_page()
- # The user clicks on the TestShib button:
+ # The user clicks on the provider's login button:
try_login_response = self.client.get(provider_login_url)
# The user should be redirected to the provider:
self.assertEqual(try_login_response.status_code, 302)
@@ -164,7 +161,7 @@ class IntegrationTestMixin(object):
if user_is_activated:
url_expected = reverse('dashboard')
else:
- url_expected = '/auth/inactive?next=/dashboard'
+ url_expected = reverse('third_party_inactive_redirect') + '?next=' + reverse('dashboard')
self.assertEqual(login_response['Location'], self.url_prefix + url_expected)
# Now we are logged in:
dashboard_response = self.client.get(reverse('dashboard'))
diff --git a/common/djangoapps/third_party_auth/tests/specs/test_lti.py b/common/djangoapps/third_party_auth/tests/specs/test_lti.py
index d6622def7e..9d7469f282 100644
--- a/common/djangoapps/third_party_auth/tests/specs/test_lti.py
+++ b/common/djangoapps/third_party_auth/tests/specs/test_lti.py
@@ -29,6 +29,8 @@ class IntegrationTestLTI(testutil.TestCase):
def setUp(self):
super(IntegrationTestLTI, self).setUp()
+ self.client.defaults['SERVER_NAME'] = 'testserver'
+ self.url_prefix = 'http://testserver'
self.configure_lti_provider(
name='Other Tool Consumer 1', enabled=True,
lti_consumer_key='other1',
diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py
index baf1ec5ca4..36ac1c698c 100644
--- a/common/djangoapps/third_party_auth/tests/testutil.py
+++ b/common/djangoapps/third_party_auth/tests/testutil.py
@@ -155,7 +155,12 @@ class ThirdPartyAuthTestMixin(object):
class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase):
"""Base class for auth test cases."""
- pass
+ def setUp(self):
+ super(TestCase, self).setUp()
+ # Explicitly set a server name that is compatible with all our providers:
+ # (The SAML lib we use doesn't like the default 'testserver' as a domain)
+ self.client.defaults['SERVER_NAME'] = 'example.none'
+ self.url_prefix = 'http://example.none'
class SAMLTestCase(TestCase):
diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py
index 69c600932b..152ffe4777 100644
--- a/common/djangoapps/third_party_auth/urls.py
+++ b/common/djangoapps/third_party_auth/urls.py
@@ -6,7 +6,7 @@ from .views import inactive_user_view, saml_metadata_view, lti_login_and_complet
urlpatterns = patterns(
'',
- url(r'^auth/inactive', inactive_user_view),
+ url(r'^auth/inactive', inactive_user_view, name="third_party_inactive_redirect"),
url(r'^auth/saml/metadata.xml', saml_metadata_view),
url(r'^auth/login/(?Plti)/$', lti_login_and_complete_view),
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
From 9ccf78ed064f9d34f0b444093883b44611225b0d Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Mon, 16 Nov 2015 17:18:43 -0800
Subject: [PATCH 042/115] Add some asserts and explanations, per review
---
common/djangoapps/third_party_auth/pipeline.py | 3 +++
common/djangoapps/third_party_auth/settings.py | 10 ++++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py
index 97e496a775..21a47f92db 100644
--- a/common/djangoapps/third_party_auth/pipeline.py
+++ b/common/djangoapps/third_party_auth/pipeline.py
@@ -511,6 +511,9 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
pass
else:
# This is an existing account, linked to a third party provider but not activated.
+ # Double-check these criteria:
+ assert user is not None
+ assert social is not None
# We now also allow them to login again, because if they had entered their email
# incorrectly then there would be no way for them to recover the account, nor
# register anew via SSO. See SOL-1324 in JIRA.
diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py
index fd9fa7abf9..d6f75a3820 100644
--- a/common/djangoapps/third_party_auth/settings.py
+++ b/common/djangoapps/third_party_auth/settings.py
@@ -73,8 +73,14 @@ def apply_settings(django_settings):
django_settings.SOCIAL_AUTH_RAISE_EXCEPTIONS = False
# Allow users to login using social auth even if their account is not verified yet
- # Otherwise users who use social auth to register with an invalid email address
- # can become "stuck". We control this in a more fine-grained manner in pipeline.py
+ # This is required since we [ab]use django's 'is_active' flag to indicate verified
+ # accounts; without this set to True, python-social-auth won't allow us to link the
+ # user's account to the third party account during registration (since the user is
+ # not verified at that point).
+ # We also generally allow unverified third party auth users to login (see the logic
+ # in ensure_user_information in pipeline.py) because otherwise users who use social
+ # auth to register with an invalid email address can become "stuck".
+ # TODO: Remove the following if/when email validation is separated from the is_active flag.
django_settings.SOCIAL_AUTH_INACTIVE_USER_LOGIN = True
django_settings.SOCIAL_AUTH_INACTIVE_USER_URL = '/auth/inactive'
From 290e577ea735c3a6aca0b3fb309bf262de487abc Mon Sep 17 00:00:00 2001
From: asadiqbal
Date: Tue, 17 Nov 2015 13:35:31 +0500
Subject: [PATCH 043/115] SOL-1380 release tag update in github.txt
---
requirements/edx/github.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index 1f257b3b35..3487466163 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -56,7 +56,7 @@ git+https://github.com/edx/edx-lint.git@v0.3.2#egg=edx_lint==0.3.2
-e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive
git+https://github.com/edx/edx-reverification-block.git@0.0.4#egg=edx-reverification-block==0.0.4
-e git+https://github.com/edx/edx-user-state-client.git@30c0ad4b9f57f8d48d6943eb585ec8a9205f4469#egg=edx-user-state-client
-git+https://github.com/edx/edx-organizations.git@release-2015-09-22#egg=edx-organizations==0.1.6
+git+https://github.com/edx/edx-organizations.git@release-2015-11-17#egg=edx-organizations==0.1.7
git+https://github.com/edx/edx-proctoring.git@0.10.20#egg=edx-proctoring==0.10.20
From 7de588237232b9563a7867075ba9b157a39a7034 Mon Sep 17 00:00:00 2001
From: wajeeha-khalid
Date: Fri, 6 Nov 2015 17:18:29 +0500
Subject: [PATCH 044/115] MA-860; No moderator permissions on closed thread
---
lms/djangoapps/discussion_api/api.py | 4 +
lms/djangoapps/discussion_api/permissions.py | 10 +-
.../discussion_api/tests/test_api.py | 4 +-
.../discussion_api/tests/test_views.py | 281 ++++++++++++------
lms/djangoapps/discussion_api/tests/utils.py | 10 +
5 files changed, 212 insertions(+), 97 deletions(-)
diff --git a/lms/djangoapps/discussion_api/api.py b/lms/djangoapps/discussion_api/api.py
index ae9e4f27a1..1e0923bdf6 100644
--- a/lms/djangoapps/discussion_api/api.py
+++ b/lms/djangoapps/discussion_api/api.py
@@ -601,6 +601,10 @@ def create_comment(request, comment_data):
except Http404:
raise ValidationError({"thread_id": ["Invalid value."]})
+ # if a thread is closed; no new comments could be made to it
+ if cc_thread['closed']:
+ raise PermissionDenied
+
_check_initializable_comment_fields(comment_data, context)
serializer = CommentSerializer(data=comment_data, context=context)
actions_form = CommentActionsForm(comment_data)
diff --git a/lms/djangoapps/discussion_api/permissions.py b/lms/djangoapps/discussion_api/permissions.py
index 0b77be202e..4385c070a3 100644
--- a/lms/djangoapps/discussion_api/permissions.py
+++ b/lms/djangoapps/discussion_api/permissions.py
@@ -56,8 +56,16 @@ def get_editable_fields(cc_content, context):
"""
Return the set of fields that the requester can edit on the given content
"""
+
+ # no edits, except 'abuse_flagged' are allowed on closed threads
+ ret = {"abuse_flagged"}
+ if (cc_content["type"] == "thread" and cc_content["closed"]) or (
+ cc_content["type"] == "comment" and context["thread"]["closed"]
+ ):
+ return ret
+
# Shared fields
- ret = {"abuse_flagged", "voted"}
+ ret |= {"voted"}
if _is_author_or_privileged(cc_content, context):
ret |= {"raw_body"}
diff --git a/lms/djangoapps/discussion_api/tests/test_api.py b/lms/djangoapps/discussion_api/tests/test_api.py
index bc640b4780..0bd5968657 100644
--- a/lms/djangoapps/discussion_api/tests/test_api.py
+++ b/lms/djangoapps/discussion_api/tests/test_api.py
@@ -614,7 +614,7 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, SharedModuleSto
"title": "Another Test Title",
"body": "More content",
"pinned": False,
- "closed": True,
+ "closed": False,
"abuse_flaggers": [],
"votes": {"up_count": 9},
"comments_count": 18,
@@ -668,7 +668,7 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, SharedModuleSto
"raw_body": "More content",
"rendered_body": "
Our mailing address is: edX 11 Cambridge Center, Suite 101 Cambridge, MA, USA 02142
This email was automatically sent from {platform_name}. You are receiving this email at address {email} because you are enrolled in {course_title}. To stop receiving email like this, update your course email settings here.