" + $.i18n.t('courses.question') + " " + $.i18n.t('courses.question1') + "
" - popoverContent += "" + $.i18n.t('courses.answer') + " " + $.i18n.t('courses.answer1') + "
" - popoverContent += "" + $.i18n.t('courses.answer2') + "
" - @$el.find('.courses-faq').popover( - animation: true - html: true - placement: 'top' - trigger: 'click' - title: popoverTitle - content: popoverContent - container: @$el - ).on 'shown.bs.popover', => - application.tracker?.trackEvent 'Subscription payment methods hover' - - onClickBuy: (e) -> - $('.continue-dialog').modal('hide') - courseID = $(e.target).data('course-id') - route = "/courses/enroll/#{courseID}" - viewClass = require 'views/courses/CourseEnrollView' - viewArgs = [{}, courseID] - navigationEvent = route: route, viewClass: viewClass, viewArgs: viewArgs - Backbone.Mediator.publish 'router:navigate', navigationEvent - - onClickEnroll: (e) -> - return @openModalView new AuthModal() if me.isAnonymous() - courseID = $(e.target).data('course-id') - prepaidCode = ($(".code-input[data-course-id=#{courseID}]").val() ? '').trim() - @courseEnrollByModal(prepaidCode) - - onClickEnter: (e) -> - $('.continue-dialog').modal('hide') - courseID = $(e.target).data('course-id') - courseInstanceID = $(".select-session[data-course-id=#{courseID}]").val() - route = "/courses/#{courseID}/#{courseInstanceID}" - viewClass = require 'views/courses/CourseDetailsView' - viewArgs = [{}, courseID, courseInstanceID] - navigationEvent = route: route, viewClass: viewClass, viewArgs: viewArgs - Backbone.Mediator.publish 'router:navigate', navigationEvent - - onClickHOCStudentContinue: (e) -> - $('.continue-dialog').modal('hide') - if e - courseID = $(e.target).data('course-id') - else - courseID = '560f1a9f22961295f9427742' - - @state = 'enrolling' - @stateMessage = undefined - @render?() - - # TODO: Copied from CourseEnrollView - - data = - name: 'Single Player' - seats: 9999 - courseID: courseID - hourOfCode: true - jqxhr = $.post('/db/course_instance/-/create', data) - jqxhr.done (data, textStatus, jqXHR) => - application.tracker?.trackEvent 'Finished HoC student course creation', {courseID: courseID} - # TODO: handle fetch errors - me.fetch(cache: false).always => - courseID = courseID - route = "/courses/#{courseID}" - viewArgs = [{}, courseID] - if data?.length > 0 - courseInstanceID = data[0]._id - route += "/#{courseInstanceID}" - viewArgs[0].courseInstanceID = courseInstanceID - Backbone.Mediator.publish 'router:navigate', - route: route - viewClass: 'views/courses/CourseDetailsView' - viewArgs: viewArgs - jqxhr.fail (xhr, textStatus, errorThrown) => - console.error 'Got an error purchasing a course:', textStatus, errorThrown - application.tracker?.trackEvent 'Failed HoC student course creation', status: textStatus - if xhr.status is 402 - @state = 'declined' - @stateMessage = arguments[2] - else - @state = 'unknown_error' - @stateMessage = "#{xhr.status}: #{xhr.responseText}" - @render?() - - onClickStudent: (e) -> - if @supermodel.finished() and @hocLandingPage - # Automatically enroll in first course - @onClickHOCStudentContinue() - return - route = "/courses/students" - route += "?hoc=true" if @hocLandingPage or @hocMode - viewClass = require 'views/courses/CoursesView' - navigationEvent = route: route, viewClass: viewClass, viewArgs: [] - Backbone.Mediator.publish 'router:navigate', navigationEvent - - onClickTeacher: (e) -> - route = "/courses/teachers" - route += "?hoc=true" if @hocLandingPage or @hocMode - viewClass = require 'views/courses/CoursesView' - navigationEvent = route: route, viewClass: viewClass, viewArgs: [] - Backbone.Mediator.publish 'router:navigate', navigationEvent - - courseEnrollByURL: (prepaidCode) -> - @state = 'enrolling' - @render?() - $.ajax({ - method: 'POST' - url: '/db/course_instance/-/redeem_prepaid' - data: prepaidCode: prepaidCode - context: @ - success: @onRedeemPrepaidSuccess - error: (xhr, textStatus, errorThrown) -> - console.error 'Got an error redeeming a course prepaid code:', textStatus, errorThrown - application.tracker?.trackEvent 'Failed to redeem course prepaid code by url', status: textStatus - @state = 'unknown_error' - @stateMessage = "Failed to redeem code: #{xhr.responseText}" - @render?() - }) - - courseEnrollByModal: (prepaidCode) -> - @state = 'enrolling-by-modal' - @renderSelectors '.student-dialog-state-row' - $.ajax({ - method: 'POST' - url: '/db/course_instance/-/redeem_prepaid' - data: prepaidCode: prepaidCode - context: @ - success: -> - $('.continue-dialog').modal('hide') - @onRedeemPrepaidSuccess(arguments...) - error: (jqxhr, textStatus, errorThrown) -> - application.tracker?.trackEvent 'Failed to redeem course prepaid code by modal', status: textStatus - @state = 'unknown_error' - if jqxhr.status is 422 - @stateMessage = 'Please enter a code.' - else if jqxhr.status is 404 - @stateMessage = 'Code not found.' - else - @stateMessage = "#{jqxhr.responseText}" - @renderSelectors '.student-dialog-state-row' - }) - - onRedeemPrepaidSuccess: (data, textStatus, jqxhr) -> - prepaidID = data[0]?.prepaidID - application.tracker?.trackEvent 'Redeemed course prepaid code', {prepaidCode: prepaidID} - me.fetch(cache: false).always => - if data?.length > 0 && data[0].courseID && data[0]._id - courseID = data[0].courseID - courseInstanceID = data[0]._id - route = "/courses/#{courseID}/#{courseInstanceID}" - viewArgs = [{}, courseID, courseInstanceID] - Backbone.Mediator.publish 'router:navigate', - route: route - viewClass: 'views/courses/CourseDetailsView' - viewArgs: viewArgs - else - @state = 'unknown_error' - @stateMessage = "Database error." - @render?() - diff --git a/app/views/courses/HourOfCodeView.coffee b/app/views/courses/HourOfCodeView.coffee new file mode 100644 index 000000000..21873d8d5 --- /dev/null +++ b/app/views/courses/HourOfCodeView.coffee @@ -0,0 +1,49 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +Course = require 'models/Course' +CourseInstance = require 'models/CourseInstance' +RootView = require 'views/core/RootView' +template = require 'templates/courses/hour-of-code-view' +utils = require 'core/utils' + + +module.exports = class HourOfCodeView extends RootView + id: 'hour-of-code-view' + template: template + + events: + 'click #student-btn': 'onClickStudentButton' + + constructor: (options) -> + super(options) + @setUpHourOfCode() + + setUpHourOfCode: -> + # If we are coming in at /hoc, then we show the landing page. + # If we have ?hoc=true (for the step after the landing page), then we show any HoC-specific instructions. + # If we haven't tracked this player as an hourOfCode player yet, and it's a new account, we do that now. + @hocLandingPage = true + @hocMode = true + elapsed = new Date() - new Date(me.get('dateCreated')) + if not me.get('hourOfCode') and (@hocLandingPage or @hocMode) and elapsed < 5 * 60 * 1000 + me.set('hourOfCode', true) + me.patch() + $('body').append($('')) + application.tracker?.trackEvent 'Hour of Code Begin' + + onClickStudentButton: -> + @state = 'enrolling' + @stateMessage = undefined + @render?() + + $.ajax({ + method: 'POST' + url: '/db/course_instance/-/create-for-hoc' + context: @ + success: (data) -> + application.tracker?.trackEvent 'Finished HoC student course creation', {courseID: data.courseID} + app.router.navigate("/courses/#{data.courseID}/#{data._id}", { + trigger: true + }) + }) \ No newline at end of file diff --git a/app/views/courses/InviteToClassroomModal.coffee b/app/views/courses/InviteToClassroomModal.coffee new file mode 100644 index 000000000..fae1a2556 --- /dev/null +++ b/app/views/courses/InviteToClassroomModal.coffee @@ -0,0 +1,32 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/courses/invite-to-classroom-modal' + +module.exports = class InviteToClassroomModal extends ModalView + id: 'invite-to-classroom-modal' + template: template + + events: + 'click #send-invites-btn': 'onClickSendInvitesButton' + + initialize: (options) -> + @classroom = options.classroom + + onClickSendInvitesButton: -> + emails = @$('#invite-emails-textarea').val() + emails = emails.split('\n') + emails = _.filter((_.string.trim(email) for email in emails)) + if not emails.length + return + url = @classroom.url() + '/invite-members' + @$('#send-invites-btn, #invite-emails-textarea').addClass('hide') + @$('#invite-emails-sending-alert').removeClass('hide') + + $.ajax({ + url: url + data: {emails: emails} + method: 'POST' + context: @ + success: -> + @$('#invite-emails-sending-alert').addClass('hide') + @$('#invite-emails-success-alert').removeClass('hide') + }) diff --git a/app/views/courses/PurchaseCoursesView.coffee b/app/views/courses/PurchaseCoursesView.coffee new file mode 100644 index 000000000..210a56287 --- /dev/null +++ b/app/views/courses/PurchaseCoursesView.coffee @@ -0,0 +1,85 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +Course = require 'models/Course' +RootView = require 'views/core/RootView' +stripeHandler = require 'core/services/stripe' +template = require 'templates/courses/purchase-courses-view' +utils = require 'core/utils' + +module.exports = class PurchaseCoursesView extends RootView + id: 'purchase-courses-view' + template: template + numberOfStudents: 30 + pricePerStudent: 4 + + initialize: (options) -> + @listenTo stripeHandler, 'received-token', @onStripeReceivedToken + super(options) + + events: + 'input #students-input': 'onInputStudentsInput' + 'click #purchase-btn': 'onClickPurchaseButton' + + getPriceString: -> '$' + (@getPrice()).toFixed(2) + getPrice: -> @pricePerStudent * @numberOfStudents + + onInputStudentsInput: -> + @numberOfStudents = parseInt(@$('#students-input').val()) or 0 + @updatePrice() + + updatePrice: -> + @renderSelectors '#price-form-group' + + onClickPurchaseButton: -> + return @openModalView new AuthModal() if me.isAnonymous() + if @numberOfStudents < 1 or not _.isFinite(@numberOfStudents) + alert("Please enter the maximum number of students needed for your class.") + return + + @state = undefined + @stateMessage = undefined + @render() + + # Show Stripe handler + application.tracker?.trackEvent 'Started course prepaid purchase', { + price: @pricePerStudent, students: @pricePerStudent} + stripeHandler.open + amount: @price + description: "Full course access for #{@numberOfStudents} students" + bitcoin: true + alipay: if me.get('country') is 'china' or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto' + + onStripeReceivedToken: (e) -> + @state = 'purchasing' + @render?() + console.log 'e', e + + data = + maxRedeemers: @numberOfStudents + type: 'course' + stripe: + token: e.token.id + timestamp: new Date().getTime() + + $.ajax({ + url: '/db/prepaid/-/purchase', + data: data, + method: 'POST', + context: @ + success: -> + application.tracker?.trackEvent 'Finished course prepaid purchase', {price: @pricePerStudent, seats: @numberOfStudents} + @state = 'purchased' + @render?() + + error: (jqxhr, textStatus, errorThrown) -> + application.tracker?.trackEvent 'Failed course prepaid purchase', status: textStatus + if jqxhr.status is 402 + @state = 'error' + @stateMessage = arguments[2] + else + @state = 'error' + @stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}" + @render?() + }) + diff --git a/app/views/courses/StudentCoursesView.coffee b/app/views/courses/StudentCoursesView.coffee new file mode 100644 index 000000000..bd9c1f928 --- /dev/null +++ b/app/views/courses/StudentCoursesView.coffee @@ -0,0 +1,93 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +Course = require 'models/Course' +Classroom = require 'models/Classroom' +User = require 'models/User' +CourseInstance = require 'models/CourseInstance' +RootView = require 'views/core/RootView' +template = require 'templates/courses/student-courses-view' +utils = require 'core/utils' + +# TODO: Implement join class +# TODO: Implement course instance links + +module.exports = class StudentCoursesView extends RootView + id: 'student-courses-view' + template: template + + events: + 'click #join-class-btn': 'onClickJoinClassButton' + + constructor: (options) -> + super(options) + @courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance}) + @courseInstances.comparator = (ci) -> return ci.get('classroomID') + ci.get('courseID') + @supermodel.loadCollection(@courseInstances, 'course_instances') + @classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom }) + @supermodel.loadCollection(@classrooms, 'classrooms', { data: {memberID: me.id} }) + @courses = new CocoCollection([], { url: "/db/course", model: Course}) + @supermodel.loadCollection(@courses, 'courses') + + onLoaded: -> + if (@classCode = utils.getQueryVariable('_cc', false)) and not me.isAnonymous() + @joinClass() + super() + + onClickJoinClassButton: (e) -> + return @openModalView new AuthModal() if me.isAnonymous() + @classCode = @$('#classroom-code-input').val() + @joinClass() + + joinClass: () -> + @state = 'enrolling' + @renderSelectors '#join-classroom-form' + $.ajax({ + method: 'POST' + url: '/db/classroom/-/members' + data: code: @classCode + context: @ + success: @onJoinClassroomSuccess + error: (jqxhr, textStatus, errorThrown) -> + application.tracker?.trackEvent 'Failed to join classroom with code', status: textStatus + @state = 'unknown_error' + if jqxhr.status is 422 + @stateMessage = 'Please enter a code.' + else if jqxhr.status is 404 + @stateMessage = 'Code not found.' + else + @stateMessage = "#{jqxhr.responseText}" + @renderSelectors '#join-classroom-form' + }) + + onJoinClassroomSuccess: (data, textStatus, jqxhr) -> + classroom = new Classroom(data) + application.tracker?.trackEvent 'Joined classroom', { + classroomID: classroom.id, + classroomName: classroom.get('name') + ownerID: classroom.get('ownerID') + } + @classrooms.add(classroom) + @render() + + classroomCourseInstances = new CocoCollection([], { url: "/db/course_instance", model: CourseInstance }) + classroomCourseInstances.fetch({ data: {classroomID: classroom.id} }) + @listenToOnce classroomCourseInstances, 'sync', -> + + # join any course instances in the classroom which are free to join + jqxhrs = [] + for courseInstance in classroomCourseInstances.models + course = @courses.get(courseInstance.get('courseID')) + if course.get('free') + jqxhrs.push $.ajax({ + method: 'POST' + url: _.result(courseInstance, 'url') + '/members' + data: { userID: me.id } + context: @ + success: (data) -> + @courseInstances.add(data) + @courseInstances.get(data._id).justJoined = true + }) + $.when(jqxhrs...).done => + @state = '' + @render() diff --git a/app/views/courses/TeacherCoursesView.coffee b/app/views/courses/TeacherCoursesView.coffee new file mode 100644 index 000000000..26cf74ad4 --- /dev/null +++ b/app/views/courses/TeacherCoursesView.coffee @@ -0,0 +1,204 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +CocoModel = require 'models/CocoModel' +Course = require 'models/Course' +Classroom = require 'models/Classroom' +User = require 'models/User' +Prepaid = require 'models/Prepaid' +CourseInstance = require 'models/CourseInstance' +RootView = require 'views/core/RootView' +template = require 'templates/courses/teacher-courses-view' +utils = require 'core/utils' +InviteToClassroomModal = require 'views/courses/InviteToClassroomModal' + +module.exports = class TeacherCoursesView extends RootView + id: 'teacher-courses-view' + template: template + + events: + 'click #create-new-class-btn': 'onClickCreateNewclassButton' + 'click .add-students-btn': 'onClickAddStudentsButton' + 'click .course-instance-membership-checkbox': 'onClickCourseInstanceMembershipCheckbox' + 'click #save-changes-btn': 'onClickSaveChangesButton' + 'click #manage-tab-link': 'onClickManageTabLink' + + constructor: (options) -> + super(options) + @courses = new CocoCollection([], { url: "/db/course", model: Course}) + @supermodel.loadCollection(@courses, 'courses') + @classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom }) + @classrooms.comparator = '_id' + @listenToOnce @classrooms, 'sync', @onceClassroomsSync + @supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}}) + @courseInstances = new CocoCollection([], { url: "/db/course_instance", model: CourseInstance }) + @courseInstances.comparator = 'courseID' + @courseInstances.sliceWithMembers = -> return @filter (courseInstance) -> _.size(courseInstance.get('members')) and courseInstance.get('classroomID') + @supermodel.loadCollection(@courseInstances, 'course_instances', {data: {ownerID: me.id}}) + @members = new CocoCollection([], { model: User }) + @prepaids = new CocoCollection([], { url: "/db/prepaid", model: Prepaid }) + sum = (numbers) -> _.reduce(numbers, (a, b) -> a + b) + @prepaids.totalMaxRedeemers = -> sum((prepaid.get('maxRedeemers') for prepaid in @models)) or 0 + @prepaids.totalRedeemers = -> sum((_.size(prepaid.get('redeemers')) for prepaid in @models)) or 0 + @prepaids.comparator = '_id' + @supermodel.loadCollection(@prepaids, 'prepaids', {data: {creator: me.id}}) + @listenTo @members, 'sync', @renderManageTab + @usersToRedeem = new CocoCollection([], { model: User }) + @hoc = utils.getQueryVariable('hoc') + @ + + onceClassroomsSync: -> + for classroom in @classrooms.models + @members.fetch({ + remove: false + url: "/db/classroom/#{classroom.id}/members" + }) + + onClickCreateNewclassButton: -> + name = @$('#new-classroom-name-input').val() + return unless name + classroom = new Classroom({ name: name }) + classroom.save() + @classrooms.add(classroom) + classroom.saving = true + @renderManageTab() + @listenTo classroom, 'sync', -> + classroom.saving = false + @fillMissingCourseInstances() + + renderManageTab: -> + isActive = @$('#manage-tab-pane').hasClass('active') + @renderSelectors('#manage-tab-pane') + @$('#manage-tab-pane').toggleClass('active', isActive) + + onClickAddStudentsButton: (e) -> + classroomID = $(e.target).data('classroom-id') + classroom = @classrooms.get(classroomID) + modal = new InviteToClassroomModal({classroom: classroom}) + @openModalView(modal) + + onLoaded: -> + super() + @linkCourseIntancesToCourses() + @fillMissingCourseInstances() + + linkCourseIntancesToCourses: -> + for courseInstance in @courseInstances.models + courseInstance.course = @courses.get(courseInstance.get('courseID')) + + fillMissingCourseInstances: -> + # TODO: Give teachers control over which courses are enabled for a given class. + # Add/remove course instances and columns in the view to match. + for classroom in @classrooms.models + classroom.filling = false + for course in @courses.models + courseInstance = @courseInstances.findWhere({classroomID: classroom.id, courseID: course.id}) + if not courseInstance + classroom.filling = true + courseInstance = new CourseInstance({ + classroomID: classroom.id + courseID: course.id + }) + # TODO: figure out a better way to get around triggering validation errors for properties + # that the server will end up filling in, like an empty members array, ownerID + courseInstance.save(null, {validate: false}) + courseInstance.course = course + @courseInstances.add(courseInstance) + @listenToOnce courseInstance, 'sync', @fillMissingCourseInstances + @renderManageTab() + return + @renderManageTab() + + onClickCourseInstanceMembershipCheckbox: -> + usersToRedeem = {} + checkedBoxes = @$('.course-instance-membership-checkbox:checked') + _.each checkedBoxes, (el) => + $el = $(el) + userID = $el.data('user-id') + return if usersToRedeem[userID] + user = @members.get(userID) + return if user.get('coursePrepaidID') + courseInstanceID = $el.data('course-instance-id') + courseInstance = @courseInstances.get(courseInstanceID) + return if courseInstance.course.get('free') + usersToRedeem[userID] = user + + @usersToRedeem = new CocoCollection(_.values(usersToRedeem), {model: User}) + @numCourseInstancesToAddTo = checkedBoxes.length + @renderSelectors '#fixed-area' + + onClickSaveChangesButton: -> + @$('.course-instance-membership-checkbox').attr('disabled', true) + checkedBoxes = @$('.course-instance-membership-checkbox:checked') + raw = _.map checkedBoxes, (el) => + $el = $(el) + userID = $el.data('user-id') + courseInstanceID = $el.data('course-instance-id') + courseInstance = @courseInstances.get(courseInstanceID) + return { + courseInstance: courseInstance + userID: userID + } + @membershipAdditions = new CocoCollection(raw, { model: User }) # TODO: Allow collections not to have models defined? + @membershipAdditions.originalSize = @membershipAdditions.size() + @usersToRedeem.originalSize = @usersToRedeem.size() + @state = 'saving-changes' + @renderSelectors '#fixed-area' + @redeemUsers() + + redeemUsers: -> + if not @usersToRedeem.size() + @addMemberships() + return + + user = @usersToRedeem.first() + prepaid = @prepaids.find (prepaid) -> prepaid.openSpots() + $.ajax({ + method: 'POST' + url: _.result(prepaid, 'url') + '/redeemers' + data: { userID: user.id } + context: @ + success: -> + @usersToRedeem.remove(user) + @renderSelectors '#fixed-area' + @redeemUsers() + error: (jqxhr, textStatus, errorThrown) -> + if jqxhr.status is 402 + @state = 'error' + @stateMessage = arguments[2] + else + @state = 'error' + @stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}" + @renderSelectors '#fixed-area' + }) + + addMemberships: -> + if not @membershipAdditions.size() + @renderSelectors '#fixed-area' + document.location.reload() + return + + membershipAddition = @membershipAdditions.first() + courseInstance = membershipAddition.get('courseInstance') + userID = membershipAddition.get('userID') + $.ajax({ + method: 'POST' + url: _.result(courseInstance, 'url') + '/members' + data: { userID: userID } + context: @ + success: -> + @membershipAdditions.remove(membershipAddition) + @renderSelectors '#fixed-area' + @addMemberships() + error: (jqxhr, textStatus, errorThrown) -> + if jqxhr.status is 402 + @state = 'error' + @stateMessage = arguments[2] + else + @state = 'error' + @stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}" + @renderSelectors '#fixed-area' + }) + + onClickManageTabLink: -> + @$('.nav-tabs a[href="#manage-tab-pane"]').tab('show') \ No newline at end of file diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee index 3a7211d9a..fde29cd63 100644 --- a/app/views/play/level/LevelLoadingView.coffee +++ b/app/views/play/level/LevelLoadingView.coffee @@ -14,6 +14,7 @@ module.exports = class LevelLoadingView extends CocoView subscriptions: 'level:loaded': 'onLevelLoaded' # If Level loads after level loading view. + 'level:session-loaded': 'onSessionLoaded' 'level:subscription-required': 'onSubscriptionRequired' # If they'd need a subscription to start playing. 'level:course-membership-required': 'onCourseMembershipRequired' # If they'd need a subscription to start playing. 'subscribe-modal:subscribed': 'onSubscribed' @@ -44,6 +45,14 @@ module.exports = class LevelLoadingView extends CocoView onLevelLoaded: (e) -> @level = e.level + @prepareGoals() + @prepareTip() + @prepareIntro() + + onSessionLoaded: (e) -> + @session = e.session if e.session.get('creator') is me.id + + prepareGoals: -> goalContainer = @$el.find('.level-loading-goals') goalList = goalContainer.find('ul') goalCount = 0 @@ -55,57 +64,121 @@ module.exports = class LevelLoadingView extends CocoView goalContainer.removeClass('secret') if goalCount is 1 goalContainer.find('.panel-heading').text $.i18n.t 'play_level.goal' # Not plural + + prepareTip: -> tip = @$el.find('.tip') if @level.get('loadingTip') loadingTip = utils.i18n @level.attributes, 'loadingTip' tip.text(loadingTip) tip.removeClass('secret') + prepareIntro: -> + @docs = @level.get('documentation') ? {} + specific = @docs.specificArticles or [] + @intro = _.find specific, name: 'Intro' + showReady: -> return if @shownReady @shownReady = true - _.delay @finishShowingReady, 1500 # Let any blocking JS hog the main thread before we show that we're done. + _.delay @finishShowingReady, 100 # Let any blocking JS hog the main thread before we show that we're done. finishShowingReady: => return if @destroyed - if @options.autoUnveil + showIntro = @getQueryVariable('intro') + autoUnveil = not showIntro and (@options.autoUnveil or @session?.get('state').complete) + if autoUnveil @startUnveiling() - @unveil() + @unveil true else @playSound 'level_loaded', 0.75 # old: loading_ready @$el.find('.progress').hide() @$el.find('.start-level-button').show() + @unveil false startUnveiling: (e) -> @playSound 'menu-button-click' + @unveiling = true Backbone.Mediator.publish 'level:loading-view-unveiling', {} _.delay @onClickStartLevel, 1000 # If they never mouse-up for the click (or a modal shows up and interrupts the click), do it anyway. onClickStartLevel: (e) => return if @destroyed - @unveil() + @unveil true onEnterPressed: (e) -> - return unless @shownReady and not @$el.hasClass 'unveiled' + return unless @shownReady and not @unveiled @startUnveiling() @onClickStartLevel() - unveil: -> - return if @$el.hasClass 'unveiled' - @$el.addClass 'unveiled' - loadingDetails = @$el.find('.loading-details') - duration = parseFloat loadingDetails.css 'transition-duration' - loadingDetails.css 'top', -loadingDetails.outerHeight(true) + unveil: (full) -> + return if @destroyed or @unveiled + @unveiled = full + @$loadingDetails = @$el.find('#loading-details') + duration = parseFloat(@$loadingDetails.css 'transition-duration') * 1000 + unless @$el.hasClass 'unveiled' + @$el.addClass 'unveiled' + @unveilWings duration + if full + @unveilLoadingFull() + _.delay @onUnveilEnded, duration + else + @unveilLoadingPreview duration + + unveilLoadingFull: -> + # Get rid of the loading details screen entirely--the level is totally ready. + unless @unveiling + Backbone.Mediator.publish 'level:loading-view-unveiling', {} + @unveiling = true + if @$el.hasClass 'preview-screen' + @$loadingDetails.css 'right', -@$loadingDetails.outerWidth(true) + else + @$loadingDetails.css 'top', -@$loadingDetails.outerHeight(true) + @$el.removeClass 'preview-screen' + $('#canvas-wrapper').removeClass 'preview-overlay' + + unveilLoadingPreview: (duration) -> + # Move the loading details screen over the code editor to preview the level. + return if @$el.hasClass 'preview-screen' + $('#canvas-wrapper').addClass 'preview-overlay' + @$el.addClass('preview-screen') + @$loadingDetails.addClass('preview') + @resize() + @onWindowResize = _.debounce @onWindowResize, 700 # Wait a bit for other views to resize before we resize + $(window).on 'resize', @onWindowResize + if @intro + @$el.find('.progress-or-start-container').addClass('intro-footer') + @$el.find('#tip-wrapper').remove() + _.delay @unveilIntro, duration + + resize: -> + maxHeight = $('#page-container').outerHeight(true) + minHeight = $('#code-area').outerHeight(true) + @$el.css height: maxHeight + @$loadingDetails.css minHeight: minHeight, maxHeight: maxHeight + $intro = @$el.find('.intro-doc') + $intro.css maxHeight: minHeight - $intro.offset().top - @$el.find('.progress-or-start-container').outerHeight() - 30 - 20 + + unveilWings: (duration) -> + @playSound 'loading-view-unveil', 0.5 @$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0' @$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0' - @playSound 'loading-view-unveil', 0.5 - _.delay @onUnveilEnded, duration * 1000 - $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration * 1000) + $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration) + + unveilIntro: => + return if @destroyed or not @intro or @unveiled + html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body')) + @$el.find('.intro-doc').html html + @resize() onUnveilEnded: => return if @destroyed Backbone.Mediator.publish 'level:loading-view-unveiled', view: @ + onWindowResize: (e) => + return if @destroyed + @$loadingDetails.css transition: 'none' + @resize() + onSubscriptionRequired: (e) -> @$el.find('.level-loading-goals, .tip, .load-progress').hide() @$el.find('.subscription-required').show() @@ -120,3 +193,7 @@ module.exports = class LevelLoadingView extends CocoView onSubscribed: -> document.location.reload() + + destroy: -> + $(window).off 'resize', @onWindowResize + super() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 627245cb3..78828d08d 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -155,7 +155,7 @@ module.exports = class PlayLevelView extends RootView afterRender: -> super() window.onPlayLevelViewLoaded? @ # still a hack - @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level # May not have @level loaded yet + @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level, session: @levelLoader?.session ? @session # May not have @level loaded yet @$el.find('#level-done-button').hide() $('body').addClass('is-playing') $('body').bind('touchmove', false) if @isIPadApp() @@ -177,7 +177,6 @@ module.exports = class PlayLevelView extends RootView @initVolume() @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) - @originalSessionState = $.extend(true, {}, @session.get('state')) @register() @controlBar.setBus(@bus) @initScriptManager() @@ -341,14 +340,16 @@ module.exports = class PlayLevelView extends RootView if window.currentModal and not window.currentModal.destroyed and window.currentModal.constructor isnt VictoryModal return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @ @surface.showLevel() - if @isEditorPreview or @observing + Backbone.Mediator.publish 'level:set-time', time: 0 + if (@isEditorPreview or @observing) and not @getQueryVariable('intro') @loadingView.startUnveiling() - @loadingView.unveil() + @loadingView.unveil true onLoadingViewUnveiling: (e) -> - @restoreSessionState() + @selectHero() onLoadingViewUnveiled: (e) -> + Backbone.Mediator.publish 'level:set-playing', playing: true @loadingView.$el.remove() @removeSubView @loadingView @loadingView = null @@ -372,21 +373,11 @@ module.exports = class PlayLevelView extends RootView @ambientSound = createjs.Sound.play src, loop: -1, volume: 0.1 createjs.Tween.get(@ambientSound).to({volume: 1.0}, 10000) - restoreSessionState: -> - return if @alreadyLoadedState - @alreadyLoadedState = true - state = @originalSessionState - if not @level or @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] - Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true - Backbone.Mediator.publish 'tome:select-primary-sprite', {} - Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false - @surface.focusOnHero() - Backbone.Mediator.publish 'level:set-time', time: 0 - Backbone.Mediator.publish 'level:set-playing', playing: true - else - if state.selected - # TODO: Should also restore selected spell here by saving spellName - Backbone.Mediator.publish 'level:select-sprite', thangID: state.selected, spellName: null + selectHero: -> + Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true + Backbone.Mediator.publish 'tome:select-primary-sprite', {} + Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false + @surface.focusOnHero() # callbacks diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee index 27c505502..9cdc15491 100644 --- a/app/views/play/level/tome/SpellPaletteEntryView.coffee +++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee @@ -3,7 +3,7 @@ template = require 'templates/play/level/tome/spell_palette_entry' {me} = require 'core/auth' filters = require 'lib/image_filter' DocFormatter = require './DocFormatter' -SpellView = require 'views/play/level/tome/SpellView' +utils = require 'core/utils' module.exports = class SpellPaletteEntryView extends CocoView tagName: 'div' # Could also try instead of , but would need to adjust colors
@@ -59,26 +59,8 @@ module.exports = class SpellPaletteEntryView extends CocoView
@aceEditors = []
aceEditors = @aceEditors
popover?.$tip?.find('.docs-ace').each ->
- contents = $(@).text()
- editor = ace.edit @
- editor.setOptions maxLines: Infinity
- editor.setReadOnly true
- editor.setTheme 'ace/theme/textmate'
- editor.setShowPrintMargin false
- editor.setShowFoldWidgets false
- editor.setHighlightActiveLine false
- editor.setHighlightActiveLine false
- editor.setBehavioursEnabled false
- editor.renderer.setShowGutter false
- editor.setValue contents
- editor.clearSelection()
- session = editor.getSession()
- session.setUseWorker false
- session.setMode SpellView.editModes[codeLanguage]
- session.setWrapLimitRange null
- session.setUseWrapMode true
- session.setNewLineMode 'unix'
- aceEditors.push editor
+ aceEditor = utils.initializeACE @, codeLanguage
+ aceEditors.push aceEditor
onMouseEnter: (e) ->
# Make sure the doc has the updated Thang so it can regenerate its prop value
diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee
index 1e7214182..95be903e7 100644
--- a/app/views/play/level/tome/SpellView.coffee
+++ b/app/views/play/level/tome/SpellView.coffee
@@ -9,6 +9,7 @@ SpellDebugView = require './SpellDebugView'
SpellToolbarView = require './SpellToolbarView'
LevelComponent = require 'models/LevelComponent'
UserCodeProblem = require 'models/UserCodeProblem'
+utils = require 'core/utils'
module.exports = class SpellView extends CocoView
id: 'spell-view'
@@ -18,14 +19,6 @@ module.exports = class SpellView extends CocoView
eventsSuppressed: true
writable: true
- @editModes:
- 'javascript': 'ace/mode/javascript'
- 'coffeescript': 'ace/mode/coffee'
- 'python': 'ace/mode/python'
- 'clojure': 'ace/mode/clojure'
- 'lua': 'ace/mode/lua'
- 'io': 'ace/mode/text'
-
keyBindings:
'default': null
'vim': 'ace/keyboard/vim'
@@ -93,7 +86,7 @@ module.exports = class SpellView extends CocoView
@aceSession = @ace.getSession()
@aceDoc = @aceSession.getDocument()
@aceSession.setUseWorker false
- @aceSession.setMode SpellView.editModes[@spell.language]
+ @aceSession.setMode utils.aceEditModes[@spell.language]
@aceSession.setWrapLimitRange null
@aceSession.setUseWrapMode true
@aceSession.setNewLineMode 'unix'
@@ -479,7 +472,7 @@ module.exports = class SpellView extends CocoView
# window.zatannaInstance = @zatanna # For debugging. Make sure to not leave active when committing.
# window.snippetEntries = snippetEntries
- lang = SpellView.editModes[e.language].substr 'ace/mode/'.length
+ lang = utils.aceEditModes[e.language].substr 'ace/mode/'.length
@zatanna.addSnippets snippetEntries, lang
@editorLang = lang
@@ -1138,8 +1131,8 @@ module.exports = class SpellView extends CocoView
onChangeLanguage: (e) ->
return unless @spell.canWrite()
- @aceSession.setMode SpellView.editModes[e.language]
- @zatanna?.set 'language', SpellView.editModes[e.language].substr('ace/mode/')
+ @aceSession.setMode utils.aceEditModes[e.language]
+ @zatanna?.set 'language', utils.aceEditModes[e.language].substr('ace/mode/')
wasDefault = @getSource() is @spell.originalSource
@spell.setLanguage e.language
@reloadCode true if wasDefault
diff --git a/app/views/play/menu/GuideView.coffee b/app/views/play/menu/GuideView.coffee
index 606c586b4..b1d727081 100644
--- a/app/views/play/menu/GuideView.coffee
+++ b/app/views/play/menu/GuideView.coffee
@@ -4,8 +4,6 @@ Article = require 'models/Article'
SubscribeModal = require 'views/core/SubscribeModal'
utils = require 'core/utils'
-# let's implement this once we have the docs database schema set up
-
module.exports = class LevelGuideView extends CocoView
template: template
id: 'guide-view'
@@ -41,10 +39,10 @@ module.exports = class LevelGuideView extends CocoView
@docs = specific.concat(general)
@docs = $.extend(true, [], @docs)
@docs = [@docs[0]] if @firstOnly and @docs[0]
- doc.html = marked(@filterCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs
+ doc.html = marked(utils.filterMarkdownCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs
doc.name = (utils.i18n doc, 'name') for doc in @docs
doc.slug = _.string.slugify(doc.name) for doc in @docs
- super()
+ super options
destroy: ->
if @vimeoListenerAttached
@@ -52,6 +50,7 @@ module.exports = class LevelGuideView extends CocoView
window.removeEventListener('message', @onMessageReceived, false)
else
window.detachEvent('onmessage', @onMessageReceived, false)
+ oldEditor.destroy() for oldEditor in @aceEditors ? []
super()
getRenderData: ->
@@ -70,13 +69,17 @@ module.exports = class LevelGuideView extends CocoView
@$el.find('.nav-tabs li:first').addClass('active')
@$el.find('.tab-content .tab-pane:first').addClass('active')
@$el.find('.nav-tabs a').click(@clickTab)
+ @configureACEEditors()
@playSound 'guide-open'
- filterCodeLanguages: (text) ->
- currentLanguage = me.get('aceConfig')?.language or 'python'
- excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'io'], currentLanguage
- exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm'
- text.replace exclusionRegex, ''
+ configureACEEditors: ->
+ oldEditor.destroy() for oldEditor in @aceEditors ? []
+ @aceEditors = []
+ aceEditors = @aceEditors
+ codeLanguage = me.get('aceConfig')?.language or 'python'
+ @$el.find('pre').each ->
+ aceEditor = utils.initializeACE @, codeLanguage
+ aceEditors.push aceEditor
clickSubscribe: (e) ->
level = @levelSlug # Save ref to level slug
diff --git a/package.json b/package.json
index afa519a5d..5dfaa11ae 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,8 @@
"brunch": "brunch",
"bower": "bower",
"dev": "brunch watch --server",
+ "nodemon": "nodemon",
+ "jasmine-node": "jasmine-node",
"multicore": "coffee multicore.coffee",
"nodemon": "nodemon"
},
diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js
index 913709a17..f154e4805 100644
--- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js
+++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js
@@ -642,7 +642,13 @@ function getRecurringRevenueCounts(startDay) {
var cursor = db.payments.find({_id: {$gte: startObj}});
while (cursor.hasNext()) {
var doc = cursor.next();
- var day = doc._id.getTimestamp().toISOString().substring(0, 10);
+ var day;
+ if (doc.created) {
+ day = doc.created.substring(0, 10);
+ }
+ else {
+ day = doc._id.getTimestamp().toISOString().substring(0, 10);
+ }
if (doc.service === 'ios' || doc.service === 'bitcoin') continue;
diff --git a/scripts/mongodb/migrations/2015-11-10-course-correction.js b/scripts/mongodb/migrations/2015-11-10-course-correction.js
new file mode 100644
index 000000000..4b6851033
--- /dev/null
+++ b/scripts/mongodb/migrations/2015-11-10-course-correction.js
@@ -0,0 +1,34 @@
+var counts = {
+ hasClassroom: 0,
+ isOwn: 0,
+ migrated: 0
+};
+
+// script for generating codes
+// JSON.stringify(_.unique(_.map(_.range(1000), function() { return _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('') })))
+var codes =
+db.course.instances.find().forEach(function(courseInstance) {
+ if(courseInstance.classroomID) {
+ counts.hasClassroom += 1;
+ return;
+ }
+ if(courseInstance.ownerID && courseInstance.members && courseInstance.ownerID.equals(courseInstance.members[0]) && courseInstance.members.length === 1) {
+ counts.isOwn += 1;
+ return;
+ }
+
+ var id = ObjectId();
+
+ var newClassroom = {
+ members: courseInstance.members,
+ ownerID: courseInstance.ownerID,
+ description: courseInstance.description,
+ name: courseInstance.name,
+ code: codes.pop(),
+ _id: id
+ };
+ print('migrating', JSON.stringify(newClassroom, null, '\t'));
+ db.classrooms.save(newClassroom);
+ courseInstance.classroomID = id;
+ db.course.instances.save(courseInstance);
+});
diff --git a/server/classrooms/Classroom.coffee b/server/classrooms/Classroom.coffee
new file mode 100644
index 000000000..38a2e3018
--- /dev/null
+++ b/server/classrooms/Classroom.coffee
@@ -0,0 +1,42 @@
+mongoose = require 'mongoose'
+log = require 'winston'
+config = require '../../server_config'
+plugins = require '../plugins/plugins'
+User = require '../users/User'
+jsonSchema = require '../../app/schemas/models/classroom.schema'
+
+ClassroomSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:config.mongo.readpref}
+
+ClassroomSchema.statics.privateProperties = []
+ClassroomSchema.statics.editableProperties = [
+ 'description'
+ 'name'
+]
+
+ClassroomSchema.statics.generateNewCode = (done) ->
+ tryCode = ->
+ code = _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('')
+ Classroom.findOne code: code, (err, classroom) ->
+ return done() if err
+ return done(code) unless classroom
+ tryCode()
+ tryCode()
+
+#ClassroomSchema.plugin plugins.NamedPlugin
+
+ClassroomSchema.pre('save', (next) ->
+ return next() if @get('code')
+ Classroom.generateNewCode (code) =>
+ @set 'code', code
+ next()
+)
+
+ClassroomSchema.methods.isOwner = (userID) ->
+ return userID.equals(@get('ownerID'))
+
+ClassroomSchema.methods.isMember = (userID) ->
+ return _.any @get('members') or [], (memberID) -> userID.equals(memberID)
+
+ClassroomSchema.statics.jsonSchema = jsonSchema
+
+module.exports = Classroom = mongoose.model 'classroom', ClassroomSchema, 'classrooms'
diff --git a/server/classrooms/classroom_handler.coffee b/server/classrooms/classroom_handler.coffee
new file mode 100644
index 000000000..5ef0d1a98
--- /dev/null
+++ b/server/classrooms/classroom_handler.coffee
@@ -0,0 +1,110 @@
+async = require 'async'
+mongoose = require 'mongoose'
+Handler = require '../commons/Handler'
+Classroom = require './Classroom'
+User = require '../users/User'
+sendwithus = require '../sendwithus'
+utils = require '../lib/utils'
+UserHandler = require '../users/user_handler'
+
+ClassroomHandler = class ClassroomHandler extends Handler
+ modelClass: Classroom
+ jsonSchema: require '../../app/schemas/models/classroom.schema'
+ allowedMethods: ['GET', 'POST', 'PUT', 'DELETE']
+
+ hasAccess: (req) ->
+ return false unless req.user
+ return true if req.method is 'GET'
+ req.method in @allowedMethods or req.user?.isAdmin()
+
+ hasAccessToDocument: (req, document, method=null) ->
+ return false unless document?
+ return true if req.user?.isAdmin()
+ return true if document.get('ownerID')?.equals req.user?._id
+ isGet = (method or req.method).toLowerCase() is 'get'
+ isMember = _.any(document.get('members') or [], (memberID) -> memberID.equals(req.user.get('_id')))
+ return true if isGet and isMember
+ false
+
+ makeNewInstance: (req) ->
+ instance = super(req)
+ instance.set 'ownerID', req.user._id
+ instance.set 'members', []
+ instance
+
+ getByRelationship: (req, res, args...) ->
+ 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 @getMembersAPI(req, res, args[0]) if args[1] is 'members'
+ super(arguments...)
+
+ getMembersAPI: (req, res, classroomID) ->
+ Classroom.findById classroomID, (err, classroom) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res) unless classroom
+ memberIDs = classroom.get('members') ? []
+ User.find {_id: {$in: memberIDs}}, (err, users) =>
+ return @sendDatabaseError(res, err) if err
+ cleandocs = (UserHandler.formatEntity(req, doc) for doc in users)
+ @sendSuccess(res, cleandocs)
+
+ joinClassroomAPI: (req, res, classroomID) ->
+ return @sendBadInputError(res, 'Need an object with a code') unless req.body?.code
+ Classroom.findOne {code: req.body.code}, (err, classroom) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res) if not classroom
+ members = _.clone(classroom.get('members'))
+ if _.any(members, (memberID) -> memberID.equals(req.user.get('_id')))
+ return @sendSuccess(res, @formatEntity(req, classroom))
+ update = { $push: { members : req.user.get('_id')}}
+ classroom.update update, (err) =>
+ return @sendDatabaseError(res, err) if err
+ members.push req.user.get('_id')
+ classroom.set('members', members)
+ return @sendSuccess(res, @formatEntity(req, classroom))
+
+ formatEntity: (req, doc) ->
+ if req.user?.isAdmin() or req.user?.get('_id').equals(doc.get('ownerID'))
+ return doc.toObject()
+ return _.omit(doc.toObject(), 'code')
+
+ inviteStudents: (req, res, classroomID) ->
+ if not req.body.emails
+ return @sendBadInputError(res, 'Emails not included')
+
+ Classroom.findById classroomID, (err, classroom) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res) unless classroom
+ return @sendForbiddenError(res) unless classroom.get('ownerID').equals(req.user.get('_id'))
+
+ for email in req.body.emails
+ context =
+ email_id: sendwithus.templates.course_invite_email
+ recipient:
+ address: email
+ email_data:
+ class_name: classroom.get('name')
+ # TODO: join_link
+ join_link: "https://codecombat.com/courses/students?_cc=" + classroom.get('code')
+ sendwithus.api.send context, _.noop
+ return @sendSuccess(res, {})
+
+ get: (req, res) ->
+ if ownerID = req.query.ownerID
+ return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or ownerID is req.user.id)
+ return @sendBadInputError(res, 'Bad ownerID') unless utils.isID ownerID
+ Classroom.find {ownerID: mongoose.Types.ObjectId(ownerID)}, (err, classrooms) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendSuccess(res, (@formatEntity(req, classroom) for classroom in classrooms))
+ else if memberID = req.query.memberID
+ return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or memberID is req.user.id)
+ return @sendBadInputError(res, 'Bad memberID') unless utils.isID memberID
+ Classroom.find {members: mongoose.Types.ObjectId(memberID)}, (err, classrooms) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendSuccess(res, (@formatEntity(req, classroom) for classroom in classrooms))
+ else
+ super(arguments...)
+
+
+module.exports = new ClassroomHandler()
diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee
index 31226e504..9dd3b4235 100644
--- a/server/commons/mapping.coffee
+++ b/server/commons/mapping.coffee
@@ -8,6 +8,7 @@ module.exports.handlers =
'article': 'articles/article_handler'
'campaign': 'campaigns/campaign_handler'
'clan': 'clans/clan_handler'
+ 'classroom': 'classrooms/classroom_handler'
'course': 'courses/course_handler'
'course_instance': 'courses/course_instance_handler'
'level': 'levels/level_handler'
diff --git a/server/courses/CourseInstance.coffee b/server/courses/CourseInstance.coffee
index 34122f879..6d1a49a78 100644
--- a/server/courses/CourseInstance.coffee
+++ b/server/courses/CourseInstance.coffee
@@ -3,15 +3,24 @@ config = require '../../server_config'
plugins = require '../plugins/plugins'
jsonSchema = require '../../app/schemas/models/course_instance.schema'
-CourseInstanceSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:config.mongo.readpref}
+CourseInstanceSchema = new mongoose.Schema {
+ ownerID: mongoose.Schema.Types.ObjectId
+ courseID: mongoose.Schema.Types.ObjectId
+ classroomID: mongoose.Schema.Types.ObjectId
+ prepaidID: mongoose.Schema.Types.ObjectId
+ members: [mongoose.Schema.Types.ObjectId]
+}, {strict: false, minimize: false, read:config.mongo.readpref}
CourseInstanceSchema.statics.privateProperties = []
CourseInstanceSchema.statics.editableProperties = [
'description'
- 'members'
'name'
'aceConfig'
]
+CourseInstanceSchema.statics.postEditableProperties = [
+ 'courseID'
+ 'classroomID'
+]
CourseInstanceSchema.statics.jsonSchema = jsonSchema
diff --git a/server/courses/course_instance_handler.coffee b/server/courses/course_instance_handler.coffee
index 82cf58722..24a0e28d1 100644
--- a/server/courses/course_instance_handler.coffee
+++ b/server/courses/course_instance_handler.coffee
@@ -1,6 +1,7 @@
async = require 'async'
Handler = require '../commons/Handler'
Campaign = require '../campaigns/Campaign'
+Classroom = require '../classrooms/Classroom'
Course = require './Course'
CourseInstance = require './CourseInstance'
LevelSession = require '../levels/sessions/LevelSession'
@@ -11,6 +12,7 @@ User = require '../users/User'
UserHandler = require '../users/user_handler'
utils = require '../../app/core/utils'
sendwithus = require '../sendwithus'
+mongoose = require 'mongoose'
CourseInstanceHandler = class CourseInstanceHandler extends Handler
modelClass: CourseInstance
@@ -30,63 +32,83 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
getByRelationship: (req, res, args...) ->
relationship = args[1]
- return @createAPI(req, res) if relationship is 'create'
+ 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 @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'
super arguments...
- createAPI: (req, res) ->
+ createHOCAPI: (req, res) ->
return @sendUnauthorizedError(res) if not req.user?
- return @sendUnauthorizedError(res) if req.user.isAnonymous() and not (req.body.hourOfCode and req.body.courseID is '560f1a9f22961295f9427742')
-
- # Required Input
- seats = req.body.seats
- unless seats > 0
- @logError(req.user, 'Course create API missing required seats count')
- return @sendBadInputError(res, 'Missing required seats count')
- # Optional - unspecified means create instances for all courses
- courseID = req.body.courseID
- # Optional
- name = req.body.name
- aceConfig = req.body.aceConfig or {}
- # Optional - as long as course(s) are all free
- stripeToken = req.body.stripe?.token
-
- query = if courseID? then {_id: courseID} else {}
- Course.find query, (err, courses) =>
- if err
- @logError(user, "Find courses error: #{JSON.stringify(err)}")
- return done(err)
-
- PrepaidHandler.purchasePrepaidCourse req.user, courses, seats, new Date().getTime(), stripeToken, (err, prepaid) =>
- if err
- @logError(req.user, err)
- return @sendBadInputError(res, err) if err is 'Missing required Stripe token'
- return @sendDatabaseError(res, err)
-
- courseInstances = []
- makeCreateInstanceFn = (course, name, prepaid, aceConfig) =>
- (done) =>
- @createInstance req, course, name, prepaid, aceConfig, (err, newInstance)=>
- courseInstances.push newInstance unless err
- done(err)
- tasks = (makeCreateInstanceFn(course, name, prepaid, aceConfig) for course in courses)
- async.parallel tasks, (err, results) =>
+ courseID = mongoose.Types.ObjectId('560f1a9f22961295f9427742')
+ CourseInstance.findOne { courseID: courseID, ownerID: req.user.get('_id'), hourOfCode: true }, (err, courseInstance) =>
+ return @sendDatabaseError(res, err) if err
+ if courseInstance
+ console.log 'already made a course instance'
+ return @sendSuccess(res, courseInstance) if courseInstance
+ console.log 'making a new course instance'
+ courseInstance = new CourseInstance({
+ courseID: courseID
+ members: [req.user.get('_id')]
+ name: 'Single Player'
+ ownerID: req.user.get('_id')
+ aceConfig: { language: 'python' }
+ hourOfCode: true
+ })
+ courseInstance.save (err, courseInstance) =>
+ return @sendDatabaseError(res, err) if err
+ @sendCreated(res, courseInstance)
+
+ addMember: (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'))
+ addingSelf = userID is req.user.id
+ return @sendForbiddenError(res) unless ownsCourseInstance or addingSelf
+ alreadyInCourseInstance = _.any courseInstance.get('members') or [], (memberID) -> memberID.toString() is userID
+ return @sendSuccess(res, @formatEntity(req, courseInstance)) if alreadyInCourseInstance
+ Prepaid.find({ 'redeemers.userID': mongoose.Types.ObjectId(userID) }).count (err, userIsPrepaid) =>
return @sendDatabaseError(res, err) if err
- @sendCreated(res, courseInstances)
-
- createInstance: (req, course, name, prepaid, aceConfig, done) =>
- courseInstance = new CourseInstance
- courseID: course.get('_id')
- members: [req.user.get('_id')]
- name: name
+ Course.findById courseInstance.get('courseID'), (err, course) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res, 'Course referenced by course instance not found') unless course
+ if not (course.get('free') or userIsPrepaid)
+ return @sendPaymentRequiredError(res, 'Cannot add this user to a course instance until they are added to a prepaid')
+ members = courseInstance.get('members')
+ members.push(userID)
+ courseInstance.set('members', members)
+ courseInstance.save (err, courseInstance) =>
+ 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
+ Classroom.findById req.body.classroomID, (err, classroom) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res, 'Classroom not found') unless classroom
+ return @sendForbiddenError(res) unless classroom.get('ownerID').equals(req.user.get('_id'))
+ Course.findById req.body.courseID, (err, course) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res, 'Course not found') unless course
+ super(req, res)
+
+ makeNewInstance: (req) ->
+ doc = new CourseInstance({
+ members: []
ownerID: req.user.get('_id')
- prepaidID: prepaid.get('_id')
- aceConfig: aceConfig
- courseInstance.save (err, newInstance) =>
- done(err, newInstance)
+ })
+ doc.set('aceConfig', {}) # constructor will ignore empty objects
+ return doc
getLevelSessionsAPI: (req, res, courseInstanceID) ->
CourseInstance.findById courseInstanceID, (err, courseInstance) =>
@@ -182,4 +204,28 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
return @sendDatabaseError(res, err) if err
@sendSuccess(res, courseInstances)
+ get: (req, res) ->
+ if ownerID = req.query.ownerID
+ return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or ownerID is req.user.id)
+ return @sendBadInputError(res, 'Bad ownerID') unless utils.isID ownerID
+ CourseInstance.find {ownerID: mongoose.Types.ObjectId(ownerID)}, (err, courseInstances) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendSuccess(res, (@formatEntity(req, courseInstance) for courseInstance in courseInstances))
+ else if memberID = req.query.memberID
+ return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or memberID is req.user.id)
+ return @sendBadInputError(res, 'Bad memberID') unless utils.isID memberID
+ CourseInstance.find {members: mongoose.Types.ObjectId(memberID)}, (err, courseInstances) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendSuccess(res, (@formatEntity(req, courseInstance) for courseInstance in courseInstances))
+ else if classroomID = req.query.classroomID
+ return @sendForbiddenError(res) unless req.user
+ return @sendBadInputError(res, 'Bad memberID') unless utils.isID classroomID
+ Classroom.findById classroomID, (err, classroom) =>
+ return @sendForbiddenError(res) unless classroom.isMember(req.user._id) or classroom.isOwner(req.user._id)
+ CourseInstance.find {classroomID: mongoose.Types.ObjectId(classroomID)}, (err, courseInstances) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendSuccess(res, (@formatEntity(req, courseInstance) for courseInstance in courseInstances))
+ else
+ super(arguments...)
+
module.exports = new CourseInstanceHandler()
diff --git a/server/prepaids/Prepaid.coffee b/server/prepaids/Prepaid.coffee
index c56a60839..5fd2ea0b0 100644
--- a/server/prepaids/Prepaid.coffee
+++ b/server/prepaids/Prepaid.coffee
@@ -1,8 +1,11 @@
mongoose = require 'mongoose'
config = require '../../server_config'
-PrepaidSchema = new mongoose.Schema {}, {strict: false, minimize: false,read:config.mongo.readpref}
+PrepaidSchema = new mongoose.Schema {
+ creator: mongoose.Schema.Types.ObjectId
+}, {strict: false, minimize: false,read:config.mongo.readpref}
PrepaidSchema.index({code: 1}, { unique: true })
+PrepaidSchema.index({'redeemers.userID': 1})
PrepaidSchema.statics.generateNewCode = (done) ->
tryCode = ->
@@ -12,5 +15,22 @@ PrepaidSchema.statics.generateNewCode = (done) ->
return done(code) unless prepaid
tryCode()
tryCode()
+
+PrepaidSchema.pre('save', (next) ->
+ @set('exhausted', @get('maxRedeemers') <= _.size(@get('redeemers')))
+ if not @get('code')
+ Prepaid.generateNewCode (code) =>
+ @set('code', code)
+ next()
+ else
+ next()
+)
+
+PrepaidSchema.post 'init', (doc) ->
+ doc.set('maxRedeemers', parseInt(doc.get('maxRedeemers')))
+
+PrepaidSchema.statics.postEditableProperties = [
+ 'creator', 'maxRedeemers', 'type'
+]
module.exports = Prepaid = mongoose.model('prepaid', PrepaidSchema)
diff --git a/server/prepaids/prepaid_handler.coffee b/server/prepaids/prepaid_handler.coffee
index 5ade97186..06b0c1671 100644
--- a/server/prepaids/prepaid_handler.coffee
+++ b/server/prepaids/prepaid_handler.coffee
@@ -2,12 +2,16 @@ Course = require '../courses/Course'
Handler = require '../commons/Handler'
hipchat = require '../hipchat'
Prepaid = require './Prepaid'
+User = require '../users/User'
StripeUtils = require '../lib/stripe_utils'
utils = require '../../app/core/utils'
+mongoose = require 'mongoose'
# TODO: Should this happen on a save() call instead of a prepaid/-/create post?
# TODO: Probably a better way to create a unique 8 charactor string property using db voodoo
+cutoffID = mongoose.Types.ObjectId('5642877accc6494a01cc6bfe')
+
PrepaidHandler = class PrepaidHandler extends Handler
modelClass: Prepaid
jsonSchema: require '../../app/schemas/models/prepaid.schema'
@@ -26,6 +30,7 @@ PrepaidHandler = class PrepaidHandler extends Handler
return @getPrepaidAPI(req, res, args[2]) if relationship is 'code'
return @createPrepaidAPI(req, res) if relationship is 'create'
return @purchasePrepaidAPI(req, res) if relationship is 'purchase'
+ return @postRedeemerAPI(req, res, args[0]) if relationship is 'redeemers'
super arguments...
getPrepaidAPI: (req, res, code) ->
@@ -44,7 +49,7 @@ PrepaidHandler = class PrepaidHandler extends Handler
createPrepaidAPI: (req, res) ->
return @sendForbiddenError(res) unless @hasAccess(req)
return @sendForbiddenError(res) unless req.body.type in ['course', 'subscription','terminal_subscription']
- return @sendForbiddenError(res) unless req.body.maxRedeemers > 0
+ return @sendForbiddenError(res) unless parseInt(req.body.maxRedeemers) > 0
properties = {}
type = req.body.type
@@ -61,6 +66,44 @@ PrepaidHandler = class PrepaidHandler extends Handler
return @sendDatabaseError(res, err) if err
@sendSuccess(res, prepaid.toObject())
+ postRedeemerAPI: (req, res, prepaidID) ->
+ return @sendForbiddenError(res) if prepaidID.toString() < cutoffID.toString()
+ return @sendMethodNotAllowed(res, 'You may only POST redeemers.') if req.method isnt 'POST'
+ return @sendBadInputError(res, 'Need an object with a userID') unless req.body?.userID
+ Prepaid.findById(prepaidID).exec (err, prepaid) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res) if not prepaid
+ return @sendForbiddenError(res) if prepaid.get('creator').toString() isnt req.user.id
+ return @sendForbiddenError(res) if _.size(prepaid.get('redeemers')) >= prepaid.get('maxRedeemers')
+ return @sendForbiddenError(res) unless prepaid.get('type') is 'course'
+ User.findById(req.body.userID).exec (err, user) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendNotFoundError(res, 'User for given ID not found') if not user
+ userID = user.get('_id')
+# Prepaid.count {'redeemers.userID': userID}, (err, count) =>
+# return @sendDatabaseError(res, err) if err
+# return @sendSuccess(res, @formatEntity(req, prepaid)) if count
+
+ query =
+ _id: prepaid.get('_id')
+ 'redeemers.userID': { $ne: req.user.get('_id') }
+ $where: "this.redeemers.length < #{prepaid.get('maxRedeemers')}"
+ update = { $push: { redeemers : { date: new Date(), userID: userID } }}
+ Prepaid.update query, update, (err, nMatched) =>
+ return @sendDatabaseError(res, err) if err
+ if nMatched is 0
+ @logError(req.user, "POST prepaid redeemer lost race on maxRedeemers")
+ return @sendForbiddenError(res)
+
+ user.set('coursePrepaidID', prepaid.get('_id'))
+ user.save (err, user) =>
+ return @sendDatabaseError(res, err) if err
+ # return prepaid with new redeemer added locally
+ redeemers = _.clone(prepaid.get('redeemers') or [])
+ redeemers.push({ date: new Date(), userID: userID })
+ prepaid.set('redeemers', redeemers)
+ @sendSuccess(res, @formatEntity(req, prepaid))
+
createPrepaid: (user, type, maxRedeemers, properties, done) ->
Prepaid.generateNewCode (code) =>
return done('Database error.') unless code
@@ -68,7 +111,7 @@ PrepaidHandler = class PrepaidHandler extends Handler
creator: user._id
type: type
code: code
- maxRedeemers: maxRedeemers
+ maxRedeemers: parseInt(maxRedeemers)
properties: properties
redeemers: []
@@ -97,40 +140,30 @@ PrepaidHandler = class PrepaidHandler extends Handler
@sendSuccess(res, prepaid.toObject())
else if req.body.type is 'course'
- courseID = req.body.courseID
-
maxRedeemers = parseInt(req.body.maxRedeemers)
timestamp = req.body.stripe?.timestamp
token = req.body.stripe?.token
return @sendBadInputError(res) unless isNaN(maxRedeemers) is false and maxRedeemers > 0
- query = if courseID? then {_id: courseID} else {}
- Course.find query, (err, courses) =>
- if err
- @logError(user, "Find courses error: #{JSON.stringify(err)}")
- return done(err)
-
- @purchasePrepaidCourse req.user, courses, maxRedeemers, timestamp, token, (err, prepaid) =>
- # TODO: this badinput detection is fragile, in course instance handler as well
- return @sendBadInputError(res, err) if err is 'Missing required Stripe token'
- return @sendDatabaseError(res, err) if err
- @sendSuccess(res, prepaid.toObject())
+ @purchasePrepaidCourse req.user, maxRedeemers, timestamp, token, (err, prepaid) =>
+ # TODO: this badinput detection is fragile, in course instance handler as well
+ return @sendBadInputError(res, err) if err is 'Missing required Stripe token'
+ return @sendDatabaseError(res, err) if err
+ @sendSuccess(res, prepaid.toObject())
else
@sendForbiddenError(res)
- purchasePrepaidCourse: (user, courses, maxRedeemers, timestamp, token, done) ->
+ purchasePrepaidCourse: (user, maxRedeemers, timestamp, token, done) ->
type = 'course'
- courseIDs = (c.get('_id') for c in courses)
- coursePrices = (c.get('pricePerSeat') for c in courses)
- amount = utils.getCourseBundlePrice(coursePrices, maxRedeemers)
+ amount = maxRedeemers * 400
if amount > 0 and not (token or user.isAdmin())
@logError(user, "Purchase prepaid courses missing required Stripe token #{amount}")
return done('Missing required Stripe token')
if amount is 0 or user.isAdmin()
- @createPrepaid(user, type, maxRedeemers, courseIDs: courseIDs, done)
+ @createPrepaid(user, type, maxRedeemers, {}, done)
else
StripeUtils.getCustomer user, token, (err, customer) =>
@@ -142,10 +175,8 @@ PrepaidHandler = class PrepaidHandler extends Handler
type: type
userID: user.id
timestamp: parseInt(timestamp)
- description: if courses.length is 1 then courses[0].get('name') else 'All Courses'
maxRedeemers: maxRedeemers
productID: "prepaid #{type}"
- courseIDs: courseIDs
StripeUtils.createCharge user, amount, metadata, (err, charge) =>
if err
@@ -156,9 +187,9 @@ PrepaidHandler = class PrepaidHandler extends Handler
if err
@logError(user, "createPayment error: #{JSON.stringify(err)}")
return done(err)
- msg = "Prepaid code purchased: #{type} seats=#{maxRedeemers} courseIDs=#{courseIDs} #{user.get('email')}"
+ msg = "Prepaid code purchased: #{type} seats=#{maxRedeemers} #{user.get('email')}"
hipchat.sendHipChatMessage msg, ['tower']
- @createPrepaid(user, type, maxRedeemers, courseIDs: courseIDs, done)
+ @createPrepaid(user, type, maxRedeemers, {}, done)
purchasePrepaidTerminalSubscription: (user, description, maxRedeemers, months, timestamp, token, done) ->
type = 'terminal_subscription'
@@ -195,7 +226,7 @@ PrepaidHandler = class PrepaidHandler extends Handler
creator: user._id
type: type
code: code
- maxRedeemers: maxRedeemers
+ maxRedeemers: parseInt(maxRedeemers)
redeemers: []
properties:
months: months
@@ -205,4 +236,25 @@ PrepaidHandler = class PrepaidHandler extends Handler
hipchat.sendHipChatMessage msg, ['tower']
return done(null, prepaid)
+
+ get: (req, res) ->
+ if creator = req.query.creator
+ return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or creator is req.user.id)
+ return @sendBadInputError(res, 'Bad creator') unless utils.isID creator
+ q = {
+ _id: {$gt: cutoffID}
+ creator: mongoose.Types.ObjectId(creator),
+ type: 'course'
+ }
+ Prepaid.find q, (err, prepaids) =>
+ return @sendDatabaseError(res, err) if err
+ return @sendSuccess(res, (@formatEntity(req, prepaid) for prepaid in prepaids))
+ else
+ super(arguments...)
+
+ makeNewInstance: (req) ->
+ prepaid = super(req)
+ prepaid.set('redeemers', [])
+ return prepaid
+
module.exports = new PrepaidHandler()
diff --git a/test/server/common.coffee b/test/server/common.coffee
index 40b815cd4..0635cbc45 100644
--- a/test/server/common.coffee
+++ b/test/server/common.coffee
@@ -32,6 +32,7 @@ models_path = [
'../../server/articles/Article'
'../../server/campaigns/Campaign'
'../../server/clans/Clan'
+ '../../server/classrooms/Classroom'
'../../server/courses/Course'
'../../server/courses/CourseInstance'
'../../server/levels/Level'
@@ -163,8 +164,6 @@ GLOBAL.purchasePrepaid = (type, properties, maxRedeemers, token, done) ->
options.json.stripe.token = token if token?
if type is 'terminal_subscription'
options.json.months = properties.months
- else if type is 'course'
- options.json.courseID = properties.courseID if properties?.courseID
request.post options, done
GLOBAL.subscribeWithPrepaid = (ppc, done) =>
diff --git a/test/server/functional/classrooms.spec.coffee b/test/server/functional/classrooms.spec.coffee
new file mode 100644
index 000000000..4bd176b1c
--- /dev/null
+++ b/test/server/functional/classrooms.spec.coffee
@@ -0,0 +1,149 @@
+config = require '../../../server_config'
+require '../common'
+utils = require '../../../app/core/utils' # Must come after require /common
+mongoose = require 'mongoose'
+
+classroomsURL = getURL('/db/classroom')
+
+describe 'GET /db/classroom?ownerID=:id', ->
+ it 'clears database users and classrooms', (done) ->
+ clearModels [User, Classroom], (err) ->
+ throw err if err
+ done()
+
+ it 'returns an array of classrooms with the given owner', (done) ->
+ loginNewUser (user1) ->
+ new Classroom({name: 'Classroom 1', ownerID: user1.get('_id') }).save (err, classroom) ->
+ expect(err).toBeNull()
+ loginNewUser (user2) ->
+ new Classroom({name: 'Classroom 2', ownerID: user2.get('_id') }).save (err, classroom) ->
+ expect(err).toBeNull()
+ url = getURL('/db/classroom?ownerID='+user2.id)
+ request.get { uri: url, json: true }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ expect(body.length).toBe(1)
+ expect(body[0].name).toBe('Classroom 2')
+ done()
+
+ it 'returns 403 when a non-admin tries to get classrooms for another user', (done) ->
+ loginNewUser (user1) ->
+ loginNewUser (user2) ->
+ url = getURL('/db/classroom?ownerID='+user1.id)
+ request.get { uri: url }, (err, res, body) ->
+ expect(res.statusCode).toBe(403)
+ done()
+
+
+describe 'GET /db/classroom/:id', ->
+ it 'clears database users and classrooms', (done) ->
+ clearModels [User, Classroom], (err) ->
+ throw err if err
+ done()
+
+ it 'returns the classroom for the given id', (done) ->
+ loginNewUser (user1) ->
+ data = { name: 'Classroom 1' }
+ request.post {uri: classroomsURL, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ classroomID = body._id
+ request.get {uri: classroomsURL + '/' + body._id }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ expect(body._id).toBe(classroomID = body._id)
+ done()
+
+describe 'POST /db/classroom', ->
+
+ it 'clears database users and classrooms', (done) ->
+ clearModels [User, Classroom], (err) ->
+ throw err if err
+ done()
+
+ it 'creates a new classroom for the given user', (done) ->
+ loginNewUser (user1) ->
+ data = { name: 'Classroom 1' }
+ request.post {uri: classroomsURL, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ expect(body.name).toBe('Classroom 1')
+ expect(body.members.length).toBe(0)
+ expect(body.ownerID).toBe(user1.id)
+ done()
+
+ it 'does not work for anonymous users', (done) ->
+ logoutUser ->
+ data = { name: 'Classroom 2' }
+ request.post {uri: classroomsURL, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(401)
+ done()
+
+
+describe 'PUT /db/classroom', ->
+
+ it 'clears database users and classrooms', (done) ->
+ clearModels [User, Classroom], (err) ->
+ throw err if err
+ done()
+
+ it 'edits name and description', (done) ->
+ loginNewUser (user1) ->
+ data = { name: 'Classroom 2' }
+ request.post {uri: classroomsURL, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ data = { name: 'Classroom 3', description: 'New Description' }
+ url = classroomsURL + '/' + body._id
+ request.put { uri: url, json: data }, (err, res, body) ->
+ expect(body.name).toBe('Classroom 3')
+ expect(body.description).toBe('New Description')
+ done()
+
+ it 'is not allowed if you are just a member', (done) ->
+ loginNewUser (user1) ->
+ data = { name: 'Classroom 4' }
+ request.post {uri: classroomsURL, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ classroomCode = body.code
+ loginNewUser (user2) ->
+ url = getURL("/db/classroom/~/members")
+ data = { code: classroomCode }
+ request.post { uri: url, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ url = classroomsURL + '/' + body._id
+ request.put { uri: url, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(403)
+ done()
+
+describe 'POST /db/classroom/:id/members', ->
+
+ it 'clears database users and classrooms', (done) ->
+ clearModels [User, Classroom], (err) ->
+ throw err if err
+ done()
+
+ it 'adds the signed in user to the list of members in the classroom', (done) ->
+ loginNewUser (user1) ->
+ data = { name: 'Classroom 5' }
+ 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)
+ done()
+
+
+describe 'POST /db/classroom/:id/invite-members', ->
+
+ it 'takes a list of emails and sends invites', (done) ->
+ loginNewUser (user1) ->
+ data = { name: 'Classroom 6' }
+ request.post {uri: classroomsURL, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ url = classroomsURL + '/' + body._id + '/invite-members'
+ data = { emails: ['test@test.com'] }
+ request.post { uri: url, json: data }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ done()
diff --git a/test/server/functional/course_instance.spec.coffee b/test/server/functional/course_instance.spec.coffee
index ff6506a01..fe7d154ae 100644
--- a/test/server/functional/course_instance.spec.coffee
+++ b/test/server/functional/course_instance.spec.coffee
@@ -2,353 +2,158 @@ async = require 'async'
config = require '../../../server_config'
require '../common'
stripe = require('stripe')(config.stripe.secretKey)
+init = require '../init'
-# TODO: add permissiosn tests
+describe 'POST /db/course_instance', ->
-describe 'CourseInstance', ->
- courseInstanceCreateURL = getURL('/db/course_instance/-/create')
- courseInstanceRedeemURL = getURL('/db/course_instance/-/redeem_prepaid')
- userURL = getURL('/db/user')
-
- createCourseInstances = (user, courseID, seats, token, done) ->
- name = createName 'course instance '
- requestBody =
- courseID: courseID
- name: name
- seats: seats
- stripe:
- token: token
- request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(201)
- CourseInstance.find {name: name}, (err, courseInstances) ->
- expect(err).toBeNull()
-
- makeCourseInstanceVerifyFn = (courseInstance) ->
- (done) ->
- expect(courseInstance.get('name')).toEqual(name)
- expect(courseInstance.get('ownerID')).toEqual(user.get('_id'))
- expect(courseInstance.get('members')).toContain(user.get('_id'))
- query = {$and: [{creator: user.get('_id')}]}
- query.$and.push {'properties.courseIDs': {$in: [courseID]}} if courseID
- Prepaid.find query, (err, prepaids) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(prepaids?.length).toEqual(1)
- return done() unless prepaids?.length > 0
- expect(prepaids[0].get('type')).toEqual('course')
- expect(prepaids[0].get('maxRedeemers')).toEqual(seats) if seats
-
- # TODO: verify Payment
-
- done(err)
-
- tasks = []
- for courseInstance in courseInstances
- tasks.push makeCourseInstanceVerifyFn(courseInstance)
- async.parallel tasks, (err) =>
- return done(err) if err
- done(err, courseInstances)
-
- it 'Clear database', (done) ->
- clearModels [User, Course, CourseInstance, Prepaid], (err) ->
- throw err if err
+ beforeEach (done) -> clearModels([CourseInstance, Course, User, Classroom], done)
+ beforeEach (done) -> loginJoe (@joe) => done()
+ beforeEach init.course()
+ beforeEach init.classroom()
+
+ it 'creates a CourseInstance', (done) ->
+ test = @
+ url = getURL('/db/course_instance')
+ data = {
+ name: 'Some Name'
+ courseID: test.course.id
+ classroomID: test.classroom.id
+ }
+ request.post {uri: url, json: data}, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ expect(body.classroomID).toBeDefined()
+ done()
+
+ it 'returns 404 if the Course does not exist', (done) ->
+ test = @
+ url = getURL('/db/course_instance')
+ data = {
+ name: 'Some Name'
+ courseID: '123456789012345678901234'
+ classroomID: test.classroom.id
+ }
+ request.post {uri: url, json: data}, (err, res, body) ->
+ expect(res.statusCode).toBe(404)
done()
- describe 'Single courses', ->
- it 'Create for free course 1 seat', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- done()
+ it 'returns 404 if the Classroom does not exist', (done) ->
+ test = @
+ url = getURL('/db/course_instance')
+ data = {
+ name: 'Some Name'
+ courseID: test.course.id
+ classroomID: '123456789012345678901234'
+ }
+ request.post {uri: url, json: data}, (err, res, body) ->
+ expect(res.statusCode).toBe(404)
+ done()
+
+ it 'return 403 if the logged in user does not own the Classroom', (done) ->
+ test = @
+ loginSam ->
+ url = getURL('/db/course_instance')
+ data = {
+ name: 'Some Name'
+ courseID: test.course.id
+ classroomID: test.classroom.id
+ }
+ request.post {uri: url, json: data}, (err, res, body) ->
+ expect(res.statusCode).toBe(403)
+ done()
+
+
+describe 'POST /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 'adds a member to the given CourseInstance', (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) ->
+ expect(res.statusCode).toBe(200)
+ expect(body.members.length).toBe(1)
+ expect(body.members[0]).toBe(test.user.id)
+ cb()
+
+ ], makeTestIterator(@), done)
+
+
+ it 'return 403 if the member is not in the classroom', (done) ->
+ async.eachSeries([
+
+ (test, cb) ->
+ url = getURL("/db/course_instance/#{test.courseInstance.id}/members")
+ request.post {uri: url, json: {userID: test.user.id}}, (err, res) ->
+ expect(res.statusCode).toBe(403)
+ cb()
+
+ ], makeTestIterator(@), done)
+
+
+ it 'returns 403 if the user does not own the course instance and is not adding self', (done) ->
+ async.eachSeries([
- it 'Create for free course no seats', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- name = createName 'course instance '
- requestBody =
- courseID: course.get('_id')
- name: createName('course instance ')
- request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(422)
- done()
+ addTestUserToClassroom,
+ (test, cb) ->
+ loginSam ->
+ 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(403)
+ cb()
- it 'Create for free course no token', (done) ->
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 2, null, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- done()
+ ], makeTestIterator(@), done)
+
+ it 'returns 200 if the user is a member of the classroom and is adding self', ->
- it 'Create for paid course 1 seat', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 7000, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(prepaid.get('maxRedeemers')).toEqual(1)
- expect(prepaid.get('properties')?.courseIDs).toEqual([course.get('_id')])
- done()
+ it 'return 402 if the course is not free and the user is not in a prepaid', (done) ->
+ async.eachSeries([
+
+ addTestUserToClassroom,
+ makeTestCourseNotFree,
+ (test, cb) ->
+ url = getURL("/db/course_instance/#{test.courseInstance.id}/members")
+ request.post {uri: url, json: {userID: test.user.id}}, (err, res) ->
+ expect(res.statusCode).toBe(402)
+ cb()
+
+ ], makeTestIterator(@), done)
+
+
+ it 'works if the course is not free and the user is in a prepaid', (done) ->
+ async.eachSeries([
+
+ addTestUserToClassroom,
+ makeTestCourseNotFree,
+ addTestUserToPrepaid,
+ (test, cb) ->
+ url = getURL("/db/course_instance/#{test.courseInstance.id}/members")
+ request.post {uri: url, json: {userID: test.user.id}}, (err, res) ->
+ expect(res.statusCode).toBe(200)
+ cb()
+
+ ], makeTestIterator(@), done)
+
+
+ makeTestCourseNotFree = (test, cb) ->
+ test.course.set('free', false)
+ test.course.save cb
+
+ addTestUserToClassroom = (test, cb) ->
+ test.classroom.set('members', [test.user.get('_id')])
+ test.classroom.save cb
- it 'Create for paid course 50 seats', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 7000, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 50, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(prepaid.get('maxRedeemers')).toEqual(50)
- expect(prepaid.get('properties')?.courseIDs).toEqual([course.get('_id')])
- done()
+ addTestUserToPrepaid = (test, cb) ->
+ test.prepaid.set('redeemers', [{userID: test.user.get('_id')}])
+ test.prepaid.save cb
- it 'Create for paid course no token', (done) ->
- loginNewUser (user1) ->
- createCourse 7000, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- name = createName 'course instance '
- requestBody =
- courseID: course.get('_id')
- name: createName('course instance ')
- seats: 1
- request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(422)
- done()
-
- it 'Create for paid course -1 seats', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 7000, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- name = createName 'course instance '
- requestBody =
- courseID: course.get('_id')
- name: createName('course instance ')
- seats: -1
- request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(422)
- done()
-
- describe 'All Courses', ->
- it 'Create for 50 seats', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 7000, (err, course1) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourse 7000, (err, course2) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, null, 50, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- Course.find {}, (err, courses) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(courses.length)
- Prepaid.find creator: user1.get('_id'), (err, prepaids) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(prepaids.length).toEqual(1)
- return done('no prepaids found') unless prepaids?.length > 0
- prepaid = prepaids[0]
- expect(prepaid.get('maxRedeemers')).toEqual(50)
- expect(prepaid.get('properties')?.courseIDs?.length).toEqual(courses.length)
- done()
-
- describe 'Invite to course', ->
- it 'takes a list of emails and sends invites', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- inviteStudentsURL = getURL("/db/course_instance/#{courseInstances[0]._id}/invite_students")
- requestBody = {
- emails: ['test@test.com']
- }
- request.post { uri: inviteStudentsURL, json: requestBody }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- done()
-
- describe 'Redeem prepaid code', ->
-
- it 'Redeem prepaid code for an instance of max 2', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 2, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
- loginNewUser (user2) ->
- request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
-
- # Check prepaid
- Prepaid.findById prepaid.id, (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(prepaid.get('redeemers')?.length).toEqual(1)
- expect(prepaid.get('redeemers')[0].date).toBeLessThan(new Date())
- expect(prepaid.get('redeemers')[0].userID).toEqual(user2.get('_id'))
-
- # Check course instance
- CourseInstance.findById courseInstances[0].id, (err, courseInstance) ->
- expect(err).toBeNull()
- return done(err) if err
- members = courseInstance.get('members')
- expect(members?.length).toEqual(2)
- # TODO: must be a better way to check membership
- usersFound = 0
- for memberID in members
- usersFound++ if memberID.equals(user1.get('_id'))
- usersFound++ if memberID.equals(user2.get('_id'))
- expect(usersFound).toEqual(2)
- done()
-
- it 'Redeem full prepaid code for on instance of max 1', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
- loginNewUser (user2) ->
- request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- loginNewUser (user3) ->
- request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(403)
- done()
-
- it 'Redeem 50 count course prepaid codes 51 times, in parallel', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- seatCount = 50
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), seatCount, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
-
- forbiddenResults = 0
- makeRedeemCall = ->
- (callback) ->
- loginNewUser (user2) ->
- request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
- expect(err).toBeNull()
- if res.statusCode is 403
- forbiddenResults++
- else
- expect(res.statusCode).toBe(200)
- callback err
- tasks = (makeRedeemCall() for i in [1..seatCount+1])
- async.parallel tasks, (err, results) ->
- expect(err?).toEqual(false)
- expect(forbiddenResults).toEqual(1)
- Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(prepaid.get('redeemers')?.length).toEqual(prepaid.get('maxRedeemers'))
- done()
-
- it 'Redeem prepaid code twice', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 0, (err, course) ->
- expect(err).toBeNull()
- return done(err) if err
- createCourseInstances user1, course.get('_id'), 2, token.id, (err, courseInstances) ->
- expect(err).toBeNull()
- return done(err) if err
- expect(courseInstances.length).toEqual(1)
- Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
- expect(err).toBeNull()
- return done(err) if err
- loginNewUser (user2) ->
- # Redeem once
- request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- # Redeem twice
- request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- done()
+makeTestIterator = (testObject) -> (func, callback) -> func(testObject, callback)
+
\ No newline at end of file
diff --git a/test/server/functional/prepaid.spec.coffee b/test/server/functional/prepaid.spec.coffee
index ece5867e3..f299e5dda 100644
--- a/test/server/functional/prepaid.spec.coffee
+++ b/test/server/functional/prepaid.spec.coffee
@@ -19,7 +19,6 @@ describe '/db/prepaid', ->
expect(prepaid.type).toEqual('course')
expect(prepaid.maxRedeemers).toBeGreaterThan(0)
expect(prepaid.code).toMatch(/^\w{8}$/)
- expect(prepaid.properties?.courseIDs?.length).toBeGreaterThan(0)
done()
verifySubscriptionPrepaid = (user, prepaid, done) ->
@@ -30,6 +29,94 @@ describe '/db/prepaid', ->
expect(prepaid.properties?.couponID).toEqual('free')
done()
+ it 'Clear database', (done) ->
+ clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) ->
+ throw err if err
+ done()
+
+ describe 'POST /db/prepaid//redeemers', ->
+
+ it 'adds a given user to the redeemers property', (done) ->
+ loginNewUser (user1) ->
+ prepaid = new Prepaid({
+ maxRedeemers: 1,
+ redeemers: [],
+ creator: user1.get('_id')
+ code: 0
+ })
+ prepaid.save (err, prepaid) ->
+ otherUser = new User()
+ otherUser.save (err, otherUser) ->
+ url = getURL("/db/prepaid/#{prepaid.id}/redeemers")
+ redeemer = { userID: otherUser.id }
+ request.post {uri: url, json: redeemer }, (err, res, body) ->
+ expect(body.redeemers.length).toBe(1)
+ expect(res.statusCode).toBe(200)
+ prepaid = Prepaid.findById body._id, (err, prepaid) ->
+ expect(err).toBeNull()
+ expect(prepaid.get('redeemers').length).toBe(1)
+ User.findById otherUser.id, (err, user) ->
+ expect(user.get('coursePrepaidID').equals(prepaid.get('_id'))).toBe(true)
+ done()
+
+ it 'does not allow more redeemers than maxRedeemers', (done) ->
+ loginNewUser (user1) ->
+ prepaid = new Prepaid({
+ maxRedeemers: 0,
+ redeemers: [],
+ creator: user1.get('_id')
+ code: 1
+ })
+ prepaid.save (err, prepaid) ->
+ otherUser = new User()
+ otherUser.save (err, otherUser) ->
+ url = getURL("/db/prepaid/#{prepaid.id}/redeemers")
+ redeemer = { userID: otherUser.id }
+ request.post {uri: url, json: redeemer }, (err, res, body) ->
+ expect(res.statusCode).toBe(403)
+ done()
+
+ it 'only allows the owner of the prepaid to add redeemers', (done) ->
+ loginNewUser (user1) ->
+ prepaid = new Prepaid({
+ maxRedeemers: 1000,
+ redeemers: [],
+ creator: user1.get('_id')
+ code: 2
+ })
+ prepaid.save (err, prepaid) ->
+ loginNewUser (user2) ->
+ otherUser = new User()
+ otherUser.save (err, otherUser) ->
+ url = getURL("/db/prepaid/#{prepaid.id}/redeemers")
+ redeemer = { userID: otherUser.id }
+ request.post {uri: url, json: redeemer }, (err, res, body) ->
+ expect(res.statusCode).toBe(403)
+ done()
+
+ it 'is idempotent across prepaids collection', (done) ->
+ loginNewUser (user1) ->
+ otherUser = new User()
+ otherUser.save (err, otherUser) ->
+ prepaid1 = new Prepaid({
+ redeemers: [{userID: otherUser.get('_id')}],
+ code: 3
+ })
+ prepaid1.save (err, prepaid1) ->
+ prepaid2 = new Prepaid({
+ maxRedeemers: 10,
+ redeemers: [],
+ creator: user1.get('_id')
+ code: 4
+ })
+ prepaid2.save (err, prepaid2) ->
+ url = getURL("/db/prepaid/#{prepaid2.id}/redeemers")
+ redeemer = { userID: otherUser.id }
+ request.post {uri: url, json: redeemer }, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ expect(body.redeemers.length).toBe(0)
+ done()
+
it 'Clear database', (done) ->
clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) ->
throw err if err
@@ -141,68 +228,36 @@ describe '/db/prepaid', ->
done() unless found
describe 'Purchase course', ->
- it 'Standard user purchases a prepaid for one course, 0 seats', (done) ->
+ it 'Standard user purchases a prepaid for 0 seats', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
- createCourse 700, (err, course) ->
+ purchasePrepaid 'course', {}, 0, token.id, (err, res, prepaid) ->
expect(err).toBeNull()
- purchasePrepaid 'course', courseID: course.id, 0, token.id, (err, res, prepaid) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(422)
- done()
- it 'Standard user purchases a prepaid for one course, 1 seat', (done) ->
+ expect(res.statusCode).toBe(422)
+ done()
+
+ it 'Standard user purchases a prepaid for 1 seat', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
- createCourse 700, (err, course) ->
+ purchasePrepaid 'course', {}, 1, token.id, (err, res, prepaid) ->
expect(err).toBeNull()
- purchasePrepaid 'course', courseID: course.id, 1, token.id, (err, res, prepaid) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- verifyCoursePrepaid(user1, prepaid, done)
- it 'Standard user purchases a prepaid for one course, 3 seats', (done) ->
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 700, (err, course) ->
- expect(err).toBeNull()
- purchasePrepaid 'course', courseID: course.id, 3, token.id, (err, res, prepaid) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- verifyCoursePrepaid(user1, prepaid, done)
- it 'Standard user purchases a prepaid for all courses, 10 seats', (done) ->
- clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) ->
- throw err if err
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user1) ->
- createCourse 700, (err, course) ->
- expect(err).toBeNull()
- createCourse 700, (err, course) ->
- expect(err).toBeNull()
- purchasePrepaid 'course', null, 10, token.id, (err, res, prepaid) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- expect(prepaid.properties?.courseIDs?.length).toEqual(2)
- verifyCoursePrepaid(user1, prepaid, done)
+ expect(res.statusCode).toBe(200)
+ verifyCoursePrepaid(user1, prepaid, done)
- it 'Standard user purchases a prepaid course for 3', (done) ->
+ it 'Standard user purchases a prepaid for 3 seats', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
- createCourse 700, (err, course) ->
+ purchasePrepaid 'course', {}, 3, token.id, (err, res, prepaid) ->
expect(err).toBeNull()
- purchasePrepaid 'course', courseID: course.id, 3, token.id, (err, res, prepaid) ->
- expect(err).toBeNull()
- expect(res.statusCode).toBe(200)
- done()
-
+ expect(res.statusCode).toBe(200)
+ verifyCoursePrepaid(user1, prepaid, done)
+
describe 'Purchase terminal_subscription', ->
it 'Anonymous submits a prepaid purchase', (done) ->
stripe.tokens.create {
@@ -306,6 +361,7 @@ describe '/db/prepaid', ->
joeCode = prepaid.code
expect(prepaid.creator).toBeDefined()
expect(prepaid.maxRedeemers).toEqual(3)
+ expect(prepaid.exhausted).toBe(false)
expect(prepaid.properties).toBeDefined()
expect(prepaid.properties.months).toEqual(3)
done()
@@ -464,47 +520,47 @@ describe '/db/prepaid', ->
expect(res.statusCode).not.toEqual(200)
done()
- it 'Test a bunch of people trying to redeem at once', (done) ->
- doRedeem = (userX, code, testnum, retry, fnDone) =>
- loginUser userX, () =>
- endDate = new moment().add(3, 'months').toISOString().substring(0, 10)
- subscribeWithPrepaid code, (err, res, result) ->
- if err
- return fnDone(err)
-
- expect(err).toBeNull()
- expect(result).toBeDefined()
- if result.stripe
- expect(result.stripe).toBeDefined()
- expect(result.stripe.free).toEqual(endDate)
- expect(result?.purchased?.gems).toEqual(10500)
- return fnDone(null, {status: "ok", msg: "Redeemed " + retry})
- else
- return fnDone(null, {status: 'error', msg: "Redeem attempt Error #{result} (#{userX.id})" + retry })
-
- redeemPrepaidFn = (code, testnum) =>
- (fnDone) =>
- loginNewUser (user1) =>
- doRedeem(user1, code, testnum, 0, fnDone)
-
- stripe.tokens.create {
- card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
- }, (err, token) ->
- loginNewUser (user) =>
- codeRedeemers = 50
- codeMonths = 3
- redeemers = 51
- purchasePrepaid 'terminal_subscription', months: codeMonths, codeRedeemers, token.id, (err, res, prepaid) ->
- expect(err).toBeNull()
- expect(prepaid).toBeDefined()
- expect(prepaid.code).toBeDefined()
- tasks = (redeemPrepaidFn(prepaid.code, i) for i in [0...redeemers])
- async.parallel tasks, (err, results) =>
- redeemed = 0
- error = 0
- for result in results
- redeemed += 1 if result.status is 'ok'
- error += 1 if result.status is 'error'
- expect(redeemed).toEqual(codeRedeemers)
- expect(error).toEqual(redeemers - codeRedeemers)
- done()
+# it 'Test a bunch of people trying to redeem at once', (done) ->
+# doRedeem = (userX, code, testnum, retry, fnDone) =>
+# loginUser userX, () =>
+# endDate = new moment().add(3, 'months').toISOString().substring(0, 10)
+# subscribeWithPrepaid code, (err, res, result) ->
+# if err
+# return fnDone(err)
+#
+# expect(err).toBeNull()
+# expect(result).toBeDefined()
+# if result.stripe
+# expect(result.stripe).toBeDefined()
+# expect(result.stripe.free).toEqual(endDate)
+# expect(result?.purchased?.gems).toEqual(10500)
+# return fnDone(null, {status: "ok", msg: "Redeemed " + retry})
+# else
+# return fnDone(null, {status: 'error', msg: "Redeem attempt Error #{result} (#{userX.id})" + retry })
+#
+# redeemPrepaidFn = (code, testnum) =>
+# (fnDone) =>
+# loginNewUser (user1) =>
+# doRedeem(user1, code, testnum, 0, fnDone)
+#
+# stripe.tokens.create {
+# card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
+# }, (err, token) ->
+# loginNewUser (user) =>
+# codeRedeemers = 50
+# codeMonths = 3
+# redeemers = 51
+# purchasePrepaid 'terminal_subscription', months: codeMonths, codeRedeemers, token.id, (err, res, prepaid) ->
+# expect(err).toBeNull()
+# expect(prepaid).toBeDefined()
+# expect(prepaid.code).toBeDefined()
+# tasks = (redeemPrepaidFn(prepaid.code, i) for i in [0...redeemers])
+# async.parallel tasks, (err, results) =>
+# redeemed = 0
+# error = 0
+# for result in results
+# redeemed += 1 if result.status is 'ok'
+# error += 1 if result.status is 'error'
+# expect(redeemed).toEqual(codeRedeemers)
+# expect(error).toEqual(redeemers - codeRedeemers)
+# done()
diff --git a/test/server/init.coffee b/test/server/init.coffee
new file mode 100644
index 000000000..912ff6b1a
--- /dev/null
+++ b/test/server/init.coffee
@@ -0,0 +1,86 @@
+
+module.exports.course = (properties) ->
+ properties ?= {}
+ _.defaults(properties, {
+ name: 'Unnamed course'
+ campaignID: ObjectId("55b29efd1cd6abe8ce07db0d")
+ concepts: ['basic_syntax', 'arguments', 'while_loops', 'strings', 'variables']
+ description: "Learn basic syntax, while loops, and the CodeCombat environment."
+ screenshot: "/images/pages/courses/101_info.png"
+ })
+
+ return (done) ->
+ test = @
+ course = new Course(properties)
+ course.save (err, course) ->
+ expect(err).toBeNull()
+ test.course = course
+ done()
+
+
+module.exports.classroom = (givenProperties) ->
+ return (done) ->
+ properties = _.defaults({}, givenProperties, {
+ name: 'Unnamed classroom'
+ })
+ test = @
+ url = getURL('/db/classroom')
+ request.post {uri: url, json: properties}, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ Classroom.findById body._id, (err, classroom) ->
+ expect(err).toBeNull()
+ expect(classroom).toBeTruthy()
+ test.classroom = classroom
+ done()
+
+
+module.exports.courseInstance = (givenProperties) ->
+ return (done) ->
+ properties = _.defaults({}, givenProperties, {
+ name: 'Unnamed course instance'
+ })
+ test = @
+ url = getURL('/db/course_instance')
+ properties.courseID ?= test.course.id
+ properties.classroomID ?= test.classroom.id
+ request.post {uri: url, json: properties}, (err, res, body) ->
+ expect(res.statusCode).toBe(200)
+ CourseInstance.findById body._id, (err, courseInstance) ->
+ expect(err).toBeNull()
+ expect(courseInstance).toBeTruthy()
+ test.courseInstance = courseInstance
+ done()
+
+
+module.exports.user = (givenOptions) ->
+ return (done) ->
+ options = _.defaults({}, givenOptions, {
+ setTo: 'user',
+ properties: {
+ name: 'User'+_.uniqueId()
+ }
+ })
+ test = @
+ user = new User(options.properties)
+ user.save (err, user) ->
+ expect(err).toBeNull()
+ test[options.setTo] = user
+ done()
+
+
+module.exports.prepaid = (givenOptions) ->
+ return (done) ->
+ options = _.defaults({}, givenOptions, {
+ setTo: 'prepaid',
+ properties: {
+ type: 'course'
+ maxRedeemers: 10
+ redeemers: []
+ }
+ })
+ test = @
+ prepaid = new Prepaid(options.properties)
+ prepaid.save (err, prepaid) ->
+ expect(err).toBeNull()
+ test[options.setTo] = prepaid
+ done()
\ No newline at end of file