diff --git a/.gitignore b/.gitignore
index 6769176b86..7f2a9d6777 100644
--- a/.gitignore
+++ b/.gitignore
@@ -80,6 +80,7 @@ lms/static/sass/lms-course.scss
lms/static/sass/lms-course-rtl.scss
lms/static/sass/lms-footer.scss
lms/static/sass/lms-footer-rtl.scss
+lms/static/certificates/sass/*.css
cms/static/css/
cms/static/sass/*.css
cms/static/sass/*.css.map
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 41fca796f4..c8fe7ce15d 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -147,6 +147,24 @@ def get_lms_link_for_about_page(course_key):
)
+# pylint: disable=invalid-name
+def get_lms_link_for_certificate_web_view(user_id, course_key, mode):
+ """
+ Returns the url to the certificate web view.
+ """
+ assert isinstance(course_key, CourseKey)
+
+ if settings.LMS_BASE is None:
+ return None
+
+ return u"//{certificate_web_base}/certificates/user/{user_id}/course/{course_id}?preview={mode}".format(
+ certificate_web_base=settings.LMS_BASE,
+ user_id=user_id,
+ course_id=unicode(course_key),
+ mode=mode
+ )
+
+
def course_image_url(course):
"""Returns the image url for the course."""
loc = StaticContent.compute_location(course.location.course_key, course.course_image)
diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py
index 4740a93910..f15e8feeb9 100644
--- a/cms/djangoapps/contentstore/views/assets.py
+++ b/cms/djangoapps/contentstore/views/assets.py
@@ -18,6 +18,7 @@ from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError
+from contentstore.views.exception import AssetNotFoundException
from django.core.exceptions import PermissionDenied
from opaque_keys.edx.keys import CourseKey, AssetKey
@@ -310,36 +311,12 @@ def _update_asset(request, course_key, asset_key):
asset_path_encoding: the odd /c4x/org/course/category/name repr of the asset (used by Backbone as the id)
"""
if request.method == 'DELETE':
- # Make sure the item to delete actually exists.
try:
- content = contentstore().find(asset_key)
- except NotFoundError:
+ delete_asset(course_key, asset_key)
+ return JsonResponse()
+ except AssetNotFoundException:
return JsonResponse(status=404)
- # ok, save the content into the trashcan
- contentstore('trashcan').save(content)
-
- # see if there is a thumbnail as well, if so move that as well
- if content.thumbnail_location is not None:
- # We are ignoring the value of the thumbnail_location-- we only care whether
- # or not a thumbnail has been stored, and we can now easily create the correct path.
- thumbnail_location = course_key.make_asset_key('thumbnail', asset_key.name)
- try:
- thumbnail_content = contentstore().find(thumbnail_location)
- contentstore('trashcan').save(thumbnail_content)
- # hard delete thumbnail from origin
- contentstore().delete(thumbnail_content.get_id())
- # remove from any caching
- del_cached_content(thumbnail_location)
- except:
- logging.warning('Could not delete thumbnail: %s', thumbnail_location)
-
- # delete the original
- contentstore().delete(content.get_id())
- # remove from cache
- del_cached_content(content.location)
- return JsonResponse()
-
elif request.method in ('PUT', 'POST'):
if 'file' in request.FILES:
return _upload_asset(request, course_key)
@@ -355,6 +332,40 @@ def _update_asset(request, course_key, asset_key):
return JsonResponse(modified_asset, status=201)
+def delete_asset(course_key, asset_key):
+ """
+ Deletes asset represented by given 'asset_key' in the course represented by given course_key.
+ """
+ # Make sure the item to delete actually exists.
+ try:
+ content = contentstore().find(asset_key)
+ except NotFoundError:
+ raise AssetNotFoundException
+
+ # ok, save the content into the trashcan
+ contentstore('trashcan').save(content)
+
+ # see if there is a thumbnail as well, if so move that as well
+ if content.thumbnail_location is not None:
+ # We are ignoring the value of the thumbnail_location-- we only care whether
+ # or not a thumbnail has been stored, and we can now easily create the correct path.
+ thumbnail_location = course_key.make_asset_key('thumbnail', asset_key.name)
+ try:
+ thumbnail_content = contentstore().find(thumbnail_location)
+ contentstore('trashcan').save(thumbnail_content)
+ # hard delete thumbnail from origin
+ contentstore().delete(thumbnail_content.get_id())
+ # remove from any caching
+ del_cached_content(thumbnail_location)
+ except Exception: # pylint: disable=broad-except
+ logging.warning('Could not delete thumbnail: %s', thumbnail_location)
+
+ # delete the original
+ contentstore().delete(content.get_id())
+ # remove from cache
+ del_cached_content(content.location)
+
+
def _get_asset_json(display_name, content_type, date, location, thumbnail_location, locked):
"""
Helper method for formatting the asset information to send to client.
diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py
new file mode 100644
index 0000000000..3a7e1a924d
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/certificates.py
@@ -0,0 +1,471 @@
+"""
+Certificates Data Model:
+
+course.certificates: {
+ 'certificates': [
+ {
+ 'version': 1, // data contract version
+ 'id': 12345, // autogenerated identifier
+ 'name': 'Certificate 1',
+ 'description': 'Certificate 1 Description',
+ 'course_title': 'course title',
+ 'signatories': [
+ {
+ 'id': 24680, // autogenerated identifier
+ 'name': 'Dr. Bob Smith',
+ 'title': 'Dean of the College',
+ 'organization': 'Awesome College'
+ }
+ ]
+ }
+ ]
+}
+"""
+import json
+
+from django.conf import settings
+from django.contrib.auth.decorators import login_required
+from django_future.csrf import ensure_csrf_cookie
+from django.http import HttpResponse
+from django.utils.translation import ugettext as _
+from django.views.decorators.http import require_http_methods
+
+from contentstore.utils import reverse_course_url
+from edxmako.shortcuts import render_to_response
+from opaque_keys.edx.keys import CourseKey, AssetKey
+from student.auth import has_studio_read_access
+from util.db import generate_int_id, MYSQL_MAX_INT
+from util.json_request import JsonResponse
+from xmodule.modulestore import EdxJSONEncoder
+from xmodule.modulestore.django import modulestore
+from contentstore.views.assets import delete_asset
+from contentstore.views.exception import AssetNotFoundException
+from django.core.exceptions import PermissionDenied
+from course_modes.models import CourseMode
+from contentstore.utils import get_lms_link_for_certificate_web_view
+
+CERTIFICATE_SCHEMA_VERSION = 1
+CERTIFICATE_MINIMUM_ID = 100
+
+
+def _get_course_and_check_access(course_key, user, depth=0):
+ """
+ Internal method used to calculate and return the locator and
+ course module for the view functions in this file.
+ """
+ if not has_studio_read_access(user, course_key):
+ raise PermissionDenied()
+ course_module = modulestore().get_course(course_key, depth=depth)
+ return course_module
+
+
+def _delete_asset(course_key, asset_key_string):
+ """
+ Internal method used to create asset key from string and
+ remove asset by calling delete_asset method of assets module.
+ """
+ if asset_key_string:
+ # remove first slash in asset path
+ # otherwise it generates InvalidKeyError in case of split modulestore
+ if '/' == asset_key_string[0]:
+ asset_key_string = asset_key_string[1:]
+ asset_key = AssetKey.from_string(asset_key_string)
+ try:
+ delete_asset(course_key, asset_key)
+ # If the asset was not found, it doesn't have to be deleted...
+ except AssetNotFoundException:
+ pass
+
+
+# Certificates Exceptions
+class CertificateException(Exception):
+ """
+ Base exception for Certificates workflows
+ """
+ pass
+
+
+class CertificateValidationError(CertificateException):
+ """
+ An exception raised when certificate information is invalid.
+ """
+ pass
+
+
+class CertificateManager(object):
+ """
+ The CertificateManager is responsible for storage, retrieval, and manipulation of Certificates
+ Certificates are not stored in the Django ORM, they are a field/setting on the course descriptor
+ """
+ @staticmethod
+ def parse(json_string):
+ """
+ Deserialize the provided JSON data into a standard Python object
+ """
+ try:
+ certificate = json.loads(json_string)
+ except ValueError:
+ raise CertificateValidationError(_("invalid JSON"))
+ # Include the data contract version
+ certificate["version"] = CERTIFICATE_SCHEMA_VERSION
+ # Ensure a signatories list is always returned
+ if certificate.get("signatories") is None:
+ certificate["signatories"] = []
+ certificate["editing"] = False
+ return certificate
+
+ @staticmethod
+ def validate(certificate_data):
+ """
+ Ensure the certificate data contains all of the necessary fields and the values match our rules
+ """
+ # Ensure the schema version meets our expectations
+ if certificate_data.get("version") != CERTIFICATE_SCHEMA_VERSION:
+ raise TypeError(
+ "Unsupported certificate schema version: {0}. Expected version: {1}.".format(
+ certificate_data.get("version"),
+ CERTIFICATE_SCHEMA_VERSION
+ )
+ )
+ if not certificate_data.get("name"):
+ raise CertificateValidationError(_("must have name of the certificate"))
+
+ @staticmethod
+ def get_used_ids(course):
+ """
+ Return a list of certificate identifiers that are already in use for this course
+ """
+ if not course.certificates or not course.certificates.get('certificates'):
+ return []
+ return [cert['id'] for cert in course.certificates['certificates']]
+
+ @staticmethod
+ def assign_id(course, certificate_data, certificate_id=None):
+ """
+ Assign an identifier to the provided certificate data.
+ If the caller did not provide an identifier, we autogenerate a unique one for them
+ In addition, we check the certificate's signatories and ensure they also have unique ids
+ """
+ used_ids = CertificateManager.get_used_ids(course)
+ if certificate_id:
+ certificate_data['id'] = int(certificate_id)
+ else:
+ certificate_data['id'] = generate_int_id(
+ CERTIFICATE_MINIMUM_ID,
+ MYSQL_MAX_INT,
+ used_ids
+ )
+
+ for index, signatory in enumerate(certificate_data['signatories']): # pylint: disable=unused-variable
+ if signatory and not signatory.get('id', False):
+ signatory['id'] = generate_int_id(used_ids=used_ids)
+ used_ids.append(signatory['id'])
+
+ return certificate_data
+
+ @staticmethod
+ def serialize_certificate(certificate):
+ """
+ Serialize the Certificate object's locally-stored certificate data to a JSON representation
+ We use direct access here for specific keys in order to enforce their presence
+ """
+ certificate_data = certificate.certificate_data
+ certificate_response = {
+ "id": certificate_data['id'],
+ "name": certificate_data['name'],
+ "description": certificate_data['description'],
+ "version": CERTIFICATE_SCHEMA_VERSION,
+ "org_logo_path": certificate_data.get('org_logo_path', ''),
+ "signatories": certificate_data['signatories']
+ }
+
+ # Some keys are not required, such as the title override...
+ if certificate_data.get('course_title'):
+ certificate_response["course_title"] = certificate_data['course_title']
+
+ return certificate_response
+
+ @staticmethod
+ def deserialize_certificate(course, value):
+ """
+ Deserialize from a JSON representation into a Certificate object.
+ 'value' should be either a Certificate instance, or a valid JSON string
+ """
+
+ # Ensure the schema fieldset meets our expectations
+ for key in ("name", "description", "version"):
+ if key not in value:
+ raise CertificateValidationError(_("Certificate dict {0} missing value key '{1}'").format(value, key))
+
+ # Load up the Certificate data
+ certificate_data = CertificateManager.parse(value)
+ CertificateManager.validate(certificate_data)
+ certificate_data = CertificateManager.assign_id(course, certificate_data, certificate_data.get('id', None))
+ certificate = Certificate(course, certificate_data)
+
+ # Return a new Certificate object instance
+ return certificate
+
+ @staticmethod
+ def get_certificates(course):
+ """
+ Retrieve the certificates list from the provided course
+ """
+ # The top-level course field is 'certificates', which contains various properties,
+ # including the actual 'certificates' list that we're working with in this context
+ return course.certificates.get('certificates', [])
+
+ @staticmethod
+ def remove_certificate(request, store, course, certificate_id):
+ """
+ Remove certificate from the course
+ """
+ for index, cert in enumerate(course.certificates['certificates']):
+ if int(cert['id']) == int(certificate_id):
+ certificate = course.certificates['certificates'][index]
+ # Remove any signatory assets prior to dropping the entire cert record from the course
+ _delete_asset(course.id, certificate['org_logo_path'])
+ for sig_index, signatory in enumerate(certificate.get('signatories')): # pylint: disable=unused-variable
+ _delete_asset(course.id, signatory['signature_image_path'])
+ # Now drop the certificate record
+ course.certificates['certificates'].pop(index)
+ store.update_item(course, request.user.id)
+ break
+
+ # pylint-disable: unused-variable
+ @staticmethod
+ def remove_signatory(request, store, course, certificate_id, signatory_id):
+ """
+ Remove the specified signatory from the provided course certificate
+ """
+ for cert_index, cert in enumerate(course.certificates['certificates']): # pylint: disable=unused-variable
+ if int(cert['id']) == int(certificate_id):
+ for sig_index, signatory in enumerate(cert.get('signatories')): # pylint: disable=unused-variable
+ if int(signatory_id) == int(signatory['id']):
+ _delete_asset(course.id, signatory['signature_image_path'])
+ del cert['signatories'][sig_index]
+ store.update_item(course, request.user.id)
+ break
+
+
+class Certificate(object):
+ """
+ The logical representation of an individual course certificate
+ """
+ def __init__(self, course, certificate_data):
+ """
+ Instantiate a Certificate object instance using the provided information.
+ """
+ self.course = course
+ self._certificate_data = certificate_data
+ self.id = certificate_data['id'] # pylint: disable=invalid-name
+
+ @property
+ def certificate_data(self):
+ """
+ Retrieve the locally-stored certificate data from the Certificate object via a helper method
+ """
+ return self._certificate_data
+
+
+@login_required
+@require_http_methods(("POST",))
+@ensure_csrf_cookie
+def certificate_activation_handler(request, course_key_string):
+ """
+ A handler for Certificate Activation/Deactivation
+
+ POST
+ json: is_active. update the activation state of certificate
+ """
+ course_key = CourseKey.from_string(course_key_string)
+ store = modulestore()
+ try:
+ course = _get_course_and_check_access(course_key, request.user)
+ except PermissionDenied:
+ msg = _('PermissionDenied: Failed in authenticating {user}').format(user=request.user)
+ return JsonResponse({"error": msg}, status=403)
+
+ data = json.loads(request.body)
+ is_active = data.get('is_active', False)
+ certificates = CertificateManager.get_certificates(course)
+
+ # for certificate activation/deactivation, we are assuming one certificate in certificates collection.
+ for certificate in certificates:
+ certificate['is_active'] = is_active
+ break
+
+ store.update_item(course, request.user.id)
+ return HttpResponse(status=200)
+
+
+@login_required
+@require_http_methods(("GET", "POST"))
+@ensure_csrf_cookie
+def certificates_list_handler(request, course_key_string):
+ """
+ A RESTful handler for Course Certificates
+
+ GET
+ html: return Certificates list page (Backbone application)
+ POST
+ json: create new Certificate
+ """
+ course_key = CourseKey.from_string(course_key_string)
+ store = modulestore()
+ with store.bulk_operations(course_key):
+ try:
+ course = _get_course_and_check_access(course_key, request.user)
+ except PermissionDenied:
+ msg = _('PermissionDenied: Failed in authenticating {user}').format(user=request.user)
+ return JsonResponse({"error": msg}, status=403)
+
+ if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
+ certificate_url = reverse_course_url('certificates.certificates_list_handler', course_key)
+ course_outline_url = reverse_course_url('course_handler', course_key)
+ upload_asset_url = reverse_course_url('assets_handler', course_key)
+ activation_handler_url = reverse_course_url(
+ handler_name='certificates.certificate_activation_handler',
+ course_key=course_key
+ )
+ course_modes = [mode.slug for mode in CourseMode.modes_for_course(course.id)]
+ certificate_web_view_url = get_lms_link_for_certificate_web_view(
+ user_id=request.user.id,
+ course_key=course_key,
+ mode=course_modes[0] # CourseMode.modes_for_course returns default mode 'honor' if doesn't find anyone.
+ )
+ certificates = None
+ is_active = False
+ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
+ certificates = CertificateManager.get_certificates(course)
+ # we are assuming only one certificate in certificates collection.
+ for certificate in certificates:
+ is_active = certificate.get('is_active', False)
+ break
+
+ return render_to_response('certificates.html', {
+ 'context_course': course,
+ 'certificate_url': certificate_url,
+ 'course_outline_url': course_outline_url,
+ 'upload_asset_url': upload_asset_url,
+ 'certificates': json.dumps(certificates),
+ 'course_modes': course_modes,
+ 'certificate_web_view_url': certificate_web_view_url,
+ 'is_active': is_active,
+ 'certificate_activation_handler_url': activation_handler_url
+ })
+ elif "application/json" in request.META.get('HTTP_ACCEPT'):
+ # Retrieve the list of certificates for the specified course
+ if request.method == 'GET':
+ certificates = CertificateManager.get_certificates(course)
+ return JsonResponse(certificates, encoder=EdxJSONEncoder)
+ elif request.method == 'POST':
+ # Add a new certificate to the specified course
+ try:
+ new_certificate = CertificateManager.deserialize_certificate(course, request.body)
+ except CertificateValidationError as err:
+ return JsonResponse({"error": err.message}, status=400)
+ if course.certificates.get('certificates') is None:
+ course.certificates['certificates'] = []
+ course.certificates['certificates'].append(new_certificate.certificate_data)
+ response = JsonResponse(CertificateManager.serialize_certificate(new_certificate), status=201)
+ response["Location"] = reverse_course_url(
+ 'certificates.certificates_detail_handler',
+ course.id,
+ kwargs={'certificate_id': new_certificate.id} # pylint: disable=no-member
+ )
+ store.update_item(course, request.user.id)
+ course = _get_course_and_check_access(course_key, request.user)
+ return response
+ else:
+ return HttpResponse(status=406)
+
+
+@login_required
+@ensure_csrf_cookie
+@require_http_methods(("POST", "PUT", "DELETE"))
+def certificates_detail_handler(request, course_key_string, certificate_id):
+ """
+ JSON API endpoint for manipulating a course certificate via its internal identifier.
+ Utilized by the Backbone.js 'certificates' application model
+
+ POST or PUT
+ json: update the specified certificate based on provided information
+ DELETE
+ json: remove the specified certificate from the course
+ """
+ course_key = CourseKey.from_string(course_key_string)
+ course = _get_course_and_check_access(course_key, request.user)
+
+ certificates_list = course.certificates.get('certificates', [])
+ match_index = None
+ match_cert = None
+ for index, cert in enumerate(certificates_list):
+ if certificate_id is not None:
+ if int(cert['id']) == int(certificate_id):
+ match_index = index
+ match_cert = cert
+
+ store = modulestore()
+ if request.method in ('POST', 'PUT'):
+ try:
+ new_certificate = CertificateManager.deserialize_certificate(course, request.body)
+ except CertificateValidationError as err:
+ return JsonResponse({"error": err.message}, status=400)
+
+ serialized_certificate = CertificateManager.serialize_certificate(new_certificate)
+ if match_cert:
+ certificates_list[match_index] = serialized_certificate
+ else:
+ certificates_list.append(serialized_certificate)
+
+ store.update_item(course, request.user.id)
+ return JsonResponse(serialized_certificate, status=201)
+
+ elif request.method == "DELETE":
+ if not match_cert:
+ return JsonResponse(status=404)
+ CertificateManager.remove_certificate(
+ request=request,
+ store=store,
+ course=course,
+ certificate_id=certificate_id
+ )
+ return JsonResponse(status=204)
+
+
+@login_required
+@ensure_csrf_cookie
+@require_http_methods(("POST", "PUT", "DELETE"))
+def signatory_detail_handler(request, course_key_string, certificate_id, signatory_id):
+ """
+ JSON API endpoint for manipulating a specific course certificate signatory via its internal identifier.
+ Utilized by the Backbone 'certificates' application.
+
+ DELETE
+ json: Remove the specified signatory from the specified certificate
+ """
+ course_key = CourseKey.from_string(course_key_string)
+ store = modulestore()
+ with store.bulk_operations(course_key):
+ course = _get_course_and_check_access(course_key, request.user)
+ certificates_list = course.certificates['certificates']
+
+ match_cert = None
+ # pylint: disable=unused-variable
+ for index, cert in enumerate(certificates_list):
+ if certificate_id is not None:
+ if int(cert['id']) == int(certificate_id):
+ match_cert = cert
+
+ if request.method == "DELETE":
+ if not match_cert:
+ return JsonResponse(status=404)
+ CertificateManager.remove_signatory(
+ request=request,
+ store=store,
+ course=course,
+ certificate_id=certificate_id,
+ signatory_id=signatory_id
+ )
+ return JsonResponse(status=204)
diff --git a/cms/djangoapps/contentstore/views/exception.py b/cms/djangoapps/contentstore/views/exception.py
new file mode 100644
index 0000000000..1c7a0c3f7c
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/exception.py
@@ -0,0 +1,10 @@
+"""
+A common module for managing exceptions. Helps to avoid circular references
+"""
+
+
+class AssetNotFoundException(Exception):
+ """
+ Raised when asset not found
+ """
+ pass
diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py
index daa7450f2b..9f9ce4b4c7 100644
--- a/cms/djangoapps/contentstore/views/tests/test_assets.py
+++ b/cms/djangoapps/contentstore/views/tests/test_assets.py
@@ -4,6 +4,7 @@ Unit tests for the asset upload endpoint.
from datetime import datetime
from io import BytesIO
from pytz import UTC
+from PIL import Image
import json
from django.conf import settings
from contentstore.tests.utils import CourseTestCase
@@ -36,18 +37,31 @@ class AssetsTestCase(CourseTestCase):
super(AssetsTestCase, self).setUp()
self.url = reverse_course_url('assets_handler', self.course.id)
- def upload_asset(self, name="asset-1", extension=".txt"):
+ def upload_asset(self, name="asset-1", asset_type='text'):
"""
Post to the asset upload url
"""
- f = self.get_sample_asset(name, extension)
- return self.client.post(self.url, {"name": name, "file": f})
+ asset = self.get_sample_asset(name, asset_type)
+ response = self.client.post(self.url, {"name": name, "file": asset})
+ return response
- def get_sample_asset(self, name, extension=".txt"):
- """Returns an in-memory file with the given name for testing"""
- f = BytesIO(name)
- f.name = name + extension
- return f
+ def get_sample_asset(self, name, asset_type='text'):
+ """
+ Returns an in-memory file of the specified type with the given name for testing
+ """
+ if asset_type == 'text':
+ sample_asset = BytesIO(name)
+ sample_asset.name = '{name}.txt'.format(name=name)
+ elif asset_type == 'image':
+ image = Image.new("RGB", size=(50, 50), color=(256, 0, 0))
+ sample_asset = BytesIO()
+ image.save(unicode(sample_asset), 'jpeg')
+ sample_asset.name = '{name}.jpg'.format(name=name)
+ sample_asset.seek(0)
+ elif asset_type == 'opendoc':
+ sample_asset = BytesIO(name)
+ sample_asset.name = '{name}.odt'.format(name=name)
+ return sample_asset
class BasicAssetsTestCase(AssetsTestCase):
@@ -138,7 +152,7 @@ class PaginationTestCase(AssetsTestCase):
self.upload_asset("asset-1")
self.upload_asset("asset-2")
self.upload_asset("asset-3")
- self.upload_asset("asset-4", ".odt")
+ self.upload_asset("asset-4", "opendoc")
# Verify valid page requests
self.assert_correct_asset_response(self.url, 0, 4, 4)
@@ -259,6 +273,10 @@ class UploadTestCase(AssetsTestCase):
resp = self.upload_asset()
self.assertEquals(resp.status_code, 200)
+ def test_upload_image(self):
+ resp = self.upload_asset("test_image", asset_type="image")
+ self.assertEquals(resp.status_code, 200)
+
def test_no_file(self):
resp = self.client.post(self.url, {"name": "file.txt"}, "application/json")
self.assertEquals(resp.status_code, 400)
@@ -409,3 +427,75 @@ class LockAssetTestCase(AssetsTestCase):
resp_asset = post_asset_update(False, course)
self.assertFalse(resp_asset['locked'])
verify_asset_locked_state(False)
+
+
+class DeleteAssetTestCase(AssetsTestCase):
+ """
+ Unit test for removing an asset.
+ """
+ def setUp(self):
+ """ Scaffolding """
+ super(DeleteAssetTestCase, self).setUp()
+ self.url = reverse_course_url('assets_handler', self.course.id)
+ # First, upload something.
+ self.asset_name = 'delete_test'
+ self.asset = self.get_sample_asset(self.asset_name)
+
+ response = self.client.post(self.url, {"name": self.asset_name, "file": self.asset})
+ self.assertEquals(response.status_code, 200)
+ self.uploaded_url = json.loads(response.content)['asset']['url']
+
+ self.asset_location = AssetLocation.from_deprecated_string(self.uploaded_url)
+ self.content = contentstore().find(self.asset_location)
+
+ def test_delete_asset(self):
+ """ Tests the happy path :) """
+ test_url = reverse_course_url(
+ 'assets_handler', self.course.id, kwargs={'asset_key_string': unicode(self.uploaded_url)})
+ resp = self.client.delete(test_url, HTTP_ACCEPT="application/json")
+ self.assertEquals(resp.status_code, 204)
+
+ def test_delete_image_type_asset(self):
+ """ Tests deletion of image type asset """
+ image_asset = self.get_sample_asset(self.asset_name, asset_type="image")
+ thumbnail_image_asset = self.get_sample_asset('delete_test_thumbnail', asset_type="image")
+
+ # upload image
+ response = self.client.post(self.url, {"name": "delete_image_test", "file": image_asset})
+ self.assertEquals(response.status_code, 200)
+ uploaded_image_url = json.loads(response.content)['asset']['url']
+
+ # upload image thumbnail
+ response = self.client.post(self.url, {"name": "delete_image_thumb_test", "file": thumbnail_image_asset})
+ self.assertEquals(response.status_code, 200)
+ thumbnail_url = json.loads(response.content)['asset']['url']
+ thumbnail_location = StaticContent.get_location_from_path(thumbnail_url)
+
+ image_asset_location = AssetLocation.from_deprecated_string(uploaded_image_url)
+ content = contentstore().find(image_asset_location)
+ content.thumbnail_location = thumbnail_location
+ contentstore().save(content)
+
+ with mock.patch('opaque_keys.edx.locator.CourseLocator.make_asset_key') as mock_asset_key:
+ mock_asset_key.return_value = thumbnail_location
+
+ test_url = reverse_course_url(
+ 'assets_handler', self.course.id, kwargs={'asset_key_string': unicode(uploaded_image_url)})
+ resp = self.client.delete(test_url, HTTP_ACCEPT="application/json")
+ self.assertEquals(resp.status_code, 204)
+
+ def test_delete_asset_with_invalid_asset(self):
+ """ Tests the sad path :( """
+ test_url = reverse_course_url(
+ 'assets_handler', self.course.id, kwargs={'asset_key_string': unicode("/c4x/edX/toy/asset/invalid.pdf")})
+ resp = self.client.delete(test_url, HTTP_ACCEPT="application/json")
+ self.assertEquals(resp.status_code, 404)
+
+ def test_delete_asset_with_invalid_thumbnail(self):
+ """ Tests the sad path :( """
+ test_url = reverse_course_url(
+ 'assets_handler', self.course.id, kwargs={'asset_key_string': unicode(self.uploaded_url)})
+ self.content.thumbnail_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/invalid')
+ contentstore().save(self.content)
+ resp = self.client.delete(test_url, HTTP_ACCEPT="application/json")
+ self.assertEquals(resp.status_code, 204)
diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py
new file mode 100644
index 0000000000..d2394ee2fc
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py
@@ -0,0 +1,537 @@
+#-*- coding: utf-8 -*-
+
+"""
+Group Configuration Tests.
+"""
+import json
+import mock
+
+from opaque_keys.edx.keys import AssetKey
+from opaque_keys.edx.locations import AssetLocation
+
+from contentstore.utils import reverse_course_url
+from contentstore.views.certificates import CERTIFICATE_SCHEMA_VERSION
+from contentstore.tests.utils import CourseTestCase
+from xmodule.contentstore.django import contentstore
+from xmodule.contentstore.content import StaticContent
+from xmodule.exceptions import NotFoundError
+from student.models import CourseEnrollment
+from contentstore.views.certificates import CertificateManager
+from django.test.utils import override_settings
+from contentstore.utils import get_lms_link_for_certificate_web_view
+
+CERTIFICATE_JSON = {
+ u'name': u'Test certificate',
+ u'description': u'Test description',
+ u'version': CERTIFICATE_SCHEMA_VERSION,
+}
+
+CERTIFICATE_JSON_WITH_SIGNATORIES = {
+ u'name': u'Test certificate',
+ u'description': u'Test description',
+ u'version': CERTIFICATE_SCHEMA_VERSION,
+ u'course_title': 'Course Title Override',
+ u'signatories': [
+ {
+ "name": "Bob Smith",
+ "title": "The DEAN.",
+ "signature_image_path": "/c4x/test/CSS101/asset/Signature.png"
+ }
+ ]
+}
+
+
+# pylint: disable=no-member
+class HelperMethods(object):
+ """
+ Mixin that provides useful methods for certificate configuration tests.
+ """
+ def _create_fake_images(self, asset_keys):
+ """
+ Creates fake image files for a list of asset_keys.
+ """
+ for asset_key_string in asset_keys:
+ asset_key = AssetKey.from_string(asset_key_string)
+ content = StaticContent(
+ asset_key, "Fake asset", "image/png", "data",
+ )
+ contentstore().save(content)
+
+ def _add_course_certificates(self, count=1, signatory_count=0):
+ """
+ Create certificate for the course.
+ """
+ signatories = [
+ {
+ 'name': 'Name ' + str(i),
+ 'title': 'Title ' + str(i),
+ 'signature_image_path': '/c4x/test/CSS101/asset/Signature{}.png'.format(i),
+ 'id': i
+ } for i in xrange(0, signatory_count)
+
+ ]
+
+ # create images for signatory signatures except the last signatory
+ for idx, signatory in enumerate(signatories):
+ if len(signatories) > 2 and idx == len(signatories) - 1:
+ continue
+ else:
+ self._create_fake_images([signatory['signature_image_path']])
+
+ certificates = [
+ {
+ 'id': i,
+ 'name': 'Name ' + str(i),
+ 'description': 'Description ' + str(i),
+ 'org_logo_path': '/c4x/test/CSS101/asset/org_logo{}.png'.format(i),
+ 'signatories': signatories,
+ 'version': CERTIFICATE_SCHEMA_VERSION,
+ 'is_active': False
+ } for i in xrange(0, count)
+ ]
+ self._create_fake_images([certificate['org_logo_path'] for certificate in certificates])
+ self.course.certificates = {'certificates': certificates}
+ self.save_course()
+
+
+# pylint: disable=no-member
+class CertificatesBaseTestCase(object):
+ """
+ Mixin with base test cases for the certificates.
+ """
+
+ def _remove_ids(self, content):
+ """
+ Remove ids from the response. We cannot predict IDs, because they're
+ generated randomly.
+ We use this method to clean up response when creating new certificate.
+ """
+ certificate_id = content.pop("id")
+ return certificate_id
+
+ def test_required_fields_are_absent(self):
+ """
+ Test required fields are absent.
+ """
+ bad_jsons = [
+ # must have name of the certificate
+ {
+ u'description': 'Test description',
+ u'version': CERTIFICATE_SCHEMA_VERSION
+ },
+
+ # an empty json
+ {},
+ ]
+
+ for bad_json in bad_jsons:
+ response = self.client.post(
+ self._url(),
+ data=json.dumps(bad_json),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest"
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertNotIn("Location", response)
+ content = json.loads(response.content)
+ self.assertIn("error", content)
+
+ def test_invalid_json(self):
+ """
+ Test invalid json handling.
+ """
+ # Invalid JSON.
+ invalid_json = "{u'name': 'Test Name', u'description': 'Test description'," \
+ " u'version': " + str(CERTIFICATE_SCHEMA_VERSION) + ", []}"
+
+ response = self.client.post(
+ self._url(),
+ data=invalid_json,
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest"
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertNotIn("Location", response)
+ content = json.loads(response.content)
+ self.assertIn("error", content)
+
+ def test_certificate_data_validation(self):
+ #Test certificate schema version
+ json_data_1 = {
+ u'version': 100,
+ u'name': u'Test certificate',
+ u'description': u'Test description'
+ }
+
+ with self.assertRaises(Exception) as context:
+ CertificateManager.validate(json_data_1)
+
+ self.assertTrue("Unsupported certificate schema version: 100. Expected version: 1." in context.exception)
+
+ #Test certificate name is missing
+ json_data_2 = {
+ u'version': CERTIFICATE_SCHEMA_VERSION,
+ u'description': u'Test description'
+ }
+
+ with self.assertRaises(Exception) as context:
+ CertificateManager.validate(json_data_2)
+
+ self.assertTrue('must have name of the certificate' in context.exception)
+
+
+# pylint: disable=no-member
+class CertificatesListHandlerTestCase(CourseTestCase, CertificatesBaseTestCase, HelperMethods):
+ """
+ Test cases for certificates_list_handler.
+ """
+ def setUp(self):
+ """
+ Set up CertificatesListHandlerTestCase.
+ """
+ super(CertificatesListHandlerTestCase, self).setUp()
+
+ def _url(self):
+ """
+ Return url for the handler.
+ """
+ return reverse_course_url('certificates.certificates_list_handler', self.course.id)
+
+ def test_can_create_certificate(self):
+ """
+ Test that you can create a certificate.
+ """
+ expected = {
+ u'version': CERTIFICATE_SCHEMA_VERSION,
+ u'name': u'Test certificate',
+ u'description': u'Test description',
+ u'org_logo_path': '',
+ u'signatories': []
+ }
+ response = self.client.ajax_post(
+ self._url(),
+ data=CERTIFICATE_JSON
+ )
+
+ self.assertEqual(response.status_code, 201)
+ self.assertIn("Location", response)
+ content = json.loads(response.content)
+ self._remove_ids(content) # pylint: disable=unused-variable
+ self.assertEqual(content, expected)
+
+ @override_settings(LMS_BASE=None)
+ def test_no_lms_base_for_certificate_web_view_link(self):
+ test_link = get_lms_link_for_certificate_web_view(
+ user_id=self.user.id,
+ course_key=self.course.id,
+ mode='honor'
+ )
+ self.assertEquals(test_link, None)
+
+ @override_settings(LMS_BASE="lms_base_url")
+ def test_lms_link_for_certificate_web_view(self):
+ test_url = "//lms_base_url/certificates/user/" \
+ + str(self.user.id) + "/course/" + unicode(self.course.id) + '?preview=honor'
+ link = get_lms_link_for_certificate_web_view(
+ user_id=self.user.id,
+ course_key=self.course.id,
+ mode='honor'
+ )
+ self.assertEquals(link, test_url)
+
+ @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True})
+ def test_certificate_info_in_response(self):
+ """
+ Test that certificate has been created and rendered properly.
+ """
+ response = self.client.ajax_post(
+ self._url(),
+ data=CERTIFICATE_JSON_WITH_SIGNATORIES
+ )
+
+ self.assertEqual(response.status_code, 201)
+
+ # in html response
+ result = self.client.get_html(self._url())
+ self.assertIn('Test certificate', result.content)
+ self.assertIn('Test description', result.content)
+
+ # in JSON response
+ response = self.client.get_json(self._url())
+ data = json.loads(response.content)
+ self.assertEquals(len(data), 1)
+ self.assertEqual(data[0]['name'], 'Test certificate')
+ self.assertEqual(data[0]['description'], 'Test description')
+ self.assertEqual(data[0]['version'], CERTIFICATE_SCHEMA_VERSION)
+
+ def test_unsupported_http_accept_header(self):
+ """
+ Test if not allowed header present in request.
+ """
+ response = self.client.get(
+ self._url(),
+ HTTP_ACCEPT="text/plain",
+ )
+ self.assertEqual(response.status_code, 406)
+
+ def test_certificate_unsupported_method(self):
+ """
+ Unit Test: test_certificate_unsupported_method
+ """
+ resp = self.client.put(self._url())
+ self.assertEqual(resp.status_code, 405)
+
+ def test_not_permitted(self):
+ """
+ Test that when user has not read access to course then permission denied exception should raised.
+ """
+ test_user_client, test_user = self.create_non_staff_authed_user_client()
+ CourseEnrollment.enroll(test_user, self.course.id)
+ response = test_user_client.ajax_post(
+ self._url(),
+ data=CERTIFICATE_JSON
+ )
+ self.assertEqual(response.status_code, 403)
+ self.assertIn("error", response.content)
+
+ def test_assign_unique_identifier_to_certificates(self):
+ """
+ Test certificates have unique ids
+ """
+ self._add_course_certificates(count=2)
+ json_data = {
+ u'version': CERTIFICATE_SCHEMA_VERSION,
+ u'name': u'New test certificate',
+ u'description': u'New test description',
+ u'signatories': []
+ }
+
+ response = self.client.post(
+ self._url(),
+ data=json.dumps(json_data),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+
+ new_certificate = json.loads(response.content)
+ for prev_certificate in self.course.certificates['certificates']:
+ self.assertNotEqual(new_certificate.get('id'), prev_certificate.get('id'))
+
+
+class CertificatesDetailHandlerTestCase(CourseTestCase, CertificatesBaseTestCase, HelperMethods):
+ """
+ Test cases for CertificatesDetailHandlerTestCase.
+ """
+
+ _id = 0
+
+ def _url(self, cid=-1):
+ """
+ Return url for the handler.
+ """
+ cid = cid if cid > 0 else self._id
+ return reverse_course_url(
+ 'certificates.certificates_detail_handler',
+ self.course.id,
+ kwargs={'certificate_id': cid},
+ )
+
+ def test_can_create_new_certificate_if_it_does_not_exist(self):
+ """
+ PUT/POST new certificate.
+ """
+ expected = {
+ u'id': 666,
+ u'version': CERTIFICATE_SCHEMA_VERSION,
+ u'name': u'Test certificate',
+ u'description': u'Test description',
+ u'course_title': u'Course Title Override',
+ u'org_logo_path': '',
+ u'signatories': []
+ }
+
+ response = self.client.put(
+ self._url(cid=666),
+ data=json.dumps(expected),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ content = json.loads(response.content)
+ self.assertEqual(content, expected)
+
+ def test_can_edit_certificate(self):
+ """
+ Edit certificate, check its id and modified fields.
+ """
+ self._add_course_certificates(count=2)
+
+ expected = {
+ u'id': 1,
+ u'version': CERTIFICATE_SCHEMA_VERSION,
+ u'name': u'New test certificate',
+ u'description': u'New test description',
+ u'course_title': u'Course Title Override',
+ u'org_logo_path': '',
+ u'signatories': []
+
+ }
+
+ response = self.client.put(
+ self._url(cid=1),
+ data=json.dumps(expected),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ content = json.loads(response.content)
+ self.assertEqual(content, expected)
+ self.reload_course()
+
+ # Verify that certificate is properly updated in the course.
+ course_certificates = self.course.certificates['certificates']
+ self.assertEqual(len(course_certificates), 2)
+ self.assertEqual(course_certificates[1].get('name'), u'New test certificate')
+ self.assertEqual(course_certificates[1].get('description'), 'New test description')
+
+ def test_can_delete_certificate_with_signatories(self):
+ """
+ Delete certificate
+ """
+ self._add_course_certificates(count=2, signatory_count=1)
+ certificates = self.course.certificates['certificates']
+ org_logo_url = certificates[1]['org_logo_path']
+ image_asset_location = AssetLocation.from_deprecated_string(org_logo_url)
+ content = contentstore().find(image_asset_location)
+ self.assertIsNotNone(content)
+ response = self.client.delete(
+ self._url(cid=1),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ self.assertEqual(response.status_code, 204)
+ self.reload_course()
+ # Verify that certificates are properly updated in the course.
+ certificates = self.course.certificates['certificates']
+ self.assertEqual(len(certificates), 1)
+ # make sure certificate org logo is deleted too
+ self.assertRaises(NotFoundError, contentstore().find, image_asset_location)
+ self.assertEqual(certificates[0].get('name'), 'Name 0')
+ self.assertEqual(certificates[0].get('description'), 'Description 0')
+
+ def test_delete_non_existing_certificate(self):
+ """
+ Try to delete a non existing certificate. It should return status code 404 Not found.
+ """
+ self._add_course_certificates(count=2)
+ response = self.client.delete(
+ self._url(cid=100),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ self.assertEqual(response.status_code, 404)
+
+ def test_can_delete_signatory(self):
+ """
+ Delete an existing certificate signatory
+ """
+ self._add_course_certificates(count=2, signatory_count=3)
+ certificates = self.course.certificates['certificates']
+ signatory = certificates[1].get("signatories")[1]
+ image_asset_location = AssetLocation.from_deprecated_string(signatory['signature_image_path'])
+ content = contentstore().find(image_asset_location)
+ self.assertIsNotNone(content)
+ test_url = '{}/signatories/1'.format(self._url(cid=1))
+ response = self.client.delete(
+ test_url,
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ self.assertEqual(response.status_code, 204)
+ self.reload_course()
+
+ # Verify that certificates are properly updated in the course.
+ certificates = self.course.certificates['certificates']
+ self.assertEqual(len(certificates[1].get("signatories")), 2)
+ # make sure signatory signature image is deleted too
+ self.assertRaises(NotFoundError, contentstore().find, image_asset_location)
+
+ def test_deleting_signatory_without_signature(self):
+ """
+ Delete an signatory whose signature image is already removed or does not exist
+ """
+ self._add_course_certificates(count=2, signatory_count=4)
+ test_url = '{}/signatories/3'.format(self._url(cid=1))
+ response = self.client.delete(
+ test_url,
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ self.assertEqual(response.status_code, 204)
+
+ def test_delete_signatory_non_existing_certificate(self):
+ """
+ Try to delete a non existing certificate signatory. It should return status code 404 Not found.
+ """
+ self._add_course_certificates(count=2)
+ test_url = '{}/signatories/1'.format(self._url(cid=100))
+ response = self.client.delete(
+ test_url,
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ self.assertEqual(response.status_code, 404)
+
+ def test_certificate_activation_success(self):
+ """
+ Activate and Deactivate the course certificate
+ """
+ test_url = reverse_course_url('certificates.certificate_activation_handler', self.course.id)
+ self._add_course_certificates(count=1, signatory_count=2)
+
+ is_active = True
+ for i in range(2):
+ if i == 1:
+ is_active = not is_active
+ response = self.client.post(
+ test_url,
+ data=json.dumps({"is_active": is_active}),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest"
+ )
+ self.assertEquals(response.status_code, 200)
+ course = self.store.get_course(self.course.id)
+ certificates = course.certificates['certificates']
+ self.assertEqual(certificates[0].get('is_active'), is_active)
+
+ def test_certificate_activation_failure(self):
+ """
+ Certificate activation should fail when user has not read access to course then permission denied exception
+ should raised.
+ """
+ test_url = reverse_course_url('certificates.certificate_activation_handler', self.course.id)
+ test_user_client, test_user = self.create_non_staff_authed_user_client()
+ CourseEnrollment.enroll(test_user, self.course.id)
+ self._add_course_certificates(count=1, signatory_count=2)
+ response = test_user_client.post(
+ test_url,
+ data=json.dumps({"is_active": True}),
+ content_type="application/json",
+ HTTP_ACCEPT="application/json",
+ HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ )
+ self.assertEquals(response.status_code, 403)
+ course = self.store.get_course(self.course.id)
+ certificates = course.certificates['certificates']
+ self.assertEqual(certificates[0].get('is_active'), False)
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
index 7e56503463..e78c45c2ab 100644
--- a/cms/djangoapps/models/settings/course_metadata.py
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -44,6 +44,7 @@ class CourseMetadata(object):
'is_entrance_exam',
'in_entrance_exam',
'language',
+ 'certificates'
]
@classmethod
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 4c22de05d0..0069746a73 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -155,6 +155,9 @@ FEATURES = {
# Enable course reruns, which will always use the split modulestore
'ALLOW_COURSE_RERUNS': True,
+ # Certificates Web/HTML Views
+ 'CERTIFICATES_HTML_VIEW': False,
+
# Social Media Sharing on Student Dashboard
'DASHBOARD_SHARE_SETTINGS': {
# Note: Ensure 'CUSTOM_COURSE_URLS' has a matching value in lms/envs/common.py
diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py
index 9d5ae5907b..0f0028d9a3 100644
--- a/cms/envs/devstack.py
+++ b/cms/envs/devstack.py
@@ -105,3 +105,6 @@ MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE)
# Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
+
+########################## Certificates Web/HTML View #######################
+FEATURES['CERTIFICATES_HTML_VIEW'] = True
diff --git a/cms/static/build.js b/cms/static/build.js
index f46db505a8..6103b8afe7 100644
--- a/cms/static/build.js
+++ b/cms/static/build.js
@@ -36,6 +36,7 @@
'js/factories/edit_tabs',
'js/factories/export',
'js/factories/group_configurations',
+ 'js/certificates/factories/certificates_page_factory',
'js/factories/import',
'js/factories/index',
'js/factories/login',
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index e2d10555d4..6052f95412 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -28,6 +28,7 @@ requirejs.config({
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
"backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min",
+ "backbone-relational": "xmodule_js/common_static/js/vendor/backbone-relational.min",
"tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min",
"jquery.tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce",
"xmodule": "xmodule_js/src/xmodule",
@@ -137,6 +138,9 @@ requirejs.config({
deps: ["backbone"],
exports: "Backbone.Paginator"
},
+ "backbone-relational": {
+ deps: ["backbone"],
+ },
"youtube": {
exports: "YT"
},
@@ -270,6 +274,12 @@ define([
"js/spec/xblock/cms.runtime.v1_spec",
+ # Certificates application test suite mappings
+ "js/certificates/spec/models/certificate_spec",
+ "js/certificates/spec/views/certificate_details_spec",
+ "js/certificates/spec/views/certificate_editor_spec",
+ "js/certificates/spec/views/certificates_list_spec",
+
# these tests are run separately in the cms-squire suite, due to process
# isolation issues with Squire.js
# "coffee/spec/views/assets_spec"
diff --git a/cms/static/js/certificates/collections/certificates.js b/cms/static/js/certificates/collections/certificates.js
new file mode 100644
index 0000000000..234dd0098b
--- /dev/null
+++ b/cms/static/js/certificates/collections/certificates.js
@@ -0,0 +1,81 @@
+// Backbone.js Application Collection: Certificates
+
+define([ // jshint ignore:line
+ 'backbone',
+ 'gettext',
+ 'js/certificates/models/certificate'
+],
+function(Backbone, gettext, Certificate) {
+ 'use strict';
+ var CertificateCollection = Backbone.Collection.extend({
+ model: Certificate,
+
+ /**
+ * It represents the maximum number of certificates that a user can create. default set to 1.
+ */
+ maxAllowed: 1,
+
+ initialize: function(attr, options) {
+ // Set up the attributes for this collection instance
+ this.url = options.certificateUrl;
+ this.bind('remove', this.onModelRemoved, this);
+ this.bind('add', this.onModelAdd, this);
+ },
+
+ certificate_array: function(certificate_info) {
+ var return_array;
+ try {
+ return_array = JSON.parse(certificate_info);
+ } catch (ex) {
+ // If it didn't parse, and `certificate_info` is an object then return as it is
+ // otherwise return empty array
+ if (typeof certificate_info === 'object'){
+ return_array = certificate_info;
+ }
+ else {
+ console.error(
+ interpolate(
+ gettext('Could not parse certificate JSON. %(message)s'), {message: ex.message}, true
+ )
+ );
+ return_array = [];
+ }
+ }
+ return return_array;
+ },
+
+ onModelRemoved: function () {
+ // remove the certificate web preview UI.
+ if(window.certWebPreview && this.length === 0) {
+ window.certWebPreview.remove();
+ }
+ this.toggleAddNewItemButtonState();
+ },
+
+ onModelAdd: function () {
+ this.toggleAddNewItemButtonState();
+ },
+
+ toggleAddNewItemButtonState: function() {
+ // user can create a new item e.g certificate; if not exceeded the maxAllowed limit.
+ if(this.length >= this.maxAllowed) {
+ $(".action-add").addClass('action-add-hidden');
+ } else {
+ $(".action-add").removeClass('action-add-hidden');
+ }
+ },
+
+ parse: function (certificatesJson) {
+ // Transforms the provided JSON into a Certificates collection
+ var modelArray = this.certificate_array(certificatesJson);
+
+ for (var i in modelArray) {
+ if (modelArray.hasOwnProperty(i)) {
+ this.push(modelArray[i]);
+ }
+ }
+ return this.models;
+ }
+ });
+ return CertificateCollection;
+});
diff --git a/cms/static/js/certificates/collections/signatories.js b/cms/static/js/certificates/collections/signatories.js
new file mode 100644
index 0000000000..3205fab41f
--- /dev/null
+++ b/cms/static/js/certificates/collections/signatories.js
@@ -0,0 +1,13 @@
+// Backbone.js Application Collection: Certificate Signatories
+
+define([ // jshint ignore:line
+ 'backbone',
+ 'js/certificates/models/signatory'
+],
+function(Backbone, Signatory) {
+ 'use strict';
+ var SignatoryCollection = Backbone.Collection.extend({
+ model: Signatory
+ });
+ return SignatoryCollection;
+});
diff --git a/cms/static/js/certificates/factories/certificates_page_factory.js b/cms/static/js/certificates/factories/certificates_page_factory.js
new file mode 100644
index 0000000000..a3943dfe78
--- /dev/null
+++ b/cms/static/js/certificates/factories/certificates_page_factory.js
@@ -0,0 +1,49 @@
+// Backbone.js Page Object Factory: Certificates
+
+/**
+Notes from Andy Armstrong:
+The basic idea of a page factory is that it is a single RequireJS dependency that can be loaded in a template
+to create a page object. This was added for the RequireJS Optimizer, which needs to have a single root to determine
+statically all of the dependencies needed by a page. The RequireJS Optimizer combines these dependencies into a single
+optimized JS file. Mako templates typically contain a block that constructs the page object using this page factory.
+Unit tests for the page factory verify that it behaves as desired. Some of these factories are more complex than others.
+The RequireJS Optimizer is only enabled in Studio at present, so the page factories aren't strictly required in the LMS.
+We do intend to enable page factories on the LMS too.
+*/
+
+define([ // jshint ignore:line
+ 'jquery',
+ 'js/certificates/collections/certificates',
+ 'js/certificates/models/certificate',
+ 'js/certificates/views/certificates_page',
+ 'js/certificates/views/certificate_preview'
+],
+function($, CertificatesCollection, Certificate, CertificatesPage, CertificatePreview) {
+ 'use strict';
+ return function (certificatesJson, certificateUrl, courseOutlineUrl, course_modes, certificate_web_view_url,
+ is_active, certificate_activation_handler_url) {
+ // Initialize the model collection, passing any necessary options to the constructor
+ var certificatesCollection = new CertificatesCollection(certificatesJson, {
+ parse: true,
+ canBeEmpty: true,
+ certificateUrl: certificateUrl
+ });
+
+ // associating the certificate_preview globally.
+ // need to show / hide this view in some other places.
+ if(!window.certWebPreview && certificate_web_view_url) {
+ window.certWebPreview = new CertificatePreview({
+ course_modes: course_modes,
+ certificate_web_view_url: certificate_web_view_url,
+ certificate_activation_handler_url: certificate_activation_handler_url,
+ is_active: is_active
+ });
+ }
+
+ // Execute the page object's rendering workflow
+ new CertificatesPage({
+ el: $('#content'),
+ certificatesCollection: certificatesCollection
+ }).render();
+ };
+});
diff --git a/cms/static/js/certificates/models/certificate.js b/cms/static/js/certificates/models/certificate.js
new file mode 100644
index 0000000000..d7e81da72a
--- /dev/null
+++ b/cms/static/js/certificates/models/certificate.js
@@ -0,0 +1,92 @@
+// Backbone.js Application Model: Certificate
+
+define([ // jshint ignore:line
+ 'underscore',
+ 'underscore.string',
+ 'backbone',
+ 'backbone-relational',
+ 'backbone.associations',
+ 'gettext',
+ 'coffee/src/main',
+ 'js/certificates/models/signatory',
+ 'js/certificates/collections/signatories'
+],
+function (_, str, Backbone, BackboneRelational, BackboneAssociations, gettext, CoffeeSrcMain,
+ SignatoryModel, SignatoryCollection) {
+ 'use strict';
+ _.str = str;
+ var Certificate = Backbone.RelationalModel.extend({
+ idAttribute: "id",
+ defaults: {
+ name: 'Name of the certificate',
+ description: 'Description of the certificate',
+ course_title: 'Title of the course',
+ org_logo_path: '',
+ version: 1,
+ is_active: false
+ },
+
+ // Certificate child collection/model mappings (backbone-relational)
+ relations: [{
+ type: Backbone.HasMany,
+ key: 'signatories',
+ relatedModel: SignatoryModel,
+ collectionType: SignatoryCollection,
+ reverseRelation: {
+ key: 'certificate',
+ includeInJSON: "id"
+ }
+ }],
+
+ initialize: function(attributes, options) {
+ // Set up the initial state of the attributes set for this model instance
+ this.canBeEmpty = options && options.canBeEmpty;
+ if(options.add) {
+ // Ensure at least one child Signatory model is defined for any new Certificate model
+ attributes.signatories = new SignatoryModel({certificate: this});
+ }
+ this.setOriginalAttributes();
+ return this;
+ },
+
+ parse: function (response) {
+ // Parse must be defined for the model, but does not need to do anything special right now
+ return response;
+ },
+
+ setOriginalAttributes: function() {
+ // Remember the current state of this model (enables edit->cancel use cases)
+ this._originalAttributes = this.parse(this.toJSON());
+
+ // If no url is defined for the signatories child collection we'll need to create that here as well
+ if(!this.isNew() && !this.get('signatories').url) {
+ this.get('signatories').url = this.collection.url + '/' + this.get('id') + '/signatories';
+ }
+ },
+
+ validate: function(attrs) {
+ // Ensure the provided attributes set meets our expectations for format, type, etc.
+ if (!_.str.trim(attrs.name)) {
+ return {
+ message: gettext('Certificate name is required.'),
+ attributes: {name: true}
+ };
+ }
+ var all_signatories_valid = _.every(attrs.signatories.models, function(signatory){
+ return signatory.isValid();
+ });
+ if (!all_signatories_valid) {
+ return {
+ message: gettext('Signatory field(s) has invalid data.'),
+ attributes: {signatories: attrs.signatories.models}
+ };
+ }
+ },
+
+ reset: function() {
+ // Revert the attributes of this model instance back to initial state
+ this.set(this._originalAttributes, { parse: true, validate: true });
+ }
+ });
+ return Certificate;
+});
diff --git a/cms/static/js/certificates/models/signatory.js b/cms/static/js/certificates/models/signatory.js
new file mode 100644
index 0000000000..aef82810d0
--- /dev/null
+++ b/cms/static/js/certificates/models/signatory.js
@@ -0,0 +1,74 @@
+// Backbone.js Application Model: Certificate Signatory
+
+define([ // jshint ignore:line
+ 'underscore',
+ 'underscore.string',
+ 'backbone',
+ 'backbone-relational',
+ 'gettext'
+],
+function(_, str, Backbone, BackboneRelational, gettext) {
+ 'use strict';
+ _.str = str;
+
+ var Signatory = Backbone.RelationalModel.extend({
+ idAttribute: "id",
+ defaults: {
+ name: 'Name of the signatory',
+ title: 'Title of the signatory',
+ organization: 'Organization of the signatory',
+ signature_image_path: ''
+ },
+
+ initialize: function() {
+ // Set up the initial state of the attributes set for this model instance
+ this.setOriginalAttributes();
+ return this;
+ },
+
+ parse: function (response) {
+ // Parse must be defined for the model, but does not need to do anything special right now
+ return response;
+ },
+
+ validate: function(attrs) {
+ var errors = null;
+ if(_.has(attrs, 'name') && attrs.name.length > 40) {
+ errors = _.extend({
+ 'name': gettext('Signatory name should not be more than 40 characters long.')
+ }, errors);
+ }
+ if(_.has(attrs, 'title')){
+ var title = attrs.title;
+ var lines = title.split(/\r\n|\r|\n/);
+ if (lines.length > 2) {
+ errors = _.extend({
+ 'title': gettext('Signatory title should span over maximum of 2 lines.')
+ }, errors);
+ }
+ else if ((lines.length > 1 && (lines[0].length > 40 || lines[1].length > 40)) ||
+ (lines.length === 1 && title.length > 40)) {
+ errors = _.extend({
+ 'title': gettext('Signatory title should have maximum of 40 characters per line.')
+ }, errors);
+ }
+
+ }
+ if(_.has(attrs, 'organization') && attrs.organization.length > 40) {
+ errors = _.extend({
+ 'organization': gettext('Signatory organization should not be more than 40 characters long.')
+ }, errors);
+ }
+ if (errors !== null){
+ return errors;
+ }
+
+ },
+
+ setOriginalAttributes: function() {
+ // Remember the current state of this model (enables edit->cancel use cases)
+ this._originalAttributes = this.parse(this.toJSON());
+ }
+ });
+ return Signatory;
+});
diff --git a/cms/static/js/certificates/spec/custom_matchers.js b/cms/static/js/certificates/spec/custom_matchers.js
new file mode 100644
index 0000000000..64b7e9f9a3
--- /dev/null
+++ b/cms/static/js/certificates/spec/custom_matchers.js
@@ -0,0 +1,32 @@
+// Custom matcher library for Jasmine test assertions
+// http://tobyho.com/2012/01/30/write-a-jasmine-matcher/
+
+define(['jquery'], function($) { // jshint ignore:line
+ 'use strict';
+ return function (that) {
+ that.addMatchers({
+
+ toContainText: function (text) {
+ // Assert the value being tested has text which matches the provided text
+ var trimmedText = $.trim($(this.actual).text());
+ if (text && $.isFunction(text.test)) {
+ return text.test(trimmedText);
+ } else {
+ return trimmedText.indexOf(text) !== -1;
+ }
+ },
+
+ toBeCorrectValuesInModel: function (values) {
+ // Assert the value being tested has key values which match the provided values
+ return _.every(values, function (value, key) {
+ return this.actual.get(key) === value;
+ }.bind(this));
+ },
+
+ toBeInstanceOf: function(expected) {
+ // Assert the type of the value being tested matches the provided type
+ return this.actual instanceof expected;
+ }
+ });
+ };
+});
diff --git a/cms/static/js/certificates/spec/models/certificate_spec.js b/cms/static/js/certificates/spec/models/certificate_spec.js
new file mode 100644
index 0000000000..5197209e2a
--- /dev/null
+++ b/cms/static/js/certificates/spec/models/certificate_spec.js
@@ -0,0 +1,56 @@
+// Jasmine Test Suite: Certifiate Model
+
+define([ // jshint ignore:line
+ 'js/certificates/models/certificate',
+ 'js/certificates/collections/certificates'
+],
+function(CertificateModel, CertificateCollection) {
+ 'use strict';
+
+ describe('CertificateModel', function() {
+ beforeEach(function() {
+ this.newModelOptions = {add: true};
+ this.model = new CertificateModel({editing: true}, this.newModelOptions);
+ this.collection = new CertificateCollection([ this.model ], {certificateUrl: '/outline'});
+ });
+
+ describe('Basic', function() {
+ it('certificate should have name by default', function() {
+ expect(this.model.get('name')).toEqual('Name of the certificate');
+ });
+
+ it('certificate should have description by default', function() {
+ expect(this.model.get('description')).toEqual('Description of the certificate');
+ });
+
+ it('certificate should be able to reset itself', function() {
+ var originalName = 'Original Name',
+ model = new CertificateModel({name: originalName}, this.newModelOptions);
+ model.set({name: 'New Name'});
+ model.reset();
+ expect(model.get('name')).toEqual(originalName);
+ });
+
+ it('certificate should have signatories in its relations', function() {
+ var relation = this.model.getRelations()[0];
+ expect(relation.key).toEqual('signatories');
+ });
+ });
+
+ describe('Validation', function() {
+ it('requires a name', function() {
+ var model = new CertificateModel({ name: '' }, this.newModelOptions);
+
+ expect(model.isValid()).toBeFalsy();
+ });
+
+ it('can pass validation', function() {
+ var model = new CertificateModel({ name: 'foo' }, this.newModelOptions);
+
+ expect(model.isValid()).toBeTruthy();
+ });
+
+ });
+ });
+
+});
diff --git a/cms/static/js/certificates/spec/views/certificate_details_spec.js b/cms/static/js/certificates/spec/views/certificate_details_spec.js
new file mode 100644
index 0000000000..55b64ece43
--- /dev/null
+++ b/cms/static/js/certificates/spec/views/certificate_details_spec.js
@@ -0,0 +1,225 @@
+// Jasmine Test Suite: Certifiate Details View
+
+define([ // jshint ignore:line
+ 'underscore',
+ 'js/models/course',
+ 'js/certificates/collections/certificates',
+ 'js/certificates/models/certificate',
+ 'js/certificates/views/certificate_details',
+ 'js/certificates/views/certificate_preview',
+ 'js/views/feedback_notification',
+ 'js/common_helpers/ajax_helpers',
+ 'js/common_helpers/template_helpers',
+ 'js/spec_helpers/view_helpers',
+ 'js/spec_helpers/validation_helpers',
+ 'js/certificates/spec/custom_matchers'
+],
+function(_, Course, CertificatesCollection, CertificateModel, CertificateDetailsView, CertificatePreview,
+ Notification, AjaxHelpers, TemplateHelpers, ViewHelpers, ValidationHelpers, CustomMatchers) {
+ 'use strict';
+
+ var SELECTORS = {
+ detailsView: '.certificate-details',
+ editView: '.certificate-edit',
+ itemView: '.certificates-list-item',
+ name: '.certificate-name',
+ description: '.certificate-description',
+ course_title: '.course-title-override .certificate-value',
+ errorMessage: '.certificate-edit-error',
+ inputName: '.collection-name-input',
+ inputDescription: '.certificate-description-input',
+ warningMessage: '.certificate-validation-text',
+ warningIcon: '.wrapper-certificate-validation > i',
+ note: '.wrapper-delete-button',
+ signatory_name_value: '.signatory-name-value',
+ signatory_title_value: '.signatory-title-value',
+ signatory_organization_value: '.signatory-organization-value',
+ edit_signatory: '.edit-signatory',
+ signatory_panel_save: '.signatory-panel-save',
+ signatory_panel_close: '.signatory-panel-close',
+ inputSignatoryName: '.signatory-name-input',
+ inputSignatoryTitle: '.signatory-title-input',
+ inputSignatoryOrganization: '.signatory-organization-input'
+ };
+
+ beforeEach(function() {
+ window.course = new Course({
+ id: '5',
+ name: 'Course Name',
+ url_name: 'course_name',
+ org: 'course_org',
+ num: 'course_num',
+ revision: 'course_rev'
+ });
+ window.certWebPreview = new CertificatePreview({
+ course_modes: ['honor', 'test'],
+ certificate_web_view_url: '/users/1/courses/orgX/009/2016'
+ });
+ });
+
+ afterEach(function() {
+ delete window.course;
+ });
+
+ describe('Certificate Details Spec:', function() {
+ var setValuesToInputs = function (view, values) {
+ _.each(values, function (value, selector) {
+ if (SELECTORS[selector]) {
+ view.$(SELECTORS[selector]).val(value);
+ view.$(SELECTORS[selector]).trigger('change');
+ }
+ });
+ };
+
+ beforeEach(function() {
+ TemplateHelpers.installTemplates(['certificate-details', 'signatory-details', 'signatory-editor'], true);
+
+ this.newModelOptions = {add: true};
+ this.model = new CertificateModel({
+ name: 'Test Name',
+ description: 'Test Description',
+ course_title: 'Test Course Title Override'
+
+ }, this.newModelOptions);
+
+ this.collection = new CertificatesCollection([ this.model ], {
+ certificateUrl: '/certificates/'+ window.course.id
+ });
+ this.model.set('id', 0);
+ this.view = new CertificateDetailsView({
+ model: this.model
+ });
+ appendSetFixtures(this.view.render().el);
+ CustomMatchers(this); // jshint ignore:line
+ });
+
+ describe('The Certificate Details view', function() {
+
+ it('should parse a JSON string collection into a Backbone model collection', function () {
+ var course_title = "Test certificate course title override 2";
+ var CERTIFICATE_JSON = '[{"course_title": "' + course_title + '", "signatories":"[]"}]';
+ this.collection.parse(CERTIFICATE_JSON);
+ var model = this.collection.at(1);
+ expect(model.get('course_title')).toEqual(course_title);
+ });
+
+ it('should parse a JSON object collection into a Backbone model collection', function () {
+ var course_title = "Test certificate course title override 2";
+ var CERTIFICATE_JSON_OBJECT = [{
+ "course_title" : course_title,
+ "signatories" : "[]"
+ }];
+ this.collection.parse(CERTIFICATE_JSON_OBJECT);
+ var model = this.collection.at(1);
+ expect(model.get('course_title')).toEqual(course_title);
+ });
+
+ it('should have empty certificate collection if there is an error parsing certifcate JSON', function () {
+ var CERTIFICATE_INVALID_JSON = '[{"course_title": Test certificate course title override, "signatories":"[]"}]'; // jshint ignore:line
+ var collection_length = this.collection.length;
+ this.collection.parse(CERTIFICATE_INVALID_JSON);
+ //collection length should remain the same since we have error parsing JSON
+ expect(this.collection.length).toEqual(collection_length);
+ });
+
+ it('should display the certificate course title override', function () {
+ expect(this.view.$(SELECTORS.course_title)).toExist();
+ expect(this.view.$(SELECTORS.course_title)).toContainText('Test Course Title Override');
+ });
+
+ it('should present an Edit action', function () {
+ expect(this.view.$('.edit')).toExist();
+ });
+
+ it('should change to "edit" mode when clicking the Edit button', function(){
+ expect(this.view.$('.action-edit .edit')).toExist();
+ this.view.$('.action-edit .edit').click();
+ expect(this.model.get('editing')).toBe(true);
+ });
+
+ it('should present a Delete action', function () {
+ expect(this.view.$('.action-delete .delete')).toExist();
+ });
+
+ it('should prompt the user when when clicking the Delete button', function(){
+ expect(this.view.$('.action-delete .delete')).toExist();
+ this.view.$('.action-delete .delete').click();
+ });
+
+ });
+
+ describe('Signatory details', function(){
+
+ beforeEach(function() {
+ this.view.render(true);
+ });
+
+ it('displays certificate signatories details', function(){
+ this.view.$('.show-details').click();
+ expect(this.view.$(SELECTORS.signatory_name_value)).toContainText(/^[A-Za-z\s]{10,40}/);
+ expect(this.view.$(SELECTORS.signatory_title_value)).toContainText('Title of the signatory');
+ expect(
+ this.view.$(SELECTORS.signatory_organization_value)
+ ).toContainText('Organization of the signatory');
+ });
+
+ it('supports in-line editing of signatory information', function() {
+
+ this.view.$(SELECTORS.edit_signatory).click();
+ expect(this.view.$(SELECTORS.inputSignatoryName)).toExist();
+ expect(this.view.$(SELECTORS.inputSignatoryTitle)).toExist();
+ expect(this.view.$(SELECTORS.inputSignatoryOrganization)).toExist();
+ });
+
+ it('correctly persists changes made during in-line signatory editing', function() {
+
+ var requests = AjaxHelpers.requests(this),
+ notificationSpy = ViewHelpers.createNotificationSpy();
+
+ this.view.$(SELECTORS.edit_signatory).click();
+
+ setValuesToInputs(this.view, {
+ inputSignatoryName: 'New Signatory Test Name'
+ });
+
+ setValuesToInputs(this.view, {
+ inputSignatoryTitle: 'New Signatory Test Title'
+ });
+
+ setValuesToInputs(this.view, {
+ inputSignatoryOrganization: 'New Signatory Test Organization'
+ });
+
+ this.view.$(SELECTORS.signatory_panel_save).click();
+
+ ViewHelpers.verifyNotificationShowing(notificationSpy, /Saving/);
+ requests[0].respond(200);
+ ViewHelpers.verifyNotificationHidden(notificationSpy);
+
+ expect(this.view.$(SELECTORS.signatory_name_value)).toContainText('New Signatory Test Name');
+ expect(this.view.$(SELECTORS.signatory_title_value)).toContainText('New Signatory Test Title');
+ expect(
+ this.view.$(SELECTORS.signatory_organization_value)
+ ).toContainText('New Signatory Test Organization');
+ });
+ it('should not allow invalid data when saving changes made during in-line signatory editing', function() {
+ this.view.$(SELECTORS.edit_signatory).click();
+
+ setValuesToInputs(this.view, {
+ inputSignatoryName: 'New Signatory Test Name'
+ });
+
+ setValuesToInputs(this.view, {
+ inputSignatoryTitle: 'New Signatory Test Title longer than 40 characters in length'
+ });
+
+ setValuesToInputs(this.view, {
+ inputSignatoryOrganization: 'New Signatory Test Organization'
+ });
+
+ this.view.$(SELECTORS.signatory_panel_save).click();
+ expect(this.view.$(SELECTORS.inputSignatoryTitle).parent()).toHaveClass('error');
+ });
+ });
+ });
+});
diff --git a/cms/static/js/certificates/spec/views/certificate_editor_spec.js b/cms/static/js/certificates/spec/views/certificate_editor_spec.js
new file mode 100644
index 0000000000..a91f47fece
--- /dev/null
+++ b/cms/static/js/certificates/spec/views/certificate_editor_spec.js
@@ -0,0 +1,344 @@
+// Jasmine Test Suite: Certifiate Editor View
+
+define([ // jshint ignore:line
+ 'underscore',
+ 'js/models/course',
+ 'js/certificates/models/certificate',
+ 'js/certificates/models/signatory',
+ 'js/certificates/collections/certificates',
+ 'js/certificates/views/certificate_editor',
+ 'js/views/feedback_notification',
+ 'js/common_helpers/ajax_helpers',
+ 'js/common_helpers/template_helpers',
+ 'js/spec_helpers/view_helpers',
+ 'js/spec_helpers/validation_helpers',
+ 'js/certificates/spec/custom_matchers'
+],
+function(_, Course, CertificateModel, SignatoryModel, CertificatesCollection, CertificateEditorView,
+ Notification, AjaxHelpers, TemplateHelpers, ViewHelpers, ValidationHelpers, CustomMatchers) {
+ 'use strict';
+
+ var MAX_SIGNATORIES = 4;
+ var SELECTORS = {
+ detailsView: '.certificate-details',
+ editView: '.certificate-edit',
+ itemView: '.certificates-list-item',
+ name: '.certificate-name',
+ description: '.certificate-description',
+ errorMessage: '.certificate-edit-error',
+ inputCertificateName: '.collection-name-input',
+ inputCertificateDescription: '.certificate-description-input',
+ inputSignatoryName: '.signatory-name-input',
+ inputSignatoryTitle: '.signatory-title-input',
+ inputSignatoryOrganization: '.signatory-organization-input',
+ inputSignatorySignature: '.signatory-signature-input',
+ warningMessage: '.certificate-validation-text',
+ warningIcon: '.wrapper-certificate-validation > i',
+ note: '.wrapper-delete-button',
+ addSignatoryButton: '.action-add-signatory',
+ signatoryDeleteButton: '.signatory-panel-delete',
+ uploadSignatureButton:'.action-upload-signature',
+ uploadDialog: 'form.upload-dialog',
+ uploadDialogButton: '.action-upload',
+ uploadDialogFileInput: 'form.upload-dialog input[type=file]',
+ uploadOrgLogoButton: '.action-upload-org-logo',
+ saveCertificateButton: 'button.action-primary'
+ };
+
+ var clickDeleteItem = function (that, promptText, element, url) {
+ var requests = AjaxHelpers.requests(that),
+ promptSpy = ViewHelpers.createPromptSpy(),
+ notificationSpy = ViewHelpers.createNotificationSpy();
+ that.view.$(element).click();
+
+ ViewHelpers.verifyPromptShowing(promptSpy, promptText);
+ ViewHelpers.confirmPrompt(promptSpy);
+ ViewHelpers.verifyPromptHidden(promptSpy);
+ if (!_.isUndefined(url) && !_.isEmpty(url)){
+ AjaxHelpers.expectJsonRequest(requests, 'POST', url);
+ expect(_.last(requests).requestHeaders['X-HTTP-Method-Override']).toBe('DELETE');
+ ViewHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
+ AjaxHelpers.respondWithNoContent(requests);
+ ViewHelpers.verifyNotificationHidden(notificationSpy);
+ }
+ };
+
+ var showConfirmPromptAndClickCancel = function (view, element, promptText) {
+ var promptSpy = ViewHelpers.createPromptSpy();
+ view.$(element).click();
+ ViewHelpers.verifyPromptShowing(promptSpy, promptText);
+ ViewHelpers.confirmPrompt(promptSpy, true);
+ ViewHelpers.verifyPromptHidden(promptSpy);
+ };
+
+ var uploadFile = function (file_path, requests){
+ $(SELECTORS.uploadDialogFileInput).change();
+ $(SELECTORS.uploadDialogButton).click();
+ AjaxHelpers.respondWithJson(requests, {asset: {url: file_path}});
+ };
+
+ beforeEach(function() {
+ window.course = new Course({
+ id: '5',
+ name: 'Course Name',
+ url_name: 'course_name',
+ org: 'course_org',
+ num: 'course_num',
+ revision: 'course_rev'
+ });
+
+
+ });
+
+ afterEach(function() {
+ delete window.course;
+ });
+
+ describe('Certificate editor view', function() {
+ var setValuesToInputs = function (view, values) {
+ _.each(values, function (value, selector) {
+ if (SELECTORS[selector]) {
+ view.$(SELECTORS[selector]).val(value);
+ view.$(SELECTORS[selector]).trigger('change');
+ }
+ });
+ };
+ var basicModalTpl = readFixtures('basic-modal.underscore'),
+ modalButtonTpl = readFixtures('modal-button.underscore'),
+ uploadDialogTpl = readFixtures('upload-dialog.underscore');
+
+ beforeEach(function() {
+ TemplateHelpers.installTemplates(['certificate-editor', 'signatory-editor'], true);
+
+ this.newModelOptions = {add: true};
+ this.model = new CertificateModel({
+ name: 'Test Name',
+ description: 'Test Description'
+
+ }, this.newModelOptions);
+
+ this.collection = new CertificatesCollection([ this.model ], {
+ certificateUrl: '/certificates/'+ window.course.id
+ });
+ this.model.set('id', 0);
+ this.view = new CertificateEditorView({
+ model: this.model
+ });
+ appendSetFixtures(this.view.render().el);
+ CustomMatchers(this); // jshint ignore:line
+ });
+
+ describe('Basic', function () {
+ beforeEach(function(){
+ appendSetFixtures(
+ $("
+% endfor
+%block>
+
+<%block name="jsextra">
+
+%block>
+
+<%block name="requirejs">
+ require(["js/certificates/factories/certificates_page_factory"], function(CertificatesPageFactory) {
+ CertificatesPageFactory(${json.dumps(certificates)}, "${certificate_url}", "${course_outline_url}", ${json.dumps(course_modes)}, ${json.dumps(certificate_web_view_url)}, ${json.dumps(is_active)}, ${json.dumps(certificate_activation_handler_url)} );
+ });
+%block>
+
+<%block name="content">
+
+
+
+
+
+
+
${_("Certificates")}
+ % if certificates is None:
+
+
+ ${_("This module is not enabled.")}
+
+
+ % else:
+
+ % endif
+
+
+
+
+
+%block>
diff --git a/cms/templates/js/certificate-details.underscore b/cms/templates/js/certificate-details.underscore
new file mode 100644
index 0000000000..047bede746
--- /dev/null
+++ b/cms/templates/js/certificate-details.underscore
@@ -0,0 +1,40 @@
+
+
+
+
+ <% if (!_.isUndefined(id)) { %>
+ -
+ <%= gettext('ID') %>:
+ <%= id %>
+
+ <% } %>
+ <% if (showDetails) { %>
+
+ <%= gettext("Certificate Details") %>
+
+ <% if (course_title) { %>
+
+ <%= gettext('Course Title Override') %>:
+ <%= course_title %>
+
+ <% } %>
+
+ <%= gettext("Certificate Signatories") %>
+
+
+ <% } %>
+
+
+
+ -
+
+
+ -
+
+
+
+
diff --git a/cms/templates/js/certificate-editor.underscore b/cms/templates/js/certificate-editor.underscore
new file mode 100644
index 0000000000..4c1bd3847b
--- /dev/null
+++ b/cms/templates/js/certificate-editor.underscore
@@ -0,0 +1,57 @@
+
diff --git a/cms/templates/js/certificate-web-preview.underscore b/cms/templates/js/certificate-web-preview.underscore
new file mode 100644
index 0000000000..16452fb96f
--- /dev/null
+++ b/cms/templates/js/certificate-web-preview.underscore
@@ -0,0 +1,17 @@
+
+
+ class="button preview-certificate-link" target="_blank">
+ <%= gettext("Preview Certificate") %>
+
+
diff --git a/cms/templates/js/list.underscore b/cms/templates/js/list.underscore
index 6ee461c6da..409c4eabb6 100644
--- a/cms/templates/js/list.underscore
+++ b/cms/templates/js/list.underscore
@@ -11,7 +11,7 @@
<% } else { %>
<% if (!isEditing) { %>
-