From 7f2ddba089d0572f4b59b656bf01b319aaaaf730 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Wed, 2 Dec 2015 11:56:38 -0800 Subject: [PATCH] Add remove student modal to ClassroomView --- app/models/Classroom.coffee | 9 +++ app/models/CourseInstance.coffee | 9 +++ app/styles/courses/remove-student-modal.sass | 3 + app/templates/courses/classroom-view.jade | 4 + .../courses/remove-student-modal.jade | 27 +++++++ app/views/courses/ClassroomView.coffee | 18 ++++- app/views/courses/RemoveStudentModal.coffee | 37 +++++++++ server/classrooms/classroom_handler.coffee | 20 +++++ server/courses/course_instance_handler.coffee | 25 +++++++ test/server/functional/classrooms.spec.coffee | 32 +++++++- .../functional/course_instance.spec.coffee | 75 ++++++++++++++++++- 11 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 app/styles/courses/remove-student-modal.sass create mode 100644 app/templates/courses/remove-student-modal.jade create mode 100644 app/views/courses/RemoveStudentModal.coffee diff --git a/app/models/Classroom.coffee b/app/models/Classroom.coffee index 5d14dca94..e4a9de914 100644 --- a/app/models/Classroom.coffee +++ b/app/models/Classroom.coffee @@ -13,4 +13,13 @@ module.exports = class Classroom extends CocoModel data: { code: code } } _.extend options, opts + @fetch(options) + + removeMember: (userID, opts) -> + options = { + url: _.result(@, 'url') + '/members' + type: 'DELETE' + data: { userID: userID } + } + _.extend options, opts @fetch(options) \ No newline at end of file diff --git a/app/models/CourseInstance.coffee b/app/models/CourseInstance.coffee index e9e8e68a8..5ec9cda11 100644 --- a/app/models/CourseInstance.coffee +++ b/app/models/CourseInstance.coffee @@ -23,5 +23,14 @@ module.exports = class CourseInstance extends CocoModel _.extend options, opts @fetch(options) + removeMember: (userID, opts) -> + options = { + url: _.result(@, 'url') + '/members' + type: 'DELETE' + data: { userID: userID } + } + _.extend options, opts + @fetch(options) + firstLevelURL: -> "/play/level/dungeons-of-kithgard?course=#{@get('courseID')}&course-instance=#{@id}" diff --git a/app/styles/courses/remove-student-modal.sass b/app/styles/courses/remove-student-modal.sass new file mode 100644 index 000000000..0c054e546 --- /dev/null +++ b/app/styles/courses/remove-student-modal.sass @@ -0,0 +1,3 @@ +#remove-student-modal + .glyphicon-warning-sign + font-size: 40px \ No newline at end of file diff --git a/app/templates/courses/classroom-view.jade b/app/templates/courses/classroom-view.jade index 2ef694cdf..8768ca1d8 100644 --- a/app/templates/courses/classroom-view.jade +++ b/app/templates/courses/classroom-view.jade @@ -29,6 +29,10 @@ block content hr for user in view.users.models + a.remove-student-link.pull-right.text-uppercase(data-user-id=user.id) + span.glyphicon.glyphicon-remove + span.spl remove student + h2= user.broadName() - var lastPlayedString = view.makeLastPlayedString(user); if lastPlayedString diff --git a/app/templates/courses/remove-student-modal.jade b/app/templates/courses/remove-student-modal.jade new file mode 100644 index 000000000..d096f2066 --- /dev/null +++ b/app/templates/courses/remove-student-modal.jade @@ -0,0 +1,27 @@ +extends /templates/core/modal-base + +block modal-header-content + .text-center + h3.modal-title Remove Student + span.glyphicon.glyphicon-warning-sign.text-danger + h3 Are you sure you want to remove this student from this class? + +block modal-body-content + p.text-center + | Student will lose access to this classroom and assigned classes. + | Progress and gameplay is NOT lost, and the student can be added back to the classroom at any time. + if view.user.get('coursePrepaidID') + | The activated paid license will not be returned. + +block modal-footer-content + #remove-student-buttons.text-center + p + button.btn.btn-lg.btn-success.text-uppercase(data-dismiss="modal") Keep Student + p - OR - + p + button#remove-student-btn.btn.btn-lg.btn-default.text-uppercase Remove Student + + #remove-student-progress.text-center.hide + .progress + .progress-bar + p.text-info Removing user \ No newline at end of file diff --git a/app/views/courses/ClassroomView.coffee b/app/views/courses/ClassroomView.coffee index 576b04b26..d5e71e60e 100644 --- a/app/views/courses/ClassroomView.coffee +++ b/app/views/courses/ClassroomView.coffee @@ -12,6 +12,7 @@ Prepaid = require 'models/Prepaid' ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal' ActivateLicensesModal = require 'views/courses/ActivateLicensesModal' InviteToClassroomModal = require 'views/courses/InviteToClassroomModal' +RemoveStudentModal = require 'views/courses/RemoveStudentModal' module.exports = class ClassroomView extends RootView id: 'classroom-view' @@ -23,6 +24,7 @@ module.exports = class ClassroomView extends RootView 'click .activate-single-license-btn': 'onClickActivateSingleLicenseButton' 'click #add-students-btn': 'onClickAddStudentsButton' 'click .enable-btn': 'onClickEnableButton' + 'click .remove-student-link': 'onClickRemoveStudentLink' initialize: (options, classroomID) -> @classroom = new Classroom({_id: classroomID}) @@ -107,4 +109,18 @@ module.exports = class ClassroomView extends RootView userID = $(e.target).data('user-id') courseInstance.addMember(userID) $(e.target).attr('disabled', true) - @listenToOnce courseInstance, 'sync', @render \ No newline at end of file + @listenToOnce courseInstance, 'sync', @render + + onClickRemoveStudentLink: (e) -> + user = @users.get($(e.target).closest('a').data('user-id')) + modal = new RemoveStudentModal({ + classroom: @classroom + user: user + courseInstances: @courseInstances + }) + @openModalView(modal) + modal.once 'remove-student', @onStudentRemoved, @ + + onStudentRemoved: (e) -> + @users.remove(e.user) + @render() \ No newline at end of file diff --git a/app/views/courses/RemoveStudentModal.coffee b/app/views/courses/RemoveStudentModal.coffee new file mode 100644 index 000000000..5e23bb0a2 --- /dev/null +++ b/app/views/courses/RemoveStudentModal.coffee @@ -0,0 +1,37 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/courses/remove-student-modal' + +module.exports = class RemoveStudentModal extends ModalView + id: 'remove-student-modal' + template: template + + events: + 'click #remove-student-btn': 'onClickRemoveStudentButton' + + initialize: (options) -> + @classroom = options.classroom + @user = options.user + @courseInstances = options.courseInstances + + onClickRemoveStudentButton: -> + @$('#remove-student-buttons').addClass('hide') + @$('#remove-student-progress').removeClass('hide') + userID = @user.id + @toRemove = @courseInstances.filter (courseInstance) -> _.contains(courseInstance.get('members'), userID) + @toRemove.push @classroom + @totalJobs = _.size(@toRemove) + @removeStudent() + + removeStudent: -> + model = @toRemove.shift() + if not model + @trigger 'remove-student', { user: @user } + @hide() + return + + model.removeMember(@user.id) + pct = (100 * (@totalJobs - @toRemove.length) / @totalJobs).toFixed(1) + '%' + @$('#remove-student-progress .progress-bar').css('width', pct) + @listenToOnce model, 'sync', -> + @removeStudent() + \ No newline at end of file diff --git a/server/classrooms/classroom_handler.coffee b/server/classrooms/classroom_handler.coffee index 1175172f8..5674d93e0 100644 --- a/server/classrooms/classroom_handler.coffee +++ b/server/classrooms/classroom_handler.coffee @@ -36,6 +36,7 @@ ClassroomHandler = class ClassroomHandler extends Handler method = req.method.toLowerCase() return @inviteStudents(req, res, args[0]) if args[1] is 'invite-members' return @joinClassroomAPI(req, res, args[0]) if method is 'post' and args[1] is 'members' + return @removeMember(req, res, args[0]) if req.method is 'DELETE' and args[1] is 'members' return @getMembersAPI(req, res, args[0]) if args[1] is 'members' super(arguments...) @@ -65,6 +66,25 @@ ClassroomHandler = class ClassroomHandler extends Handler classroom.set('members', members) return @sendSuccess(res, @formatEntity(req, classroom)) + removeMember: (req, res, classroomID) -> + userID = req.body.userID + return @sendBadInputError(res, 'Input must be a MongoDB ID') unless utils.isID(userID) + Classroom.findById classroomID, (err, classroom) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Classroom referenced by course instance not found') unless classroom + return @sendForbiddenError(res) unless _.any(classroom.get('members'), (memberID) -> memberID.toString() is userID) + ownsClassroom = classroom.get('ownerID').equals(req.user.get('_id')) + removingSelf = userID is req.user.id + return @sendForbiddenError(res) unless ownsClassroom or removingSelf + alreadyNotInClassroom = not _.any classroom.get('members') or [], (memberID) -> memberID.toString() is userID + return @sendSuccess(res, @formatEntity(req, classroom)) if alreadyNotInClassroom + members = _.clone(classroom.get('members')) + members.splice(members.indexOf(userID), 1) + classroom.set('members', members) + classroom.save (err, classroom) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, @formatEntity(req, classroom)) + formatEntity: (req, doc) -> if req.user?.isAdmin() or req.user?.get('_id').equals(doc.get('ownerID')) return doc.toObject() diff --git a/server/courses/course_instance_handler.coffee b/server/courses/course_instance_handler.coffee index 311400994..59ac5f352 100644 --- a/server/courses/course_instance_handler.coffee +++ b/server/courses/course_instance_handler.coffee @@ -35,6 +35,7 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler return @createHOCAPI(req, res) if relationship is 'create-for-hoc' return @getLevelSessionsAPI(req, res, args[0]) if args[1] is 'level_sessions' return @addMember(req, res, args[0]) if req.method is 'POST' and args[1] is 'members' + return @removeMember(req, res, args[0]) if req.method is 'DELETE' and args[1] is 'members' return @getMembersAPI(req, res, args[0]) if args[1] is 'members' return @inviteStudents(req, res, args[0]) if relationship is 'invite_students' return @redeemPrepaidCodeAPI(req, res) if args[1] is 'redeem_prepaid' @@ -94,6 +95,30 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler return @sendDatabaseError(res, err) if err @sendSuccess(res, @formatEntity(req, courseInstance)) + removeMember: (req, res, courseInstanceID) -> + userID = req.body.userID + return @sendBadInputError(res, 'Input must be a MongoDB ID') unless utils.isID(userID) + CourseInstance.findById courseInstanceID, (err, courseInstance) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Course instance not found') unless courseInstance + Classroom.findById courseInstance.get('classroomID'), (err, classroom) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Classroom referenced by course instance not found') unless classroom + return @sendForbiddenError(res) unless _.any(classroom.get('members'), (memberID) -> memberID.toString() is userID) + ownsCourseInstance = courseInstance.get('ownerID').equals(req.user.get('_id')) + removingSelf = userID is req.user.id + return @sendForbiddenError(res) unless ownsCourseInstance or removingSelf + alreadyNotInCourseInstance = not _.any courseInstance.get('members') or [], (memberID) -> memberID.toString() is userID + return @sendSuccess(res, @formatEntity(req, courseInstance)) if alreadyNotInCourseInstance + members = _.clone(courseInstance.get('members')) + members.splice(members.indexOf(userID), 1) + courseInstance.set('members', members) + courseInstance.save (err, courseInstance) => + return @sendDatabaseError(res, err) if err + User.update {_id: mongoose.Types.ObjectId(userID)}, {$pull: {courseInstances: courseInstance.get('_id')}}, (err) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, @formatEntity(req, courseInstance)) + post: (req, res) -> return @sendBadInputError(res, 'No classroomID') unless req.body.classroomID return @sendBadInputError(res, 'No courseID') unless req.body.courseID diff --git a/test/server/functional/classrooms.spec.coffee b/test/server/functional/classrooms.spec.coffee index 4bd176b1c..72fcaff29 100644 --- a/test/server/functional/classrooms.spec.coffee +++ b/test/server/functional/classrooms.spec.coffee @@ -111,7 +111,7 @@ describe 'PUT /db/classroom', -> expect(res.statusCode).toBe(403) done() -describe 'POST /db/classroom/:id/members', -> +describe 'POST /db/classroom/~/members', -> it 'clears database users and classrooms', (done) -> clearModels [User, Classroom], (err) -> @@ -135,6 +135,36 @@ describe 'POST /db/classroom/:id/members', -> done() +describe 'DELETE /db/classroom/:id/members', -> + + it 'clears database users and classrooms', (done) -> + clearModels [User, Classroom], (err) -> + throw err if err + done() + + it 'removes the given user from the list of members in the classroom', (done) -> + loginNewUser (user1) -> + data = { name: 'Classroom 6' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + classroomCode = body.code + classroomID = body._id + expect(res.statusCode).toBe(200) + loginNewUser (user2) -> + url = getURL("/db/classroom/~/members") + data = { code: classroomCode } + request.post { uri: url, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + Classroom.findById classroomID, (err, classroom) -> + expect(classroom.get('members').length).toBe(1) + url = getURL("/db/classroom/#{classroom.id}/members") + data = { userID: user2.id } + request.del { uri: url, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + Classroom.findById classroomID, (err, classroom) -> + expect(classroom.get('members').length).toBe(0) + done() + + describe 'POST /db/classroom/:id/invite-members', -> it 'takes a list of emails and sends invites', (done) -> diff --git a/test/server/functional/course_instance.spec.coffee b/test/server/functional/course_instance.spec.coffee index fe7d154ae..114d079c6 100644 --- a/test/server/functional/course_instance.spec.coffee +++ b/test/server/functional/course_instance.spec.coffee @@ -85,8 +85,19 @@ describe 'POST /db/course_instance/:id/members', -> cb() ], makeTestIterator(@), done) - + it 'adds the CourseInstance id to the user', (done) -> + async.eachSeries([ + + addTestUserToClassroom, + (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.post {uri: url, json: {userID: test.user.id}}, (err, res, body) -> + User.findById(test.user.id).exec (err, user) -> + expect(_.size(user.get('courseInstances'))).toBe(1) + cb() + ], makeTestIterator(@), done) + it 'return 403 if the member is not in the classroom', (done) -> async.eachSeries([ @@ -155,5 +166,67 @@ describe 'POST /db/course_instance/:id/members', -> test.prepaid.set('redeemers', [{userID: test.user.get('_id')}]) test.prepaid.save cb + +describe 'DELETE /db/course_instance/:id/members', -> + + beforeEach (done) -> clearModels([CourseInstance, Course, User, Classroom, Prepaid], done) + beforeEach (done) -> loginJoe (@joe) => done() + beforeEach init.course({free: true}) + beforeEach init.classroom() + beforeEach init.courseInstance() + beforeEach init.user() + beforeEach init.prepaid() + + it 'removes a member to the given CourseInstance', (done) -> + async.eachSeries([ + + addTestUserToClassroom, + addTestUserToCourseInstance, + (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.del {uri: url, json: {userID: test.user.id}}, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.members.length).toBe(0) + cb() + + ], makeTestIterator(@), done) + + it 'removes the CourseInstance from the User.courseInstances', (done) -> + async.eachSeries([ + + addTestUserToClassroom, + addTestUserToCourseInstance, + (test, cb) -> + User.findById(test.user.id).exec (err, user) -> + expect(_.size(user.get('courseInstances'))).toBe(1) + cb() + removeTestUserFromCourseInstance, + (test, cb) -> + User.findById(test.user.id).exec (err, user) -> + expect(_.size(user.get('courseInstances'))).toBe(0) + cb() + + ], makeTestIterator(@), done) + + addTestUserToClassroom = (test, cb) -> + test.classroom.set('members', [test.user.get('_id')]) + test.classroom.save cb + + addTestUserToCourseInstance = (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.post {uri: url, json: {userID: test.user.id}}, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.members.length).toBe(1) + expect(body.members[0]).toBe(test.user.id) + cb() + + removeTestUserFromCourseInstance = (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.del {uri: url, json: {userID: test.user.id}}, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.members.length).toBe(0) + cb() + + makeTestIterator = (testObject) -> (func, callback) -> func(testObject, callback) \ No newline at end of file