Commit af7277cd by Matt Drayer

New Feature: Certificates Web View

- SOL-465: Initial implementation of certificates web view and signatories (names/titles)

- SOL-718 Close button is working properly

- SOL-801 Backbone Signatories Modeling

- SOL-803 Underscore template: Editor (Add)

- SOL-802 Signatories: Underscore template - Details

- SOL-804 Signatories: Underscore template: Editor (Edit)

- Add signatory delete Django view

- SOL-805 Signatory editor (Delete)

- Add Coffeescript router

- SOL-716 Jasmine Tests

- Added missing minified JS library

- client side validation of signatory fields

- SOL-390 signatories names

- Remove obsolete extends Sass files

- input maxlength limiting for signatory information

- SOL-389: Course title override

- SOL-466: Add capability to upload digitized signatures in Studio

- ziafazal: fixed css for upload signature image

- ziafazal: completed deletion of signature images

- UX-1741: Add initial static rendering/styling for Open edX web certs
  * creating new global static dir
  * adding static version of edX UX pattern library assets
  * adding web certificates static assets
  * adding static (+abstracted) web certificates rendering
  * creating two tiers of rendering (base + distinguished)
  * providing sample assets for certificate rendering
  * supporting RTL layouts
  * adding certifcates assests to edX static asset pipeline
  * temporarily hiding the mozilla open badges share action
  * wiring print button to print view/page
  * fixup! addressing conflict artifact in valid cert template
  * fixup! adding missing %hd-subsection sass extend + components comment clean up
  * fixup! correcting pattern library .hd-4 font-weight value

- SOL-468 Linked Student View for Web View Credential

- SOL-467: Add capability to upload organization logos for certificates

- SOL-391 / SOL-387: Signatory related info (assets) in certificates web view

- kelketek: Fixes for static asset collection in certificate HTML view.

- SOL-398 Web View: Public Access

- mattdrayer: Post-merge branch stabilization

- catong: Initial changes to Studio template and Help config file

- ziafazal: Branch stabilizations

- SOL-387: Display organization logo on LMS web view

- talbs/mattdrayer: Branch Stabilizations

- talbs: converting backpack action to use a button HTML element

- talbs: revising placeholder assets + their rendering in cert view

- mattdrayer: Username web view wireup

- SOL-386 Certificate Mode Previews

- SOL-905: Make organization logo and signatory signature uneditable

- SOL-922: Improve test coverage

- SOL-765: Add LinkedIn sharing

- [marco] temporary styling adjustment to account for smaller linkedin share image / fake button

- SOL-921: Address hardcoded template items

- SOL-927: Deleting certificate should delete org logo image also
  * updated invalid template
  * removed hr
  * fix invalid certificate error

- clrux: Add i18n to certificate templates and partials

- mattdrayer: Pylint violations

- SOL-920 Certificate Activation/Deactivation

- mattdrayer: Added LMS support

- SOL-932: Fix preview mode support in certificate view

- SOL-934: Fixed bug reported and broken tests

- SOL-935 removed the 'valid' word from web view title

- talbs: RTL support updates/fixes
  * revising certificate type icon/name vertical alignment
  * removing unused older certificate template
  * revising styling for message/banner actions
  * abstracting accomplishment type to use course mode + adding in honor/verified-specific placeholders

- mattdrayer: JSHint violations
parent f81d09cb
...@@ -80,6 +80,7 @@ lms/static/sass/lms-course.scss ...@@ -80,6 +80,7 @@ lms/static/sass/lms-course.scss
lms/static/sass/lms-course-rtl.scss lms/static/sass/lms-course-rtl.scss
lms/static/sass/lms-footer.scss lms/static/sass/lms-footer.scss
lms/static/sass/lms-footer-rtl.scss lms/static/sass/lms-footer-rtl.scss
lms/static/certificates/sass/*.css
cms/static/css/ cms/static/css/
cms/static/sass/*.css cms/static/sass/*.css
cms/static/sass/*.css.map cms/static/sass/*.css.map
......
...@@ -147,6 +147,24 @@ def get_lms_link_for_about_page(course_key): ...@@ -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): def course_image_url(course):
"""Returns the image url for the course.""" """Returns the image url for the course."""
loc = StaticContent.compute_location(course.location.course_key, course.course_image) loc = StaticContent.compute_location(course.location.course_key, course.course_image)
......
...@@ -18,6 +18,7 @@ from xmodule.contentstore.django import contentstore ...@@ -18,6 +18,7 @@ from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from contentstore.views.exception import AssetNotFoundException
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from opaque_keys.edx.keys import CourseKey, AssetKey from opaque_keys.edx.keys import CourseKey, AssetKey
...@@ -310,36 +311,12 @@ def _update_asset(request, course_key, asset_key): ...@@ -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) asset_path_encoding: the odd /c4x/org/course/category/name repr of the asset (used by Backbone as the id)
""" """
if request.method == 'DELETE': if request.method == 'DELETE':
# Make sure the item to delete actually exists.
try: try:
content = contentstore().find(asset_key) delete_asset(course_key, asset_key)
except NotFoundError: return JsonResponse()
except AssetNotFoundException:
return JsonResponse(status=404) 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'): elif request.method in ('PUT', 'POST'):
if 'file' in request.FILES: if 'file' in request.FILES:
return _upload_asset(request, course_key) return _upload_asset(request, course_key)
...@@ -355,6 +332,40 @@ def _update_asset(request, course_key, asset_key): ...@@ -355,6 +332,40 @@ def _update_asset(request, course_key, asset_key):
return JsonResponse(modified_asset, status=201) 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): def _get_asset_json(display_name, content_type, date, location, thumbnail_location, locked):
""" """
Helper method for formatting the asset information to send to client. Helper method for formatting the asset information to send to client.
......
"""
A common module for managing exceptions. Helps to avoid circular references
"""
class AssetNotFoundException(Exception):
"""
Raised when asset not found
"""
pass
...@@ -4,6 +4,7 @@ Unit tests for the asset upload endpoint. ...@@ -4,6 +4,7 @@ Unit tests for the asset upload endpoint.
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
from pytz import UTC from pytz import UTC
from PIL import Image
import json import json
from django.conf import settings from django.conf import settings
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
...@@ -36,18 +37,31 @@ class AssetsTestCase(CourseTestCase): ...@@ -36,18 +37,31 @@ class AssetsTestCase(CourseTestCase):
super(AssetsTestCase, self).setUp() super(AssetsTestCase, self).setUp()
self.url = reverse_course_url('assets_handler', self.course.id) 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 Post to the asset upload url
""" """
f = self.get_sample_asset(name, extension) asset = self.get_sample_asset(name, asset_type)
return self.client.post(self.url, {"name": name, "file": f}) response = self.client.post(self.url, {"name": name, "file": asset})
return response
def get_sample_asset(self, name, extension=".txt"): def get_sample_asset(self, name, asset_type='text'):
"""Returns an in-memory file with the given name for testing""" """
f = BytesIO(name) Returns an in-memory file of the specified type with the given name for testing
f.name = name + extension """
return f 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): class BasicAssetsTestCase(AssetsTestCase):
...@@ -138,7 +152,7 @@ class PaginationTestCase(AssetsTestCase): ...@@ -138,7 +152,7 @@ class PaginationTestCase(AssetsTestCase):
self.upload_asset("asset-1") self.upload_asset("asset-1")
self.upload_asset("asset-2") self.upload_asset("asset-2")
self.upload_asset("asset-3") self.upload_asset("asset-3")
self.upload_asset("asset-4", ".odt") self.upload_asset("asset-4", "opendoc")
# Verify valid page requests # Verify valid page requests
self.assert_correct_asset_response(self.url, 0, 4, 4) self.assert_correct_asset_response(self.url, 0, 4, 4)
...@@ -259,6 +273,10 @@ class UploadTestCase(AssetsTestCase): ...@@ -259,6 +273,10 @@ class UploadTestCase(AssetsTestCase):
resp = self.upload_asset() resp = self.upload_asset()
self.assertEquals(resp.status_code, 200) 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): def test_no_file(self):
resp = self.client.post(self.url, {"name": "file.txt"}, "application/json") resp = self.client.post(self.url, {"name": "file.txt"}, "application/json")
self.assertEquals(resp.status_code, 400) self.assertEquals(resp.status_code, 400)
...@@ -409,3 +427,75 @@ class LockAssetTestCase(AssetsTestCase): ...@@ -409,3 +427,75 @@ class LockAssetTestCase(AssetsTestCase):
resp_asset = post_asset_update(False, course) resp_asset = post_asset_update(False, course)
self.assertFalse(resp_asset['locked']) self.assertFalse(resp_asset['locked'])
verify_asset_locked_state(False) 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)
...@@ -44,6 +44,7 @@ class CourseMetadata(object): ...@@ -44,6 +44,7 @@ class CourseMetadata(object):
'is_entrance_exam', 'is_entrance_exam',
'in_entrance_exam', 'in_entrance_exam',
'language', 'language',
'certificates'
] ]
@classmethod @classmethod
......
...@@ -155,6 +155,9 @@ FEATURES = { ...@@ -155,6 +155,9 @@ FEATURES = {
# Enable course reruns, which will always use the split modulestore # Enable course reruns, which will always use the split modulestore
'ALLOW_COURSE_RERUNS': True, 'ALLOW_COURSE_RERUNS': True,
# Certificates Web/HTML Views
'CERTIFICATES_HTML_VIEW': False,
# Social Media Sharing on Student Dashboard # Social Media Sharing on Student Dashboard
'DASHBOARD_SHARE_SETTINGS': { 'DASHBOARD_SHARE_SETTINGS': {
# Note: Ensure 'CUSTOM_COURSE_URLS' has a matching value in lms/envs/common.py # Note: Ensure 'CUSTOM_COURSE_URLS' has a matching value in lms/envs/common.py
......
...@@ -105,3 +105,6 @@ MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE) ...@@ -105,3 +105,6 @@ MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE)
# Dummy secret key for dev # Dummy secret key for dev
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
########################## Certificates Web/HTML View #######################
FEATURES['CERTIFICATES_HTML_VIEW'] = True
...@@ -36,6 +36,7 @@ ...@@ -36,6 +36,7 @@
'js/factories/edit_tabs', 'js/factories/edit_tabs',
'js/factories/export', 'js/factories/export',
'js/factories/group_configurations', 'js/factories/group_configurations',
'js/certificates/factories/certificates_page_factory',
'js/factories/import', 'js/factories/import',
'js/factories/index', 'js/factories/index',
'js/factories/login', 'js/factories/login',
......
...@@ -28,6 +28,7 @@ requirejs.config({ ...@@ -28,6 +28,7 @@ requirejs.config({
"backbone": "xmodule_js/common_static/js/vendor/backbone-min", "backbone": "xmodule_js/common_static/js/vendor/backbone-min",
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min", "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
"backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.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", "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", "jquery.tinymce": "xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce",
"xmodule": "xmodule_js/src/xmodule", "xmodule": "xmodule_js/src/xmodule",
...@@ -137,6 +138,9 @@ requirejs.config({ ...@@ -137,6 +138,9 @@ requirejs.config({
deps: ["backbone"], deps: ["backbone"],
exports: "Backbone.Paginator" exports: "Backbone.Paginator"
}, },
"backbone-relational": {
deps: ["backbone"],
},
"youtube": { "youtube": {
exports: "YT" exports: "YT"
}, },
...@@ -270,6 +274,12 @@ define([ ...@@ -270,6 +274,12 @@ define([
"js/spec/xblock/cms.runtime.v1_spec", "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 # these tests are run separately in the cms-squire suite, due to process
# isolation issues with Squire.js # isolation issues with Squire.js
# "coffee/spec/views/assets_spec" # "coffee/spec/views/assets_spec"
......
// 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;
});
// 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;
});
// 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();
};
});
// 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;
});
// 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;
});
// 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;
}
});
};
});
// 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();
});
});
});
});
// 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');
});
});
});
});
// Jasmine Test Suite: Certificate List 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_editor',
'js/certificates/views/certificate_item',
'js/certificates/views/certificates_list',
'js/certificates/views/certificate_preview',
'js/views/feedback_notification',
'js/common_helpers/ajax_helpers',
'js/common_helpers/template_helpers',
'js/certificates/spec/custom_matchers'
],
function(_, Course, CertificatesCollection, CertificateModel, CertificateDetailsView, CertificateEditorView,
CertificateItemView, CertificatesListView, CertificatePreview, Notification, AjaxHelpers, TemplateHelpers,
CustomMatchers) {
'use strict';
var SELECTORS = {
itemView: '.certificates-list-item',
itemEditView: '.certificate-edit',
noContent: '.no-content',
newCertificateButton: '.new-button'
};
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('Certificates list view', function() {
var emptyMessage = 'You have not created any certificates yet.';
beforeEach(function() {
TemplateHelpers.installTemplates(
['certificate-editor', 'certificate-edit', 'list']
);
this.model = new CertificateModel({
course_title: 'Test Course Title Override'
}, {add: true});
this.collection = new CertificatesCollection([], {
certificateUrl: '/certificates/'+ window.course.id
});
this.view = new CertificatesListView({
collection: this.collection
});
appendSetFixtures(this.view.render().el);
CustomMatchers(this); // jshint ignore:line
});
describe('empty template', function () {
it('should be rendered if no certificates', function() {
expect(this.view.$(SELECTORS.noContent)).toExist();
expect(this.view.$(SELECTORS.noContent)).toContainText(emptyMessage);
expect(this.view.$(SELECTORS.newCertificateButton)).toExist();
expect(this.view.$(SELECTORS.itemView)).not.toExist();
});
it('should disappear if certificate is added', function() {
expect(this.view.$el).toContainText(emptyMessage);
expect(this.view.$(SELECTORS.itemView)).not.toExist();
this.collection.add(this.model);
expect(this.view.$el).not.toContainText(emptyMessage);
expect(this.view.$(SELECTORS.itemView)).toExist();
});
it('should appear if certificate(s) were removed', function() {
this.collection.add(this.model);
expect(this.view.$(SELECTORS.itemView)).toExist();
this.collection.remove(this.model);
expect(this.view.$el).toContainText(emptyMessage);
expect(this.view.$(SELECTORS.itemView)).not.toExist();
});
it('should open in edit mode if model has editing attribute', function() {
this.model.set({editing: true});
this.collection.add(this.model);
expect(this.view.$(SELECTORS.itemEditView)).toExist();
});
});
});
});
// Backbone Application View: Certificate Details
define([ // jshint ignore:line
'jquery',
'underscore',
'underscore.string',
'gettext',
'js/views/baseview',
'js/certificates/models/signatory',
'js/certificates/views/signatory_details'
],
function($, _, str, gettext, BaseView, SignatoryModel, SignatoryDetailsView) {
'use strict';
var CertificateDetailsView = BaseView.extend({
tagName: 'div',
events: {
'click .edit': 'editCertificate'
},
className: function () {
// Determine the CSS class names for this model instance
return [
'collection',
'certificates',
'certificate-details'
].join(' ');
},
initialize: function() {
// Set up the initial state of the attributes set for this model instance
this.showDetails = true;
this.template = this.loadTemplate('certificate-details');
this.listenTo(this.model, 'change', this.render);
},
editCertificate: function(event) {
// Flip the model into 'editing' mode
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set('editing', true);
},
render: function(showDetails) {
// Assemble the details view for this model
// Expand to show all model data, if requested
var attrs = $.extend({}, this.model.attributes, {
index: this.model.collection.indexOf(this.model),
showDetails: this.showDetails || showDetails || false
});
this.$el.html(this.template(attrs));
if(this.showDetails || showDetails) {
var self = this;
this.model.get("signatories").each(function (modelSignatory) {
var signatory_detail_view = new SignatoryDetailsView({model: modelSignatory});
self.$('div.signatory-details-list').append($(signatory_detail_view.render().$el));
});
}
if(this.model.collection.length > 0 && window.certWebPreview) {
window.certWebPreview.show();
}
return this;
}
});
return CertificateDetailsView;
});
// Backbone Application View: Certificate Editor
define([ // jshint ignore:line
'jquery',
'underscore',
'backbone',
'gettext',
'js/views/list_item_editor',
'js/certificates/models/signatory',
'js/certificates/views/signatory_editor',
'js/models/uploads',
'js/views/uploads'
],
function($, _, Backbone, gettext,
ListItemEditorView, SignatoryModel, SignatoryEditorView, FileUploadModel, FileUploadDialog) {
'use strict';
var MAX_SIGNATORIES_LIMIT = 4;
var CertificateEditorView = ListItemEditorView.extend({
tagName: 'div',
events: {
'change .collection-name-input': 'setName',
'change .certificate-description-input': 'setDescription',
'change .certificate-course-title-input': 'setCourseTitle',
'change .org-logo-input': 'setOrgLogoPath',
'focus .input-text': 'onFocus',
'blur .input-text': 'onBlur',
'submit': 'setAndClose',
'click .action-cancel': 'cancel',
'click .action-add-signatory': 'addSignatory',
'click .action-upload-org-logo': 'uploadLogoImage'
},
className: function () {
// Determine the CSS class names for this model instance
var index = this.model.collection.indexOf(this.model);
return [
'collection-edit',
'certificates',
'certificate-edit',
'certificate-edit-' + index
].join(' ');
},
initialize: function() {
// Set up the initial state of the attributes set for this model instance
_.bindAll(this, "onSignatoryRemoved", "clearErrorMessage");
this.eventAgg = _.extend({}, Backbone.Events);
this.eventAgg.bind("onSignatoryRemoved", this.onSignatoryRemoved);
this.eventAgg.bind("onSignatoryUpdated", this.clearErrorMessage);
ListItemEditorView.prototype.initialize.call(this);
this.template = this.loadTemplate('certificate-editor');
},
onSignatoryRemoved: function() {
// Event handler for model deletions
this.model.setOriginalAttributes();
this.render();
},
clearErrorMessage: function() {
// Hides away the error message displayed during field validations
this.$('.certificate-edit-error').remove();
},
render: function() {
// Assemble the editor view for this model
ListItemEditorView.prototype.render.call(this);
var self = this;
// Ensure we have at least one signatory associated with the certificate.
this.model.get("signatories").each(function( modelSignatory) {
var signatory_view = new SignatoryEditorView({
model: modelSignatory,
isEditingAllCollections: true,
eventAgg: self.eventAgg
});
self.$('div.signatory-edit-list').append($(signatory_view.render()));
});
this.disableAddSignatoryButton();
return this;
},
addSignatory: function() {
// Append a new signatory to the certificate model's signatories collection
var signatory = new SignatoryModel({certificate: this.getSaveableModel()}); // jshint ignore:line
this.render();
},
disableAddSignatoryButton: function() {
// Disable the 'Add Signatory' link if the constraint has been met.
if(this.$(".signatory-edit-list > div.signatory-edit").length >= MAX_SIGNATORIES_LIMIT) {
this.$(".action-add-signatory").addClass("disableClick");
}
},
getTemplateOptions: function() {
// Retrieves the current attributes/options for the model
return {
id: this.model.get('id'),
uniqueId: _.uniqueId(),
name: this.model.escape('name'),
description: this.model.escape('description'),
course_title: this.model.escape('course_title'),
org_logo_path: this.model.escape('org_logo_path'),
isNew: this.model.isNew()
};
},
getSaveableModel: function() {
// Returns the current model instance
return this.model;
},
setName: function(event) {
// Updates the indicated model field (still requires persistence on server)
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'name', this.$('.collection-name-input').val(),
{ silent: true }
);
},
setDescription: function(event) {
// Updates the indicated model field (still requires persistence on server)
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'description',
this.$('.certificate-description-input').val(),
{ silent: true }
);
},
setCourseTitle: function(event) {
// Updates the indicated model field (still requires persistence on server)
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'course_title',
this.$('.certificate-course-title-input').val(),
{ silent: true }
);
},
setOrgLogoPath: function(event) {
// Updates the indicated model field (still requires persistence on server)
if (event && event.preventDefault) { event.preventDefault(); }
var org_logo_path = this.$('.org-logo-input').val();
this.model.set(
'org_logo_path', org_logo_path,
{ silent: true }
);
this.$('.current-org-logo img.org-logo').attr('src', org_logo_path);
},
setValues: function() {
// Update the specified values in the local model instance
this.setName();
this.setDescription();
this.setCourseTitle();
this.setOrgLogoPath();
return this;
},
uploadLogoImage: function(event) {
event.preventDefault();
var upload = new FileUploadModel({
title: gettext("Upload organization logo."),
message: gettext("Maximum logo height should be 125px."),
mimeTypes: ['image/png', 'image/jpeg']
});
var self = this;
var modal = new FileUploadDialog({
model: upload,
onSuccess: function(response) {
var org_logo_path = response.asset.url;
self.model.set('org_logo_path', org_logo_path);
self.$('.current-org-logo img.org-logo').attr('src', org_logo_path);
self.$('.current-org-logo').show();
self.$('input.org-logo-input').attr('value', org_logo_path);
}
});
modal.show();
}
});
return CertificateEditorView;
});
// Backbone Application View: Certificate Item
// Renders an editor view or a details view depending on the state of the underlying model.
define([ // jshint ignore:line
'gettext',
'js/views/list_item',
'js/certificates/views/certificate_details',
'js/certificates/views/certificate_editor'
],
function (gettext, ListItemView, CertificateDetailsView, CertificateEditorView) {
'use strict';
var CertificateItemView = ListItemView.extend({
events: {
'click .delete': 'deleteItem'
},
tagName: 'section',
baseClassName: 'certificate',
canDelete: true,
// Translators: This field pertains to the custom label for a certificate.
itemDisplayName: gettext('certificate'),
attributes: function () {
// Retrieves the defined attribute set
return {
'id': this.model.get('id'),
'tabindex': -1
};
},
createEditView: function() {
// Renders the editor view for this model
return new CertificateEditorView({model: this.model});
},
createDetailsView: function() {
// Renders the details view for this model
return new CertificateDetailsView({model: this.model});
}
});
return CertificateItemView;
});
// Backbone Application View: Certificate Preview
// User can preview the certificate web layout/styles. 'Preview Certificate' button will open a new tab in LMS for
// the selected course mode from the drop down.
define([ // jshint ignore:line
'underscore',
'gettext',
'js/views/baseview',
'js/views/utils/view_utils',
'js/views/feedback_notification'
],
function(_, gettext, BaseView, ViewUtils, NotificationView) {
'use strict';
var CertificateWebPreview = BaseView.extend({
el: $(".preview-certificate"),
events: {
"change #course-modes": "courseModeChanged",
"click .activate-cert": "toggleCertificateActivation"
},
initialize: function (options) {
this.course_modes = options.course_modes;
this.certificate_web_view_url = options.certificate_web_view_url;
this.certificate_activation_handler_url = options.certificate_activation_handler_url;
this.is_active = options.is_active;
this.template = this.loadTemplate('certificate-web-preview');
},
render: function () {
this.$el.html(this.template({
course_modes: this.course_modes,
certificate_web_view_url: this.certificate_web_view_url,
is_active: this.is_active
}));
return this;
},
toggleCertificateActivation: function() {
var msg = "Activating";
if(this.is_active) {
msg = "Deactivating";
}
var notification = new NotificationView.Mini({
title: gettext(msg)
});
$.ajax({
url: this.certificate_activation_handler_url,
type: "POST",
dataType: "json",
contentType: "application/json",
data: JSON.stringify({
is_active: !this.is_active
}),
beforeSend: function() {
notification.show();
},
success: function(){
notification.hide();
location.reload();
}
});
},
courseModeChanged: function (event) {
$('.preview-certificate-link').attr('href', function(index, value){
return value.replace(/preview=([^&]+)/, function() {
return 'preview=' + event.target.options[event.target.selectedIndex].text;
});
});
},
show: function() {
this.render();
},
remove: function() {
this.is_active = false;
this.$el.empty();
return this;
}
});
return CertificateWebPreview;
});
// Backbone Application View: Certificates List
define([ // jshint ignore:line
'gettext',
'js/views/list',
'js/certificates/views/certificate_item'
],
function (gettext, ListView, CertificateItemView) {
'use strict';
var CertificatesListView = ListView.extend({
tagName: 'div',
className: 'certificates-list',
newModelOptions: {},
// Translators: this refers to a collection of certificates.
itemCategoryDisplayName: gettext('certificate'),
// Translators: This line refers to the initial state of the form when no data has been inserted
emptyMessage: gettext('You have not created any certificates yet.'),
createItemView: function(options) {
// Returns either an editor view or a details view, depending on context
return new CertificateItemView(options);
}
});
return CertificatesListView;
});
// Backbone Application View: Certificates Page
define([ // jshint ignore:line
'jquery',
'underscore',
'gettext',
'js/common_helpers/page_helpers',
'js/views/pages/base_page',
'js/certificates/views/certificates_list'
],
function ($, _, gettext, PageHelpers, BasePage, CertificatesListView) {
'use strict';
var CertificatesPage = BasePage.extend({
initialize: function(options) {
// Set up the initial state of this object instance
BasePage.prototype.initialize.call(this);
this.certificatesCollection = options.certificatesCollection;
this.certificatesListView = new CertificatesListView({
collection: this.certificatesCollection
});
},
renderPage: function() {
// Override the base operation with a class-specific workflow
this.$('.wrapper-certificates.certificates-list').append(this.certificatesListView.render().el);
return $.Deferred().resolve().promise();
}
});
return CertificatesPage;
});
// Backbone Application View: Signatory Details
define([ // jshint ignore:line
'jquery',
'underscore',
'underscore.string',
'backbone',
'gettext',
'js/utils/templates',
'js/views/utils/view_utils',
'js/views/baseview',
'js/certificates/views/signatory_editor'
],
function ($, _, str, Backbone, gettext, TemplateUtils, ViewUtils, BaseView, SignatoryEditorView) {
'use strict';
var SignatoryDetailsView = BaseView.extend({
tagName: 'div',
events: {
'click .edit-signatory': 'editSignatory',
'click .signatory-panel-save': 'saveSignatoryData',
'click .signatory-panel-close': 'closeSignatoryEditView'
},
className: function () {
// Determine the CSS class names for this model instance
var index = this.model.collection.indexOf(this.model);
return [
'signatory-details',
'signatory-details-view-' + index
].join(' ');
},
initialize: function() {
// Set up the initial state of the attributes set for this model instance
this.eventAgg = _.extend({}, Backbone.Events);
this.edit_view = new SignatoryEditorView({
model: this.model,
isEditingAllCollections: false,
eventAgg: this.eventAgg
});
this.template = this.loadTemplate('signatory-details');
},
loadTemplate: function(name) {
// Retrieve the corresponding template for this model
return TemplateUtils.loadTemplate(name);
},
editSignatory: function(event) {
// Retrieve the edit view for this model
if (event && event.preventDefault) { event.preventDefault(); }
this.$el.html(this.edit_view.render());
this.edit_view.delegateEvents();
this.delegateEvents();
},
saveSignatoryData: function(event) {
// Persist the data for this model
if (event && event.preventDefault) { event.preventDefault(); }
var certificate = this.model.get('certificate');
if (!certificate.isValid()){
return;
}
var self = this;
ViewUtils.runOperationShowingMessage(
gettext('Saving'),
function () {
var dfd = $.Deferred();
var actionableModel = certificate;
actionableModel.save({}, {
success: function() {
actionableModel.setOriginalAttributes();
dfd.resolve();
self.closeSignatoryEditView();
}.bind(this)
});
return dfd;
}.bind(this));
},
closeSignatoryEditView: function(event) {
// Enable the cancellation workflow for the editing view
if (event && event.preventDefault) { event.preventDefault(); }
this.render();
},
render: function() {
// Assemble the detail view for this model
var attributes = $.extend({}, this.model.attributes, {
signatory_number: this.model.collection.indexOf(this.model) + 1
});
return $(this.el).html(this.template(attributes));
}
});
return SignatoryDetailsView;
});
// Backbone Application View: Signatory Editor
define([ // jshint ignore:line
'jquery',
'underscore',
'backbone',
'gettext',
'js/utils/templates',
'js/views/utils/view_utils',
'js/views/feedback_prompt',
'js/views/feedback_notification',
'js/models/uploads',
'js/views/uploads'
],
function ($, _, Backbone, gettext,
TemplateUtils, ViewUtils, PromptView, NotificationView, FileUploadModel, FileUploadDialog) {
'use strict';
var SignatoryEditorView = Backbone.View.extend({
tagName: 'div',
events: {
'change .signatory-name-input': 'setSignatoryName',
'change .signatory-title-input': 'setSignatoryTitle',
'change .signatory-organization-input': 'setSignatoryOrganization',
'click .signatory-panel-delete': 'deleteItem',
'change .signatory-signature-input': 'setSignatorySignatureImagePath',
'click .action-upload-signature': 'uploadSignatureImage'
},
className: function () {
// Determine the CSS class names for this model instance
var index = this.getModelIndex(this.model);
return [
'signatory-edit',
'signatory-edit-view-' + index
].join(' ');
},
initialize: function(options) {
// Set up the initial state of the attributes set for this model instance
_.bindAll(this, 'render');
this.model.bind('change', this.render);
this.eventAgg = options.eventAgg;
this.isEditingAllCollections = options.isEditingAllCollections;
this.template = this.loadTemplate('signatory-editor');
},
getModelIndex: function(givenModel) {
// Retrieve the position of this model in its collection
return this.model.collection.indexOf(givenModel);
},
loadTemplate: function(name) {
// Retrieve the corresponding template for this model
return TemplateUtils.loadTemplate(name);
},
getTotalSignatoriesOnServer: function() {
// Retrieve the count of signatories stored server-side
var count = 0;
this.model.collection.each(function( modelSignatory) {
if(!modelSignatory.isNew()) {
count ++;
}
});
return count;
},
render: function() {
// Assemble the editor view for this model
var attributes = $.extend({
modelIsValid: this.model.isValid(),
error: this.model.validationError
}, this.model.attributes, {
signatory_number: this.getModelIndex(this.model) + 1,
signatories_count: this.model.collection.length,
isNew: this.model.isNew(),
is_editing_all_collections: this.isEditingAllCollections,
total_saved_signatories: this.getTotalSignatoriesOnServer()
});
return $(this.el).html(this.template(attributes));
},
setSignatoryName: function(event) {
// Update the model with the provided data
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'name',
this.$('.signatory-name-input').val()
);
this.eventAgg.trigger("onSignatoryUpdated", this.model);
},
setSignatoryTitle: function(event) {
// Update the model with the provided data
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'title',
this.$('.signatory-title-input').val()
);
this.eventAgg.trigger("onSignatoryUpdated", this.model);
},
setSignatoryOrganization: function(event) {
// Update the model with the provided data
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'organization',
this.$('.signatory-organization-input').val()
);
this.eventAgg.trigger("onSignatoryUpdated", this.model);
},
setSignatorySignatureImagePath: function(event) {
if (event && event.preventDefault) { event.preventDefault(); }
this.model.set(
'signature_image_path',
this.$('.signatory-signature-input').val(),
{ silent: true }
);
},
deleteItem: function(event) {
// Remove the specified model from the collection
if (event && event.preventDefault) { event.preventDefault(); }
var model = this.model;
var self = this;
var titleText = gettext('Delete "<%= signatoryName %>" from the list of signatories?');
var confirm = new PromptView.Warning({
title: _.template(titleText, {signatoryName: model.get('name')}),
message: gettext('This action cannot be undone.'),
actions: {
primary: {
text: gettext('Delete'),
click: function () {
var deleting = new NotificationView.Mini({
title: gettext('Deleting')
});
if (model.isNew()){
model.collection.remove(model);
self.eventAgg.trigger("onSignatoryRemoved", model);
}
else {
deleting.show();
model.destroy({
wait: true,
success: function (model) {
deleting.hide();
self.eventAgg.trigger("onSignatoryRemoved", model);
}
});
}
confirm.hide();
}
},
secondary: {
text: gettext('Cancel'),
click: function() {
confirm.hide();
}
}
}
});
confirm.show();
},
uploadSignatureImage: function(event) {
event.preventDefault();
var upload = new FileUploadModel({
title: gettext("Upload signature image."),
message: gettext("Image must be 450px X 150px transparent PNG."),
mimeTypes: ['image/png']
});
var self = this;
var modal = new FileUploadDialog({
model: upload,
onSuccess: function(response) {
self.model.set('signature_image_path', response.asset.url);
}
});
modal.show();
}
});
return SignatoryEditorView;
});
...@@ -100,28 +100,14 @@ define([ ...@@ -100,28 +100,14 @@ define([
expect(view.$(detailsView)).toExist(); expect(view.$(detailsView)).toExist();
expect(view.$(editView)).not.toExist(); expect(view.$(editView)).not.toExist();
}; };
var clickDeleteItem = function (that, promptSpy, promptText) {
that.view.$('.delete').click();
ViewHelpers.verifyPromptShowing(promptSpy, promptText);
ViewHelpers.confirmPrompt(promptSpy);
ViewHelpers.verifyPromptHidden(promptSpy);
};
var patchAndVerifyRequest = function (requests, url, notificationSpy) {
// Backbone.emulateHTTP is enabled in our system, so setting this
// option will fake PUT, PATCH and DELETE requests with a HTTP POST,
// setting the X-HTTP-Method-Override header with the true method.
AjaxHelpers.expectJsonRequest(requests, 'POST', url);
expect(_.last(requests).requestHeaders['X-HTTP-Method-Override']).toBe('DELETE');
ViewHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
};
var assertAndDeleteItemError = function (that, url, promptText) { var assertAndDeleteItemError = function (that, url, promptText) {
var requests = AjaxHelpers.requests(that), var requests = AjaxHelpers.requests(that),
promptSpy = ViewHelpers.createPromptSpy(), promptSpy = ViewHelpers.createPromptSpy(),
notificationSpy = ViewHelpers.createNotificationSpy(); notificationSpy = ViewHelpers.createNotificationSpy();
clickDeleteItem(that, promptSpy, promptText); ViewHelpers.clickDeleteItem(that, promptSpy, promptText);
patchAndVerifyRequest(requests, url, notificationSpy); ViewHelpers.patchAndVerifyRequest(requests, url, notificationSpy);
AjaxHelpers.respondWithNoContent(requests); AjaxHelpers.respondWithNoContent(requests);
ViewHelpers.verifyNotificationHidden(notificationSpy); ViewHelpers.verifyNotificationHidden(notificationSpy);
...@@ -132,25 +118,13 @@ define([ ...@@ -132,25 +118,13 @@ define([
promptSpy = ViewHelpers.createPromptSpy(), promptSpy = ViewHelpers.createPromptSpy(),
notificationSpy = ViewHelpers.createNotificationSpy(); notificationSpy = ViewHelpers.createNotificationSpy();
clickDeleteItem(that, promptSpy, promptText); ViewHelpers.clickDeleteItem(that, promptSpy, promptText);
patchAndVerifyRequest(requests, url, notificationSpy); ViewHelpers.patchAndVerifyRequest(requests, url, notificationSpy);
AjaxHelpers.respondWithError(requests); AjaxHelpers.respondWithError(requests);
ViewHelpers.verifyNotificationShowing(notificationSpy, /Deleting/); ViewHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
expect($(listItemView)).toExist(); expect($(listItemView)).toExist();
}; };
var submitAndVerifyFormSuccess = function (view, requests, notificationSpy) {
view.$('form').submit();
ViewHelpers.verifyNotificationShowing(notificationSpy, /Saving/);
requests[0].respond(200);
ViewHelpers.verifyNotificationHidden(notificationSpy);
};
var submitAndVerifyFormError = function (view, requests, notificationSpy) {
view.$('form').submit();
ViewHelpers.verifyNotificationShowing(notificationSpy, /Saving/);
AjaxHelpers.respondWithError(requests);
ViewHelpers.verifyNotificationShowing(notificationSpy, /Saving/);
};
var assertCannotDeleteUsed = function (that, toolTipText, warningText){ var assertCannotDeleteUsed = function (that, toolTipText, warningText){
setUsageInfo(that.model); setUsageInfo(that.model);
that.view.render(); that.view.render();
...@@ -405,7 +379,7 @@ define([ ...@@ -405,7 +379,7 @@ define([
inputDescription: 'New Description' inputDescription: 'New Description'
}); });
submitAndVerifyFormSuccess(this.view, requests, notificationSpy); ViewHelpers.submitAndVerifyFormSuccess(this.view, requests, notificationSpy);
expect(this.model).toBeCorrectValuesInModel({ expect(this.model).toBeCorrectValuesInModel({
name: 'New Configuration', name: 'New Configuration',
...@@ -423,7 +397,7 @@ define([ ...@@ -423,7 +397,7 @@ define([
notificationSpy = ViewHelpers.createNotificationSpy(); notificationSpy = ViewHelpers.createNotificationSpy();
setValuesToInputs(this.view, { inputName: 'New Configuration' }); setValuesToInputs(this.view, { inputName: 'New Configuration' });
submitAndVerifyFormError(this.view, requests, notificationSpy); ViewHelpers.submitAndVerifyFormError(this.view, requests, notificationSpy);
}); });
it('does not save on cancel', function() { it('does not save on cancel', function() {
...@@ -978,7 +952,7 @@ define([ ...@@ -978,7 +952,7 @@ define([
this.view.$('.action-add').click(); this.view.$('.action-add').click();
this.view.$(SELECTORS.inputName).val('New Content Group'); this.view.$(SELECTORS.inputName).val('New Content Group');
submitAndVerifyFormSuccess(this.view, requests, notificationSpy); ViewHelpers.submitAndVerifyFormSuccess(this.view, requests, notificationSpy);
expect(this.model).toBeCorrectValuesInModel({ expect(this.model).toBeCorrectValuesInModel({
name: 'New Content Group' name: 'New Content Group'
...@@ -991,7 +965,7 @@ define([ ...@@ -991,7 +965,7 @@ define([
notificationSpy = ViewHelpers.createNotificationSpy(); notificationSpy = ViewHelpers.createNotificationSpy();
this.view.$(SELECTORS.inputName).val('New Content Group') this.view.$(SELECTORS.inputName).val('New Content Group')
submitAndVerifyFormError(this.view, requests, notificationSpy) ViewHelpers.submitAndVerifyFormError(this.view, requests, notificationSpy)
}); });
it('does not save on cancel', function() { it('does not save on cancel', function() {
......
/**
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/
define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt", "js/common_helpers/template_helpers"],
function($, NotificationView, Prompt, TemplateHelpers) {
var installViewTemplates, createFeedbackSpy, verifyFeedbackShowing,
verifyFeedbackHidden, createNotificationSpy, verifyNotificationShowing,
verifyNotificationHidden, createPromptSpy, confirmPrompt, inlineEdit, verifyInlineEditChange,
installMockAnalytics, removeMockAnalytics, verifyPromptShowing, verifyPromptHidden;
assertDetailsView = function (view, text) {
expect(view.$el).toContainText(text);
expect(view.$el).toContainText('ID: 0');
expect(view.$('.delete')).toExist();
};
assertControllerView = function (view, detailsView, editView) {
// Details view by default
expect(view.$(detailsView)).toExist();
view.$('.action-edit .edit').click();
expect(view.$(editView)).toExist();
expect(view.$(detailsView)).not.toExist();
view.$('.action-cancel').click();
expect(view.$(detailsView)).toExist();
expect(view.$(editView)).not.toExist();
};
assertAndDeleteItemError = function (that, url, promptText) {
var requests = AjaxHelpers.requests(that),
promptSpy = ViewHelpers.createPromptSpy(),
notificationSpy = ViewHelpers.createNotificationSpy();
clickDeleteItem(that, promptSpy, promptText);
patchAndVerifyRequest(requests, url, notificationSpy);
AjaxHelpers.respondToDelete(requests);
ViewHelpers.verifyNotificationHidden(notificationSpy);
expect($(SELECTORS.itemView)).not.toExist();
};
assertAndDeleteItemWithError = function (that, url, listItemView, promptText) {
var requests = AjaxHelpers.requests(that),
promptSpy = ViewHelpers.createPromptSpy(),
notificationSpy = ViewHelpers.createNotificationSpy();
clickDeleteItem(that, promptSpy, promptText);
patchAndVerifyRequest(requests, url, notificationSpy);
AjaxHelpers.respondWithError(requests);
ViewHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
expect($(listItemView)).toExist();
};
assertUnusedOptions = function (that) {
that.model.set('usage', []);
that.view.render();
expect(that.view.$(SELECTORS.warningMessage)).not.toExist();
expect(that.view.$(SELECTORS.warningIcon)).not.toExist();
};
assertCannotDeleteUsed = function (that, toolTipText, warningText){
setUsageInfo(that.model);
that.view.render();
expect(that.view.$(SELECTORS.note)).toHaveAttr(
'data-tooltip', toolTipText
);
expect(that.view.$(SELECTORS.warningMessage)).toContainText(warningText);
expect(that.view.$(SELECTORS.warningIcon)).toExist();
expect(that.view.$('.delete')).toHaveClass('is-disabled');
};
return {
'installViewTemplates': installViewTemplates,
'createNotificationSpy': createNotificationSpy,
'verifyNotificationShowing': verifyNotificationShowing,
'verifyNotificationHidden': verifyNotificationHidden,
'confirmPrompt': confirmPrompt,
'createPromptSpy': createPromptSpy,
'verifyPromptShowing': verifyPromptShowing,
'verifyPromptHidden': verifyPromptHidden,
'inlineEdit': inlineEdit,
'verifyInlineEditChange': verifyInlineEditChange,
'installMockAnalytics': installMockAnalytics,
'removeMockAnalytics': removeMockAnalytics
};
});
/** /**
* Provides helper methods for invoking Studio modal windows in Jasmine tests. * Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/ */
define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt", "js/common_helpers/template_helpers"], define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt", 'js/common_helpers/ajax_helpers',
function($, NotificationView, Prompt, TemplateHelpers) { "js/common_helpers/template_helpers"],
function($, NotificationView, Prompt, AjaxHelpers, TemplateHelpers) {
var installViewTemplates, createFeedbackSpy, verifyFeedbackShowing, var installViewTemplates, createFeedbackSpy, verifyFeedbackShowing,
verifyFeedbackHidden, createNotificationSpy, verifyNotificationShowing, verifyFeedbackHidden, createNotificationSpy, verifyNotificationShowing,
verifyNotificationHidden, createPromptSpy, confirmPrompt, inlineEdit, verifyInlineEditChange, verifyNotificationHidden, createPromptSpy, confirmPrompt, inlineEdit, verifyInlineEditChange,
...@@ -94,6 +95,36 @@ define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt", ...@@ -94,6 +95,36 @@ define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt",
} }
}; };
clickDeleteItem = function (that, promptSpy, promptText) {
that.view.$('.delete').click();
verifyPromptShowing(promptSpy, promptText);
confirmPrompt(promptSpy);
verifyPromptHidden(promptSpy);
};
patchAndVerifyRequest = function (requests, url, notificationSpy) {
// Backbone.emulateHTTP is enabled in our system, so setting this
// option will fake PUT, PATCH and DELETE requests with a HTTP POST,
// setting the X-HTTP-Method-Override header with the true method.
AjaxHelpers.expectJsonRequest(requests, 'POST', url);
expect(_.last(requests).requestHeaders['X-HTTP-Method-Override']).toBe('DELETE');
verifyNotificationShowing(notificationSpy, /Deleting/);
};
submitAndVerifyFormSuccess = function (view, requests, notificationSpy) {
view.$('form').submit();
verifyNotificationShowing(notificationSpy, /Saving/);
AjaxHelpers.respondWithJson(requests, {});
verifyNotificationHidden(notificationSpy);
};
submitAndVerifyFormError = function (view, requests, notificationSpy) {
view.$('form').submit();
verifyNotificationShowing(notificationSpy, /Saving/);
AjaxHelpers.respondWithError(requests);
verifyNotificationShowing(notificationSpy, /Saving/);
};
return { return {
'installViewTemplates': installViewTemplates, 'installViewTemplates': installViewTemplates,
'createNotificationSpy': createNotificationSpy, 'createNotificationSpy': createNotificationSpy,
...@@ -106,6 +137,10 @@ define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt", ...@@ -106,6 +137,10 @@ define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt",
'inlineEdit': inlineEdit, 'inlineEdit': inlineEdit,
'verifyInlineEditChange': verifyInlineEditChange, 'verifyInlineEditChange': verifyInlineEditChange,
'installMockAnalytics': installMockAnalytics, 'installMockAnalytics': installMockAnalytics,
'removeMockAnalytics': removeMockAnalytics 'removeMockAnalytics': removeMockAnalytics,
'clickDeleteItem': clickDeleteItem,
'patchAndVerifyRequest': patchAndVerifyRequest,
'submitAndVerifyFormSuccess': submitAndVerifyFormSuccess,
'submitAndVerifyFormError': submitAndVerifyFormError
}; };
}); });
...@@ -40,7 +40,8 @@ define([ ...@@ -40,7 +40,8 @@ define([
itemCategoryDisplayName: this.itemCategoryDisplayName, itemCategoryDisplayName: this.itemCategoryDisplayName,
emptyMessage: this.emptyMessage, emptyMessage: this.emptyMessage,
length: this.collection.length, length: this.collection.length,
isEditing: model && model.get('editing') isEditing: model && model.get('editing'),
canCreateNewItem: this.canCreateItem(this.collection)
})); }));
this.collection.each(function(model) { this.collection.each(function(model) {
...@@ -83,6 +84,17 @@ define([ ...@@ -83,6 +84,17 @@ define([
view.$el.focus(); view.$el.focus();
}, },
canCreateItem: function(collection) {
var canCreateNewItem = true;
if (collection.length > 0) {
var maxAllowed = collection.maxAllowed;
if (!_.isUndefined(maxAllowed) && collection.length >= maxAllowed) {
canCreateNewItem = false;
}
}
return canCreateNewItem;
},
onAddItem: function(event) { onAddItem: function(event) {
if (event && event.preventDefault) { event.preventDefault(); } if (event && event.preventDefault) { event.preventDefault(); }
this.collection.add({editing: true}, this.newModelOptions); this.collection.add({editing: true}, this.newModelOptions);
......
...@@ -40,6 +40,7 @@ lib_paths: ...@@ -40,6 +40,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/backbone-min.js - xmodule_js/common_static/js/vendor/backbone-min.js
- xmodule_js/common_static/js/vendor/backbone-associations-min.js - xmodule_js/common_static/js/vendor/backbone-associations-min.js
- xmodule_js/common_static/js/vendor/backbone.paginator.min.js - xmodule_js/common_static/js/vendor/backbone.paginator.min.js
- xmodule_js/common_static/js/vendor/backbone-relational.min.js
- xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js - xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js
- xmodule_js/common_static/js/vendor/jquery.leanModal.min.js - xmodule_js/common_static/js/vendor/jquery.leanModal.min.js
- xmodule_js/common_static/js/vendor/jquery.ajaxQueue.js - xmodule_js/common_static/js/vendor/jquery.ajaxQueue.js
...@@ -76,12 +77,15 @@ src_paths: ...@@ -76,12 +77,15 @@ src_paths:
- js - js
- js/common_helpers - js/common_helpers
- js/factories - js/factories
- js/certificates
# Paths to spec (test) JavaScript files # Paths to spec (test) JavaScript files
# We should define the custom path mapping in /coffee/spec/main.coffee as well e.g. certificates etc.
spec_paths: spec_paths:
- coffee/spec/main.js - coffee/spec/main.js
- coffee/spec - coffee/spec
- js/spec - js/spec
- js/certificates/spec
# Paths to fixture files (optional) # Paths to fixture files (optional)
# The fixture path will be set automatically when using jasmine-jquery. # The fixture path will be set automatically when using jasmine-jquery.
......
...@@ -30,6 +30,7 @@ require.config({ ...@@ -30,6 +30,7 @@ require.config({
"underscore": "js/vendor/underscore-min", "underscore": "js/vendor/underscore-min",
"underscore.string": "js/vendor/underscore.string.min", "underscore.string": "js/vendor/underscore.string.min",
"backbone": "js/vendor/backbone-min", "backbone": "js/vendor/backbone-min",
"backbone-relational" : "js/vendor/backbone-relational.min",
"backbone.associations": "js/vendor/backbone-associations-min", "backbone.associations": "js/vendor/backbone-associations-min",
"backbone.paginator": "js/vendor/backbone.paginator.min", "backbone.paginator": "js/vendor/backbone.paginator.min",
"tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min", "tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min",
......
...@@ -66,6 +66,7 @@ ...@@ -66,6 +66,7 @@
@import 'views/export-git'; @import 'views/export-git';
@import 'views/group-configuration'; @import 'views/group-configuration';
@import 'views/video-upload'; @import 'views/video-upload';
@import 'views/certificates';
// +Base - Contexts // +Base - Contexts
// ==================== // ====================
......
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "certificates" %></%def>
<%namespace name='static' file='static_content.html'/>
<%! import json %>
<%!
from contentstore import utils
from django.utils.translation import ugettext as _
%>
<%block name="title">${_("Course Certificates")}</%block>
<%block name="bodyclass">is-signedin course view-certificates</%block>
<%block name="header_extras">
% for template_name in ["certificate-details", "certificate-editor", "signatory-editor", "signatory-details", "basic-modal", "modal-button", "list", "upload-dialog", "certificate-web-preview"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
window.CMS = window.CMS || {};
CMS.URL = CMS.URL || {};
CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
</script>
</%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">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Settings")}</small>
<span class="sr">&gt; </span>${_("Certificates")}
</h1>
<div class="preview-certificate nav-actions"></div>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<div class="wrapper-certificates certificates-list">
<h2 class="sr title">${_("Certificates")}</h2>
% if certificates is None:
<div class="notice notice-incontext notice-moduledisabled">
<p class="copy">
${_("This module is not enabled.")}
</p>
</div>
% else:
<div class="ui-loading">
<p><span class="spin"><i class="icon fa fa-refresh" aria-hidden="true"></i></span> <span class="copy">${_("Loading")}</span></p>
</div>
% endif
</div>
</article>
<aside class="content-supplementary" role="complementary">
<div class="bit">
<div class="certificates-doc">
<h2 class="title-3">${_("Certificates")}</h2>
<p>${_("Upon successful completion of your course, learners receive a certificate to acknowledge their accomplishment. Course team members with the Admin role in Studio can create course certificates based on templates that exist for your organization.")}</p>
<p>${_("Course team members with the Admin role can also add signatory names for a certificate, and upload assets including signature image files for signatories. {em_start}Note:{em_end} Signature images are used only for verified certificates.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("Click {em_start}New Certificate{em_end} to add a new certificate. To edit a certficate, hover over its box and click {em_start}Edit{em_end}. You can delete a certificate only if it has not been issued to a learner. To delete a certificate, hover over its box and click the delete icon.").format(em_start="<strong>", em_end="</strong>")}</p>
<p><a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn More")}</a></p>
</div>
</div>
<div class="bit">
% if context_course:
<%
details_url = utils.reverse_course_url('settings_handler', context_course.id)
grading_url = utils.reverse_course_url('grading_handler', context_course.id)
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
advanced_settings_url = utils.reverse_course_url('advanced_settings_handler', context_course.id)
%>
<h2 class="title-3">${_("Other Course Settings")}</h2>
<nav class="nav-related" aria-label="${_('Other Course Settings')}">
<ul>
<li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
</ul>
</nav>
% endif
</div>
</aside>
</section>
</div>
</%block>
<div class="collection-details wrapper-certificate">
<header class="collection-header">
<h3 class="sr title">
<%= name %>
</h3>
</header>
<ol class="collection-info certificate-info certificate-info-<% if(showDetails){ print('block'); } else { print('inline'); } %>">
<% if (!_.isUndefined(id)) { %>
<li class="sr certificate-id">
<span class="certificate-label"><%= gettext('ID') %>: </span>
<span class="certificate-value"><%= id %></span>
</li>
<% } %>
<% if (showDetails) { %>
<header>
<span class="title"><%= gettext("Certificate Details") %></span>
</header>
<% if (course_title) { %>
<div class="course-title-override">
<span class="certificate-label"><%= gettext('Course Title Override') %>: </span>
<span class="certificate-value"><%= course_title %></span>
</div>
<% } %>
<header style='margin-top: 30px;'>
<span class="title"><%= gettext("Certificate Signatories") %></span>
</header>
<div class="signatory-details-list"></div>
<% } %>
</ol>
<ul class="actions certificate-actions">
<li class="action action-edit">
<button class="edit"><i class="icon fa fa-pencil" aria-hidden="true"></i> <%= gettext("Edit") %></button>
</li>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%= gettext('Delete') %>">
<button class="delete action-icon"><i class="icon fa fa-trash-o" aria-hidden="true"></i><span><%= gettext("Delete") %></span></button>
</li>
</ul>
</div>
<form class="collection-edit-form certificate-edit-form">
<div aria-live="polite">
<% if (error && error.message) { %>
<div class="certificate-edit-error message message-status message-status error is-shown" name="certificate-edit-error">
<%= gettext(error.message) %>
</div>
<% } %>
</div>
<div class="wrapper-form">
<fieldset class="collection-fields certificate-fields">
<legend class="sr"><%= gettext("Certificate Information") %></legend>
<div class="sr input-wrap field text required add-collection-name add-certificate-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="certificate-name-<%= uniqueId %>"><%= gettext("Certificate Name") %></label>
<input id="certificate-name-<%= uniqueId %>" class="collection-name-input input-text" name="certificate-name" type="text" placeholder="<%= gettext("Name of the certificate") %>" value="<%= name %>" aria-describedby="certificate-name-<%=uniqueId %>-tip" />
<span id="certificate-name-<%= uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Name of the certificate") %></span>
</div>
<div class="sr input-wrap field text add-certificate-description">
<label for="certificate-description-<%= uniqueId %>"><%= gettext("Description") %></label>
<textarea id="certificate-description-<%= uniqueId %>" class="certificate-description-input text input-text" name="certificate-description" placeholder="<%= gettext("Description of the certificate") %>" aria-describedby="certificate-description-<%=uniqueId %>-tip"><%= description %></textarea>
<span id="certificate-description-<%= uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Description of the certificate") %></span>
</div>
<div class="input-wrap field text add-certificate-course-title">
<label for="certificate-course-title-<%= uniqueId %>"><%= gettext("Course Title Override") %></label>
<input id="certificate-course-title-<%= uniqueId %>" class="certificate-course-title-input input-text" name="certificate-course-title" type="text" placeholder="<%= gettext("Course title") %>" value="<%= course_title %>" aria-describedby="certificate-course-title-<%=uniqueId %>-tip" />
<span id="certificate-course-title-<%= uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Title of the course") %></span>
</div>
<div class="input-wrap field text add-org-logo">
<label for="certificate-org-logo-<%= uniqueId %>"><%= gettext("Organization Logo") %></label>
<div class="current-org-logo" <% if (org_logo_path == "") { print('style="display:none"'); } %>><span class="wrapper-org-logo"><img class="org-logo" src="<%= org_logo_path %>" alt="Organization Logo"></span></div>
<div class="org-logo-upload-wrapper">
<div class="org-logo-upload-input-wrapper">
<input id="certificate-org-logo-<%= uniqueId %>" class="collection-name-input input-text org-logo-input" name="org-logo-path" type="text" placeholder="<%= gettext("Path to organization logo") %>" value="<%= org_logo_path %>" aria-describedby="certificate-org-logo-<%=uniqueId %>-tip" readonly />
<span id="certificate-org-logo-<%=uniqueId %>-tip" class="tip tip-stacked"><%= gettext("Maximum logo height 125px, width variable") %></span>
</div>
<button type="button" class="action action-upload-org-logo">Upload Organization Logo</button>
</div>
</div>
</fieldset>
<header>
<span class="title"><%= gettext("Certificate Signatories") %></legend>
</header>
<div class="signatory-edit-list"> </div>
<span>
<button class="action action-add-signatory" type="button"><%= gettext("Add Signatory") %></button>
<span class="tip tip-stacked"><%= gettext("(Up to 4 signatories are allowed for a certificate)") %></span>
</span>
</div>
<div class="actions">
<button class="action action-primary" type="submit"><% if (isNew) { print(gettext("Create")) } else { print(gettext("Save")) } %></button>
<button class="action action-secondary action-cancel"><%= gettext("Cancel") %></button>
<% if (!isNew) { %>
<span class="wrapper-delete-button">
<a class="button action-delete delete" href="#"><%= gettext("Delete") %></a>
</span>
<% } %>
</div>
</form>
<label for="course-modes"><%= gettext("Choose mode") %></label>
<select id="course-modes">
<% _.each(course_modes, function(course_mode) { %>
<option value= "<%= course_mode %>"><%= course_mode %></option>
<% }); %>
</select>
<a href=<%= certificate_web_view_url %> class="button preview-certificate-link" target="_blank">
<%= gettext("Preview Certificate") %>
</a>
<button class="button activate-cert">
<span>
<% if(!is_active) { %>
<%= gettext("Activate") %></span>
<% } else { %>
<%= gettext("Deactivate") %></span>
<% } %>
</button>
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<% } else { %> <% } else { %>
<div class="list-items"></div> <div class="list-items"></div>
<% if (!isEditing) { %> <% if (!isEditing) { %>
<button class="action action-add"> <button class="action action-add <% if(!canCreateNewItem) {%> action-add-hidden <% }%>" >
<i class="icon fa fa-plus"></i> <i class="icon fa fa-plus"></i>
<%- interpolate( <%- interpolate(
gettext('New %(item_type)s'), {item_type: itemCategoryDisplayName}, true gettext('New %(item_type)s'), {item_type: itemCategoryDisplayName}, true
......
<div class="signatory-panel-default">
<div class="actions certificate-actions signatory-panel-edit">
<span class="action action-edit-signatory">
<a href="javascript:void(0);" class="edit-signatory"><i class="icon fa fa-pencil" aria-hidden="true"></i> <%= gettext("Edit") %></a>
</span>
</div>
<div class="signatory-panel-header">Signatory <%= signatory_number %>&nbsp;</div>
<div class="signatory-panel-body">
<div>
<span class="signatory-name-label"><%= gettext("Name") %>:&nbsp;</span>
<span class="signatory-name-value"><%= name %></span>
</div>
<div>
<span class="signatory-title-label"><%= gettext("Title") %>:&nbsp;</span>
<span class="signatory-title-value"><%= title %></span>
</div>
<div>
<span class="signatory-organization-label"><%= gettext("Organization") %>:&nbsp;</span>
<span class="signatory-organization-value"><%= organization %></span>
</div>
</div>
</div>
<div class="signatory-panel-default">
<% if (!is_editing_all_collections) { %>
<a class="signatory-panel-close" href="javascript:void(0);" data-tooltip="Close">
<i class="icon fa fa-close" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext("Close") %></span>
</a>
<a class="signatory-panel-save" href="javascript:void(0);" data-tooltip="Save">
<i class="icon fa fa-save" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext("Save") %></span>
</a>
<% } else if (signatories_count > 1 && (total_saved_signatories > 1 || isNew) ) { %>
<a class="signatory-panel-delete" href="#" data-tooltip="Delete">
<i class="icon fa fa-trash-o" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext("Delete") %></span>
</a>
<% } %>
<div class="signatory-panel-header">Signatory <%= signatory_number %></div>
<div class="signatory-panel-body">
<fieldset class="collection-fields signatory-fields">
<legend class="sr"><%= gettext("Certificate Signatory Configuration") %></legend>
<div class="input-wrap field text add-signatory-name <% if(error && error.name) { print('error'); } %>">
<label for="signatory-name-<%= signatory_number %>"><%= gettext("Name ") %></label>
<input id="signatory-name-<%= signatory_number %>" class="collection-name-input input-text signatory-name-input" name="signatory-name" type="text" placeholder="<%= gettext("Name of the signatory") %>" value="<%= name %>" aria-describedby="signatory-name-<%= signatory_number %>-tip" maxlength="40" />
<span id="signatory-name-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("Maximum 40 characters") %></span>
<% if(error && error.name) { %>
<span class="message-error"><%= error.name %></span>
<% } %>
</div>
<div class="input-wrap field text add-signatory-title <% if(error && error.title) { print('error'); } %>">
<label for="signatory-title-<%= signatory_number %>"><%= gettext("Title ") %></label>
<textarea id="signatory-title-<%= signatory_number %>" class="collection-name-input text input-text signatory-title-input" name="signatory-title" cols="40" rows="2" placeholder="<%= gettext("Title of the signatory") %>" aria-describedby="signatory-title-<%= signatory_number %>-tip" maxlength="80"><%= title %></textarea>
<span id="signatory-title-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("2 Lines, 40 characters each") %></span>
<% if(error && error.title) { %>
<span class="message-error"><%= error.title %></span>
<% } %>
</div>
<div class="input-wrap field text add-signatory-organization <% if(error && error.organization) { print('error'); } %>">
<label for="signatory-organization-<%= signatory_number %>"><%= gettext("Organization ") %></label>
<input id="signatory-organization-<%= signatory_number %>" class="collection-name-input input-text signatory-organization-input" name="signatory-organization" type="text" placeholder="<%= gettext("Organization of the signatory") %>" value="<%= organization %>" aria-describedby="signatory-organization-<%= signatory_number %>-tip" maxlength="40" />
<span id="signatory-organization-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("Maximum 40 characters") %></span>
<% if(error && error.organization) { %>
<span class="message-error"><%= error.organization %></span>
<% } %>
</div>
<div class="input-wrap field text add-signatory-signature">
<label for="signatory-signature-<%= signatory_number %>"><%= gettext("Signature Image") %></label>
<% if (signature_image_path != "") { %>
<div class="current-signature-image"><span class="wrapper-signature-image"><img class="signature-image" src="<%= signature_image_path %>" alt="Signature Image"></span></div>
<% } %>
<div class="signature-upload-wrapper">
<div class="signature-upload-input-wrapper">
<input id="signatory-signature-<%= signatory_number %>" class="collection-name-input input-text signatory-signature-input" name="signatory-signature-url" type="text" placeholder="<%= gettext("Path to Signature Image") %>" value="<%= signature_image_path %>" aria-describedby="signatory-signature-<%= signatory_number %>-tip" readonly />
<span id="signatory-signature-<%= signatory_number %>-tip" class="tip tip-stacked"><%= gettext("Image must be 450px X 150px transparent PNG") %></span>
</div>
<button type="button" class="action action-upload-signature">Upload Signature Image</button>
</div>
</div>
</fieldset>
</div>
</div>
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from contentstore.context_processors import doc_url from contentstore.context_processors import doc_url
from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole
%> %>
<%page args="online_help_token"/> <%page args="online_help_token"/>
...@@ -34,6 +35,7 @@ ...@@ -34,6 +35,7 @@
grading_url = reverse('contentstore.views.grading_handler', kwargs={'course_key_string': unicode(course_key)}) grading_url = reverse('contentstore.views.grading_handler', kwargs={'course_key_string': unicode(course_key)})
advanced_settings_url = reverse('contentstore.views.advanced_settings_handler', kwargs={'course_key_string': unicode(course_key)}) advanced_settings_url = reverse('contentstore.views.advanced_settings_handler', kwargs={'course_key_string': unicode(course_key)})
tabs_url = reverse('contentstore.views.tabs_handler', kwargs={'course_key_string': unicode(course_key)}) tabs_url = reverse('contentstore.views.tabs_handler', kwargs={'course_key_string': unicode(course_key)})
certificates_url = reverse('contentstore.views.certificates.certificates_list_handler', kwargs={'course_key_string': unicode(course_key)})
%> %>
<h2 class="info-course"> <h2 class="info-course">
<span class="sr">${_("Current Course:")}</span> <span class="sr">${_("Current Course:")}</span>
...@@ -98,6 +100,15 @@ ...@@ -98,6 +100,15 @@
<li class="nav-item nav-course-settings-advanced"> <li class="nav-item nav-course-settings-advanced">
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a> <a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
</li> </li>
% if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False) and \
(user.is_superuser or user.is_staff or \
CourseCreatorRole(course_key).has_user(user) or \
CourseInstructorRole(course_key).has_user(user) or \
CourseStaffRole(course_key).has_user(user)):
<li class="nav-item nav-course-settings-certificates">
<a href="${certificates_url}">${_("Certificates")}</a>
</li>
% endif
</ul> </ul>
</div> </div>
</div> </div>
......
...@@ -113,6 +113,13 @@ urlpatterns += patterns( ...@@ -113,6 +113,13 @@ urlpatterns += patterns(
url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'), url(r'^group_configurations/{}$'.format(settings.COURSE_KEY_PATTERN), 'group_configurations_list_handler'),
url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format( url(r'^group_configurations/{}/(?P<group_configuration_id>\d+)(/)?(?P<group_id>\d+)?$'.format(
settings.COURSE_KEY_PATTERN), 'group_configurations_detail_handler'), settings.COURSE_KEY_PATTERN), 'group_configurations_detail_handler'),
url(r'^certificates/{}$'.format(settings.COURSE_KEY_PATTERN), 'certificates.certificates_list_handler'),
url(r'^certificates/{}/(?P<certificate_id>\d+)/signatories/(?P<signatory_id>\d+)?$'.format(
settings.COURSE_KEY_PATTERN), 'certificates.signatory_detail_handler'),
url(r'^certificates/{}/(?P<certificate_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN),
'certificates.certificates_detail_handler'),
url(r'^certificates/activation/{}/'.format(settings.COURSE_KEY_PATTERN),
'certificates.certificate_activation_handler'),
url(r'^api/val/v0/', include('edxval.urls')), url(r'^api/val/v0/', include('edxval.urls')),
) )
......
...@@ -5,11 +5,16 @@ import ddt ...@@ -5,11 +5,16 @@ import ddt
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from mock import patch
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error
from certificates.api import get_certificate_url # pylint: disable=import-error
# pylint: disable=no-member
@ddt.ddt @ddt.ddt
...@@ -32,10 +37,51 @@ class CertificateDisplayTest(ModuleStoreTestCase): ...@@ -32,10 +37,51 @@ class CertificateDisplayTest(ModuleStoreTestCase):
self.update_course(self.course, self.user.username) self.update_course(self.course, self.user.username)
@ddt.data('verified', 'professional') @ddt.data('verified', 'professional')
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False})
def test_display_verified_certificate(self, enrollment_mode): def test_display_verified_certificate(self, enrollment_mode):
self._create_certificate(enrollment_mode) self._create_certificate(enrollment_mode)
self._check_can_download_certificate() self._check_can_download_certificate()
@ddt.data('verified', 'honor')
@override_settings(CERT_NAME_SHORT='Test_Certificate')
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True})
def test_display_download_certificate_button(self, enrollment_mode):
"""
Tests if CERTIFICATES_HTML_VIEW is True and there is no active certificate configuration available
then any of the Download certificate button should not be visible.
"""
self._create_certificate(enrollment_mode)
self._check_can_not_download_certificate()
@ddt.data('verified')
@override_settings(CERT_NAME_SHORT='Test_Certificate')
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True})
def test_linked_student_to_web_view_credential(self, enrollment_mode):
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id)
)
self._create_certificate(enrollment_mode)
certificates = [
{
'id': 0,
'name': 'Test Name',
'description': 'Test Description',
'is_active': True,
'signatories': [],
'version': 1
}
]
self.course.certificates = {'certificates': certificates}
self.course.save() # pylint: disable=no-member
self.store.update_item(self.course, self.user.id)
response = self.client.get(reverse('dashboard'))
self.assertContains(response, u'View Test_Certificate')
self.assertContains(response, test_url)
def _create_certificate(self, enrollment_mode): def _create_certificate(self, enrollment_mode):
"""Simulate that the user has a generated certificate. """ """Simulate that the user has a generated certificate. """
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, mode=enrollment_mode) CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, mode=enrollment_mode)
...@@ -52,3 +98,13 @@ class CertificateDisplayTest(ModuleStoreTestCase): ...@@ -52,3 +98,13 @@ class CertificateDisplayTest(ModuleStoreTestCase):
response = self.client.get(reverse('dashboard')) response = self.client.get(reverse('dashboard'))
self.assertContains(response, u'Download Your ID Verified') self.assertContains(response, u'Download Your ID Verified')
self.assertContains(response, self.DOWNLOAD_URL) self.assertContains(response, self.DOWNLOAD_URL)
def _check_can_not_download_certificate(self):
"""
Make sure response does not have any of the download certificate buttons
"""
response = self.client.get(reverse('dashboard'))
self.assertNotContains(response, u'View Test_Certificate')
self.assertNotContains(response, u'Download Your Test_Certificate (PDF)')
self.assertNotContains(response, u'Download Test_Certificate (PDF)')
self.assertNotContains(response, self.DOWNLOAD_URL)
...@@ -60,6 +60,7 @@ class CourseEndingTest(TestCase): ...@@ -60,6 +60,7 @@ class CourseEndingTest(TestCase):
link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=user_id) link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=user_id)
self.assertEqual(process_survey_link(link2, user), link2_expected) self.assertEqual(process_survey_link(link2, user), link2_expected)
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False})
def test_cert_info(self): def test_cert_info(self):
user = Mock(username="fred") user = Mock(username="fred")
survey_url = "http://a_survey.com" survey_url = "http://a_survey.com"
...@@ -439,6 +440,7 @@ class DashboardTest(ModuleStoreTestCase): ...@@ -439,6 +440,7 @@ class DashboardTest(ModuleStoreTestCase):
self.assertNotContains(response, response_url) self.assertNotContains(response, response_url)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False})
def test_linked_in_add_to_profile_btn_with_certificate(self): def test_linked_in_add_to_profile_btn_with_certificate(self):
# If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button # If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button
# should be visible. and it has URL value with valid parameters. # should be visible. and it has URL value with valid parameters.
......
...@@ -60,6 +60,7 @@ from student.forms import AccountCreationForm, PasswordResetFormNoActive ...@@ -60,6 +60,7 @@ from student.forms import AccountCreationForm, PasswordResetFormNoActive
from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
from certificates.api import get_certificate_url, get_active_web_certificate # pylint: disable=import-error
from dark_lang.models import DarkLangConfig from dark_lang.models import DarkLangConfig
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -304,7 +305,19 @@ def _cert_info(user, course, cert_status, course_mode): ...@@ -304,7 +305,19 @@ def _cert_info(user, course, cert_status, course_mode):
status_dict['show_survey_button'] = False status_dict['show_survey_button'] = False
if status == 'ready': if status == 'ready':
if 'download_url' not in cert_status: # showing the certificate web view button if certificate is ready state and feature flags are enabled.
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
if get_active_web_certificate(course) is not None:
status_dict.update({
'show_cert_web_view': True,
'cert_web_view_url': u'{url}'.format(
url=get_certificate_url(user_id=user.id, course_id=unicode(course.id))
)
})
else:
# don't show download certificate button if we don't have an active certificate for course
status_dict['show_download_url'] = False
elif 'download_url' not in cert_status:
log.warning( log.warning(
u"User %s has a downloadable cert for %s, but no download url", u"User %s has a downloadable cert for %s, but no download url",
user.username, user.username,
......
...@@ -673,6 +673,7 @@ class CourseFields(object): ...@@ -673,6 +673,7 @@ class CourseFields(object):
## Course level Certificate Name overrides. ## Course level Certificate Name overrides.
cert_name_short = String( cert_name_short = String(
help=_( help=_(
"Use this setting only when generating PDF certificates. "
"Between quotation marks, enter the short name of the course to use on the certificate that " "Between quotation marks, enter the short name of the course to use on the certificate that "
"students receive when they complete the course." "students receive when they complete the course."
), ),
...@@ -682,6 +683,7 @@ class CourseFields(object): ...@@ -682,6 +683,7 @@ class CourseFields(object):
) )
cert_name_long = String( cert_name_long = String(
help=_( help=_(
"Use this setting only when generating PDF certificates. "
"Between quotation marks, enter the long name of the course to use on the certificate that students " "Between quotation marks, enter the long name of the course to use on the certificate that students "
"receive when they complete the course." "receive when they complete the course."
), ),
...@@ -697,6 +699,15 @@ class CourseFields(object): ...@@ -697,6 +699,15 @@ class CourseFields(object):
scope=Scope.settings, scope=Scope.settings,
) )
# Specific certificate information managed via Studio (should eventually fold other cert settings into this)
certificates = Dict(
# Translators: This field is the container for course-specific certifcate configuration values
display_name=_("Certificate Configuration"),
# Translators: These overrides allow for an alternative configuration of the certificate web view
help=_("Enter course-specific configuration information here (JSON format)"),
scope=Scope.settings,
)
# An extra property is used rather than the wiki_slug/number because # An extra property is used rather than the wiki_slug/number because
# there are courses that change the number for different runs. This allows # there are courses that change the number for different runs. This allows
# courses to share the same css_class across runs even if they have # courses to share the same css_class across runs even if they have
......
define([ // jshint ignore:line
],
function() {
'use strict';
var getLocationHash;
/**
* Helper method that returns url hash.
* @return {String} Returns anchor part of current url.
*/
getLocationHash = function() {
return window.location.hash;
};
return {
'getLocationHash': getLocationHash
};
});
"""
Acceptance tests for Studio's Setting pages
"""
from .base_studio_test import StudioCourseTest
from ...pages.studio.settings_certificates import CertificatesPage
class CertificatesTest(StudioCourseTest):
"""
Tests for settings/certificates Page.
"""
def setUp(self, is_staff=False):
super(CertificatesTest, self).setUp(is_staff)
self.certificates_page = CertificatesPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
def make_signatory_data(self, prefix='First'):
"""
Makes signatory dict which can be used in the tests to create certificates
"""
return {
'name': '{prefix} Signatory Name'.format(prefix=prefix),
'title': '{prefix} Signatory Title'.format(prefix=prefix),
'organization': '{prefix} Signatory Organization'.format(prefix=prefix),
}
def create_and_verify_certificate(self, course_title_override, existing_certs, signatories):
"""
Creates a new certificate and verifies that it was properly created.
"""
self.assertEqual(existing_certs, len(self.certificates_page.certificates))
if existing_certs == 0:
self.certificates_page.wait_for_first_certificate_button()
self.certificates_page.click_first_certificate_button()
else:
self.certificates_page.wait_for_add_certificate_button()
self.certificates_page.click_add_certificate_button()
certificate = self.certificates_page.certificates[existing_certs]
# Set the certificate properties
certificate.course_title = course_title_override
# add signatories
added_signatories = 0
for idx, signatory in enumerate(signatories):
certificate.signatories[idx].name = signatory['name']
certificate.signatories[idx].title = signatory['title']
certificate.signatories[idx].organization = signatory['organization']
certificate.signatories[idx].upload_signature_image('Signature-{}.png'.format(idx))
added_signatories += 1
if len(signatories) > added_signatories:
certificate.click_add_signatory_button()
# Save the certificate
self.assertEqual(certificate.get_text('.action-primary'), "Create")
certificate.click_create_certificate_button()
self.assertIn(course_title_override, certificate.course_title)
return certificate
def test_no_certificates_by_default(self):
"""
Scenario: Ensure that message telling me to create a new certificate is
shown when no certificate exist.
Given I have a course without certificates
When I go to the Certificates page in Studio
Then I see "You have not created any certificates yet." message
"""
self.certificates_page.visit()
self.assertTrue(self.certificates_page.no_certificates_message_shown)
self.assertIn(
"You have not created any certificates yet.",
self.certificates_page.no_certificates_message_text
)
def test_can_create_and_edit_certficate(self):
"""
Scenario: Ensure that the certificates can be created and edited correctly.
Given I have a course without certificates
When I click button 'Add your first Certificate'
And I set new the course title override and signatory and click the button 'Create'
Then I see the new certificate is added and has correct data
When I edit the certificate
And I change the name and click the button 'Save'
Then I see the certificate is saved successfully and has the new name
"""
self.certificates_page.visit()
self.certificates_page.wait_for_first_certificate_button()
certificate = self.create_and_verify_certificate(
"Course Title Override",
0,
[self.make_signatory_data('first'), self.make_signatory_data('second')]
)
# Edit the certificate
certificate.click_edit_certificate_button()
certificate.course_title = "Updated Course Title Override 2"
self.assertEqual(certificate.get_text('.action-primary'), "Save")
certificate.click_save_certificate_button()
self.assertIn("Updated Course Title Override 2", certificate.course_title)
def test_can_delete_certificate(self):
"""
Scenario: Ensure that the user can delete certificate.
Given I have a course with 1 certificate
And I go to the Certificates page
When I delete the Certificate with name "New Certificate"
Then I see that there is no certificate
When I refresh the page
Then I see that the certificate has been deleted
"""
self.certificates_page.visit()
certificate = self.create_and_verify_certificate(
"Course Title Override",
0,
[self.make_signatory_data('first'), self.make_signatory_data('second')]
)
certificate.wait_for_certificate_delete_button()
self.assertEqual(len(self.certificates_page.certificates), 1)
# Delete certificate
certificate.delete_certificate()
self.certificates_page.visit()
self.assertEqual(len(self.certificates_page.certificates), 0)
def test_can_create_and_edit_signatories_of_certficate(self):
"""
Scenario: Ensure that the certificates can be created with signatories and edited correctly.
Given I have a course without certificates
When I click button 'Add your first Certificate'
And I set new the course title override and signatory and click the button 'Create'
Then I see the new certificate is added and has one signatory inside it
When I click 'Edit' button of signatory panel
And I set the name and click the button 'Save' icon
Then I see the signatory name updated with newly set name
When I refresh the certificates page
Then I can see course has one certificate with new signatory name
When I click 'Edit' button of signatory panel
And click on 'Close' button
Then I can see no change in signatory detail
"""
self.certificates_page.visit()
certificate = self.create_and_verify_certificate(
"Course Title Override",
0,
[self.make_signatory_data('first')]
)
self.assertEqual(len(self.certificates_page.certificates), 1)
# Edit the signatory in certificate
signatory = certificate.signatories[0]
signatory.edit()
signatory.name = 'Updated signatory name'
signatory.title = 'Update signatory title'
signatory.organization = 'Updated signatory organization'
signatory.save()
self.assertEqual(len(self.certificates_page.certificates), 1)
signatory = self.certificates_page.certificates[0].signatories[0]
self.assertIn("Updated signatory name", signatory.name)
self.assertIn("Update signatory title", signatory.title)
self.assertIn("Updated signatory organization", signatory.organization)
signatory.edit()
signatory.close()
self.assertIn("Updated signatory name", signatory.name)
def test_can_cancel_creation_of_certificate(self):
"""
Scenario: Ensure that creation of a certificate can be canceled correctly.
Given I have a course without certificates
When I click button 'Add your first Certificate'
And I set name of certificate and click the button 'Cancel'
Then I see that there is no certificates in the course
"""
self.certificates_page.visit()
self.certificates_page.click_first_certificate_button()
certificate = self.certificates_page.certificates[0]
certificate.course_title = "Title Override"
certificate.click_cancel_edit_certificate()
self.assertEqual(len(self.certificates_page.certificates), 0)
...@@ -40,6 +40,7 @@ content_groups = cohorts/cohorted_courseware.html ...@@ -40,6 +40,7 @@ content_groups = cohorts/cohorted_courseware.html
group_configurations = content_experiments/content_experiments_configure.html#set-up-group-configurations-in-edx-studio group_configurations = content_experiments/content_experiments_configure.html#set-up-group-configurations-in-edx-studio
container = developing_course/course_components.html#components-that-contain-other-components container = developing_course/course_components.html#components-that-contain-other-components
video = index.html video = index.html
certificates = index.html
# below are the language directory names for the different locales # below are the language directory names for the different locales
[locales] [locales]
......
...@@ -5,6 +5,9 @@ Other Django apps should use the API functions defined in this module ...@@ -5,6 +5,9 @@ Other Django apps should use the API functions defined in this module
rather than importing Django models directly. rather than importing Django models directly.
""" """
import logging import logging
from django.core.urlresolvers import reverse
from certificates.models import ( from certificates.models import (
CertificateStatuses as cert_status, CertificateStatuses as cert_status,
certificate_status_for_student, certificate_status_for_student,
...@@ -185,3 +188,27 @@ def example_certificates_status(course_key): ...@@ -185,3 +188,27 @@ def example_certificates_status(course_key):
""" """
return ExampleCertificateSet.latest_status(course_key) return ExampleCertificateSet.latest_status(course_key)
# pylint: disable=no-member
def get_certificate_url(user_id, course_id):
"""
:return certificate url
"""
url = u'{url}'.format(url=reverse('cert_html_view',
kwargs=dict(
user_id=str(user_id),
course_id=unicode(course_id))))
return url
def get_active_web_certificate(course, is_preview_mode=None):
"""
Retrieves the active web certificate configuration for the specified course
"""
certificates = getattr(course, 'certificates', '{}')
configurations = certificates.get('certificates', [])
for config in configurations:
if config.get('is_active') or is_preview_mode:
return config
return None
...@@ -12,30 +12,41 @@ class Migration(DataMigration): ...@@ -12,30 +12,41 @@ class Migration(DataMigration):
Bootstraps the HTML view template with some default configuration parameters Bootstraps the HTML view template with some default configuration parameters
""" """
json_config = """{ json_config = """{
{ "default": {
"default": { "accomplishment_class_append": "accomplishment-certificate",
"accomplishment_class_append": "accomplishment-certificate", "platform_name": "edX",
"platform_name": "edX", "company_about_url": "http://www.edx.org/about-us",
"company_privacy_url": "http://www.edx.org/edx-privacy-policy", "company_privacy_url": "http://www.edx.org/edx-privacy-policy",
"company_tos_url": "http://www.edx.org/edx-terms-service", "company_tos_url": "http://www.edx.org/edx-terms-service",
"company_verified_certificate_url": "http://www.edx.org/verified-certificate", "company_verified_certificate_url": "http://www.edx.org/verified-certificate",
"document_stylesheet_url_application": "/static/certificates/sass/main-ltr.css", "logo_src": "/static/certificates/images/logo-edx.svg",
"logo_src": "/static/certificates/images/logo-edx.svg", "logo_url": "http://www.edx.org"
"logo_url": "http://www.edx.org" },
}, "honor": {
"honor": { "certificate_type": "honor",
"certificate_type": "Honor Code", "certificate_title": "Honor Certificate",
"document_body_class_append": "is-honorcode" "document_body_class_append": "is-honorcode"
}, },
"verified": { "verified": {
"certificate_type": "Verified", "certificate_type": "verified",
"document_body_class_append": "is-idverified" "certificate_title": "Verified Certificate",
}, "document_body_class_append": "is-idverified"
"xseries": { },
"certificate_type": "XSeries", "xseries": {
"document_body_class_append": "is-xseries" "certificate_type": "xseries",
} "certificate_title": "XSeries Certificate",
} "document_body_class_append": "is-xseries"
},
"base": {
"certificate_type": "base",
"certificate_title": "Certificate of Achievement",
"document_body_class_append": "is-base"
},
"distinguished": {
"certificate_type": "distinguished",
"certificate_title": "Distinguished Certificate of Achievement",
"document_body_class_append": "is-distinguished"
}
}""" }"""
orm.CertificateHtmlViewConfiguration.objects.create( orm.CertificateHtmlViewConfiguration.objects.create(
configuration=json_config, configuration=json_config,
......
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from student.models import LinkedInAddToProfileConfiguration
from certificates.models import ( from certificates.models import (
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist
) )
...@@ -54,3 +56,12 @@ class CertificateHtmlViewConfigurationFactory(DjangoModelFactory): ...@@ -54,3 +56,12 @@ class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
"document_body_class_append": "is-xseries" "document_body_class_append": "is-xseries"
} }
}""" }"""
class LinkedInAddToProfileConfigurationFactory(DjangoModelFactory):
FACTORY_FOR = LinkedInAddToProfileConfiguration
enabled = True
company_identifier = "0_0dPSPyS070e0HsE9HNz_13_d11_"
trk_partner_name = 'unittest'
...@@ -1418,6 +1418,20 @@ PIPELINE_CSS = { ...@@ -1418,6 +1418,20 @@ PIPELINE_CSS = {
], ],
'output_filename': 'css/lms-footer-edx-rtl.css' 'output_filename': 'css/lms-footer-edx-rtl.css'
}, },
'style-certificates': {
'source_filenames': [
'certificates/sass/main-ltr.css',
'css/vendor/font-awesome.css',
],
'output_filename': 'css/certificates-style.css'
},
'style-certificates-rtl': {
'source_filenames': [
'certificates/sass/main-rtl.css',
'css/vendor/font-awesome.css',
],
'output_filename': 'css/certificates-style-rtl.css'
},
} }
......
...@@ -13,6 +13,7 @@ DEBUG = True ...@@ -13,6 +13,7 @@ DEBUG = True
USE_I18N = True USE_I18N = True
TEMPLATE_DEBUG = True TEMPLATE_DEBUG = True
SITE_NAME = 'localhost:8000' SITE_NAME = 'localhost:8000'
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'Devstack')
# By default don't use a worker, execute tasks as if they were local functions # By default don't use a worker, execute tasks as if they were local functions
CELERY_ALWAYS_EAGER = True CELERY_ALWAYS_EAGER = True
......
This source diff could not be displayed because it is too large. You can view the blob instead.
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="688px" height="768px" viewBox="0 0 688 768" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
<title>ico-base</title>
<desc>Created with Sketch.</desc>
<defs>
<path id="path-1" d="M41.7152778,232.467126 C41.7152778,226.94113 45.5795391,220.204922 50.3469125,217.421055 L334.368365,51.5691115 C339.135485,48.7853917 346.864261,48.7852438 351.631635,51.5691115 L635.653087,217.421055 C640.420208,220.204775 644.284722,226.938981 644.284722,232.467126 L644.284722,533.532874 C644.284722,539.05887 640.420461,545.795078 635.653087,548.578945 L351.631635,714.430888 C346.864515,717.214608 339.135739,717.214756 334.368365,714.430888 L50.3469125,548.578945 C45.5797923,545.795225 41.7152778,539.061019 41.7152778,533.532874 L41.7152778,232.467126 Z"></path>
<path id="path-3" d="M0.5,14.5 C0.5,6.7680135 11.6946912,0.5 25.5004128,0.5 L660.499587,0.5 C674.306934,0.5 685.5,11.700539 685.5,14.5 L685.5,0.5 C685.5,8.2319865 675.835363,20.1435837 663.912854,27.1056324 L364.587146,201.894368 C352.664894,208.856266 333.335363,208.856416 321.412854,201.894368 L22.0871456,27.1056324 C10.1648943,20.1437339 0.5,3.29946098 0.5,0.5 L0.5,14.5 Z"></path>
<path id="path-5" d="M0.25,210.83436 C0.25,205.311282 4.11047363,198.57965 8.88680434,195.790552 L335.363196,5.14740399 C340.133171,2.36201694 347.860474,2.35830577 352.636804,5.14740399 L679.113196,195.790552 C683.883171,198.575939 687.75,205.317176 687.75,210.83436 L687.75,557.16564 C687.75,562.688718 683.889526,569.42035 679.113196,572.209448 L352.636804,762.852596 C347.866829,765.637983 340.139526,765.641694 335.363196,762.852596 L8.88680434,572.209448 C4.11682902,569.424061 0.25,562.682824 0.25,557.16564 L0.25,210.83436 Z"></path>
</defs>
<g id="Prod" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="ico-base" sketch:type="MSArtboardGroup">
<g id="Group" sketch:type="MSLayerGroup">
<g id="BG" transform="translate(1.000000, 1.000000)">
<path d="M0.5,210.499753 C0.5,204.977042 4.36114745,198.245315 9.13763731,195.456124 L334.362363,5.5438758 C339.132798,2.75822012 346.861147,2.75468464 351.637637,5.5438758 L676.862363,195.456124 C681.632798,198.24178 685.5,204.983265 685.5,210.499753 L685.5,555.500247 C685.5,561.022958 681.638853,567.754685 676.862363,570.543876 L351.637637,760.456124 C346.867202,763.24178 339.138853,763.245315 334.362363,760.456124 L9.13763731,570.543876 C4.36720195,567.75822 0.5,561.016735 0.5,555.500247 L0.5,210.499753 Z" id="Stroke" fill="#272C2E" sketch:type="MSShapeGroup"></path>
<mask id="mask-2" sketch:name="MASK" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="MASK" sketch:type="MSShapeGroup" xlink:href="#path-1"></use>
<rect id="Color" fill="#CED8DC" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="-15" width="796" height="796"></rect>
</g>
<g id="Title" transform="translate(1.000000, 552.000000)">
<mask id="mask-4" sketch:name="Mask" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="Mask" fill="#272C2E" sketch:type="MSShapeGroup" xlink:href="#path-3"></use>
<rect id="Rectangle-62" fill="#272C2E" sketch:type="MSShapeGroup" mask="url(#mask-4)" x="-36" y="-100" width="779" height="378"></rect>
</g>
<g id="MASK-+-ribbon">
<mask id="mask-6" sketch:name="Mask" fill="white">
<use xlink:href="#path-5"></use>
</mask>
<use id="Mask" sketch:type="MSShapeGroup" xlink:href="#path-5"></use>
</g>
<path d="M258.034733,393.484295 C212.753563,384.060295 182.183027,352.800198 182.183027,330.27454 L182.183027,308.208589 L241.025562,308.208589 C241.025562,344.295613 248.380879,372.337759 258.034733,393.484295 L258.034733,393.484295 Z M505.816973,330.27454 C505.816973,352.800198 475.246437,384.060295 429.965267,393.484295 C439.619121,372.337759 446.974438,344.295613 446.974438,308.208589 L505.816973,308.208589 L505.816973,330.27454 Z M535.238241,300.853272 C535.238241,288.671028 525.354534,278.787321 513.17229,278.787321 L446.974438,278.787321 L446.974438,256.72137 C446.974438,236.494248 430.424974,219.944785 410.197853,219.944785 L277.802147,219.944785 C257.575026,219.944785 241.025562,236.494248 241.025562,256.72137 L241.025562,278.787321 L174.82771,278.787321 C162.645466,278.787321 152.761759,288.671028 152.761759,300.853272 L152.761759,330.27454 C152.761759,373.946734 205.628099,422.216002 277.34244,425.663807 C286.536586,437.386343 295.041171,444.281953 299.178537,447.499904 C311.360781,458.53288 314.578732,470.025562 314.578732,484.736196 C314.578732,499.44683 307.223415,514.157464 285.157464,514.157464 C263.091513,514.157464 241.025562,528.868098 241.025562,550.934049 L241.025562,565.644683 C241.025562,569.782049 244.243514,573 248.380879,573 L439.619121,573 C443.756486,573 446.974438,569.782049 446.974438,565.644683 L446.974438,550.934049 C446.974438,528.868098 424.908487,514.157464 402.842536,514.157464 C380.776585,514.157464 373.421268,499.44683 373.421268,484.736196 C373.421268,470.025562 376.639219,458.53288 388.821463,447.499904 C392.958829,444.281953 401.463414,437.386343 410.65756,425.663807 C482.371901,422.216002 535.238241,373.946734 535.238241,330.27454 L535.238241,300.853272 Z" id="" fill="#272C2E" sketch:type="MSShapeGroup"></path>
</g>
</g>
</g>
</svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="688px" height="768px" viewBox="0 0 688 768" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
<title>ico-distinguished</title>
<desc>Created with Sketch.</desc>
<defs>
<path id="path-1" d="M41.7152778,232.467126 C41.7152778,226.94113 45.5795391,220.204922 50.3469125,217.421055 L334.368365,51.5691115 C339.135485,48.7853917 346.864261,48.7852438 351.631635,51.5691115 L635.653087,217.421055 C640.420208,220.204775 644.284722,226.938981 644.284722,232.467126 L644.284722,533.532874 C644.284722,539.05887 640.420461,545.795078 635.653087,548.578945 L351.631635,714.430888 C346.864515,717.214608 339.135739,717.214756 334.368365,714.430888 L50.3469125,548.578945 C45.5797923,545.795225 41.7152778,539.061019 41.7152778,533.532874 L41.7152778,232.467126 Z"></path>
<path id="path-3" d="M0.5,14.5 C0.5,6.7680135 11.6946912,0.5 25.5004128,0.5 L660.499587,0.5 C674.306934,0.5 685.5,11.700539 685.5,14.5 L685.5,0.5 C685.5,8.2319865 675.835363,20.1435837 663.912854,27.1056324 L364.587146,201.894368 C352.664894,208.856266 333.335363,208.856416 321.412854,201.894368 L22.0871456,27.1056324 C10.1648943,20.1437339 0.5,3.29946098 0.5,0.5 L0.5,14.5 Z"></path>
<path id="path-5" d="M0.25,210.83436 C0.25,205.311282 4.11047363,198.57965 8.88680434,195.790552 L335.363196,5.14740399 C340.133171,2.36201694 347.860474,2.35830577 352.636804,5.14740399 L679.113196,195.790552 C683.883171,198.575939 687.75,205.317176 687.75,210.83436 L687.75,557.16564 C687.75,562.688718 683.889526,569.42035 679.113196,572.209448 L352.636804,762.852596 C347.866829,765.637983 340.139526,765.641694 335.363196,762.852596 L8.88680434,572.209448 C4.11682902,569.424061 0.25,562.682824 0.25,557.16564 L0.25,210.83436 Z"></path>
</defs>
<g id="Prod" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="ico-distinguished" sketch:type="MSArtboardGroup">
<g id="Group" sketch:type="MSLayerGroup">
<g id="BG" transform="translate(1.000000, 1.000000)">
<path d="M0.5,210.499753 C0.5,204.977042 4.36114745,198.245315 9.13763731,195.456124 L334.362363,5.5438758 C339.132798,2.75822012 346.861147,2.75468464 351.637637,5.5438758 L676.862363,195.456124 C681.632798,198.24178 685.5,204.983265 685.5,210.499753 L685.5,555.500247 C685.5,561.022958 681.638853,567.754685 676.862363,570.543876 L351.637637,760.456124 C346.867202,763.24178 339.138853,763.245315 334.362363,760.456124 L9.13763731,570.543876 C4.36720195,567.75822 0.5,561.016735 0.5,555.500247 L0.5,210.499753 Z" id="Stroke" fill="#272C2E" sketch:type="MSShapeGroup"></path>
<mask id="mask-2" sketch:name="MASK" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="MASK" sketch:type="MSShapeGroup" xlink:href="#path-1"></use>
<rect id="Color" fill="#00ACFF" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="-15" width="796" height="796"></rect>
<rect id="Band" opacity="0.5" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="321" width="796" height="100"></rect>
<rect id="Band-Copy" opacity="0.4" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="221" width="796" height="100"></rect>
<rect id="Band-Copy-2" opacity="0.2" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="121" width="796" height="100"></rect>
<rect id="Band-Copy-3" opacity="0.2" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="501" width="796" height="100"></rect>
</g>
<g id="Title" transform="translate(1.000000, 552.000000)">
<mask id="mask-4" sketch:name="Mask" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="Mask" fill="#272C2E" sketch:type="MSShapeGroup" xlink:href="#path-3"></use>
<rect id="Rectangle-62" fill="#272C2E" sketch:type="MSShapeGroup" mask="url(#mask-4)" x="-36" y="-100" width="779" height="378"></rect>
</g>
<g id="MASK-+-ribbon">
<mask id="mask-6" sketch:name="Mask" fill="white">
<use xlink:href="#path-5"></use>
</mask>
<use id="Mask" sketch:type="MSShapeGroup" xlink:href="#path-5"></use>
</g>
<path d="M258.034733,393.484295 C212.753563,384.060295 182.183027,352.800198 182.183027,330.27454 L182.183027,308.208589 L241.025562,308.208589 C241.025562,344.295613 248.380879,372.337759 258.034733,393.484295 L258.034733,393.484295 Z M505.816973,330.27454 C505.816973,352.800198 475.246437,384.060295 429.965267,393.484295 C439.619121,372.337759 446.974438,344.295613 446.974438,308.208589 L505.816973,308.208589 L505.816973,330.27454 Z M535.238241,300.853272 C535.238241,288.671028 525.354534,278.787321 513.17229,278.787321 L446.974438,278.787321 L446.974438,256.72137 C446.974438,236.494248 430.424974,219.944785 410.197853,219.944785 L277.802147,219.944785 C257.575026,219.944785 241.025562,236.494248 241.025562,256.72137 L241.025562,278.787321 L174.82771,278.787321 C162.645466,278.787321 152.761759,288.671028 152.761759,300.853272 L152.761759,330.27454 C152.761759,373.946734 205.628099,422.216002 277.34244,425.663807 C286.536586,437.386343 295.041171,444.281953 299.178537,447.499904 C311.360781,458.53288 314.578732,470.025562 314.578732,484.736196 C314.578732,499.44683 307.223415,514.157464 285.157464,514.157464 C263.091513,514.157464 241.025562,528.868098 241.025562,550.934049 L241.025562,565.644683 C241.025562,569.782049 244.243514,573 248.380879,573 L439.619121,573 C443.756486,573 446.974438,569.782049 446.974438,565.644683 L446.974438,550.934049 C446.974438,528.868098 424.908487,514.157464 402.842536,514.157464 C380.776585,514.157464 373.421268,499.44683 373.421268,484.736196 C373.421268,470.025562 376.639219,458.53288 388.821463,447.499904 C392.958829,444.281953 401.463414,437.386343 410.65756,425.663807 C482.371901,422.216002 535.238241,373.946734 535.238241,330.27454 L535.238241,300.853272 Z" id="" fill="#272C2E" sketch:type="MSShapeGroup"></path>
</g>
</g>
</g>
</svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="688px" height="768px" viewBox="0 0 688 768" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
<title>ico-base</title>
<desc>Created with Sketch.</desc>
<defs>
<path id="path-1" d="M41.7152778,232.467126 C41.7152778,226.94113 45.5795391,220.204922 50.3469125,217.421055 L334.368365,51.5691115 C339.135485,48.7853917 346.864261,48.7852438 351.631635,51.5691115 L635.653087,217.421055 C640.420208,220.204775 644.284722,226.938981 644.284722,232.467126 L644.284722,533.532874 C644.284722,539.05887 640.420461,545.795078 635.653087,548.578945 L351.631635,714.430888 C346.864515,717.214608 339.135739,717.214756 334.368365,714.430888 L50.3469125,548.578945 C45.5797923,545.795225 41.7152778,539.061019 41.7152778,533.532874 L41.7152778,232.467126 Z"></path>
<path id="path-3" d="M0.5,14.5 C0.5,6.7680135 11.6946912,0.5 25.5004128,0.5 L660.499587,0.5 C674.306934,0.5 685.5,11.700539 685.5,14.5 L685.5,0.5 C685.5,8.2319865 675.835363,20.1435837 663.912854,27.1056324 L364.587146,201.894368 C352.664894,208.856266 333.335363,208.856416 321.412854,201.894368 L22.0871456,27.1056324 C10.1648943,20.1437339 0.5,3.29946098 0.5,0.5 L0.5,14.5 Z"></path>
<path id="path-5" d="M0.25,210.83436 C0.25,205.311282 4.11047363,198.57965 8.88680434,195.790552 L335.363196,5.14740399 C340.133171,2.36201694 347.860474,2.35830577 352.636804,5.14740399 L679.113196,195.790552 C683.883171,198.575939 687.75,205.317176 687.75,210.83436 L687.75,557.16564 C687.75,562.688718 683.889526,569.42035 679.113196,572.209448 L352.636804,762.852596 C347.866829,765.637983 340.139526,765.641694 335.363196,762.852596 L8.88680434,572.209448 C4.11682902,569.424061 0.25,562.682824 0.25,557.16564 L0.25,210.83436 Z"></path>
</defs>
<g id="Prod" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="ico-base" sketch:type="MSArtboardGroup">
<g id="Group" sketch:type="MSLayerGroup">
<g id="BG" transform="translate(1.000000, 1.000000)">
<path d="M0.5,210.499753 C0.5,204.977042 4.36114745,198.245315 9.13763731,195.456124 L334.362363,5.5438758 C339.132798,2.75822012 346.861147,2.75468464 351.637637,5.5438758 L676.862363,195.456124 C681.632798,198.24178 685.5,204.983265 685.5,210.499753 L685.5,555.500247 C685.5,561.022958 681.638853,567.754685 676.862363,570.543876 L351.637637,760.456124 C346.867202,763.24178 339.138853,763.245315 334.362363,760.456124 L9.13763731,570.543876 C4.36720195,567.75822 0.5,561.016735 0.5,555.500247 L0.5,210.499753 Z" id="Stroke" fill="#272C2E" sketch:type="MSShapeGroup"></path>
<mask id="mask-2" sketch:name="MASK" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="MASK" sketch:type="MSShapeGroup" xlink:href="#path-1"></use>
<rect id="Color" fill="#CED8DC" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="-15" width="796" height="796"></rect>
</g>
<g id="Title" transform="translate(1.000000, 552.000000)">
<mask id="mask-4" sketch:name="Mask" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="Mask" fill="#272C2E" sketch:type="MSShapeGroup" xlink:href="#path-3"></use>
<rect id="Rectangle-62" fill="#272C2E" sketch:type="MSShapeGroup" mask="url(#mask-4)" x="-36" y="-100" width="779" height="378"></rect>
</g>
<g id="MASK-+-ribbon">
<mask id="mask-6" sketch:name="Mask" fill="white">
<use xlink:href="#path-5"></use>
</mask>
<use id="Mask" sketch:type="MSShapeGroup" xlink:href="#path-5"></use>
</g>
<path d="M258.034733,393.484295 C212.753563,384.060295 182.183027,352.800198 182.183027,330.27454 L182.183027,308.208589 L241.025562,308.208589 C241.025562,344.295613 248.380879,372.337759 258.034733,393.484295 L258.034733,393.484295 Z M505.816973,330.27454 C505.816973,352.800198 475.246437,384.060295 429.965267,393.484295 C439.619121,372.337759 446.974438,344.295613 446.974438,308.208589 L505.816973,308.208589 L505.816973,330.27454 Z M535.238241,300.853272 C535.238241,288.671028 525.354534,278.787321 513.17229,278.787321 L446.974438,278.787321 L446.974438,256.72137 C446.974438,236.494248 430.424974,219.944785 410.197853,219.944785 L277.802147,219.944785 C257.575026,219.944785 241.025562,236.494248 241.025562,256.72137 L241.025562,278.787321 L174.82771,278.787321 C162.645466,278.787321 152.761759,288.671028 152.761759,300.853272 L152.761759,330.27454 C152.761759,373.946734 205.628099,422.216002 277.34244,425.663807 C286.536586,437.386343 295.041171,444.281953 299.178537,447.499904 C311.360781,458.53288 314.578732,470.025562 314.578732,484.736196 C314.578732,499.44683 307.223415,514.157464 285.157464,514.157464 C263.091513,514.157464 241.025562,528.868098 241.025562,550.934049 L241.025562,565.644683 C241.025562,569.782049 244.243514,573 248.380879,573 L439.619121,573 C443.756486,573 446.974438,569.782049 446.974438,565.644683 L446.974438,550.934049 C446.974438,528.868098 424.908487,514.157464 402.842536,514.157464 C380.776585,514.157464 373.421268,499.44683 373.421268,484.736196 C373.421268,470.025562 376.639219,458.53288 388.821463,447.499904 C392.958829,444.281953 401.463414,437.386343 410.65756,425.663807 C482.371901,422.216002 535.238241,373.946734 535.238241,330.27454 L535.238241,300.853272 Z" id="" fill="#272C2E" sketch:type="MSShapeGroup"></path>
</g>
</g>
</g>
</svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="688px" height="768px" viewBox="0 0 688 768" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
<title>ico-distinguished</title>
<desc>Created with Sketch.</desc>
<defs>
<path id="path-1" d="M41.7152778,232.467126 C41.7152778,226.94113 45.5795391,220.204922 50.3469125,217.421055 L334.368365,51.5691115 C339.135485,48.7853917 346.864261,48.7852438 351.631635,51.5691115 L635.653087,217.421055 C640.420208,220.204775 644.284722,226.938981 644.284722,232.467126 L644.284722,533.532874 C644.284722,539.05887 640.420461,545.795078 635.653087,548.578945 L351.631635,714.430888 C346.864515,717.214608 339.135739,717.214756 334.368365,714.430888 L50.3469125,548.578945 C45.5797923,545.795225 41.7152778,539.061019 41.7152778,533.532874 L41.7152778,232.467126 Z"></path>
<path id="path-3" d="M0.5,14.5 C0.5,6.7680135 11.6946912,0.5 25.5004128,0.5 L660.499587,0.5 C674.306934,0.5 685.5,11.700539 685.5,14.5 L685.5,0.5 C685.5,8.2319865 675.835363,20.1435837 663.912854,27.1056324 L364.587146,201.894368 C352.664894,208.856266 333.335363,208.856416 321.412854,201.894368 L22.0871456,27.1056324 C10.1648943,20.1437339 0.5,3.29946098 0.5,0.5 L0.5,14.5 Z"></path>
<path id="path-5" d="M0.25,210.83436 C0.25,205.311282 4.11047363,198.57965 8.88680434,195.790552 L335.363196,5.14740399 C340.133171,2.36201694 347.860474,2.35830577 352.636804,5.14740399 L679.113196,195.790552 C683.883171,198.575939 687.75,205.317176 687.75,210.83436 L687.75,557.16564 C687.75,562.688718 683.889526,569.42035 679.113196,572.209448 L352.636804,762.852596 C347.866829,765.637983 340.139526,765.641694 335.363196,762.852596 L8.88680434,572.209448 C4.11682902,569.424061 0.25,562.682824 0.25,557.16564 L0.25,210.83436 Z"></path>
</defs>
<g id="Prod" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="ico-distinguished" sketch:type="MSArtboardGroup">
<g id="Group" sketch:type="MSLayerGroup">
<g id="BG" transform="translate(1.000000, 1.000000)">
<path d="M0.5,210.499753 C0.5,204.977042 4.36114745,198.245315 9.13763731,195.456124 L334.362363,5.5438758 C339.132798,2.75822012 346.861147,2.75468464 351.637637,5.5438758 L676.862363,195.456124 C681.632798,198.24178 685.5,204.983265 685.5,210.499753 L685.5,555.500247 C685.5,561.022958 681.638853,567.754685 676.862363,570.543876 L351.637637,760.456124 C346.867202,763.24178 339.138853,763.245315 334.362363,760.456124 L9.13763731,570.543876 C4.36720195,567.75822 0.5,561.016735 0.5,555.500247 L0.5,210.499753 Z" id="Stroke" fill="#272C2E" sketch:type="MSShapeGroup"></path>
<mask id="mask-2" sketch:name="MASK" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="MASK" sketch:type="MSShapeGroup" xlink:href="#path-1"></use>
<rect id="Color" fill="#00ACFF" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="-15" width="796" height="796"></rect>
<rect id="Band" opacity="0.5" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="321" width="796" height="100"></rect>
<rect id="Band-Copy" opacity="0.4" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="221" width="796" height="100"></rect>
<rect id="Band-Copy-2" opacity="0.2" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="121" width="796" height="100"></rect>
<rect id="Band-Copy-3" opacity="0.2" fill="#F2F2F2" sketch:type="MSShapeGroup" mask="url(#mask-2)" x="-55" y="501" width="796" height="100"></rect>
</g>
<g id="Title" transform="translate(1.000000, 552.000000)">
<mask id="mask-4" sketch:name="Mask" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="Mask" fill="#272C2E" sketch:type="MSShapeGroup" xlink:href="#path-3"></use>
<rect id="Rectangle-62" fill="#272C2E" sketch:type="MSShapeGroup" mask="url(#mask-4)" x="-36" y="-100" width="779" height="378"></rect>
</g>
<g id="MASK-+-ribbon">
<mask id="mask-6" sketch:name="Mask" fill="white">
<use xlink:href="#path-5"></use>
</mask>
<use id="Mask" sketch:type="MSShapeGroup" xlink:href="#path-5"></use>
</g>
<path d="M258.034733,393.484295 C212.753563,384.060295 182.183027,352.800198 182.183027,330.27454 L182.183027,308.208589 L241.025562,308.208589 C241.025562,344.295613 248.380879,372.337759 258.034733,393.484295 L258.034733,393.484295 Z M505.816973,330.27454 C505.816973,352.800198 475.246437,384.060295 429.965267,393.484295 C439.619121,372.337759 446.974438,344.295613 446.974438,308.208589 L505.816973,308.208589 L505.816973,330.27454 Z M535.238241,300.853272 C535.238241,288.671028 525.354534,278.787321 513.17229,278.787321 L446.974438,278.787321 L446.974438,256.72137 C446.974438,236.494248 430.424974,219.944785 410.197853,219.944785 L277.802147,219.944785 C257.575026,219.944785 241.025562,236.494248 241.025562,256.72137 L241.025562,278.787321 L174.82771,278.787321 C162.645466,278.787321 152.761759,288.671028 152.761759,300.853272 L152.761759,330.27454 C152.761759,373.946734 205.628099,422.216002 277.34244,425.663807 C286.536586,437.386343 295.041171,444.281953 299.178537,447.499904 C311.360781,458.53288 314.578732,470.025562 314.578732,484.736196 C314.578732,499.44683 307.223415,514.157464 285.157464,514.157464 C263.091513,514.157464 241.025562,528.868098 241.025562,550.934049 L241.025562,565.644683 C241.025562,569.782049 244.243514,573 248.380879,573 L439.619121,573 C443.756486,573 446.974438,569.782049 446.974438,565.644683 L446.974438,550.934049 C446.974438,528.868098 424.908487,514.157464 402.842536,514.157464 C380.776585,514.157464 373.421268,499.44683 373.421268,484.736196 C373.421268,470.025562 376.639219,458.53288 388.821463,447.499904 C392.958829,444.281953 401.463414,437.386343 410.65756,425.663807 C482.371901,422.216002 535.238241,373.946734 535.238241,330.27454 L535.238241,300.853272 Z" id="" fill="#272C2E" sketch:type="MSShapeGroup"></path>
</g>
</g>
</g>
</svg>
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment