diff --git a/lms/envs/common.py b/lms/envs/common.py index 3be88b5932..556b21599e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1129,6 +1129,7 @@ verify_student_js = [ 'js/src/string_utils.js', 'js/verify_student/models/verification_model.js', 'js/verify_student/views/error_view.js', + 'js/verify_student/views/image_input_view.js', 'js/verify_student/views/webcam_photo_view.js', 'js/verify_student/views/step_view.js', 'js/verify_student/views/intro_step_view.js', diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 21d4bc2b03..1911feca4a 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -423,6 +423,16 @@ }, 'js/verify_student/views/webcam_photo_view': { exports: 'edx.verify_student.WebcamPhotoView', + deps: [ + 'jquery', + 'underscore', + 'backbone', + 'gettext', + 'js/verify_student/views/image_input_view' + ] + }, + 'js/verify_student/views/image_input_view': { + exports: 'edx.verify_student.ImageInputView', deps: [ 'jquery', 'underscore', 'backbone', 'gettext' ] }, 'js/verify_student/views/step_view': { @@ -540,6 +550,7 @@ 'lms/include/js/spec/student_profile/profile_spec.js', 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', + 'lms/include/js/spec/verify_student/image_input_spec.js', 'lms/include/js/spec/verify_student/review_photos_step_view_spec.js', 'lms/include/js/spec/verify_student/make_payment_step_view_spec.js', 'lms/include/js/spec/edxnotes/utils/logger_spec.js', diff --git a/lms/static/js/spec/verify_student/image_input_spec.js b/lms/static/js/spec/verify_student/image_input_spec.js new file mode 100644 index 0000000000..5f9d474786 --- /dev/null +++ b/lms/static/js/spec/verify_student/image_input_spec.js @@ -0,0 +1,156 @@ +define([ + 'jquery', + 'backbone', + 'js/common_helpers/template_helpers', + 'js/common_helpers/ajax_helpers', + 'js/verify_student/views/image_input_view', + 'js/verify_student/models/verification_model' +], function( $, Backbone, TemplateHelpers, AjaxHelpers, ImageInputView, VerificationModel ) { + 'use strict'; + + describe( 'edx.verify_student.ImageInputView', function() { + + var IMAGE_DATA = 'abcd1234'; + + var createView = function() { + return new ImageInputView({ + el: $( '#current-step-container' ), + model: new VerificationModel({}), + modelAttribute: 'faceImage', + errorModel: new ( Backbone.Model.extend({}) )(), + submitButton: $( '#submit_button' ), + }).render(); + }; + + var uploadImage = function( view, fileType, callback ) { + var imageCapturedEvent = false, + errorEvent = false; + + // Since image upload is an asynchronous process, + // we need to wait for the upload to complete + // before checking the outcome. + runs(function() { + var fakeFile, + fakeEvent = { target: { files: [] } }; + + // If no file type is specified, don't add any files. + // This simulates what happens when the user clicks + // "cancel" after clicking the input. + if ( fileType !== null) { + fakeFile = new Blob( + [ IMAGE_DATA ], + { type: 'image/' + fileType } + ); + fakeEvent.target.files = [ fakeFile ]; + } + + // Wait for either a successful upload or an error + view.on( 'imageCaptured', function() { + imageCapturedEvent = true; + }); + view.on( 'error', function() { + errorEvent = true; + }); + + // Trigger the file input change + // It's impossible to trigger this directly due + // to browser security restrictions, so we call + // the handler instead. + view.handleInputChange( fakeEvent ); + }); + + // Check that the image upload has completed, + // either successfully or with an error. + waitsFor(function() { + return ( imageCapturedEvent || errorEvent ); + }); + + // Execute the callback to check expectations. + runs( callback ); + }; + + var expectPreview = function( view, fileType ) { + var previewImage = view.$preview.attr('src'); + if ( fileType ) { + expect( previewImage ).toContain( 'data:image/' + fileType ); + } else { + expect( previewImage ).toEqual( '' ); + } + }; + + var expectSubmitEnabled = function( isEnabled ) { + var appearsDisabled = $( '#submit_button' ).hasClass( 'is-disabled' ), + isDisabled = $( '#submit_button' ).prop( 'disabled' ); + + expect( !appearsDisabled ).toEqual( isEnabled ); + expect( !isDisabled ).toEqual( isEnabled ); + }; + + var expectImageData = function( view, fileType ) { + var imageData = view.model.get( view.modelAttribute ); + if ( fileType ) { + expect( imageData ).toContain( 'data:image/' + fileType ); + } else { + expect( imageData ).toEqual( '' ); + } + }; + + var expectError = function( view ) { + expect( view.errorModel.get('shown') ).toBe(true); + }; + + beforeEach(function() { + setFixtures( + '
' + + '' + ); + TemplateHelpers.installTemplate( 'templates/verify_student/image_input' ); + }); + + it( 'initially disables the submit button', function() { + createView(); + expectSubmitEnabled( false ); + }); + + it( 'uploads a png image', function() { + var view = createView(); + + uploadImage( view, 'png', function() { + expectPreview( view, 'png' ); + expectSubmitEnabled( true ); + expectImageData( view, 'png' ); + }); + }); + + it( 'uploads a jpeg image', function() { + var view = createView(); + + uploadImage( view, 'jpeg', function() { + expectPreview( view, 'jpeg' ); + expectSubmitEnabled( true ); + expectImageData( view, 'jpeg' ); + } ); + }); + + it( 'hides the preview when the user cancels the upload', function() { + var view = createView(); + + uploadImage( view, null, function() { + expectPreview( view, null ); + expectSubmitEnabled( false ); + expectImageData( view, null ); + } ); + }); + + it( 'shows an error if the file type is not supported', function() { + var view = createView(); + + uploadImage( view, 'txt', function() { + expectPreview( view, null ); + expectError( view ); + expectSubmitEnabled( false ); + expectImageData( view, null ); + } ); + }); + }); +}); diff --git a/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js index 0998945932..b700846eae 100644 --- a/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js +++ b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js @@ -13,7 +13,8 @@ define(['jquery', 'js/common_helpers/template_helpers', 'js/verify_student/views 'make_payment_step', 'payment_confirmation_step', 'review_photos_step', - 'webcam_photo' + 'webcam_photo', + 'image_input' ]; var INTRO_STEP = { diff --git a/lms/static/js/spec/verify_student/webcam_photo_view_spec.js b/lms/static/js/spec/verify_student/webcam_photo_view_spec.js index 9a4fc13c71..c9abb4aa71 100644 --- a/lms/static/js/spec/verify_student/webcam_photo_view_spec.js +++ b/lms/static/js/spec/verify_student/webcam_photo_view_spec.js @@ -45,14 +45,14 @@ define([ }; }; - var createView = function( backends ) { + var createView = function( backendStub ) { return new WebcamPhotoView({ el: $( '#current-step-container' ), model: new VerificationModel({}), modelAttribute: 'faceImage', errorModel: new ( Backbone.Model.extend({}) )(), submitButton: $( '#submit_button' ), - backends: backends + backend: backendStub }).render(); }; @@ -91,7 +91,7 @@ define([ }); it( 'takes a snapshot', function() { - var view = createView( [ StubBackend( "html5" ) ] ); + var view = createView( new StubBackend( "html5" ) ); // Spy on the backend spyOn( view.backend, 'snapshot' ).andCallThrough(); @@ -122,7 +122,7 @@ define([ }); it( 'resets the camera', function() { - var view = createView( [ StubBackend( "html5" ) ]); + var view = createView( new StubBackend( "html5" ) ); // Spy on the backend spyOn( view.backend, 'reset' ).andCallThrough(); @@ -145,30 +145,8 @@ define([ expect( view.model.get( 'faceImage' ) ).toEqual( "" ); }); - it( 'falls back to a second video capture backend', function() { - var backends = [ StubBackend( "html5", false ), StubBackend( "flash", true ) ], - view = createView( backends ); - - // Expect that the second backend is chosen - expect( view.backend.name ).toEqual( backends[1].name ); - }); - - it( 'displays an error if no video backend is supported', function() { - var backends = [ StubBackend( "html5", false ), StubBackend( "flash", false ) ], - view = createView( backends ); - - // Expect an error - expect( view.errorModel.get( 'errorTitle' ) ).toEqual( 'Flash Not Detected' ); - expect( view.errorModel.get( 'errorMsg' ) ).toContain( 'Get Flash' ); - expect( view.errorModel.get( 'shown' ) ).toBe( true ); - - // Expect that submission is disabled - expectSubmitEnabled( false ); - }); - it( 'displays an error if the snapshot fails', function() { - var backends = [ StubBackend( "html5", true, false ) ], - view = createView( backends ); + var view = createView( new StubBackend( "html5", true, false ) ); // Take a snapshot takeSnapshot(); @@ -189,7 +167,7 @@ define([ }); it( 'displays an error triggered by the backend', function() { - var view = createView( [ StubBackend( "html5") ] ); + var view = createView( new StubBackend( "html5") ); // Simulate an error triggered by the backend // This could occur at any point, including diff --git a/lms/static/js/verify_student/views/error_view.js b/lms/static/js/verify_student/views/error_view.js index 861209628f..085cb78336 100644 --- a/lms/static/js/verify_student/views/error_view.js +++ b/lms/static/js/verify_student/views/error_view.js @@ -17,7 +17,7 @@ errorMsg: "", shown: false }); - this.listenToOnce( this.model, 'change', this.render ); + this.listenTo( this.model, 'change', this.render ); }, render: function() { diff --git a/lms/static/js/verify_student/views/face_photo_step_view.js b/lms/static/js/verify_student/views/face_photo_step_view.js index f1d573cf53..c962eaab8e 100644 --- a/lms/static/js/verify_student/views/face_photo_step_view.js +++ b/lms/static/js/verify_student/views/face_photo_step_view.js @@ -17,7 +17,7 @@ var edx = edx || {}; }, postRender: function() { - var webcam = new edx.verify_student.WebcamPhotoView({ + var webcam = edx.verify_student.getSupportedWebcamView({ el: $( '#facecam' ), model: this.model, modelAttribute: 'faceImage', diff --git a/lms/static/js/verify_student/views/id_photo_step_view.js b/lms/static/js/verify_student/views/id_photo_step_view.js index 60a02e4c7b..289e772017 100644 --- a/lms/static/js/verify_student/views/id_photo_step_view.js +++ b/lms/static/js/verify_student/views/id_photo_step_view.js @@ -17,7 +17,7 @@ var edx = edx || {}; }, postRender: function() { - var webcam = new edx.verify_student.WebcamPhotoView({ + var webcam = edx.verify_student.getSupportedWebcamView({ el: $( '#idcam' ), model: this.model, modelAttribute: 'identificationImage', diff --git a/lms/static/js/verify_student/views/image_input_view.js b/lms/static/js/verify_student/views/image_input_view.js new file mode 100644 index 0000000000..4c6724270b --- /dev/null +++ b/lms/static/js/verify_student/views/image_input_view.js @@ -0,0 +1,112 @@ +/** + * Allow users to upload an image using a file input. + * + * This uses HTML Media Capture so that iOS will + * allow users to use their camera instead of choosing + * a file. + */ + + var edx = edx || {}; + + (function( $, _, Backbone, gettext ) { + 'use strict'; + + edx.verify_student = edx.verify_student || {}; + + edx.verify_student.ImageInputView = Backbone.View.extend({ + + template: '#image_input-tpl', + + initialize: function( obj ) { + this.$submitButton = obj.submitButton ? $( obj.submitButton ) : ''; + this.modelAttribute = obj.modelAttribute || ''; + this.errorModel = obj.errorModel || null; + }, + + render: function() { + var renderedHtml = _.template( $( this.template ).html(), {} ); + $( this.el ).html( renderedHtml ); + + // Set the submit button to disabled by default + this.setSubmitButtonEnabled( false ); + + this.$input = $( 'input.image-upload' ); + this.$preview = $( 'img.preview' ); + this.$input.on('change', _.bind( this.handleInputChange, this ) ); + + // Initially hide the preview + this.displayImage( false ); + + return this; + }, + + handleInputChange: function( event ) { + var files = event.target.files, + reader = new FileReader(); + if ( files[0] && files[0].type.match( 'image.[png|jpg|jpeg]' ) ) { + reader.onload = _.bind( this.handleImageUpload, this ); + reader.onerror = _.bind( this.handleUploadError, this ); + reader.readAsDataURL( files[0] ); + } else if ( files.length === 0 ) { + this.handleUploadError( false ); + } else { + this.handleUploadError( true ); + } + }, + + handleImageUpload: function( event ) { + var imageData = event.target.result; + this.model.set( this.modelAttribute, imageData ); + this.displayImage( imageData ); + this.setSubmitButtonEnabled( true ); + + // Hide any errors we may have displayed previously + if ( this.errorModel ) { + this.errorModel.set({ shown: false }); + } + + this.trigger( 'imageCaptured' ); + }, + + displayImage: function( imageData ) { + if ( imageData ) { + this.$preview + .attr( 'src', imageData ) + .removeClass('is-hidden') + .attr('aria-hidden', 'false'); + } else { + this.$preview + .attr( 'src', '' ) + .addClass('is-hidden') + .attr('aria-hidden', 'true'); + } + }, + + handleUploadError: function( displayError ) { + this.displayImage( null ); + this.setSubmitButtonEnabled( false ); + if ( this.errorModel ) { + if ( displayError ) { + this.errorModel.set({ + errorTitle: gettext( 'Image Upload Error' ), + errorMsg: gettext( 'Please verify that you have uploaded a valid image (PNG and JPEG).' ), + shown: true + }); + } else { + this.errorModel.set({ + shown: false + }); + } + } + this.trigger( 'error' ); + }, + + setSubmitButtonEnabled: function( isEnabled ) { + this.$submitButton + .toggleClass( 'is-disabled', !isEnabled ) + .prop( 'disabled', !isEnabled ) + .attr('aria-disabled', !isEnabled); + } + }); + + })( jQuery, _, Backbone, gettext ); diff --git a/lms/static/js/verify_student/views/webcam_photo_view.js b/lms/static/js/verify_student/views/webcam_photo_view.js index 1a760c9015..ee2245a216 100644 --- a/lms/static/js/verify_student/views/webcam_photo_view.js +++ b/lms/static/js/verify_student/views/webcam_photo_view.js @@ -1,6 +1,6 @@ /** * Interface for retrieving webcam photos. - * Supports both HTML5 and Flash. + * Supports HTML5 and Flash. */ var edx = edx || {}; @@ -13,8 +13,8 @@ template: "#webcam_photo-tpl", - backends: [ - { + backends: { + "html5": { name: "html5", initialize: function( obj ) { @@ -24,18 +24,21 @@ this.stream = null; // Start the capture - this.getUserMediaFunc()( - { - video: true, + var getUserMedia = this.getUserMediaFunc(); + if ( getUserMedia ) { + getUserMedia( + { + video: true, - // Specify the `fake` constraint if we detect we are running in a test - // environment. In Chrome, this will do nothing, but in Firefox, it will - // instruct the browser to use a fake video device. - fake: window.location.hostname === 'localhost' - }, - _.bind( this.getUserMediaCallback, this ), - _.bind( this.handleVideoFailure, this ) - ); + // Specify the `fake` constraint if we detect we are running in a test + // environment. In Chrome, this will do nothing, but in Firefox, it will + // instruct the browser to use a fake video device. + fake: window.location.hostname === 'localhost' + }, + _.bind( this.getUserMediaCallback, this ), + _.bind( this.handleVideoFailure, this ) + ); + } }, isSupported: function() { @@ -98,16 +101,14 @@ } }, - { + "flash": { + name: "flash", initialize: function( obj ) { this.wrapper = obj.wrapper || ""; this.imageData = ""; - // Replace the camera section with the flash object - $( this.wrapper ).html( this.flashObjectTag() ); - // Wait for the player to load, then verify camera support // Trigger an error if no camera is available. this.checkCameraSupported(); @@ -203,36 +204,26 @@ // so we don't need to keep checking. } } - ], + }, initialize: function( obj ) { this.submitButton = obj.submitButton || ""; this.modelAttribute = obj.modelAttribute || ""; this.errorModel = obj.errorModel || null; - this.backend = _.find( - obj.backends || this.backends, - function( backend ) { - return backend.isSupported(); - } - ); + this.backend = this.backends[obj.backendName] || obj.backend; - if ( !this.backend ) { - this.handleError( - gettext( "Flash Not Detected" ), - gettext( "You don't seem to have Flash installed." ) + " " + - _.sprintf( - gettext( "%(a_start)s Get Flash %(a_end)s to continue your enrollment." ), - { - a_start: '', - a_end: '' - } - ) - ); - } - else { - _.extend( this.backend, Backbone.Events ); - this.listenTo( this.backend, 'error', this.handleError ); - } + this.backend.initialize({ + wrapper: "#camera", + video: '#photo_id_video', + canvas: '#photo_id_canvas' + }); + + _.extend( this.backend, Backbone.Events ); + this.listenTo( this.backend, 'error', this.handleError ); + }, + + isSupported: function() { + return this.backend.isSupported(); }, render: function() { @@ -242,26 +233,18 @@ this.setSubmitButtonEnabled( false ); // Load the template for the webcam into the DOM - renderedHtml = _.template( $( this.template ).html(), {} ); + renderedHtml = _.template( + $( this.template ).html(), + { backendName: this.backend.name } + ); $( this.el ).html( renderedHtml ); - // Initialize the video capture backend - // We need to do this after rendering the template - // so that the backend has the opportunity to modify the DOM. - if ( this.backend ) { - this.backend.initialize({ - wrapper: "#camera", - video: '#photo_id_video', - canvas: '#photo_id_canvas' - }); + // Install event handlers + $( "#webcam_reset_button", this.el ).on( 'click', _.bind( this.reset, this ) ); + $( "#webcam_capture_button", this.el ).on( 'click', _.bind( this.capture, this ) ); - // Install event handlers - $( "#webcam_reset_button", this.el ).on( 'click', _.bind( this.reset, this ) ); - $( "#webcam_capture_button", this.el ).on( 'click', _.bind( this.capture, this ) ); - - // Show the capture button - $( "#webcam_capture_button", this.el ).removeClass('is-hidden'); - } + // Show the capture button + $( "#webcam_capture_button", this.el ).removeClass('is-hidden'); return this; }, @@ -325,4 +308,38 @@ } }); + /** + * Retrieve a supported webcam view implementation. + * + * The priority order from most to least preferable is: + * 1) HTML5 + * 2) Flash + * 3) File input + * + * @param {Object} obj Parameters to the webcam view. + * @return {Object} A Backbone view. + */ + edx.verify_student.getSupportedWebcamView = function( obj ) { + var view = null; + + // First choice is HTML5, supported by most web browsers + obj.backendName = "html5"; + view = new edx.verify_student.WebcamPhotoView( obj ); + if ( view.isSupported() ) { + return view; + } + + // Second choice is Flash, required for older versions of IE + obj.backendName = "flash"; + view = new edx.verify_student.WebcamPhotoView( obj ); + if ( view.isSupported() ) { + return view; + } + + // Last resort is HTML file input with image capture. + // This will work everywhere, and on iOS it will + // allow users to take a photo with the camera. + return new edx.verify_student.ImageInputView( obj ); + }; + })( jQuery, _, Backbone, gettext ); diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 2d532225d2..9549b2093a 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -973,6 +973,11 @@ .controls { height: ($baseline*4); } + + .preview { + width: 100%; + height: 100%; + } } // ==================== diff --git a/lms/templates/verify_student/image_input.underscore b/lms/templates/verify_student/image_input.underscore new file mode 100644 index 0000000000..20f50691fd --- /dev/null +++ b/lms/templates/verify_student/image_input.underscore @@ -0,0 +1,5 @@ +<%- gettext("/> + diff --git a/lms/templates/verify_student/pay_and_verify.html b/lms/templates/verify_student/pay_and_verify.html index d5aef5cf8f..3e84f6c047 100644 --- a/lms/templates/verify_student/pay_and_verify.html +++ b/lms/templates/verify_student/pay_and_verify.html @@ -23,7 +23,7 @@ from verify_student.views import PayAndVerifyView <%block name="header_extras"> <% template_names = ( - ["webcam_photo", "error"] + + ["webcam_photo", "image_input", "error"] + [step['templateName'] for step in display_steps] ) %> diff --git a/lms/templates/verify_student/webcam_photo.underscore b/lms/templates/verify_student/webcam_photo.underscore index 7315067d97..69da45b7ae 100644 --- a/lms/templates/verify_student/webcam_photo.underscore +++ b/lms/templates/verify_student/webcam_photo.underscore @@ -1,10 +1,22 @@
-
-

<%- gettext( "Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission." ) %>

-
+ <% if ( backendName === 'html5' ) { %> +
+

<%- gettext( "Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission." ) %>

+
-
- +
+ + <% } else if ( backendName === 'flash' ) { %> + + + + + <% } %>