Add Enrollment start/end dates, remove self-serve

* Refactor several related endpoints and views
* Redesign EnrollmentView, add TeacherContactModal
* Add "Enrollment Status" tab to TeacherClassView
* Delete PurchaseCoursesView and related files
* Style-flatten RemoveStudentModal
* Fix error handling in ActivateLicensesModal
* TeacherCoursesView loads faster by only loading course campaigns, and not load prepaids
This commit is contained in:
Scott Erickson 2016-05-09 15:16:54 -07:00 committed by phoenixeliot
parent 8496343a02
commit f0fa88206d
65 changed files with 1975 additions and 1163 deletions

View file

@ -0,0 +1,7 @@
StripeCoupon = require 'models/StripeCoupon'
CocoCollection = require 'collections/CocoCollection'
module.exports = class StripeCoupons extends CocoCollection
model: StripeCoupon
url: '/stripe/coupons'

View file

@ -29,3 +29,9 @@ module.exports = class Users extends CocoCollection
@remove @filter (user) ->
user.get('deleted')
true
search: (term) ->
return @slice() unless term
term = term.toLowerCase()
return @filter (user) ->
user.broadName().toLowerCase().indexOf(term) > -1 or (user.get('email') ? '').indexOf(term) > -1

View file

@ -1,10 +1,19 @@
module.exports.sendContactMessage = (contactMessageObject, modal) ->
modal?.find('.sending-indicator').show()
jqxhr = $.post '/contact', contactMessageObject, (response) ->
return unless modal
modal.find('.sending-indicator').hide()
modal.find('#contact-message').val('Thanks!')
_.delay ->
modal.find('#contact-message').val('')
modal.modal 'hide'
, 1000
module.exports = {
sendContactMessage: (contactMessageObject, modal) ->
# deprecated
modal?.find('.sending-indicator').show()
return $.post '/contact', contactMessageObject, (response) ->
return unless modal
modal.find('.sending-indicator').hide()
modal.find('#contact-message').val('Thanks!')
_.delay ->
modal.find('#contact-message').val('')
modal.modal 'hide'
, 1000
send: (options={}) ->
options.type = 'POST'
options.url = '/contact'
$.ajax(options)
}

View file

@ -1,4 +1,4 @@
module.exports = nativeDescription: "English", englishDescription: "English", translation:
module.exports = nativeDescription: "English", englishDescription: "English", translation:
home:
slogan: "Learn to Code by Playing a Game"
no_ie: "CodeCombat does not run in Internet Explorer 8 or older. Sorry!" # Warning that only shows up in IE8 and older
@ -1120,7 +1120,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr
already_enrolled: "already enrolled"
licenses_remaining: "licenses remaining:"
insufficient_enrollments: "insufficient paid enrollments"
get_enrollments: "Get More Enrollments"
get_enrollments: "Get Enrollments" # {change}
change_language: "Change Course Language"
keep_using: "Keep Using"
switch_to: "Switch To"
@ -1364,6 +1364,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr
earliest_incomplete: "Earliest incomplete level"
latest_complete: "Latest completed level"
enroll_student: "Enroll student"
revoke_enrollment: "Revoke Enrollment"
adding_students: "Adding students"
course_progress: "Course Progress"
not_applicable: "N/A"
@ -1399,8 +1400,8 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr
enrollments_blurb_1: "Students taking Computer Science" # Enrollments page
enrollments_blurb_2: "require enrollments to access the courses."
credits_available: "Credits Available"
total_unique_students: "Total Unique Students"
total_enrolled_students: "Total Enrolled Students"
total_unique_students: "Total Students (unique)" # {change}
total_enrolled_students: "Total Enrolled" # {change}
unenrolled_students: "Unenrolled Students"
add_enrollment_credits: "Add Enrollment Credits"
purchasing: "Purchasing..."
@ -1419,6 +1420,23 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr
send_recovery_email: "Send recovery email"
change_password: "Change Password"
changed: "Changed"
available_credits: "Available Credits"
pending_credits: "Pending Credits"
credits: "credits"
start_date: "start date:"
end_date: "end date:"
num_enrollments_needed: "Number of enrollments needed:"
get_enrollments_blurb: " We'll help you build a solution that meets the needs of your class, school or district."
enroll_request_sent_blurb1: "Thanks! Your request has been sent."
enroll_request_sent_blurb2: "Our classroom success team will be in touch shortly to help you find the best solution for your students' needs!"
enroll_request_sent_blurb3: "Please reach out to <a href='mailto:schools@codecombat.com'>schools@codecombat.com</a> if you have additional questions at this time."
request_sent: "Request Sent!"
enrollment_status: "Enrollment Status"
status_expired: "Expired on {{date}}"
status_not_enrolled: "Not Enrolled"
status_enrolled: "Expires on {{date}}"
revoke_confirm: "Are you sure you want to revoke enrollment from {{student_name}}?"
revoking: "Revoking..."
classes:
archmage_title: "Archmage"
@ -1576,7 +1594,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr
scribe_introduction_url_mozilla: "Mozilla Developer Network"
scribe_introduction_suf: " has built. If your idea of fun is articulating the concepts of programming in Markdown form, then this class might be for you."
scribe_attribute_1: "Skill in words is pretty much all you need. Not only grammar and spelling, but able to convey complicated ideas to others."
contact_us_url: "Contact us"
contact_us_url: "Contact Us" # {change}
scribe_join_description: "tell us a little about yourself, your experience with programming and what sort of things you'd like to write about. We'll go from there!"
scribe_subscribe_desc: "Get emails about article writing announcements."
diplomat_introduction_pref: "So, if there's one thing we learned from the "

View file

@ -19,3 +19,31 @@ module.exports = class Prepaid extends CocoModel
maxRedeemers = @get('maxRedeemers')
if _.isString(maxRedeemers)
@set 'maxRedeemers', parseInt(maxRedeemers)
status: ->
endDate = @get('endDate')
if endDate and new Date(endDate) < new Date()
return 'expired'
startDate = @get('startDate')
if startDate and new Date(startDate) > new Date()
return 'pending'
if @openSpots() <= 0
return 'empty'
return 'available'
redeem: (user, options={}) ->
options.url = _.result(@, 'url')+'/redeemers'
options.type = 'POST'
options.data ?= {}
options.data.userID = user.id or user
@fetch(options)
revoke: (user, options={}) ->
options.url = _.result(@, 'url')+'/redeemers'
options.type = 'DELETE'
options.data ?= {}
options.data.userID = user.id or user
@fetch(options)

View file

@ -0,0 +1,19 @@
CocoModel = require './CocoModel'
module.exports = class StripeCoupon extends CocoModel
@className: 'StripeCoupon'
@schema: {}
urlRoot: '/stripe/coupons'
idAttribute: 'id'
formatString: ->
bits = [@id]
if @get('percent_off')
bits.push "(#{@get('percent_off')}% off)"
else if @get('amount_off')
bits.push "($#{@get('amount_off')} off)"
if @get('duration')
bits.push "(duration: #{@get('duration')})"
if @redeem_by
bits.push "(redeem by: #{moment(@get('redeem_by')).format('lll')}"
return bits.join(' ')

View file

@ -208,9 +208,6 @@ module.exports = class User extends CocoModel
return true if me.hasSubscription()
return false
isEnrolled: ->
Boolean(@get('coursePrepaidID'))
isOnPremiumServer: ->
me.get('country') in ['china', 'brazil']
@ -225,6 +222,14 @@ module.exports = class User extends CocoModel
@trigger 'email-verify-error'
})
isEnrolled: -> @prepaidStatus() is 'enrolled'
prepaidStatus: -> # 'not-enrolled', 'enrolled', 'expired'
coursePrepaid = @get('coursePrepaid')
return 'not-enrolled' unless coursePrepaid
return 'enrolled' unless coursePrepaid.endDate
return if coursePrepaid.endDate > new Date().toISOString() then 'enrolled' else 'expired'
# Function meant for "me"
spy: (user, options={}) ->
@ -279,6 +284,12 @@ module.exports = class User extends CocoModel
options.data.facebookID = facebookID
options.data.facebookAccessToken = application.facebookHandler.token()
@fetch(options)
makeCoursePrepaid: ->
coursePrepaid = @get('coursePrepaid')
return null unless coursePrepaid
Prepaid = require 'models/Prepaid'
return new Prepaid(coursePrepaid)
becomeStudent: (options={}) ->
options.url = '/db/user/-/become-student'

View file

@ -6,11 +6,13 @@ PrepaidSchema = c.object({title: 'Prepaid', required: ['creator', 'type']}, {
c.object {required: ['date', 'userID']},
date: c.date {title: 'Redeemed date'}
userID: c.objectId(links: [ {rel: 'extra', href: '/db/user/{($)}'} ])
maxRedeemers: { type: 'integer'}
maxRedeemers: { type: 'integer' }
code: c.shortString(title: "Unique code to redeem")
type: { type: 'string' }
properties: {type: 'object'}
properties: {type: 'object' }
exhausted: { type: 'boolean' }
startDate: c.stringDate()
endDate: c.stringDate()
})
c.extendBasicProperties(PrepaidSchema, 'prepaid')

View file

@ -328,6 +328,16 @@ _.extend UserSchema.properties,
coursePrepaidID: c.objectId({
description: 'Prepaid which has paid for this user\'s course access'
})
coursePrepaid: {
type: 'object'
properties: {
_id: c.objectId()
startDate: c.stringDate()
endDate: c.stringDate()
}
}
enrollmentRequestSent: { type: 'boolean' }
schoolName: {type: 'string'}
role: {type: 'string', enum: ["God", "advisor", "parent", "principal", "student", "superintendent", "teacher", "technology coordinator"]}
birthday: c.stringDate({title: "Birthday"})

View file

@ -0,0 +1,3 @@
#administer-user-modal
.modal-dialog
width: 90%

View file

@ -1,4 +1,28 @@
@import "app/styles/bootstrap/variables"
#enrollments-view
@media (min-width: $screen-md-min)
#prepaids-col
padding-right: 40px
#actions-col
border-left: 1px solid gray // Will tend to be the longer one
padding-left: 40px
.prepaid-card
border-radius: 10px
p
color: white
h1
font-size: 68px
&.pending-prepaid-card
background: #6e939f
.how-to-enroll
padding: 10px
ol
@ -8,11 +32,20 @@
border-radius: 5px
#students-input
width: 100px
height: 50px
line-height: 30px
font-size: 30px
width: 180px
height: 80px
font-size: 60px
&::-webkit-inner-spin-button, &::-webkit-outer-spin-button
-webkit-appearance: none
margin: 0
#enrollment-stats-table
td, th
border: none
.classroom-name-td
padding-left: 20px
th
padding-bottom: 10px

View file

@ -1,11 +0,0 @@
#purchase-courses-view
.enrollment-count
font-size: 30px
width: 120px
.not-enrolled
line-height: 16px
.uppercase
text-transform: uppercase

View file

@ -280,3 +280,30 @@
.export-student-progress-btn
margin-top: 10px
// Enrollment Status Tab
#search-form-group
position: relative
input
width: auto
.glyphicon
color: $gray
position: absolute
top: 8px
right: 5px
#enrollment-status-table
// These column widths are just to keep the cells from resizing on search
.checkbox-col
width: 75px
.student-info-col
width: 240px
.status-col
width: 300px
.enroll-col
width: 140px
.revoke-col
width: 170px
td
vertical-align: middle

View file

@ -248,6 +248,11 @@ body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang='
background-color: $burgandy
color: white
.btn-burgandy-alt
background-color: white
border: 1px solid $burgandy
color: $burgandy
.btn-lg
font-size: 18px

View file

@ -0,0 +1,3 @@
#teachers-contact-modal
textarea
height: 200px

View file

@ -1,6 +1,5 @@
#test-view
background-color: #eee
margin: 0 20px
padding: 0
#test-h2

View file

@ -1,9 +1,11 @@
extends /templates/core/modal-base-flat
// DNT
block modal-header-content
h3 Administer User
h4 #{user.get('name') || 'Unnamed'} / #{user.get('email')}
span= user.id
h4 #{view.user.get('name') || 'Unnamed'} / #{view.user.get('email')}
span= view.user.id
block modal-body-content
@ -13,27 +15,48 @@ block modal-body-content
.form-group
.radio
label
input(type="radio" name="stripe-benefit" value="" checked=none)
input(type="radio" name="stripe-benefit" value="" checked=view.none)
| None
.radio
label
input(type="radio" name="stripe-benefit" value="free" checked=free)
input(type="radio" name="stripe-benefit" value="free" checked=view.free)
| Free
.radio
label
input(type="radio" name="stripe-benefit" value="free-until" checked=FreeUntil)
input(type="radio" name="stripe-benefit" value="free-until" checked=view.freeUntil)
| Free Until
input.form-control.spl(type="date" name="stripe-free-until" value=freeUntilDate)#free-until-date
input.form-control.spl(type="date" name="stripe-free-until" value=view.freeUntilDate)#free-until-date
.radio
label
input(type="radio" name="stripe-benefit" value="coupon" checked=coupon)
input(type="radio" name="stripe-benefit" value="coupon" checked=view.coupon)
| Coupon
select.form-control#coupon-select
for couponOption in coupons
option(value=couponOption.id selected=coupon===couponOption.id)= couponOption.format
for coupon in view.coupons.models
option(value=coupon.id selected=coupon.id===view.currentCouponID)= coupon.formatString()
button#save-changes.btn.btn-primary Save Changes
h3 Grant Prepaid for Courses
if view.prepaids.size()
h3.m-t-3 Existing Prepaids
table.table.table-condensed
tr
th ID
th Type
th Start
th End
th Used
for prepaid in view.prepaids.models
tr
td= prepaid.id
td= prepaid.get('type')
td
if prepaid.get('startDate')
= moment(prepaid.get('startDate')).utc().format('lll')
td
if prepaid.get('endDate')
= moment(prepaid.get('endDate')).utc().format('lll')
td #{(prepaid.get('redeemers') || []).length} / #{prepaid.get('maxRedeemers') || 0}
h3.m-t-3 Grant Prepaid for Courses
#prepaid-form.form
if view.state === 'creating-prepaid'
.progress.progress-striped.active
@ -45,10 +68,16 @@ block modal-body-content
else
.form-group
label Seats
input#seats-input.form-control(type="number")
input#seats-input.form-control(type="number", name="maxRedeemers")
.form-group
label Start Date
input.form-control(type="date" name="startDate" value=moment().format('YYYY-MM-DD'))
.form-group
label End Date
input.form-control(type="date" name="endDate" value=moment().add(1, 'year').format('YYYY-MM-DD')))
.form-group
button#add-seats-btn.btn.btn-primary Add Seats
block modal-footer-content
block modal-footer

View file

@ -57,8 +57,8 @@ block content
.tab-pane#tab_active_classes
h3 Active Classes 90 days
.small Active class: 12+ students in a classroom, with 6+ who played in last 30 days. Played == 'Started Level' analytics event.
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
.small Paid student: user.coursePrepaid set and prepaid.properties.trialRequestID NOT set
.small Trial student: user.coursePrepaid set and prepaid.properties.trialRequestID set
.small Paid class: at least one paid student in the classroom
.small Trial class: not paid, at least one trial student in classroom
.small Free class: not paid, not trial
@ -135,8 +135,8 @@ block content
.tab-pane#tab_classroom
h3 Classroom Daily Active Users 90 days
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
.small Paid student: user.coursePrepaid set and prepaid.properties.trialRequestID NOT set
.small Trial student: user.coursePrepaid set and prepaid.properties.trialRequestID set
.small Free student: not paid, not trial
.classroom-daily-active-users-chart-90.line-chart-container
@ -159,8 +159,8 @@ block content
.small Student: member of a course instance (assigned to course)
.small For course instances != Single Player, hourOfCode != true
.small Counts are not summed. I.e. a student or teacher only contributes to the count of one course
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
.small Paid student: user.coursePrepaid set and prepaid.properties.trialRequestID NOT set
.small Trial student: user.coursePrepaid set and prepaid.properties.trialRequestID set
.small Free student: not paid, not trial
.small Paid teacher: at least one paid student in course instance
.small Trial teacher: at least one trial student in course instance, and no paid students

View file

@ -21,16 +21,16 @@ block modal-body-content
span.spr :
select.classroom-select
each classroom in view.classrooms.models
option(selected=(classroom.id === view.classroom.id), value=classroom.id)
option(selected=(view.classroom ? classroom.id === view.classroom.id : false), value=classroom.id)
= classroom.get('name')
option(selected=(view.classroom.id === 'all-students'), value='all-students' data-i18n='teacher.all_students')
option(selected=(!view.classroom), value='' data-i18n='teacher.all_students')
form.form.m-t-3
span(data-i18n="teacher.enroll_the_following_students")
span :
.well.form-group
- var enrolledUsers = view.users.filter(function(user){ return Boolean(user.get('coursePrepaidID')) })
- var unenrolledUsers = view.users.filter(function(user){ return !Boolean(user.get('coursePrepaidID')) })
- var enrolledUsers = view.users.filter(function(user){ return user.isEnrolled() })
- var unenrolledUsers = view.users.filter(function(user){ return !user.isEnrolled() })
for user in unenrolledUsers
- var selected = Boolean(paid || state.get('selectedUsers').get(user.id))
.checkbox

View file

@ -83,7 +83,7 @@ block content
span.spr :
span= playtime
- var paidFor = user.get('coursePrepaidID');
- var paidFor = user.isEnrolled();
for courseInstance in view.courseInstances.models
- var inCourse = _.contains(courseInstance.get('members'), user.id);
if !(inCourse || view.teacherMode)

View file

@ -31,79 +31,105 @@ block content
a.btn.btn-primary.btn-lg(href="/teachers/update-account") Upgrade to teacher account
.container.m-t-5
.pull-right
span.glyphicon.glyphicon-question-sign
=' '
a#how-to-enroll-link(data-i18n="teacher.how_to_enroll")
h3(data-i18n='teacher.enrollments')
h4
h4#enrollments-blurb
span(data-i18n='teacher.enrollments_blurb_1')
span 2&ndash;8
span 2&ndash;#{view.state.get('totalCourses')}
span(data-i18n='teacher.enrollments_blurb_2')
- var available = view.state.get('prepaidGroups').available
- var pending = view.state.get('prepaidGroups').pending
- var anyPrepaids = available || pending
.row.m-t-3
.col-xs-4
+enrollmentStats
.col-xs-4
+addCredits
.col-xs-3.col-xs-offset-1
+howToEnroll
+quoteSection
if anyPrepaids
#prepaids-col.col-md-9
if available
h5.m-b-1(data-i18n="teacher.available_credits")
.row
for prepaid in available
.col-sm-4.col-xs-6
+prepaidCard(prepaid)
if pending
h5.m-b-1.m-t-3(data-i18n="teacher.pending_credits")
.row
for prepaid in pending
.col-sm-4.col-xs-6
+prepaidCard(prepaid, 'pending-prepaid-card')
#actions-col.col-md-3
+addCredits
+enrollmentStats
else
// no prepaids
.col-sm-6.col-lg-4.col-lg-offset-2
+addCredits
.col-sm-6.col-lg-4
+enrollmentStats
mixin prepaidCard(prepaid, className)
.prepaid-card.bg-navy.text-center.m-b-2.p-a-2(class=className)
h1.m-t-2.m-b-0= prepaid.openSpots()
div(data-i18n="teacher.credits")
hr
em.small-details
.pull-left(data-i18n="teacher.start_date")
.pull-right= moment(prepaid.get('startDate')).utc().format('l')
.clearfix
.pull-left(data-i18n="teacher.end_date")
.pull-right= moment(prepaid.get('endDate')).utc().format('l')
.clearfix
mixin addCredits
.text-center.m-b-3.m-t-3
h5(data-i18n="courses.get_enrollments")
if me.get('enrollmentRequestSent')
#enrollment-request-sent-blurb.small
p(data-i18n="teacher.enroll_request_sent_blurb1")
p(data-i18n="teacher.enroll_request_sent_blurb2")
p(data-i18n="[html]teacher.enroll_request_sent_blurb3")
button#request-sent-btn.btn-lg.btn.btn-forest(disabled=true, data-i18n="teacher.request_sent")
else
p(data-i18n="teacher.num_enrollments_needed")
div.m-t-2
input#students-input.enrollment-count.text-center(value=view.state.get('numberOfStudents') type='number')
strong(data-i18n="teacher.credits")
p.m-y-2(data-i18n="teacher.get_enrollments_blurb")
button#contact-us-btn.btn-lg.btn.btn-forest(data-i18n="contribute.contact_us_url")
mixin enrollmentStats
h5
span(data-i18n='teacher.credits_available')
span.spr :
= view.prepaids.totalAvailable()
.small-details
span(data-i18n='teacher.total_unique_students')
span.spr :
= view.totalEnrolled + view.totalNotEnrolled
.small-details
span(data-i18n='teacher.total_enrolled_students')
span.spr :
= view.totalEnrolled
h5.small-details.m-t-3
span(data-i18n='teacher.unenrolled_students')
each classroom in view.classrooms.models
if classroom.get('members').length > 0 && view.classroomNotEnrolledMap && view.classroomNotEnrolledMap[classroom.id] > 0
.small-details
span= classroom.get('name')
span.spr :
span= view.classroomNotEnrolledMap[classroom.id]
h5.text-center.m-t-3.m-b-2(data-i18n='teacher.enrollment_status')
table#enrollment-stats-table.table-condensed.table.small-details
tr
td
span(data-i18n='teacher.total_unique_students')
span.spr :
td= view.state.get('totalEnrolled') + view.state.get('totalNotEnrolled')
tr
td
span(data-i18n='teacher.total_enrolled_students')
span.spr :
td= view.state.get('totalEnrolled')
tr
th(data-i18n='teacher.unenrolled_students')
th= view.state.get('totalNotEnrolled')
each classroom in view.classrooms.models
if classroom.get('members').length > 0 && view.state.get('classroomNotEnrolledMap')[classroom.id] > 0
tr
td.classroom-name-td
span= classroom.get('name')
span.spr :
td= view.state.get('classroomNotEnrolledMap')[classroom.id]
.small-details
span(data-i18n='teacher.total_unenrolled')
span.spr :
= view.totalNotEnrolled
//- .enroll-students.btn.btn-lg.btn-navy
//- span(data-i18n='teacher.enroll_students')
mixin addCredits
.text-center
h5(data-i18n='teacher.add_enrollment_credits')
div.m-t-1
input#students-input.text-center.enrollment-count(value=view.numberOfStudents type='number')
div.m-t-1
if view.state === 'purchasing'
.purchase-now.btn.btn-lg.btn-forest.disabled
span(data-i18n='teacher.purchasing')
else if view.state === 'purchased'
.purchase-now.btn.btn-lg.btn-forest
span(data-i18n='teacher.purchased')
else
.purchase-now.btn.btn-lg.btn-forest
span(data-i18n='teacher.purchase_now')
mixin howToEnroll
.how-to-enroll.small-details
.text-center
b(data-i18n='teacher.how_to_enroll')
ol
li.m-t-1(data-i18n='teacher.how_to_enroll_blurb_1')
li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_2')
li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_3')
mixin quoteSection
.text-center.m-t-5
h4(data-i18n='teacher.bulk_pricing_blurb')
a.request-quote.btn.btn-lg.btn-navy.m-t-2(href='/teachers/demo')
span(data-i18n='teachers_quote.title')
button#enroll-students-btn.btn.btn-lg.btn-navy
span(data-i18n='teacher.enroll_students')

View file

@ -1,98 +0,0 @@
extends /templates/base
block content
if view.state === 'purchasing'
p.text-center(data-i18n="buy_gems.purchasing")
.progress.progress-striped.active
.progress-bar(style="width: 100%")
else if view.state === 'purchased'
p
span.spr(data-i18n="courses.thank_you_pref")
span= view.numberOfStudents
span.spl(data-i18n="courses.thank_you_suff")
p.text-center
if view.fromClassroom
a(href="/courses/"+view.fromClassroom, data-i18n="courses.return_to_class")
else
a(href="/teachers/classes", data-i18n="courses.return_to_course_man")
else
h2.text-center(data-i18n="courses.purchase_enrollments")
br
if view.state === 'error'
.alert.alert-danger= view.stateMessage
- var usedEnrollments = view.prepaids.totalRedeemers();
- var totalEnrollments = view.prepaids.totalMaxRedeemers();
- var remainingEnrollments = totalEnrollments - usedEnrollments;
.row
.col-md-4
.col-md-3
strong.uppercase(data-i18n="courses.you_have2")
.col-md-1
strong= remainingEnrollments
br
.row
.col-md-4
.col-md-3
strong.uppercase(data-i18n="courses.students_not_enrolled")
.row
.col-md-4
.col-md-3
each classroom in view.classrooms.models
if classroom.get('members').length > 0
.not-enrolled= classroom.get('name')
.not-enrolled(data-i18n="courses.total_all_classes")
.col-md-1
- var totalNotEnrolled = 0
each classroom in view.classrooms.models
if classroom.get('members').length > 0 && view.classroomNotEnrolledMap
.not-enrolled
strong= view.classroomNotEnrolledMap[classroom.id]
.not-enrolled
strong= view.totalNotEnrolled
br
br
p.text-center
strong(data-i18n="courses.how_many_enrollments")
br
p.text-center
input#students-input.text-center.enrollment-count(
value= view.numberOfStudents
type='number'
)
br
.container-fluid
.row
.col-md-offset-3.col-md-6
span(data-i18n="courses.each_student_access")
br
p.text-center#price-form-group
if view.numberOfStudentsIsValid()
strong
span(data-i18n="account_prepaid.purchase_total")
span.spr : #{view.numberOfStudents}
span(data-i18n="courses.enrollments")
span.spl x $#{(view.pricePerStudent/100).toFixed(2)} = #{view.getPriceString()}
else
strong Invalid number of students
p.text-center
button#purchase-btn.btn.btn-lg.btn-success.uppercase(data-i18n="courses.purchase_now" disabled=me.isAnonymous())
if me.isAnonymous()
// DNT. Temporary redirect until teacher-dashboard is finished
.alert.alert-danger.text-center
h2 You must be signed up to purchase enrollments.
p
a.btn.btn-primary.btn-lg(href="/teachers/signup") Create a teacher account

View file

@ -1,24 +1,24 @@
extends /templates/core/modal-base
extends /templates/core/modal-base-flat
block modal-header-content
.text-center
h3.modal-title(data-i18n="courses.remove_student1")
h1.modal-title(data-i18n="courses.remove_student1")
span.glyphicon.glyphicon-warning-sign.text-danger
h3(data-i18n="courses.are_you_sure")
h2(data-i18n="courses.are_you_sure")
block modal-body-content
p.text-center
span(data-i18n="courses.remove_description1")
if view.user.get('coursePrepaidID')
if view.user.isEnrolled()
span(data-i18n="courses.remove_description2")
block modal-footer-content
#remove-student-buttons.text-center
p
button.btn.btn-lg.btn-success.text-uppercase(data-dismiss="modal", data-i18n="courses.keep_student")
button.btn.btn-lg.btn-forest.text-uppercase(data-dismiss="modal", data-i18n="courses.keep_student")
p - OR -
p
button#remove-student-btn.btn.btn-lg.btn-default.text-uppercase(data-i18n="courses.remove_student1")
button#remove-student-btn.btn.btn-lg.btn-burgandy.text-uppercase(data-i18n="courses.remove_student1")
#remove-student-progress.text-center.hide
.progress

View file

@ -108,21 +108,28 @@ block content
+copyCodes
+addStudentsButton
ul#student-info-tabs.nav.nav-tabs.m-t-5(role='tablist')
li(class=(state.get('activeTab')==="#students-tab" ? 'active' : ''))
ul.nav.nav-tabs.m-t-5(role='tablist')
- var activeTab = state.get('activeTab');
li(class=(activeTab === "#students-tab" ? 'active' : ''))
a.students-tab-btn(href='#students-tab')
.small-details.text-center(data-i18n='teacher.students')
.tab-spacer
li(class=(state.get('activeTab')==="#course-progress-tab" ? 'active' : ''))
li(class=(activeTab === "#course-progress-tab" ? 'active' : ''))
a.course-progress-tab-btn(href='#course-progress-tab')
.small-details.text-center(data-i18n='teacher.course_progress')
.tab-spacer
li(class=(activeTab === "#enrollment-status-tab" ? 'active' : ''))
a.course-progress-tab-btn(href='#enrollment-status-tab')
.small-details.text-center(data-i18n='teacher.enrollment_status')
.tab-filler
.tab-content
if state.get('activeTab')=='#students-tab'
if activeTab === '#students-tab'
+studentsTab
else
else if activeTab === '#course-progress-tab'
+courseProgressTab
else
+enrollmentStatusTab
mixin breadcrumbs
.breadcrumbs
@ -179,8 +186,8 @@ mixin sortButtons
.sort-buttons.small
span(data-i18n='teacher.sort_by')
span.spr :
button.sort-button.sort-by-name(data-i18n='general.name')
button.sort-button.sort-by-progress(data-i18n='teacher.progress')
button.sort-button.sort-by-name(data-i18n='general.name', value='name')
button.sort-button.sort-by-progress(data-i18n='teacher.progress', value='progress')
mixin studentRow(student)
tr.student-row.alternating-background
@ -369,3 +376,46 @@ mixin bulkAssignControls
span(data-i18n='teacher.assign_to_selected_students')
button.btn.btn-primary-alt.enroll-selected-students
span(data-i18n='teacher.enroll_selected_students')
mixin enrollmentStatusTab
form.form-inline.text-center.m-t-3
#search-form-group.form-group
label(for="student-search") Search for student:
input#student-search.form-control.m-l-1(type="search")
span.glyphicon.glyphicon-search.form-control-feedback
table.table#enrollment-status-table.table-condensed
thead
//th.checkbox-col.select-all
.checkbox-flat
input(type='checkbox' id='checkbox-all-students')
label.checkmark(for='checkbox-all-students')
th
.sort-buttons.small
span(data-i18n='teacher.sort_by')
span.spr :
button.sort-button.sort-by-name(data-i18n='general.name', value='name')
button.sort-button.sort-by-status(data-i18n='user.status', value='status')
tbody
- var searchTerm = view.state.get('searchTerm');
each student in state.get('students').search(searchTerm)
- var status = student.prepaidStatus()
tr.student-row.alternating-background
//td.checkbox-col.student-checkbox
.checkbox-flat
input(type='checkbox' id='checkbox-student-' + student.id, data-student-id=student.id)
label.checkmark(for='checkbox-student-' + student.id)
td.student-info-col
.student-info
div.student-name= student.broadName()
div.student-email.small-details= student.get('email')
td.status-col
span(data-i18n='user.status')
span.spr :
strong(class= status === 'expired' ? 'text-danger' : '')= view.studentStatusString(student)
td.enroll-col
if status !== 'enrolled'
button.enroll-student-button.btn.btn-navy(data-i18n="teacher.enroll_student", data-user-id=student.id)
td.revoke-col
if status === 'enrolled'
button.revoke-student-button.btn.btn-burgandy-alt(data-i18n="teacher.revoke_enrollment", data-user-id=student.id)

View file

@ -0,0 +1,12 @@
extends /templates/core/modal-base-flat
block modal-header-content
.text-center
h3(data-i18n='teacher.how_to_enroll')
block modal-body
ol
li.m-t-1(data-i18n='teacher.how_to_enroll_blurb_1')
li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_2')
li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_3')

View file

@ -0,0 +1,42 @@
extends /templates/core/modal-base-flat
block modal-header-content
.text-center
h3 Contact Our Classroom Team
block modal-body-content
p Send us a message and our classroom success team will be in touch to help find the best solution for your students' needs!
form
- var sending = view.state.get('sendingState') === 'sending';
- var values = view.state.get('formValues');
- var errors = view.state.get('formErrors');
.form-group(class=errors.email ? 'has-error' : '')
label.control-label(for="email" data-i18n="general.email")
+formErrors(errors.email)
input.form-control(name="email", type="email", value=values.email || '', tabindex=1, disabled=sending)
.form-group(class=errors.message ? 'has-error' : '')
label.control-label(for="message" data-i18n="general.message")
+formErrors(errors.message)
textarea.form-control(name="message", tabindex=1 disabled=sending)= values.message
if view.state.get('sendingState') === 'error'
.alert.alert-danger Could not send message.
if view.state.get('sendingState') === 'sent'
.alert.alert-success Message sent!
.text-right
- var sent = view.state.get('sendingState') === 'sent';
button#submit-btn.btn.btn-navy.btn-lg(type='submit' disabled=sending || sent) Submit
block modal-footer
mixin formErrors(errors)
if _.isString(errors)
- errors = [errors]
if _.size(errors)
.help-block
for error in errors
div= error

View file

@ -1,3 +1,5 @@
#demo-area
h2#test-h2 Testing Page
ol.breadcrumb
@ -10,7 +12,6 @@ ol.breadcrumb
.row
.col-md-8
#test-wrapper.well
#demo-area
#testing-area
.col-md-4.hidden-sm.hidden-xs
@ -31,4 +32,4 @@ ol.breadcrumb
span.spl= child.name
if child.type == 'folder'
strong (#{child.size})

View file

@ -60,14 +60,10 @@ module.exports = TestView = class TestView extends RootView
@specFiles = (f for f in @specFiles when _.string.startsWith f, prefix)
@runTests: (specFiles, demosOn=false) ->
application.testing = true
specFiles ?= @getAllSpecFiles()
if demosOn
jasmine.demoEl = ($el) ->
$el.css({
'border': '2px solid black'
'background': 'white'
'padding': '20px'
})
$('#demo-area').append($el)
jasmine.demoModal = _.once (modal) ->
currentView.openModalView(modal)

View file

@ -2,47 +2,37 @@ ModalView = require 'views/core/ModalView'
template = require 'templates/admin/administer-user-modal'
User = require 'models/User'
Prepaid = require 'models/Prepaid'
StripeCoupons = require 'collections/StripeCoupons'
forms = require 'core/forms'
Prepaids = require 'collections/Prepaids'
module.exports = class AdministerUserModal extends ModalView
id: "administer-user-modal"
id: 'administer-user-modal'
template: template
events:
'click #save-changes': 'onSaveChanges'
'click #save-changes': 'onClickSaveChanges'
'click #add-seats-btn': 'onClickAddSeatsButton'
constructor: (options, @userHandle) ->
super(options)
@user = @supermodel.loadModel(new User({_id:@userHandle}), {cache: false}).model
options = {cache: false, url: '/stripe/coupons'}
options.success = (@coupons) =>
@couponsResource = @supermodel.addRequestResource('coupon', options)
@couponsResource.load()
getRenderData: ->
c = super()
initialize: (options, @userHandle) ->
@user = new User({_id:@userHandle})
@supermodel.trackRequest @user.fetch({cache: false})
@coupons = new StripeCoupons()
@supermodel.trackRequest @coupons.fetch({cache: false})
@prepaids = new Prepaids()
@supermodel.trackRequest @prepaids.fetchByCreator(@userHandle)
onLoaded: ->
# TODO: Figure out a better way to expose this info, perhaps User methods?
stripe = @user.get('stripe') or {}
c.free = stripe.free is true
c.freeUntil = _.isString(stripe.free)
c.freeUntilDate = if c.freeUntil then stripe.free else new Date().toISOString()[...10]
c.coupon = stripe.couponID
c.coupons = @coupons or []
for coupon in c.coupons
bits = [coupon.id]
if coupon.percent_off
bits.push "(#{coupon.percent_off}% off)"
else if coupon.amount_off
bits.push "($#{coupon.amount_off} off)"
if coupon.duration
bits.push "(duration: #{coupon.duration})"
if coupon.redeem_by
bits.push "(redeem by: #{moment(coupon.redeem_by).format('lll')}"
coupon.format = bits.join(' ')
c.none = not (c.free or c.freeUntil or c.coupon)
c.user = @user
c
onSaveChanges: ->
@free = stripe.free is true
@freeUntil = _.isString(stripe.free)
@freeUntilDate = if @freeUntil then stripe.free else new Date().toISOString()[...10]
@currentCouponID = stripe.couponID
@none = not (@free or @freeUntil or @coupon)
super()
onClickSaveChanges: ->
stripe = _.clone(@user.get('stripe') or {})
delete stripe.free
delete stripe.couponID
@ -61,15 +51,20 @@ module.exports = class AdministerUserModal extends ModalView
@user.patch(options)
onClickAddSeatsButton: ->
maxRedeemers = parseInt(@$('#seats-input').val())
return unless maxRedeemers and maxRedeemers > 0
prepaid = new Prepaid({
maxRedeemers: maxRedeemers
attrs = forms.formToObject(@$('#prepaid-form'))
attrs.maxRedeemers = parseInt(attrs.maxRedeemers)
return unless _.all(_.values(attrs))
return unless attrs.maxRedeemers > 0
return unless attrs.endDate and attrs.startDate and attrs.endDate > attrs.startDate
attrs.startDate = new Date(attrs.startDate).toISOString()
attrs.endDate = new Date(attrs.endDate).toISOString()
_.extend(attrs, {
type: 'course'
creator: @user.id
properties:
adminAdded: me.id
})
prepaid = new Prepaid(attrs)
prepaid.save()
@state = 'creating-prepaid'
@renderSelectors('#prepaid-form')

View file

@ -290,7 +290,7 @@ module.exports = class AnalyticsView extends RootView
prepaidUserMap = {}
for user in data.students
continue unless studentPaidStatusMap[user._id]
if prepaidID = user.coursePrepaidID
if prepaidID = user.coursePrepaidID or user.course.coursePrepaid?._id # TODO: make sure this works for coursePrepaid
studentPaidStatusMap[user._id] = 'paid'
prepaidUserMap[prepaidID] ?= []
prepaidUserMap[prepaidID].push(user._id)

View file

@ -18,7 +18,8 @@ module.exports = class ActivateLicensesModal extends ModalView
'submit form': 'onSubmitForm'
getInitialState: (options) ->
selectedUserModels = _.filter(options.selectedUsers.models, (user) -> not user.isEnrolled())
selectedUsers = options.selectedUsers or options.users
selectedUserModels = _.filter(selectedUsers.models, (user) -> not user.isEnrolled())
{
selectedUsers: new Users(selectedUserModels)
visibleSelectedUsers: new Users(selectedUserModels)
@ -31,11 +32,10 @@ module.exports = class ActivateLicensesModal extends ModalView
@users = options.users.clone()
@users.comparator = (user) -> user.broadName().toLowerCase()
@prepaids = new Prepaids()
@prepaids.comparator = '_id'
@prepaids.fetchByCreator(me.id)
@supermodel.trackCollection(@prepaids)
@prepaids.comparator = 'endDate' # use prepaids in order of expiration
@supermodel.trackRequest @prepaids.fetchByCreator(me.id)
@classrooms = new Classrooms()
@classrooms.fetchMine({
@supermodel.trackRequest @classrooms.fetchMine({
data: {archived: false}
success: =>
@classrooms.each (classroom) =>
@ -43,7 +43,6 @@ module.exports = class ActivateLicensesModal extends ModalView
jqxhrs = classroom.users.fetchForClassroom(classroom, { removeDeleted: true })
@supermodel.trackRequests(jqxhrs)
})
@supermodel.trackCollection(@classrooms)
@listenTo @state, 'change', @render
@listenTo @state.get('selectedUsers'), 'change add remove reset', ->
@ -56,6 +55,10 @@ module.exports = class ActivateLicensesModal extends ModalView
@state.set {
unusedEnrollments: @prepaids.totalMaxRedeemers() - @prepaids.totalRedeemers()
}
onLoaded: ->
@prepaids.reset(@prepaids.filter((prepaid) -> prepaid.status() is 'available'))
super()
afterRender: ->
super()
@ -73,8 +76,7 @@ module.exports = class ActivateLicensesModal extends ModalView
replaceStudentList: (e) ->
selectedClassroomID = $(e.currentTarget).val()
@classroom = @classrooms.get(selectedClassroomID)
if selectedClassroomID is 'all-students'
@classroom = new Classroom({ _id: 'all-students', name: 'All Students' }) # TODO: This is a horrible hack so the select shows the right option!
if not @classroom
users = _.uniq _.flatten @classrooms.map (classroom) -> classroom.users.models
@users.reset(users)
@users.sort()
@ -96,26 +98,17 @@ module.exports = class ActivateLicensesModal extends ModalView
return
user = usersToRedeem.first()
prepaid = @prepaids.find((prepaid) -> prepaid.get('properties')?.endDate? and prepaid.openSpots() > 0)
prepaid = @prepaids.find((prepaid) -> prepaid.openSpots() > 0) unless prepaid
$.ajax({
method: 'POST'
url: _.result(prepaid, 'url') + '/redeemers'
data: { userID: user.id }
context: @
success: (prepaid) ->
user.set('coursePrepaidID', prepaid._id)
prepaid = @prepaids.find((prepaid) -> prepaid.status() is 'available')
prepaid.redeem(user, {
success: (prepaid) =>
user.set('coursePrepaid', prepaid.pick('_id', 'startDate', 'endDate'))
usersToRedeem.remove(user)
# pct = 100 * (usersToRedeem.originalSize - usersToRedeem.size() / usersToRedeem.originalSize)
# @$('#progress-area .progress-bar').css('width', "#{pct.toFixed(1)}%")
application.tracker?.trackEvent 'Enroll modal finished enroll student', category: 'Courses', userID: user.id
@redeemUsers(usersToRedeem)
error: (jqxhr, textStatus, errorThrown) ->
if jqxhr.status is 402
message = arguments[2]
else
message = "#{jqxhr.status}: #{jqxhr.responseText}"
@state.set { error: message } # TODO: Test this! ("should" never happen. Only on server responding with an error.)
error: (prepaid, jqxhr) =>
@state.set { error: jqxhr.responseJSON.message }
})
finishRedeemUsers: ->

View file

@ -118,8 +118,7 @@ module.exports = class ClassroomView extends RootView
userID = $(e.target).closest('.btn').data('user-id')
if @prepaids.totalMaxRedeemers() - @prepaids.totalRedeemers() > 0
# Have an unused enrollment, enroll student immediately instead of opening the enroll modal
prepaid = @prepaids.find((prepaid) -> prepaid.get('properties')?.endDate? and prepaid.openSpots() > 0)
prepaid = @prepaids.find((prepaid) -> prepaid.openSpots() > 0) unless prepaid
prepaid = @prepaids.find((prepaid) -> prepaid.status() is 'available')
$.ajax({
method: 'POST'
url: _.result(prepaid, 'url') + '/redeemers'
@ -182,7 +181,7 @@ module.exports = class ClassroomView extends RootView
stats.averageLevelsComplete = if @users.size() then (_.size(completeSessions) / @users.size()).toFixed(1) else 'N/A' # '
stats.totalLevelsComplete = _.size(completeSessions)
enrolledUsers = @users.filter (user) -> user.get('coursePrepaidID')
enrolledUsers = @users.filter (user) -> user.isEnrolled()
stats.enrolledUsers = _.size(enrolledUsers)
return stats

View file

@ -1,90 +1,79 @@
app = require 'core/application'
CreateAccountModal = require 'views/core/CreateAccountModal'
Classroom = require 'models/Classroom'
Classrooms = require 'collections/Classrooms'
CocoCollection = require 'collections/CocoCollection'
Course = require 'models/Course'
Prepaids = require 'collections/Prepaids'
RootView = require 'views/core/RootView'
stripeHandler = require 'core/services/stripe'
Classrooms = require 'collections/Classrooms'
State = require 'models/State'
Prepaids = require 'collections/Prepaids'
template = require 'templates/courses/enrollments-view'
User = require 'models/User'
Users = require 'collections/Users'
utils = require 'core/utils'
Products = require 'collections/Products'
Courses = require 'collections/Courses'
HowToEnrollModal = require 'views/teachers/HowToEnrollModal'
TeachersContactModal = require 'views/teachers/TeachersContactModal'
ActivateLicensesModal = require 'views/courses/ActivateLicensesModal'
module.exports = class EnrollmentsView extends RootView
id: 'enrollments-view'
template: template
numberOfStudents: 15
pricePerStudent: 0
initialize: (options) ->
@ownedClassrooms = new Classrooms()
@ownedClassrooms.fetchMine({data: {project: '_id'}})
@supermodel.trackCollection(@ownedClassrooms)
@listenTo stripeHandler, 'received-token', @onStripeReceivedToken
@fromClassroom = utils.getQueryVariable('from-classroom')
@members = new Users()
# @listenTo @members, 'sync add remove', @calculateEnrollmentStats
@classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom })
@classrooms.comparator = '_id'
@listenToOnce @classrooms, 'sync', @onceClassroomsSync
@supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}})
@prepaids = new Prepaids()
@prepaids.comparator = '_id'
@prepaids.fetchByCreator(me.id)
@supermodel.loadCollection(@prepaids, 'prepaids')
@products = new Products()
@supermodel.loadCollection(@products, 'products')
super(options)
events:
'input #students-input': 'onInputStudentsInput'
'click .purchase-now': 'onClickPurchaseButton'
# 'click .enroll-students': 'onClickEnrollStudents'
'click #enroll-students-btn': 'onClickEnrollStudentsButton'
'click #how-to-enroll-link': 'onClickHowToEnrollLink'
'click #contact-us-btn': 'onClickContactUsButton'
onLoaded: ->
@calculateEnrollmentStats()
@pricePerStudent = @products.findWhere({name: 'course'}).get('amount')
super()
initialize: ->
@state = new State({
totalEnrolled: 0
totalNotEnrolled: 0
classroomNotEnrolledMap: {}
classroomEnrolledMap: {}
numberOfStudents: 15
totalCourses: 0
prepaidGroups: {
'available': []
'pending': []
}
})
getPriceString: -> '$' + (@getPrice()/100).toFixed(2)
getPrice: -> @pricePerStudent * @numberOfStudents
@courses = new Courses()
@supermodel.trackRequest @courses.fetch({data: { project: 'free' }})
@members = new Users()
@classrooms = new Classrooms()
@classrooms.comparator = '_id'
@listenToOnce @classrooms, 'sync', @onceClassroomsSync
@supermodel.trackRequest @classrooms.fetchMine()
@prepaids = new Prepaids()
@prepaids.comparator = '_id'
@supermodel.trackRequest @prepaids.fetchByCreator(me.id)
@debouncedRender = _.debounce @render, 0
@listenTo @prepaids, 'all', -> @state.set('prepaidGroups', @prepaids.groupBy((p) -> p.status()))
@listenTo(@state, 'all', @debouncedRender)
onceClassroomsSync: ->
for classroom in @classrooms.models
@supermodel.trackRequests @members.fetchForClassroom(classroom, {remove: false, removeDeleted: true})
onLoaded: ->
@calculateEnrollmentStats()
@state.set('totalCourses', @courses.size())
super()
calculateEnrollmentStats: ->
@removeDeletedStudents()
@memberEnrolledMap = {}
for user in @members.models
@memberEnrolledMap[user.id] = user.get('coursePrepaidID')?
@totalEnrolled = _.reduce @members.models, ((sum, user) ->
sum + (if user.get('coursePrepaidID') then 1 else 0)
), 0
@numberOfStudents = @totalNotEnrolled = _.reduce @members.models, ((sum, user) ->
sum + (if not user.get('coursePrepaidID') then 1 else 0)
), 0
# sort users into enrolled, not enrolled
groups = @members.groupBy (m) -> m.isEnrolled()
enrolledUsers = new Users(groups.true)
@notEnrolledUsers = new Users(groups.false)
map = {}
@classroomEnrolledMap = _.reduce @classrooms.models, ((map, classroom) =>
enrolled = _.reduce classroom.get('members'), ((sum, userID) =>
sum + (if @members.get(userID).get('coursePrepaidID') then 1 else 0)
), 0
map[classroom.id] = enrolled
map
), {}
for classroom in @classrooms.models
map[classroom.id] = _.countBy(classroom.get('members'), (userID) -> enrolledUsers.get(userID)?).false
@classroomNotEnrolledMap = _.reduce @classrooms.models, ((map, classroom) =>
enrolled = _.reduce classroom.get('members'), ((sum, userID) =>
sum + (if not @members.get(userID).get('coursePrepaidID') then 1 else 0)
), 0
map[classroom.id] = enrolled
map
), {}
@state.set({
totalEnrolled: enrolledUsers.size()
totalNotEnrolled: @notEnrolledUsers.size()
classroomNotEnrolledMap: map
})
true
@ -95,70 +84,21 @@ module.exports = class EnrollmentsView extends RootView
)
true
onClickHowToEnrollLink: ->
@openModalView(new HowToEnrollModal())
onClickContactUsButton: ->
@openModalView(new TeachersContactModal({ enrollmentsNeeded: @state.get('numberOfStudents') }))
onInputStudentsInput: ->
input = @$('#students-input').val()
if input isnt "" and (parseFloat(input) isnt parseInt(input) or _.isNaN parseInt(input))
@$('#students-input').val(@numberOfStudents)
@$('#students-input').val(@state.get('numberOfStudents'))
else
@numberOfStudents = Math.max(parseInt(@$('#students-input').val()) or 0, 0)
@updatePrice()
@state.set('numberOfStudents', Math.max(parseInt(@$('#students-input').val()) or 0, 0))
updatePrice: ->
@renderSelectors '#price-form-group'
numberOfStudentsIsValid: -> 0 < @get('numberOfStudents') < 100000
numberOfStudentsIsValid: -> 0 < @numberOfStudents < 100000
# onClickEnrollStudents: ->
# TODO: Needs "All students" in modal dropdown
onClickPurchaseButton: ->
return @openModalView new CreateAccountModal() if me.isAnonymous()
unless @numberOfStudentsIsValid()
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: @numberOfStudents}
stripeHandler.open
amount: @numberOfStudents * @pricePerStudent
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?()
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: (prepaid) ->
application.tracker?.trackEvent 'Finished course prepaid purchase', {price: @pricePerStudent, seats: @numberOfStudents}
@state = 'purchased'
@prepaids.add(prepaid)
@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?()
})
onClickEnrollStudentsButton: ->
modal = new ActivateLicensesModal({ selectedUsers: @notEnrolledUsers, users: @members })
@openModalView(modal)

View file

@ -1,126 +0,0 @@
app = require 'core/application'
Classroom = require 'models/Classroom'
CocoCollection = require 'collections/CocoCollection'
Course = require 'models/Course'
Prepaids = require 'collections/Prepaids'
RootView = require 'views/core/RootView'
stripeHandler = require 'core/services/stripe'
template = require 'templates/courses/purchase-courses-view'
User = require 'models/User'
utils = require 'core/utils'
Products = require 'collections/Products'
module.exports = class PurchaseCoursesView extends RootView
id: 'purchase-courses-view'
template: template
numberOfStudents: 30
pricePerStudent: 0
initialize: (options) ->
@listenTo stripeHandler, 'received-token', @onStripeReceivedToken
@fromClassroom = utils.getQueryVariable('from-classroom')
@members = new CocoCollection([], { model: User })
@listenTo @members, 'sync', @membersSync
@classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom })
@classrooms.comparator = '_id'
@listenToOnce @classrooms, 'sync', @onceClassroomsSync
@supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}})
@prepaids = new Prepaids()
@prepaids.comparator = '_id'
@prepaids.fetchByCreator(me.id)
@supermodel.loadCollection(@prepaids, 'prepaids')
@products = new Products()
@supermodel.loadCollection(@products, 'products')
super(options)
events:
'input #students-input': 'onInputStudentsInput'
'click #purchase-btn': 'onClickPurchaseButton'
onLoaded: ->
@pricePerStudent = @products.findWhere({name: 'course'}).get('amount')
super()
getPriceString: -> '$' + (@getPrice()/100).toFixed(2)
getPrice: -> @pricePerStudent * @numberOfStudents
onceClassroomsSync: ->
for classroom in @classrooms.models
@members.fetch({
remove: false
url: "/db/classroom/#{classroom.id}/members"
})
membersSync: ->
@memberEnrolledMap = {}
for user in @members.models
@memberEnrolledMap[user.id] = user.get('coursePrepaidID')?
@classroomNotEnrolledMap = {}
@totalNotEnrolled = 0
for classroom in @classrooms.models
@classroomNotEnrolledMap[classroom.id] = 0
for memberID in classroom.get('members')
@classroomNotEnrolledMap[classroom.id]++ unless @memberEnrolledMap[memberID]
@totalNotEnrolled += @classroomNotEnrolledMap[classroom.id]
@numberOfStudents = @totalNotEnrolled
@render?()
onInputStudentsInput: ->
@numberOfStudents = Math.max(parseInt(@$('#students-input').val()) or 0, 0)
@updatePrice()
updatePrice: ->
@renderSelectors '#price-form-group'
numberOfStudentsIsValid: -> @numberOfStudents > 0 and @numberOfStudents < 100000
onClickPurchaseButton: ->
return application.router.navigate('/teachers/signup', {trigger: true}) if me.isAnonymous()
unless @numberOfStudentsIsValid()
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: @numberOfStudents}
stripeHandler.open
amount: @numberOfStudents * @pricePerStudent
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?()
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?()
})

View file

@ -24,40 +24,30 @@ module.exports = class TeacherClassView extends RootView
template: template
events:
'click .students-tab-btn': (e) ->
e.preventDefault()
@trigger 'open-students-tab'
'click .course-progress-tab-btn': (e) ->
e.preventDefault()
@trigger 'open-course-progress-tab'
'click .nav-tabs a': 'onClickNavTabLink'
'click .unarchive-btn': 'onClickUnarchive'
'click .edit-classroom': 'onClickEditClassroom'
'click .add-students-btn': 'onClickAddStudents'
'click .sort-by-name': 'sortByName'
'click .sort-by-progress': 'sortByProgress'
'click #copy-url-btn': 'copyURL'
'click #copy-code-btn': 'copyCode'
'click .edit-student-link': 'onClickEditStudentLink'
'click .sort-button': 'onClickSortButton'
'click #copy-url-btn': 'onClickCopyURLButton'
'click #copy-code-btn': 'onClickCopyCodeButton'
'click .remove-student-link': 'onClickRemoveStudentLink'
'click .assign-student-button': 'onClickAssign'
'click .enroll-student-button': 'onClickEnroll'
'click .assign-student-button': 'onClickAssignStudentButton'
'click .enroll-student-button': 'onClickEnrollStudentButton'
'click .revoke-student-button': 'onClickRevokeStudentButton'
'click .assign-to-selected-students': 'onClickBulkAssign'
'click .enroll-selected-students': 'onClickBulkEnroll'
'click .export-student-progress-btn': 'onClickExportStudentProgress'
'click .select-all': 'onClickSelectAll'
'click .student-checkbox': 'onClickStudentCheckbox'
'change .course-select, .bulk-course-select': (e) ->
@trigger 'course-select:change', { selectedCourse: @courses.get($(e.currentTarget).val()) }
'keyup #student-search': 'onKeyPressStudentSearch'
getInitialState: ->
if Backbone.history.getHash() in ['students-tab', 'course-progress-tab']
activeTab = '#' + Backbone.history.getHash()
else
activeTab = '#students-tab'
{
sortAttribute: 'name'
sortDirection: 1
activeTab
activeTab: '#' + (Backbone.history.getHash() or 'students-tab')
students: new Users()
classCode: ""
joinURL: ""
@ -80,38 +70,59 @@ module.exports = class TeacherClassView extends RootView
@allStudentsLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-all-students-single-level'
@state = new State(@getInitialState())
window.location.hash = @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab)
@updateHash @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab)
@classroom = new Classroom({ _id: classroomID })
@classroom.fetch()
@supermodel.trackModel(@classroom)
@supermodel.trackRequest @classroom.fetch()
@onKeyPressStudentSearch = _.debounce(@onKeyPressStudentSearch, 200)
@students = new Users()
@listenTo @classroom, 'sync', ->
jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true)
if jqxhrs.length > 0
@supermodel.trackCollection(@students)
@supermodel.trackRequests jqxhrs
@classroom.sessions = new LevelSessions()
requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom)
@supermodel.trackRequests(requests)
@courses = new Courses()
@courses.fetch()
@supermodel.trackCollection(@courses)
@students.comparator = (student1, student2) =>
dir = @state.get('sortDirection')
value = @state.get('sortValue')
if value is 'name'
return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir)
if value is 'progress'
# TODO: I would like for this to be in the Level model,
# but it doesn't know about its own courseNumber.
level1 = student1.latestCompleteLevel
level2 = student2.latestCompleteLevel
return -dir if not level1
return dir if not level2
return dir * (level1.courseNumber - level2.courseNumber or level1.levelNumber - level2.levelNumber)
if value is 'status'
statusMap = { expired: 0, 'not-enrolled': 1, enrolled: 2 }
diff = statusMap[student1.prepaidStatus()] - statusMap[student2.prepaidStatus()]
return dir * diff if diff
return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir)
@courses = new Courses()
@supermodel.trackRequest @courses.fetch()
@courseInstances = new CourseInstances()
@courseInstances.fetchForClassroom(classroomID)
@supermodel.trackCollection(@courseInstances)
@supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID)
@levels = new Levels()
@levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}})
@supermodel.trackCollection(@levels)
@supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}})
@attachMediatorEvents()
attachMediatorEvents: () ->
@listenTo @state, 'sync change', @render
@listenTo @state, 'sync change', ->
if _.isEmpty(_.omit(@state.changed, 'searchTerm'))
@renderSelectors('#enrollment-status-table')
else
@render()
# Model/Collection events
@listenTo @classroom, 'sync change update', ->
@removeDeletedStudents()
@ -128,8 +139,6 @@ module.exports = class TeacherClassView extends RootView
@render() # TODO: use state
@listenTo @courseInstances, 'add-members', ->
noty text: $.i18n.t('teacher.assigned'), layout: 'center', type: 'information', killer: true, timeout: 5000
@listenToOnce @students, 'sync', # TODO: This seems like it's in the wrong place?
@sortByName
@listenTo @students, 'sync change update add remove reset', ->
# Set state/props of things that depend on students?
# Set specific parts of state based on the models, rather than just dumping the collection there?
@ -141,18 +150,6 @@ module.exports = class TeacherClassView extends RootView
@listenTo @students, 'sort', ->
@state.set students: @students
@render()
# DOM events
@listenTo @, 'open-students-tab', ->
if window.location.hash isnt '#students-tab'
window.location.hash = '#students-tab'
@state.set activeTab: '#students-tab'
@listenTo @, 'open-course-progress-tab', ->
if window.location.hash isnt '#course-progress-tab'
window.location.hash = '#course-progress-tab'
@state.set activeTab: '#course-progress-tab'
@listenTo @, 'course-select:change', ({ selectedCourse }) ->
@state.set selectedCourse: selectedCourse
setCourseMembers: =>
for course in @courses.models
@ -196,12 +193,22 @@ module.exports = class TeacherClassView extends RootView
progressData
classStats: @calculateClassStats()
}
copyCode: ->
onClickNavTabLink: (e) ->
e.preventDefault()
hash = $(e.target).closest('a').attr('href')
@updateHash(hash)
@state.set activeTab: hash
updateHash: (hash) ->
return if application.testing
window.location.hash = hash
onClickCopyCodeButton: ->
@$('#join-code-input').val(@state.get('classCode')).select()
@tryCopy()
copyURL: ->
onClickCopyURLButton: ->
@$('#join-url-input').val(@state.get('joinURL')).select()
@tryCopy()
@ -253,35 +260,20 @@ module.exports = class TeacherClassView extends RootView
)
true
sortByName: (e) ->
if @state.get('sortValue') is 'name'
onClickSortButton: (e) ->
value = $(e.target).val()
if value is @state.get('sortValue')
@state.set('sortDirection', -@state.get('sortDirection'))
else
@state.set('sortValue', 'name')
@state.set('sortDirection', 1)
dir = @state.get('sortDirection')
@students.comparator = (student1, student2) ->
return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir)
@state.set({
sortValue: value
sortDirection: 1
})
@students.sort()
sortByProgress: (e) ->
if @state.get('sortValue') is 'progress'
@state.set('sortDirection', -@state.get('sortDirection'))
else
@state.set('sortValue', 'progress')
@state.set('sortDirection', 1)
dir = @state.get('sortDirection')
@students.comparator = (student) ->
#TODO: I would like for this to be in the Level model,
# but it doesn't know about its own courseNumber
level = student.latestCompleteLevel
if not level
return -dir
return dir * ((1000 * level.courseNumber) + level.levelNumber)
@students.sort()
onKeyPressStudentSearch: (e) ->
console.log 'emit event'
@state.set('searchTerm', $(e.target).val())
getSelectedStudentIDs: ->
@$('.student-row .checkbox-flat input:checked').map (index, checkbox) ->
@ -289,7 +281,7 @@ module.exports = class TeacherClassView extends RootView
ensureInstance: (courseID) ->
onClickEnroll: (e) ->
onClickEnrollStudentButton: (e) ->
userID = $(e.currentTarget).data('user-id')
user = @students.get(userID)
selectedUsers = new Users([user])
@ -337,7 +329,7 @@ module.exports = class TeacherClassView extends RootView
window.open(encodedUri)
onClickAssign: (e) ->
onClickAssignStudentButton: (e) ->
userID = $(e.currentTarget).data('user-id')
user = @students.get(userID)
members = [userID]
@ -380,6 +372,23 @@ module.exports = class TeacherClassView extends RootView
courseInstance.addMembers members
}
null
onClickRevokeStudentButton: (e) ->
button = $(e.currentTarget)
userID = button.data('user-id')
user = @students.get(userID)
s = $.i18n.t('teacher.revoke_confirm').replace('{{student_name}}', user.broadName())
return unless confirm(s)
prepaid = user.makeCoursePrepaid()
button.text($.i18n.t('teacher.revoking'))
prepaid.revoke(user, {
success: =>
user.unset('coursePrepaid')
error: (prepaid, jqxhr) =>
msg = jqxhr.responseJSON.message
noty text: msg, layout: 'center', type: 'error', killer: true, timeout: 3000
complete: => @render()
})
onClickSelectAll: (e) ->
e.preventDefault()
@ -419,7 +428,16 @@ module.exports = class TeacherClassView extends RootView
stats.averageLevelsComplete = if @students.size() then (_.size(completeSessions) / @students.size()).toFixed(1) else 'N/A' # '
stats.totalLevelsComplete = _.size(completeSessions)
enrolledUsers = @students.filter (user) -> user.get('coursePrepaidID')
enrolledUsers = @students.filter (user) -> user.isEnrolled()
stats.enrolledUsers = _.size(enrolledUsers)
return stats
studentStatusString: (student) ->
status = student.prepaidStatus()
expires = student.get('coursePrepaid')?.endDate
string = switch status
when 'not-enrolled' then $.i18n.t('teacher.status_not_enrolled')
when 'enrolled' then (if expires then $.i18n.t('teacher.status_enrolled') else '-')
when 'expired' then $.i18n.t('teacher.status_expired')
return string.replace('{{date}}', moment(expires).utc().format('l'))

View file

@ -12,7 +12,6 @@ CourseInstance = require 'models/CourseInstance'
RootView = require 'views/core/RootView'
template = require 'templates/courses/teacher-courses-view'
ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal'
Prepaids = require 'collections/Prepaids'
module.exports = class TeacherCoursesView extends RootView
id: 'teacher-courses-view'
@ -52,17 +51,11 @@ module.exports = class TeacherCoursesView extends RootView
@listenToOnce @classrooms, 'sync', @onceClassroomsSync
@supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}})
@campaigns = new Campaigns()
@campaigns.fetch()
@supermodel.trackCollection(@campaigns)
@supermodel.trackRequest @campaigns.fetchByType('course', { data: { project: 'levels,levelsUpdated' } })
@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}})
@prepaids = new Prepaids()
@prepaids.comparator = '_id'
if not me.isAnonymous()
@prepaids.fetchByCreator(me.id)
@supermodel.loadCollection(@prepaids, 'prepaids') # just registers
@members = new CocoCollection([], { model: User })
@listenTo @members, 'sync', @render
@

View file

@ -0,0 +1,6 @@
ModalView = require 'views/core/ModalView'
module.exports = class HowToEnrollModal extends ModalView
id: 'how-to-enroll-modal'
template: require 'templates/teachers/how-to-enroll-modal'

View file

@ -0,0 +1,70 @@
ModalView = require 'views/core/ModalView'
State = require 'models/State'
TrialRequests = require 'collections/TrialRequests'
forms = require 'core/forms'
contact = require 'core/contact'
module.exports = class TeachersContactModal extends ModalView
id: 'teachers-contact-modal'
template: require 'templates/teachers/teachers-contact-modal'
events:
'submit form': 'onSubmitForm'
'change form': 'onChangeForm'
initialize: (options={}) ->
@state = new State({
formValues: {
email: ''
message: ''
}
formErrors: {}
sendingState: 'standby' # 'sending', 'sent', 'error'
})
@enrollmentsNeeded = options.enrollmentsNeeded or '-'
@trialRequests = new TrialRequests()
@supermodel.trackRequest @trialRequests.fetchOwn()
@state.on 'change', @render, @
onLoaded: ->
trialRequest = @trialRequests.first()
props = trialRequest?.get('properties') or {}
message = """
Name of School/District: #{props.organization or ''}
Your Name: #{props.name || ''}
Enrollments Needed: #{@enrollmentsNeeded}
Message: Hi CodeCombat! I want to learn more about the Classroom experience and get enrollments so that my students can access Computer Science 2 and on.
"""
email = props.email or me.get('email') or ''
@state.set('formValues', { email, message })
super()
onChangeForm: ->
# Want to re-render without losing form focus. TODO: figure out how in state system.
@$('#submit-btn').attr('disabled', false)
onSubmitForm: (e) ->
e.preventDefault()
return if @state.get('sendingState') is 'sending'
formValues = forms.formToObject @$el
@state.set('formValues', formValues)
formErrors = {}
if not forms.validateEmail(formValues.email)
formErrors.email = 'Invalid email.'
if not formValues.message
formErrors.message = 'Message required.'
@state.set({ formErrors, formValues, sendingState: 'standby' })
return unless _.isEmpty(formErrors)
@state.set('sendingState', 'sending')
data = _.extend({ country: me.get('country'), recipientID: 'schools@codecombat.com' }, formValues)
contact.send({
data
context: @
success: -> @state.set({ sendingState: 'sent' })
error: -> @state.set({ sendingState: 'error' })
})

View file

@ -113,7 +113,7 @@
"karma-coverage": "~0.5.1",
"karma-firefox-launcher": "~0.1.3",
"karma-html2js-preprocessor": "~0.1.0",
"karma-jasmine": "~0.2.0",
"karma-jasmine": "^1.0.2",
"karma-phantomjs-launcher": "~0.1.1",
"karma-requirejs": "~0.2.1",
"karma-script-launcher": "~0.1.0",

View file

@ -0,0 +1,40 @@
// Migrate users from coursePrepaidID to coursePrepaid
startDate = new Date(Date.UTC(2016,4,22)).toISOString(); // NOTE: Month is 0 indexed...
endDate = new Date(Date.UTC(2017,4,22)).toISOString();
print('Setting start/end', startDate, endDate);
db.prepaids.find({type: 'course'}).limit(10).forEach(function (prepaid) {
var properties = prepaid.properties || {};
if (!(prepaid.endDate && prepaid.startDate)) {
if (!prepaid.endDate) {
if(properties.endDate) {
print('Updating from existing end date', properties.endDate);
prepaid.endDate = properties.endDate.toISOString();
}
else {
prepaid.endDate = endDate;
}
}
if (!prepaid.startDate) {
prepaid.startDate = startDate;
}
print('updating prepaid', JSON.stringify(prepaid, null, '\t'));
//print(db.prepaids.save(prepaid));
}
var redeemers = prepaid.redeemers || [];
for (var index in redeemers) {
var redeemer = redeemers[index];
var user = db.users.findOne({ _id: redeemer.userID }, { coursePrepaid: 1, coursePrepaidID: 1 });
if (user.coursePrepaidID && !user.coursePrepaid) {
var update = {
$set: { coursePrepaid: { _id: user.coursePrepaidID, startDate: prepaid.startDate, endDate: prepaid.endDate } },
$unset: { coursePrepaidID: '' }
}
print('updating user', JSON.stringify(user, null, ' '), JSON.stringify(update, null, ' '));
//print(db.users.update({_id: user._id}, update));
}
}
});

View file

@ -38,7 +38,6 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
return @removeMember(req, res, args[0]) if req.method is 'DELETE' and args[1] is 'members'
return @getMembersAPI(req, res, args[0]) if args[1] is 'members'
return @inviteStudents(req, res, args[0]) if relationship is 'invite_students'
return @getRecentAPI(req, res) if relationship is 'recent'
return @redeemPrepaidCodeAPI(req, res) if args[1] is 'redeem_prepaid'
return @getMyCourseLevelSessionsAPI(req, res, args[0]) if args[1] is 'my-course-level-sessions'
return @findByLevel(req, res, args[2]) if args[1] is 'find_by_level'
@ -169,33 +168,6 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
cleandocs = (UserHandler.formatEntity(req, doc) for doc in users)
@sendSuccess(res, cleandocs)
getRecentAPI: (req, res) ->
return @sendUnauthorizedError(res) unless req.user?.isAdmin()
query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]}
query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay?
query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay?
CourseInstance.find query, {courseID: 1, members: 1, ownerID: 1}, (err, courseInstances) =>
return @sendDatabaseError(res, err) if err
userIDs = []
for courseInstance in courseInstances
if members = courseInstance.get('members')
userIDs.push(userID) for userID in members
User.find {_id: {$in: userIDs}}, {coursePrepaidID: 1}, (err, users) =>
return @sendDatabaseError(res, err) if err
prepaidIDs = []
for user in users
if prepaidID = user.get('coursePrepaidID')
prepaidIDs.push(prepaidID)
Prepaid.find {_id: {$in: prepaidIDs}}, {properties: 1}, (err, prepaids) =>
return @sendDatabaseError(res, err) if err
data =
courseInstances: (@formatEntity(req, courseInstance) for courseInstance in courseInstances)
students: (@formatEntity(req, user) for user in users)
prepaids: (@formatEntity(req, prepaid) for prepaid in prepaids)
@sendSuccess(res, data)
inviteStudents: (req, res, courseInstanceID) ->
return @sendUnauthorizedError(res) if not req.user?
if not req.body.emails

View file

@ -30,7 +30,6 @@ 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...
getCoursePrepaidsAPI: (req, res, code) ->
@ -78,45 +77,6 @@ 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 prepaid.get('redeemers')? and _.size(prepaid.get('redeemers')) >= prepaid.get('maxRedeemers')
return @sendForbiddenError(res) unless prepaid.get('type') is 'course'
return @sendForbiddenError(res) if prepaid.get('properties')?.endDate < new Date()
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
return @sendSuccess(res, @formatEntity(req, prepaid)) if user.get('coursePrepaidID')
return @sendForbiddenError(res, 'Teachers may not be enrolled') if user.isTeacher()
userID = user.get('_id')
query =
_id: prepaid.get('_id')
'redeemers.userID': { $ne: user.get('_id') }
$where: "this.maxRedeemers > 0 && (!this.redeemers || this.redeemers.length < #{prepaid.get('maxRedeemers')})"
update = { $push: { redeemers : { date: new Date(), userID: userID } }}
Prepaid.update query, update, (err, result) =>
return @sendDatabaseError(res, err) if err
if result.nModified is 0
@logError(req.user, "POST prepaid redeemer lost race on maxRedeemers")
return @sendForbiddenError(res)
user.set('coursePrepaidID', prepaid.get('_id'))
user.set('role', 'student') if not user.get('role')
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
@ -254,23 +214,6 @@ PrepaidHandler = class PrepaidHandler extends Handler
slack.sendSlackMessage msg, ['tower']
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
documents = []
for prepaid in prepaids
documents.push(@formatEntity(req, prepaid)) unless prepaid.get('properties')?.endDate < new Date()
return @sendSuccess(res, documents)
else
super(arguments...)
makeNewInstance: (req) ->
prepaid = super(req)

View file

@ -98,12 +98,7 @@ module.exports =
user = req.body.user
throw new errors.UnprocessableEntity('Specify an id, username or email to espionage.') unless user
if utils.isID(user)
query = {_id: mongoose.Types.ObjectId(user)}
else
user = user.toLowerCase()
query = $or: [{nameLower: user}, {emailLower: user}]
user = yield User.findOne(query)
user = yield User.search(user)
amActually = req.user
throw new errors.NotFound() unless user
req.loginAsync = Promise.promisify(req.login)
@ -210,4 +205,4 @@ module.exports =
if originalName is name
res.send 200, response
else
throw new errors.Conflict('Name is taken', response)
throw new errors.Conflict('Name is taken', response)

View file

@ -10,6 +10,8 @@ Course = require '../models/Course'
User = require '../models/User'
Level = require '../models/Level'
parse = require '../commons/parse'
{objectIdFromTimestamp} = require '../lib/utils'
Prepaid = require '../models/Prepaid'
module.exports =
addMembers: wrap (req, res) ->
@ -42,13 +44,13 @@ module.exports =
throw new errors.Forbidden('You must own the classroom to add members')
# Only the enrolled users
users = yield User.find({ _id: { $in: userIDs }}).select('coursePrepaidID')
usersArePrepaid = _.all((user.get('coursePrepaidID') for user in users))
users = yield User.find({ _id: { $in: userIDs }}).select('coursePrepaid coursePrepaidID') # TODO: remove coursePrepaidID once migrated
usersAreEnrolled = _.all((user.isEnrolled() for user in users))
course = yield Course.findById courseInstance.get('courseID')
throw new errors.NotFound('Course referenced by course instance not found') unless course
if not (course.get('free') or usersArePrepaid)
if not (course.get('free') or usersAreEnrolled)
throw new errors.PaymentRequired('Cannot add users to a course instance until they are added to a prepaid')
userObjectIDs = (mongoose.Types.ObjectId(userID) for userID in userIDs)
@ -123,3 +125,28 @@ module.exports =
classroom = classroom.toObject({req: req})
res.status(200).send(classroom)
fetchRecent: wrap (req, res) ->
query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]}
query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay?
query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay?
courseInstances = yield CourseInstance.find(query, {courseID: 1, members: 1, ownerID: 1})
userIDs = []
for courseInstance in courseInstances
if members = courseInstance.get('members')
userIDs.push(userID) for userID in members
users = yield User.find({_id: {$in: userIDs}}, {coursePrepaid: 1})
prepaidIDs = []
for user in users
if prepaidID = user.get('coursePrepaid')
prepaidIDs.push(prepaidID._id)
prepaids = yield Prepaid.find({_id: {$in: prepaidIDs}}, {properties: 1})
res.send({
courseInstances: (courseInstance.toObject({req: req}) for courseInstance in courseInstances)
students: (user.toObject({req: req}) for user in users)
prepaids: (prepaid.toObject({req: req}) for prepaid in prepaids)
})

View file

@ -8,8 +8,10 @@ module.exports =
courses: require './courses'
files: require './files'
healthcheck: require './healthcheck'
levels: require './levels'
named: require './named'
patchable: require './patchable'
prepaids: require './prepaids'
rest: require './rest'
trialRequests: require './trial-requests'
users: require './users'

View file

@ -0,0 +1,81 @@
wrap = require 'co-express'
errors = require '../commons/errors'
Level = require '../models/Level'
LevelSession = require '../models/LevelSession'
CourseInstance = require '../models/CourseInstance'
Classroom = require '../models/Classroom'
Course = require '../models/Course'
database = require '../commons/database'
module.exports =
upsertSession: wrap (req, res) ->
level = yield database.getDocFromHandle(req, Level)
if not level
throw new errors.NotFound('Level not found.')
sessionQuery =
level:
original: level.get('original').toString()
majorVersion: level.get('version').major
creator: req.user.id
if req.query.team?
sessionQuery.team = req.query.team
session = yield LevelSession.findOne(sessionQuery)
if session
return res.send(session.toObject({req: req}))
attrs = sessionQuery
_.extend(attrs, {
state:
complete: false
scripts:
currentScript: null # will not save empty objects
permissions: [
{target: req.user.id, access: 'owner'}
{target: 'public', access: 'write'}
]
codeLanguage: req.user.get('aceConfig')?.language ? 'python'
})
if level.get('type') in ['course', 'course-ladder'] or req.query.course?
# Find the course and classroom that has assigned this level, verify access
courseInstances = yield CourseInstance.find({members: req.user._id})
classroomIDs = (courseInstance.get('classroomID') for courseInstance in courseInstances)
classroomIDs = _.filter _.uniq classroomIDs, false, (objectID='') -> objectID.toString()
classrooms = yield Classroom.find({ _id: { $in: classroomIDs }})
classroomWithLevel = null
courseID = null
classroomMap = {}
classroomMap[classroom.id] = classroom for classroom in classrooms
for courseInstance in courseInstances
classroom = classroomMap[courseInstance.get('classroomID').toString()]
courseID = courseInstance.get('courseID')
classroomCourse = _.find(classroom.get('courses'), (c) -> c._id.equals(courseID))
for courseLevel in classroomCourse.levels
if courseLevel.original.equals(level._id)
classroomWithLevel = classroom
break
break if classroomWithLevel
unless classroomWithLevel
throw new errors.PaymentRequired('You must be in a course which includes this level to play it')
course = yield Course.findById(courseID).select('free')
unless course.get('free') or req.user.isEnrolled()
throw new errors.PaymentRequired('You must be enrolled to access this content')
lang = classroomWithLevel.get('aceConfig').language
attrs.codeLanguage = lang if lang
else
requiresSubscription = level.get('requiresSubscription') or (req.user.isOnPremiumServer() and level.get('campaign') and not (level.slug in ['dungeons-of-kithgard', 'gems-in-the-deep', 'shadow-guard', 'forgetful-gemsmith', 'signs-and-portents', 'true-names']))
canPlayAnyway = req.user.isPremium() or level.get 'adventurer'
if requiresSubscription and not canPlayAnyway
throw new errors.PaymentRequired('This level requires a subscription to play')
session = new LevelSession(attrs)
yield session.save()
res.send(session.toObject({req: req}))

View file

@ -0,0 +1,152 @@
wrap = require 'co-express'
errors = require '../commons/errors'
database = require '../commons/database'
Prepaid = require '../models/Prepaid'
User = require '../models/User'
mongoose = require 'mongoose'
cutoffDate = new Date(2015,11,11)
cutoffID = mongoose.Types.ObjectId(Math.floor(cutoffDate/1000).toString(16)+'0000000000000000')
module.exports =
logError: (user, msg) ->
console.warn "Prepaid Error: [#{user.get('slug')} (#{user._id})] '#{msg}'"
post: wrap (req, res) ->
validTypes = ['course']
unless req.body.type in validTypes
throw new errors.UnprocessableEntity("type must be on of: #{validTypes}.")
# TODO: deprecate or refactor other prepaid types
if req.body.creator
user = yield User.search(req.body.creator)
if not user
throw new errors.NotFound('User not found')
req.body.creator = user.id
prepaid = database.initDoc(req, Prepaid)
database.assignBody(req, prepaid)
prepaid.set('code', yield Prepaid.generateNewCodeAsync())
prepaid.set('redeemers', [])
database.validateDoc(prepaid)
yield prepaid.save()
res.status(201).send(prepaid.toObject())
redeem: wrap (req, res) ->
if not req.user?.isTeacher()
throw new errors.Forbidden('Must be a teacher to use enrollments')
prepaid = yield database.getDocFromHandle(req, Prepaid)
if not prepaid
throw new errors.NotFound('Prepaid not found.')
if prepaid._id.getTimestamp().getTime() < cutoffDate.getTime()
throw new errors.Forbidden('Cannot redeem from prepaids older than November 11, 2015')
unless prepaid.get('creator').equals(req.user._id)
throw new errors.Forbidden('You may not redeem enrollments from this prepaid')
if prepaid.get('redeemers')? and _.size(prepaid.get('redeemers')) >= prepaid.get('maxRedeemers')
throw new errors.Forbidden('This prepaid is exhausted')
unless prepaid.get('type') is 'course'
throw new errors.Forbidden('This prepaid is not of type "course"')
if prepaid.get('endDate') and new Date(prepaid.get('endDate')) < new Date()
throw new errors.Forbidden('This prepaid is expired')
user = yield User.findById(req.body?.userID)
if not user
throw new errors.NotFound('User not found.')
if user.isEnrolled()
return res.status(200).send(prepaid.toObject({req: req}))
if user.isTeacher()
throw new errors.Forbidden('Teachers may not be enrolled')
query =
_id: prepaid._id
'redeemers.userID': { $ne: user._id }
$where: "this.maxRedeemers > 0 && (!this.redeemers || this.redeemers.length < #{prepaid.get('maxRedeemers')})"
update = { $push: { redeemers : { date: new Date(), userID: user._id } }}
result = yield Prepaid.update(query, update)
if result.nModified is 0
@logError(req.user, "POST prepaid redeemer lost race on maxRedeemers")
throw new errors.Forbidden('This prepaid is exhausted')
update = {
$set: {
coursePrepaid: {
_id: prepaid._id
startDate: prepaid.get('startDate')
endDate: prepaid.get('endDate')
}
}
}
if not user.get('role')
update.$set.role = 'student'
yield user.update(update)
# return prepaid with new redeemer added locally
redeemers = _.clone(prepaid.get('redeemers') or [])
redeemers.push({ date: new Date(), userID: user._id })
prepaid.set('redeemers', redeemers)
res.status(201).send(prepaid.toObject({req: req}))
revoke: wrap (req, res) ->
if not req.user?.isTeacher()
throw new errors.Forbidden('Must be a teacher to use enrollments')
prepaid = yield database.getDocFromHandle(req, Prepaid)
if not prepaid
throw new errors.NotFound('Prepaid not found.')
unless prepaid.get('creator').equals(req.user._id)
throw new errors.Forbidden('You may not revoke enrollments you do not own.')
unless prepaid.get('type') is 'course'
throw new errors.Forbidden('This prepaid is not of type "course".')
if prepaid.get('endDate') and new Date(prepaid.get('endDate')) < new Date()
throw new errors.Forbidden('This prepaid is expired.')
user = yield User.findById(req.body?.userID)
if not user
throw new errors.NotFound('User not found.')
if not user.isEnrolled()
throw new errors.UnprocessableEntity('User to revoke must be enrolled first.')
if not _.any(prepaid.get('redeemers'), (obj) -> obj.userID.equals(user._id))
throw new errors.UnprocessableEntity('User was not enrolled with this set of enrollments')
query =
_id: prepaid._id
'redeemers.userID': { $eq: user._id }
update = { $pull: { redeemers : { userID: user._id } }}
result = yield Prepaid.update(query, update)
if result.nModified is 0
@logError(req.user, "POST prepaid redeemer lost race on maxRedeemers")
throw new errors.UnprocessableEntity('User was not enrolled with this set of enrollments (race)')
user.set('coursePrepaid', undefined)
yield user.save()
# return prepaid with new redeemer added locally
prepaid.set('redeemers', _.filter(prepaid.get('redeemers') or [], (obj) -> not obj.userID.equals(user._id)))
res.status(200).send(prepaid.toObject({req: req}))
fetchByCreator: wrap (req, res, next) ->
creator = req.query.creator
return next() if not creator
unless req.user.isAdmin() or creator is req.user.id
throw new errors.Forbidden('Must be logged in as given creator')
unless database.isID(creator)
throw new errors.UnprocessableEntity('Invalid creator')
q = {
_id: { $gt: cutoffID }
creator: mongoose.Types.ObjectId(creator)
type: 'course'
}
prepaids = yield Prepaid.find(q)
res.send((prepaid.toObject({req: req}) for prepaid in prepaids))

View file

@ -90,4 +90,20 @@ LevelSessionSchema.statics.editableProperties = ['multiplayer', 'players', 'code
'browser']
LevelSessionSchema.statics.jsonSchema = jsonschema
LevelSessionSchema.set('toObject', {
transform: (doc, ret, options) ->
req = options.req
return ret unless req # TODO: Make deleting properties the default, but the consequences are far reaching
submittedCode = doc.get('submittedCode')
unless req.user?.isAdmin() or req.user?.id is doc.get('creator') or ('employer' in (req.user?.get('permissions') ? [])) or not doc.get('submittedCode') # TODO: only allow leaderboard access to non-top-5 solutions
ret = _.omit ret, LevelSession.privateProperties
if req.query.interpret
plan = submittedCode[if doc.get('team') is 'humans' then 'hero-placeholder' else 'hero-placeholder-1']?.plan ? ''
plan = LZString.compressToUTF16 plan
ret.interpret = plan
ret.code = submittedCode
return ret
})
module.exports = LevelSession = mongoose.model('level.session', LevelSessionSchema, 'level.sessions')

View file

@ -1,13 +1,21 @@
Promise = require 'bluebird'
mongoose = require 'mongoose'
config = require '../../server_config'
PrepaidSchema = new mongoose.Schema {
creator: mongoose.Schema.Types.ObjectId
}, {strict: false, minimize: false,read:config.mongo.readpref}
co = require 'co'
jsonSchema = require '../../app/schemas/models/prepaid.schema'
PrepaidSchema.index({code: 1}, { unique: true })
PrepaidSchema.index({'redeemers.userID': 1})
PrepaidSchema.index({owner: 1, endDate: 1}, { sparse: true })
PrepaidSchema.statics.DEFAULT_START_DATE = new Date(2016,4,15).toISOString()
PrepaidSchema.statics.DEFAULT_END_DATE = new Date(2017,5,1).toISOString()
PrepaidSchema.statics.generateNewCode = (done) ->
# Deprecated for not following Node callback convention. TODO: Remove
tryCode = ->
code = _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('')
Prepaid.findOne code: code, (err, prepaid) ->
@ -15,6 +23,14 @@ PrepaidSchema.statics.generateNewCode = (done) ->
return done(code) unless prepaid
tryCode()
tryCode()
PrepaidSchema.statics.generateNewCodeAsync = co.wrap (done) ->
code = null
while true
code = _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('')
prepaid = yield Prepaid.findOne({code: code})
break if not prepaid
return code
PrepaidSchema.pre('save', (next) ->
@set('exhausted', @get('maxRedeemers') <= _.size(@get('redeemers')))
@ -27,10 +43,17 @@ PrepaidSchema.pre('save', (next) ->
)
PrepaidSchema.post 'init', (doc) ->
doc.set('maxRedeemers', parseInt(doc.get('maxRedeemers')))
doc.set('maxRedeemers', parseInt(doc.get('maxRedeemers') ? 0))
if @get('type') is 'course'
if not @get('startDate')
@set('startDate', Prepaid.DEFAULT_START_DATE)
if not @get('endDate')
@set('endDate', Prepaid.DEFAULT_END_DATE)
PrepaidSchema.statics.postEditableProperties = [
'creator', 'maxRedeemers', 'properties', 'type'
'creator', 'maxRedeemers', 'properties', 'type', 'startDate', 'endDate'
]
PrepaidSchema.statics.editableProperties = []
PrepaidSchema.statics.jsonSchema = jsonSchema
module.exports = Prepaid = mongoose.model('prepaid', PrepaidSchema)

View file

@ -35,6 +35,7 @@ UserSchema.index({'siteref': 1}, {name: 'siteref index', sparse: true})
UserSchema.index({'schoolName': 1}, {name: 'schoolName index', sparse: true})
UserSchema.index({'country': 1}, {name: 'country index', sparse: true})
UserSchema.index({'role': 1}, {name: 'role index', sparse: true})
UserSchema.index({'coursePrepaid._id': 1}, {name: 'course prepaid id index', sparse: true})
UserSchema.post('init', ->
@set('anonymous', false) if @get('email')
@ -106,6 +107,15 @@ UserSchema.methods.trackActivity = (activityName, increment) ->
activity[activityName].last = now
@set 'activity', activity
activity
UserSchema.statics.search = (term, done) ->
utils = require '../lib/utils'
if utils.isID(term)
query = {_id: mongoose.Types.ObjectId(term)}
else
term = term.toLowerCase()
query = $or: [{nameLower: term}, {emailLower: term}]
return User.findOne(query).exec(done)
emailNameMap =
generalNews: 'announcement'
@ -291,6 +301,12 @@ UserSchema.methods.level = ->
a = 5
b = c = 100
if xp > 0 then Math.floor(a * Math.log((1 / b) * (xp + c))) + 1 else 1
UserSchema.methods.isEnrolled = ->
coursePrepaid = @get('coursePrepaid')
return false unless coursePrepaid
return true unless coursePrepaid.endDate
return coursePrepaid.endDate > new Date().toISOString()
UserSchema.statics.saveActiveUser = (id, event, done=null) ->
# TODO: Disabling this until we know why our app servers CPU grows out of control.
@ -350,9 +366,18 @@ UserSchema.post 'save', (doc) ->
doc.newsSubsChanged = not _.isEqual(_.pick(doc.get('emails'), mail.NEWS_GROUPS), _.pick(doc.startingEmails, mail.NEWS_GROUPS))
UserSchema.statics.updateServiceSettings(doc)
UserSchema.post 'init', (doc) ->
doc.wasTeacher = doc.isTeacher()
doc.startingEmails = _.cloneDeep(doc.get('emails'))
if @get('coursePrepaidID') and not @get('coursePrepaid')
Prepaid = require './Prepaid'
@set('coursePrepaid', {
_id: @get('coursePrepaidID')
startDate: Prepaid.DEFAULT_START_DATE
endDate: Prepaid.DEFAULT_END_DATE
})
@set('coursePrepaidID', undefined)
UserSchema.statics.hashPassword = (password) ->
password = password.toLowerCase()
@ -370,7 +395,7 @@ UserSchema.statics.privateProperties = [
'permissions', 'email', 'mailChimp', 'firstName', 'lastName', 'gender', 'facebookID',
'gplusID', 'music', 'volume', 'aceConfig', 'employerAt', 'signedEmployerAgreement',
'emailSubscriptions', 'emails', 'activity', 'stripe', 'stripeCustomerID', 'chinaVersion', 'country',
'schoolName', 'ageRange', 'role'
'schoolName', 'ageRange', 'role', 'enrollmentRequestSent'
]
UserSchema.statics.jsonSchema = jsonschema
UserSchema.statics.editableProperties = [
@ -378,7 +403,8 @@ UserSchema.statics.editableProperties = [
'firstName', 'lastName', 'gender', 'ageRange', 'facebookID', 'gplusID', 'emails',
'testGroupNumber', 'music', 'hourOfCode', 'hourOfCodeComplete', 'preferredLanguage',
'wizard', 'aceConfig', 'autocastDelay', 'lastLevel', 'jobProfile', 'savedEmployerFilterAlerts',
'heroConfig', 'iosIdentifierForVendor', 'siteref', 'referrer', 'schoolName', 'role', 'birthday'
'heroConfig', 'iosIdentifierForVendor', 'siteref', 'referrer', 'schoolName', 'role', 'birthday',
'enrollmentRequestSent'
]
UserSchema.statics.serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP']

View file

@ -52,7 +52,11 @@ createMailContext = (req, done) ->
email_data:
subject: "[CodeCombat] #{subject ? ('Feedback - ' + (sender or user.get('email')))}"
content: content
if recipientID and (user.isAdmin() or ('employer' in (user.get('permissions') ? [])))
if recipientID is 'schools@codecombat.com'
context.recipient.address = 'schools@codecombat.com'
req.user.update({$set: { enrollmentRequestSent: true }}).exec(_.noop)
done context
else if recipientID and (user.isAdmin() or ('employer' in (user.get('permissions') ? [])))
User.findById(recipientID, 'email').exec (err, document) ->
if err
log.error "Error looking up recipient to email from #{recipientID}: #{err}" if err

View file

@ -72,6 +72,7 @@ module.exports.setup = (app) ->
app.get('/db/course/:handle', mw.rest.getByHandle(Course))
app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel)
app.get('/db/course_instance/-/recent', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchRecent)
app.get('/db/course_instance/:handle/levels/:levelOriginal/next', mw.courseInstances.fetchNextLevel)
app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers)
app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom)
@ -84,6 +85,13 @@ module.exports.setup = (app) ->
app.put('/db/user/-/remain-teacher', mw.users.remainTeacher)
app.post('/db/user/:userID/request-verify-email', mw.users.sendVerificationEmail)
app.post('/db/user/:userID/verify/:verificationCode', mw.users.verifyEmailAddress) # TODO: Finalize URL scheme
app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession)
app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator)
app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post)
app.post('/db/prepaid/:handle/redeemers', mw.prepaids.redeem)
app.delete('/db/prepaid/:handle/redeemers', mw.prepaids.revoke)
app.get '/db/products', require('./db/product').get

View file

@ -36,7 +36,7 @@ if (database.generateMongoConnectionString() !== dbString) {
throw Error('Stopping server tests because db connection string was not as expected.');
}
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 120; // for long Stripe tests
//jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 120; // for long Stripe tests
require('../server/common'); // Make sure global testing functions are set up
var initialized = false;
@ -93,4 +93,4 @@ beforeEach(function(done) {
initialized = true;
done();
});
});
});

View file

@ -0,0 +1,25 @@
utils = require '../utils'
sendwithus = require '../../../server/sendwithus'
request = require '../request'
User = require '../../../server/models/User'
describe 'POST /contact', ->
beforeEach utils.wrap (done) ->
spyOn(sendwithus.api, 'send')
@teacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(@teacher)
done()
describe 'when recipientID is "schools@codecombat.com"', ->
it 'sends to that email', utils.wrap (done) ->
[res, body] = yield request.postAsync({url: getURL('/contact'), json: {
sender: 'some@email.com'
message: 'A message'
recipientID: 'schools@codecombat.com'
}})
expect(sendwithus.api.send).toHaveBeenCalled()
user = yield User.findById(@teacher.id)
yield new Promise((resolve) -> setTimeout(resolve, 10))
expect(user.get('enrollmentRequestSent')).toBe(true)
done()

View file

@ -11,6 +11,7 @@ Campaign = require '../../../server/models/Campaign'
Level = require '../../../server/models/Level'
Prepaid = require '../../../server/models/Prepaid'
request = require '../request'
moment = require 'moment'
courseFixture = {
name: 'Unnamed course'
@ -101,27 +102,19 @@ describe 'POST /db/course_instance', ->
describe 'POST /db/course_instance/:id/members', ->
beforeEach utils.wrap (done) ->
utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid])
yield utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid, Campaign, Level])
@teacher = yield utils.initUser({role: 'teacher'})
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
@level = yield utils.makeLevel({type: 'course'})
@campaign = yield utils.makeCampaign({}, {levels: [@level]})
@course = yield utils.makeCourse({free: true}, {campaign: @campaign})
@student = yield utils.initUser({role: 'student'})
@prepaid = yield utils.makePrepaid({creator: @teacher.id})
members = [@student]
yield utils.loginUser(@teacher)
courseData = _.extend({free: true}, courseFixture)
@course = yield new Course(courseData).save()
classroomData = _.extend({ownerID: @teacher._id}, classroomFixture)
@classroom = yield new Classroom(classroomData).save()
url = getURL('/db/course_instance')
data = {
name: 'Some Name'
courseID: @course.id
classroomID: @classroom.id
}
[res, body] = yield request.postAsync {uri: url, json: data}
@courseInstance = yield CourseInstance.findById res.body._id
@student = yield utils.initUser()
@prepaid = yield new Prepaid({
type: 'course'
maxRedeemers: 10
redeemers: []
}).save()
@classroom = yield utils.makeClassroom({aceConfig: { language: 'javascript' }}, { members })
@courseInstance = yield utils.makeCourseInstance({}, { @course, @classroom })
done()
it 'adds an array of members to the given CourseInstance', utils.wrap (done) ->
@ -135,8 +128,6 @@ describe 'POST /db/course_instance/:id/members', ->
done()
it 'adds a member to the given CourseInstance', utils.wrap (done) ->
@classroom.set('members', [@student._id])
yield @classroom.save()
url = getURL("/db/course_instance/#{@courseInstance.id}/members")
[res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}}
expect(res.statusCode).toBe(200)
@ -145,8 +136,6 @@ describe 'POST /db/course_instance/:id/members', ->
done()
it 'adds the CourseInstance id to the user', utils.wrap (done) ->
@classroom.set('members', [@student._id])
yield @classroom.save()
url = getURL("/db/course_instance/#{@courseInstance.id}/members")
[res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}}
user = yield User.findById(@student.id)
@ -154,14 +143,14 @@ describe 'POST /db/course_instance/:id/members', ->
done()
it 'return 403 if the member is not in the classroom', utils.wrap (done) ->
@classroom.set('members', [])
yield @classroom.save()
url = getURL("/db/course_instance/#{@courseInstance.id}/members")
[res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}}
expect(res.statusCode).toBe(403)
done()
it 'returns 403 if the user does not own the course instance and is not adding self', utils.wrap (done) ->
@classroom.set('members', [@student._id])
yield @classroom.save()
otherUser = yield utils.initUser()
yield utils.loginUser(otherUser)
url = getURL("/db/course_instance/#{@courseInstance.id}/members")
@ -171,9 +160,7 @@ describe 'POST /db/course_instance/:id/members', ->
it 'returns 200 if the user is a member of the classroom and is adding self', ->
it 'return 402 if the course is not free and the user is not in a prepaid', utils.wrap (done) ->
@classroom.set('members', [@student._id])
yield @classroom.save()
it 'return 402 if the course is not free and the user is not enrolled', utils.wrap (done) ->
@course.set('free', false)
yield @course.save()
url = getURL("/db/course_instance/#{@courseInstance.id}/members")
@ -181,9 +168,17 @@ describe 'POST /db/course_instance/:id/members', ->
expect(res.statusCode).toBe(402)
done()
it 'works if the course is not free and the user is in a prepaid', utils.wrap (done) ->
@classroom.set('members', [@student._id])
yield @classroom.save()
it 'works if the course is not free and the user is enrolled', utils.wrap (done) ->
@course.set('free', false)
yield @course.save()
@student.set('coursePrepaid', _.pick(@prepaid.toObject(), '_id', 'startDate', 'endDate'))
yield @student.save()
url = getURL("/db/course_instance/#{@courseInstance.id}/members")
[res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}}
expect(res.statusCode).toBe(200)
done()
it 'works if the course is not free and the user is enrolled but is not migrated', utils.wrap (done) ->
@course.set('free', false)
yield @course.save()
@student.set('coursePrepaidID', @prepaid._id)
@ -365,3 +360,56 @@ describe 'GET /db/course_instance/:handle/classroom', ->
[res, body] = yield request.getAsync(@url, {json: true})
expect(res.statusCode).toBe(403)
done()
describe 'GET /db/course_instance/-/recent', ->
url = getURL('/db/course_instance/-/recent')
beforeEach utils.wrap (done) ->
yield utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid, Campaign, Level])
@teacher = yield utils.initUser({role: 'teacher'})
@admin = yield utils.initAdmin()
yield utils.loginUser(@admin)
@campaign = yield utils.makeCampaign()
@course = yield utils.makeCourse({free: true}, {campaign: @campaign})
@student = yield utils.initUser({role: 'student'})
@prepaid = yield utils.makePrepaid({creator: @teacher.id})
members = [@student]
yield utils.loginUser(@teacher)
@classroom = yield utils.makeClassroom({aceConfig: { language: 'javascript' }}, { members })
@courseInstance = yield utils.makeCourseInstance({}, { @course, @classroom, members })
[res, body] = yield request.postAsync({url: getURL("/db/prepaid/#{@prepaid.id}/redeemers"), json: { userID: @student.id} })
yield utils.loginUser(@admin)
done()
it 'returns all non-HoC course instances and their related users and prepaids', utils.wrap (done) ->
[res, body] = yield request.getAsync(url, { json: true })
expect(res.statusCode).toBe(200)
expect(res.body.courseInstances[0]._id).toBe(@courseInstance.id)
expect(res.body.students[0]._id).toBe(@student.id)
expect(res.body.prepaids[0]._id).toBe(@prepaid.id)
done()
it 'returns course instances within a specified range', utils.wrap (done) ->
startDay = moment().subtract(1, 'day').format('YYYY-MM-DD')
endDay = moment().add(1, 'day').format('YYYY-MM-DD')
[res, body] = yield request.getAsync(url, { json: { startDay, endDay } })
expect(res.body.courseInstances.length).toBe(1)
startDay = moment().add(1, 'day').format('YYYY-MM-DD')
endDay = moment().add(2, 'day').format('YYYY-MM-DD')
[res, body] = yield request.getAsync(url, { json: { startDay, endDay } })
expect(res.body.courseInstances.length).toBe(0)
startDay = moment().subtract(2, 'day').format('YYYY-MM-DD')
endDay = moment().subtract(1, 'day').format('YYYY-MM-DD')
[res, body] = yield request.getAsync(url, { json: { startDay, endDay } })
expect(res.body.courseInstances.length).toBe(0)
done()
it 'returns 403 if not an admin', utils.wrap (done) ->
yield utils.loginUser(@teacher)
[res, body] = yield request.getAsync(url, { json: true })
expect(res.statusCode).toBe(403)
done()

View file

@ -6,6 +6,8 @@ CourseInstance = require '../../../server/models/CourseInstance'
Level = require '../../../server/models/Level'
User = require '../../../server/models/User'
request = require '../request'
utils = require '../utils'
moment = require 'moment'
describe 'Level', ->
@ -38,85 +40,99 @@ describe 'Level', ->
done()
describe 'GET /db/level/<id>/session', ->
describe 'GET /db/level/:handle/session', ->
describe 'when level is a course level', ->
levelID = null
beforeEach utils.wrap (done) ->
yield utils.clearModels([Campaign, Course, CourseInstance, Level, User])
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
@level = yield utils.makeLevel({type: 'course'})
@campaign = yield utils.makeCampaign({}, {levels: [@level]})
@course = yield utils.makeCourse({free: true}, {campaign: @campaign})
@student = yield utils.initUser({role: 'student'})
members = [@student]
teacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(teacher)
@classroom = yield utils.makeClassroom({aceConfig: { language: 'javascript' }}, { members })
@courseInstance = yield utils.makeCourseInstance({}, { @course, @classroom, members })
@url = getURL("/db/level/#{@level.id}/session")
yield utils.loginUser(@student)
done()
it 'creates a new session if the user is in a course with that level', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: @url, json: true }
expect(res.statusCode).toBe(200)
expect(body.codeLanguage).toBe('javascript')
done()
it 'sets up a course instance', (done) ->
clearModels [Campaign, Course, CourseInstance, Level, User], (err) ->
loginAdmin (admin) ->
url = getURL('/db/level')
body =
name: 'Course Level'
type: 'course'
permissions: simplePermissions
request.post {uri: url, json: body }, (err, res, level) ->
levelID = level._id
url = getURL('/db/campaign')
body =
name: 'Course Campaign'
levels: {}
body.levels[level.original] = { 'original': level.original }
request.post { uri: url, json: body }, (err, res, campaign) ->
course = new Course({
name: 'Test Course'
campaignID: ObjectId(campaign._id)
})
course.save (err) ->
expect(err).toBeNull()
loginJoe (joe) ->
classroom = new Classroom({
name: 'Test Classroom'
members: [ joe.get('_id') ]
aceConfig: { language: 'javascript' }
})
classroom.save (err, classroom) ->
expect(err).toBeNull()
courseInstance = new CourseInstance({
name: 'Course Instance'
members: [
joe.get('_id')
]
courseID: ObjectId(course.id)
classroomID: ObjectId(classroom.id)
})
it 'returns 402 if the user is not in a course with that level', utils.wrap (done) ->
otherStudent = yield utils.initUser({role: 'student'})
yield utils.loginUser(otherStudent)
[res, body] = yield request.getAsync({ uri: @url, json: true })
expect(res.statusCode).toBe(402)
expect(res.body.message).toBe('You must be in a course which includes this level to play it')
done()
describe 'when the course is not free', ->
courseInstance.save (err) ->
expect(err).toBeNull()
done()
beforeEach utils.wrap (done) ->
@course.set({free: false})
yield @course.save()
done()
it 'returns 402 if the user is not enrolled', utils.wrap (done) ->
[res, body] = yield request.getAsync({ uri: @url, json: true })
expect(res.statusCode).toBe(402)
expect(res.body.message).toBe('You must be enrolled to access this content')
done()
it 'creates the session if the user is enrolled', utils.wrap (done) ->
@student.set({
coursePrepaid: {
_id: {}
startDate: moment().subtract(1, 'month').toISOString()
endDate: moment().add(1, 'month').toISOString()
}
})
@student.save()
[res, body] = yield request.getAsync({ uri: @url, json: true })
expect(res.statusCode).toBe(200)
done()
it 'creates a new session if the user is in a course with that level', (done) ->
loginJoe (joe) ->
url = getURL("/db/level/#{levelID}/session")
request.get { uri: url, json: true }, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.codeLanguage).toBe('javascript')
done()
it 'does not create a new session if the user is not in a course with that level', (done) ->
loginSam (sam) ->
url = getURL("/db/level/#{levelID}/session")
request.get { uri: url }, (err, res, body) ->
expect(res.statusCode).toBe(402)
done()
it 'returns 402 if the user\'s enrollment is expired', utils.wrap (done) ->
@student.set({
coursePrepaid: {
_id: {}
startDate: moment().subtract(2, 'month').toISOString()
endDate: moment().subtract(1, 'month').toISOString()
}
})
@student.save()
[res, body] = yield request.getAsync({ uri: @url, json: true })
expect(res.statusCode).toBe(402)
expect(res.body.message).toBe('You must be enrolled to access this content')
done()
describe 'when the level is NOT a course level', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([Level, User])
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
@level = yield utils.makeLevel()
@player = yield utils.initUser()
yield utils.loginUser(@player)
@url = getURL("/db/level/#{@level.id}/session")
done()
it 'idempotently creates and returns a session for that level', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: @url, json: true }
expect(res.statusCode).toBe(200)
sessionID = body._id
[res, body] = yield request.getAsync { uri: @url, json: true }
expect(body._id).toBe(sessionID)
done()

View file

@ -1,6 +1,7 @@
require '../common'
Level = require '../../../server/models/Level'
LevelComponent = require '../../../server/models/LevelComponent'
User = require '../../../server/models/User'
request = require '../request'
describe 'LevelComponent', ->
@ -20,7 +21,7 @@ describe 'LevelComponent', ->
url = getURL('/db/level.component')
it 'preparing test : clears things first.', (done) ->
clearModels [Level, LevelComponent], (err) ->
clearModels [Level, LevelComponent, User], (err) ->
expect(err).toBeNull()
done()

View file

@ -13,9 +13,248 @@ Course = require '../../../server/models/Course'
CourseInstance = require '../../../server/models/CourseInstance'
request = require '../request'
describe 'POST /db/prepaid', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([User, Prepaid])
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
done()
it 'creates a new prepaid for type "course"', utils.wrap (done) ->
user = yield utils.initUser()
[res, body] = yield request.postAsync({url: getURL('/db/prepaid'), json: {
type: 'course'
creator: user.id
}})
expect(res.statusCode).toBe(201)
prepaid = yield Prepaid.findById(res.body._id)
expect(prepaid).toBeDefined()
expect(prepaid.get('creator').equals(user._id)).toBe(true)
expect(prepaid.get('code')).toBeDefined()
done()
it 'does not work for non-admins', utils.wrap (done) ->
user = yield utils.initUser()
yield utils.loginUser(user)
[res, body] = yield request.postAsync({url: getURL('/db/prepaid'), json: {
type: 'course'
creator: user.id
}})
expect(res.statusCode).toBe(403)
done()
it 'accepts start and end dates', utils.wrap (done) ->
user = yield utils.initUser()
[res, body] = yield request.postAsync({url: getURL('/db/prepaid'), json: {
type: 'course'
creator: user.id
startDate: new Date().toISOString(2001,1,1)
endDate: new Date().toISOString(2010,1,1)
}})
expect(res.statusCode).toBe(201)
prepaid = yield Prepaid.findById(res.body._id)
expect(prepaid).toBeDefined()
expect(prepaid.get('startDate')).toBeDefined()
expect(prepaid.get('endDate')).toBeDefined()
done()
describe 'GET /db/prepaid/:handle', ->
it 'populates startDate and endDate with default values', utils.wrap (done) ->
prepaid = new Prepaid({type: 'course' })
yield prepaid.save()
[res, body] = yield request.getAsync({url: getURL("/db/prepaid/#{prepaid.id}"), json: true})
expect(body.endDate).toBe(Prepaid.DEFAULT_END_DATE)
expect(body.startDate).toBe(Prepaid.DEFAULT_START_DATE)
done()
describe 'POST /db/prepaid/:handle/redeemers', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([Course, CourseInstance, Payment, Prepaid, User])
@teacher = yield utils.initUser({role: 'teacher'})
@admin = yield utils.initAdmin()
yield utils.loginUser(@admin)
@prepaid = yield utils.makePrepaid({ creator: @teacher.id })
yield utils.loginUser(@teacher)
@student = yield utils.initUser()
@url = getURL("/db/prepaid/#{@prepaid.id}/redeemers")
done()
it 'adds a given user to the redeemers property', utils.wrap (done) ->
[res, body] = yield request.postAsync {uri: @url, json: { userID: @student.id } }
expect(body.redeemers.length).toBe(1)
expect(res.statusCode).toBe(201)
prepaid = yield Prepaid.findById(body._id)
expect(prepaid.get('redeemers').length).toBe(1)
@student = yield User.findById(@student.id)
expect(@student.get('coursePrepaid')._id.equals(@prepaid._id)).toBe(true)
expect(@student.get('role')).toBe('student')
done()
it 'returns 403 if maxRedeemers is reached', utils.wrap (done) ->
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
prepaid = yield utils.makePrepaid({ creator: @teacher.id, maxRedeemers: 0 })
url = getURL("/db/prepaid/#{prepaid.id}/redeemers")
yield utils.loginUser(@teacher)
[res, body] = yield request.postAsync({uri: url, json: { userID: @student.id } })
expect(res.statusCode).toBe(403)
expect(res.body.message).toBe('This prepaid is exhausted')
done()
it 'returns 403 unless the user is the "creator"', utils.wrap (done) ->
@otherTeacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(@otherTeacher)
[res, body] = yield request.postAsync({uri: @url, json: { userID: @student.id } })
expect(res.statusCode).toBe(403)
expect(res.body.message).toBe('You may not redeem enrollments from this prepaid')
done()
it 'returns 403 if the prepaid is expired', utils.wrap (done) ->
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
prepaid = yield utils.makePrepaid({ creator: @teacher.id, endDate: moment().subtract(1, 'month').toISOString() })
url = getURL("/db/prepaid/#{prepaid.id}/redeemers")
yield utils.loginUser(@teacher)
[res, body] = yield request.postAsync({uri: url, json: { userID: @student.id } })
expect(res.statusCode).toBe(403)
expect(res.body.message).toBe('This prepaid is expired')
done()
it 'is idempotent across prepaids collection', utils.wrap (done) ->
student = yield utils.initUser({ coursePrepaid: { _id: new Prepaid()._id } })
[res, body] = yield request.postAsync({uri: @url, json: { userID: student.id } })
expect(res.statusCode).toBe(200)
expect(body.redeemers.length).toBe(0)
done()
it 'is idempotent to itself', utils.wrap (done) ->
[res, body] = yield request.postAsync({uri: @url, json: { userID: @student.id } })
expect(body.redeemers?.length).toBe(1)
expect(res.statusCode).toBe(201)
[res, body] = yield request.postAsync({uri: @url, json: { userID: @student.id } })
expect(body.redeemers?.length).toBe(1)
expect(res.statusCode).toBe(200)
prepaid = yield Prepaid.findById(body._id)
expect(prepaid.get('redeemers').length).toBe(1)
student = yield User.findById(@student.id)
expect(student.get('coursePrepaid')._id.equals(@prepaid._id)).toBe(true)
done()
it 'updates the user if their enrollment is expired', utils.wrap (done) ->
yield utils.loginUser(@admin)
prepaid = yield utils.makePrepaid({
creator: @teacher.id
startDate: moment().subtract(2, 'month').toISOString()
endDate: moment().subtract(1, 'month').toISOString()
})
@student.set('coursePrepaid', _.pick(prepaid.toObject(), '_id', 'startDate', 'endDate'))
yield @student.save()
yield utils.loginUser(@teacher)
[res, body] = yield request.postAsync {uri: @url, json: { userID: @student.id } }
expect(body.redeemers.length).toBe(1)
expect(res.statusCode).toBe(201)
student = yield User.findById(@student.id)
expect(student.get('coursePrepaid')._id.equals(@prepaid._id)).toBe(true)
done()
describe 'DELETE /db/prepaid/:handle/redeemers', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([Course, CourseInstance, Payment, Prepaid, User])
@teacher = yield utils.initUser({role: 'teacher'})
@admin = yield utils.initAdmin()
yield utils.loginUser(@admin)
@prepaid = yield utils.makePrepaid({ creator: @teacher.id })
yield utils.loginUser(@teacher)
@student = yield utils.initUser()
@url = getURL("/db/prepaid/#{@prepaid.id}/redeemers")
[res, body] = yield request.postAsync {uri: @url, json: { userID: @student.id } }
expect(res.statusCode).toBe(201)
done()
it 'removes a given user to the redeemers property', utils.wrap (done) ->
prepaid = yield Prepaid.findById(@prepaid.id)
expect(prepaid.get('redeemers').length).toBe(1)
[res, body] = yield request.delAsync {uri: @url, json: { userID: @student.id } }
expect(body.redeemers.length).toBe(0)
expect(res.statusCode).toBe(200)
prepaid = yield Prepaid.findById(body._id)
expect(prepaid.get('redeemers').length).toBe(0)
student = yield User.findById(@student.id)
expect(student.get('coursePrepaid')).toBeUndefined()
done()
it 'works if the user has not migrated from coursePrepaidID to coursePrepaid', utils.wrap (done) ->
yield @student.update({
$set: { coursePrepaidID: @prepaid._id }
$unset: { coursePrepaid: '' }
})
yield @student.save()
[res, body] = yield request.delAsync {uri: @url, json: { userID: @student.id } }
expect(body.redeemers.length).toBe(0)
expect(res.statusCode).toBe(200)
prepaid = yield Prepaid.findById(body._id)
expect(prepaid.get('redeemers').length).toBe(0)
student = yield User.findById(@student.id)
expect(student.get('coursePrepaid')).toBeUndefined()
expect(student.get('coursePrepaidID')).toBeUndefined()
done()
it 'returns 403 unless the user is the "creator"', utils.wrap (done) ->
otherTeacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(otherTeacher)
[res, body] = yield request.delAsync {uri: @url, json: { userID: @student.id } }
expect(res.statusCode).toBe(403)
done()
it 'returns 422 unless the target user is in "redeemers"', utils.wrap (done) ->
otherStudent = yield utils.initUser({role: 'student'})
[res, body] = yield request.delAsync {uri: @url, json: { userID: otherStudent.id } }
expect(res.statusCode).toBe(422)
done()
describe 'GET /db/prepaid?creator=:id', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([Course, CourseInstance, Payment, Prepaid, User])
@teacher = yield utils.initUser({role: 'teacher'})
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
@prepaid = yield utils.makePrepaid({ creator: @teacher.id })
@otherPrepaid = yield utils.makePrepaid({ creator: admin.id })
@expiredPrepaid = yield utils.makePrepaid({ creator: @teacher.id, endDate: moment().subtract(1, 'month').toISOString() })
@unmigratedPrepaid = yield utils.makePrepaid({ creator: @teacher.id })
yield @unmigratedPrepaid.update({$unset: { endDate: '', startDate: '' }})
yield utils.loginUser(@teacher)
done()
it 'return all prepaids for the creator', utils.wrap (done) ->
url = getURL("/db/prepaid?creator=#{@teacher.id}")
[res, body] = yield request.getAsync({uri: url, json: true})
expect(res.statusCode).toBe(200)
expect(res.body.length).toEqual(3)
if _.any((prepaid._id is @otherPrepaid.id for prepaid in res.body))
fail('Found the admin prepaid in response')
for prepaid in res.body
unless prepaid.startDate and prepaid.endDate
fail('All prepaids should have start and end dates')
expect(res.body[0]._id).toBe(@prepaid.id)
done()
it 'returns 403 if the user tries to view another user\'s prepaids', utils.wrap (done) ->
anotherUser = yield utils.initUser()
url = getURL("/db/prepaid?creator=#{anotherUser.id}")
[res, body] = yield request.getAsync({uri: url, json: true})
expect(res.statusCode).toBe(403)
done()
describe '/db/prepaid', ->
prepaidURL = getURL('/db/prepaid')
prepaidCreateURL = getURL('/db/prepaid/-/create')
headers = {'X-Change-Plan': 'true'}
@ -53,257 +292,6 @@ describe '/db/prepaid', ->
throw err if err
done()
describe 'POST /db/prepaid/<id>/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
type: 'course'
})
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)
expect(user.get('role')).toBe('student')
done()
it 'does not allow more redeemers than maxRedeemers', (done) ->
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 0,
redeemers: [],
creator: user1.get('_id')
code: 1
type: 'course'
})
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
type: 'course'
})
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({
'coursePrepaidID': new ObjectId()
})
otherUser.save (err, otherUser) ->
prepaid1 = new Prepaid({
redeemers: [{userID: otherUser.get('_id')}],
code: 3
type: 'course'
})
prepaid1.save (err, prepaid1) ->
otherUser.set 'coursePrepaidID', prepaid1.id
otherUser.save (err, otherUser) ->
prepaid2 = new Prepaid({
maxRedeemers: 10,
redeemers: [],
creator: user1.get('_id')
code: 4
type: 'course'
})
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 'is idempotent to itself for a user other than the creator', (done) ->
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 2,
redeemers: [],
creator: user1.get('_id')
code: 0
type: 'course'
})
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)
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 'is idempotent to itself for the creator', (done) ->
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 2,
redeemers: [],
creator: user1.get('_id')
code: 0
type: 'course'
})
prepaid.save (err, prepaid) ->
otherUser = new User()
otherUser.save (err, otherUser) ->
url = getURL("/db/prepaid/#{prepaid.id}/redeemers")
redeemer = { userID: user1.id }
request.post {uri: url, json: redeemer }, (err, res, body) ->
expect(body.redeemers?.length).toBe(1)
expect(res.statusCode).toBe(200)
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 user1.id, (err, user) ->
expect(user.get('coursePrepaidID').equals(prepaid.get('_id'))).toBe(true)
redeemer = { userID: otherUser.id }
request.post {uri: url, json: redeemer }, (err, res, body) ->
expect(body.redeemers?.length).toBe(2)
expect(res.statusCode).toBe(200)
done()
it 'return terminal prepaids', (done) ->
endDate = new Date()
endDate.setUTCMonth(endDate.getUTCMonth() + 2)
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 500,
redeemers: [],
creator: user1.get('_id')
type: 'course'
properties:
endDate: endDate
})
prepaid.save (err, prepaid) ->
expect(err).toBeNull()
url = getURL("/db/prepaid?creator=#{user1.id}")
request.get {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(200)
documents = JSON.parse(body)
expect(documents.length).toEqual(1)
return done() unless documents.length is 1
expect(documents[0]?.properties?.endDate).toEqual(endDate.toISOString())
done()
it 'do not return expired terminal prepaids', (done) ->
endDate = new Date()
endDate.setUTCMonth(endDate.getUTCMonth() - 1)
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 500,
redeemers: [],
creator: user1.get('_id')
type: 'course'
properties:
endDate: endDate
})
prepaid.save (err, prepaid) ->
expect(err).toBeNull()
url = getURL("/db/prepaid?creator=#{user1.id}")
request.get {uri: url}, (err, res, body) ->
expect(res.statusCode).toBe(200)
documents = JSON.parse(body)
expect(documents.length).toEqual(0)
done()
it 'redeem terminal prepaids', (done) ->
endDate = new Date()
endDate.setUTCMonth(endDate.getUTCMonth() + 2)
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 500,
redeemers: [],
creator: user1.get('_id')
type: 'course'
properties:
endDate: endDate
})
prepaid.save (err, prepaid) ->
expect(err).toBeNull()
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)
return done() unless res.statusCode is 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 'do not redeem expired terminal prepaids', (done) ->
endDate = new Date()
endDate.setUTCMonth(endDate.getUTCMonth() - 1)
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 500,
redeemers: [],
creator: user1.get('_id')
type: 'course'
properties:
endDate: endDate
})
prepaid.save (err, prepaid) ->
expect(err).toBeNull()
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 'Clear database', (done) ->
clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) ->
throw err if err
done()
it 'Anonymous creates prepaid code', (done) ->
createPrepaid 'subscription', 1, 0, (err, res, body) ->
expect(err).toBeNull()

View file

@ -3,6 +3,7 @@ utils = require '../utils'
urlUser = '/db/user'
User = require '../../../server/models/User'
Classroom = require '../../../server/models/Classroom'
Prepaid = require '../../../server/models/Prepaid'
request = require '../request'
describe 'POST /db/user', ->
@ -511,6 +512,18 @@ describe 'GET /db/user', ->
# Add to the test case above an extra data check
xit 'can fetch another user with restricted fields'
describe 'GET /db/user/:handle', ->
it 'populates coursePrepaid from coursePrepaidID', utils.wrap (done) ->
course = yield utils.makeCourse()
user = yield utils.initUser({coursePrepaidID: course.id})
[res, body] = yield request.getAsync({url: getURL("/db/user/#{user.id}"), json: true})
expect(res.statusCode).toBe(200)
expect(res.body.coursePrepaid._id).toBe(course.id)
expect(res.body.coursePrepaid.startDate).toBe(Prepaid.DEFAULT_START_DATE)
done()
describe 'DELETE /db/user', ->
it 'can delete a user', utils.wrap (done) ->

View file

@ -6,6 +6,11 @@ User = require '../../server/models/User'
Level = require '../../server/models/Level'
Achievement = require '../../server/models/Achievement'
Campaign = require '../../server/models/Campaign'
Course = require '../../server/models/Course'
Prepaid = require '../../server/models/Prepaid'
Classroom = require '../../server/models/Classroom'
CourseInstance = require '../../server/models/CourseInstance'
moment = require 'moment'
campaignSchema = require '../../app/schemas/models/campaign.schema'
campaignLevelProperties = _.keys(campaignSchema.properties.levels.additionalProperties.properties)
campaignAdjacentCampaignProperties = _.keys(campaignSchema.properties.adjacentCampaigns.additionalProperties.properties)
@ -124,4 +129,60 @@ module.exports = mw =
request.post { uri: getURL('/db/campaign'), json: data }, (err, res) ->
return done(err) if err
Campaign.findById(res.body._id).exec done
Campaign.findById(res.body._id).exec done
makeCourse: (data={}, sources={}) ->
if sources.campaign and not data.campaignID
data.campaignID = sources.campaign._id
course = new Course(data)
return course.save()
makePrepaid: Promise.promisify (data, sources, done) ->
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]
data = _.extend({}, {
type: 'course'
maxRedeemers: 9001
endDate: moment().add(1, 'month').toISOString()
startDate: new Date().toISOString()
}, data)
request.post { uri: getURL('/db/prepaid'), json: data }, (err, res) ->
return done(err) if err
expect(res.statusCode).toBe(201)
Prepaid.findById(res.body._id).exec done
makeClassroom: (data={}, sources={}) -> co ->
data = _.extend({}, {
name: _.uniqueId('Classroom ')
}, data)
[res, body] = yield request.postAsync { uri: getURL('/db/classroom'), json: data }
expect(res.statusCode).toBe(201)
classroom = yield Classroom.findById(res.body._id)
if sources.members
classroom.set('members', _.map(sources.members, '_id'))
yield classroom.save()
return classroom
makeCourseInstance: (data={}, sources={}) -> co ->
if sources.course and not data.courseID
data.courseID = sources.course.id
if sources.classroom and not data.classroomID
data.classroomID = sources.classroom.id
[res, body] = yield request.postAsync({ uri: getURL('/db/course_instance'), json: data })
expect(res.statusCode).toBe(200)
courseInstance = yield CourseInstance.findById(res.body._id)
if sources.members
userIDs = _.map(sources.members, 'id')
[res, body] = yield request.postAsync({
url: getURL("/db/course_instance/#{courseInstance.id}/members")
json: { userIDs: userIDs }
})
expect(res.statusCode).toBe(200)
courseInstance = yield CourseInstance.findById(res.body._id)
return courseInstance

View file

@ -9,6 +9,7 @@ Achievement = require 'models/Achievement'
EarnedAchievement = require 'models/EarnedAchievement'
ThangType = require 'models/ThangType'
Users = require 'collections/Users'
Prepaid = require 'models/Prepaid'
module.exports = {
@ -36,7 +37,7 @@ module.exports = {
}, attrs)
return new Level(attrs)
makeUser: (attrs) ->
makeUser: (attrs, sources={}) ->
_id = _.uniqueId('user_')
attrs = _.extend({
_id: _id
@ -45,6 +46,10 @@ module.exports = {
anonymous: false
name: _.string.humanize(_id)
}, attrs)
if sources.prepaid and not attrs.coursePrepaid
attrs.coursePrepaid = sources.prepaid.pick('_id', 'startDate', 'endDate')
return new User(attrs)
makeClassroom: (attrs, sources={}) ->
@ -148,6 +153,38 @@ module.exports = {
}, attrs)
return new ThangType(attrs)
makePrepaid: (attrs, sources={}) ->
_id = _.uniqueId('prepaid_')
attrs = _.extend({}, {
_id
type: 'course'
maxRedeemers: 10
endDate: moment().add(1, 'month').toISOString()
startDate: moment().subtract(1, 'month').toISOString()
}, attrs)
if not attrs.redeemers
redeemers = sources.redeemers or new Users()
attrs.redeemers = ({
userID: redeemer.id
date: moment().subtract(1, 'month').toISOString()
} for redeemer in redeemers.models)
return new Prepaid(attrs)
makeTrialRequest: (attrs, sources={}) ->
_id = _.uniqueId('trial_request_')
attrs = _.extend({}, {
_id
properties: {
firstName: 'Mr'
lastName: 'Professorson'
name: 'Mr Professorson'
email: 'an@email.com'
phoneNumber: '555-555-5555'
organization: 'Greendale'
}
}, attrs)
}

View file

@ -0,0 +1,100 @@
EnrollmentsView = require 'views/courses/EnrollmentsView'
Courses = require 'collections/Courses'
Prepaids = require 'collections/Prepaids'
Users = require 'collections/Users'
Classrooms = require 'collections/Classrooms'
factories = require 'test/app/factories'
TeachersContactModal = require 'views/teachers/TeachersContactModal'
describe 'EnrollmentsView', ->
beforeEach (done) ->
me.set('anonymous', false)
me.set('role', 'teacher')
@view = new EnrollmentsView()
# Make three classrooms, sharing users from a pool of 10, 5 of which are enrolled
prepaid = factories.makePrepaid()
students = new Users(_.times(10, (i) ->
factories.makeUser({}, { prepaid: if i%2 then prepaid else null }))
)
userSlices = [
new Users(students.slice(0, 5))
new Users(students.slice(3, 8))
new Users(students.slice(7, 10))
]
classrooms = new Classrooms(factories.makeClassroom({}, {members: userSlice}) for userSlice in userSlices)
@view.classrooms.fakeRequests[0].respondWith({ status: 200, responseText: classrooms.stringify() })
for request, i in @view.members.fakeRequests
request.respondWith({status: 200, responseText: userSlices[i].stringify()})
# Make prepaids of various status
prepaids = new Prepaids([
factories.makePrepaid({}, {redeemers: new Users(_.times(5, -> factories.makeUser()))})
factories.makePrepaid()
factories.makePrepaid({ # pending
startDate: moment().add(2, 'months').toISOString()
endDate: moment().add(14, 'months').toISOString()
})
factories.makePrepaid( # empty
{ maxRedeemers: 2 },
{redeemers: new Users(_.times(2, -> factories.makeUser()))}
)
])
@view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: prepaids.stringify() })
# Make a few courses, one free
courses = new Courses([
factories.makeCourse({free: true})
factories.makeCourse({free: false})
factories.makeCourse({free: false})
factories.makeCourse({free: false})
])
@view.courses.fakeRequests[0].respondWith({ status: 200, responseText: courses.stringify() })
jasmine.demoEl(@view.$el)
window.view = @view
@view.supermodel.once 'loaded-all', done
it 'shows how many courses there are which enrolled students will have access to', ->
expect(_.contains(@view.$('#enrollments-blurb').text(), '24')).toBe(true)
if @view.$('#actions-col').length isnt 1
fail('There should be an #action-col, other tests depend on it.')
describe '"Get Enrollments" area', ->
describe '"Contact Us" button', ->
it 'opens a TeachersContactModal, passing in the number of enrollments', ->
spyOn(@view, 'openModalView')
@view.state.set('numberOfStudents', 20)
@view.$('#contact-us-btn').click()
expect(view.openModalView).toHaveBeenCalled()
args = view.openModalView.calls.argsFor(0)
expect(args[0] instanceof TeachersContactModal).toBe(true)
expect(args[0].enrollmentsNeeded).toBe(20)
describe 'when the teacher has made contact', ->
beforeEach ->
me.set('enrollmentRequestSent', true)
@view.render()
it 'shows confirmation and a mailto link to schools@codecombat.com', ->
if not @view.$('#request-sent-btn').length
fail('Request button not found.')
if not @view.$('#enrollment-request-sent-blurb').length
fail('Enrollment request sent blurb not found.')
# TODO: Figure out why this fails in Travis. Seems like it's not loading en locale
# if not @view.$('a[href="mailto:schools@codecombat.com"]').length
# fail('Mailto: link not found.')
describe 'when there are no prepaids to show', ->
beforeEach (done) ->
@view.prepaids.reset()
_.defer(done)
it 'fills the void with the rest of the page content', ->
expect(@view.$('#actions-col').length).toBe(0)

View file

@ -0,0 +1,53 @@
TeachersContactModal = require 'views/teachers/TeachersContactModal'
TrialRequests = require 'collections/TrialRequests'
factories = require 'test/app/factories'
describe 'TeachersContactModal', ->
beforeEach (done) ->
@modal = new TeachersContactModal({ enrollmentsNeeded: 10 })
@modal.render()
trialRequests = new TrialRequests([factories.makeTrialRequest()])
@modal.trialRequests.fakeRequests[0].respondWith({ status: 200, responseText: trialRequests.stringify() })
@modal.supermodel.once('loaded-all', done)
jasmine.demoModal(@modal)
it 'shows an error when the email is invalid and the form is submitted', ->
@modal.$('input[name="email"]').val('not an email')
@modal.$('form').submit()
expect(@modal.$('input[name="email"]').closest('.form-group').hasClass('has-error')).toBe(true)
it 'shows an error when the message is empty and the form is submitted', ->
@modal.$('textarea[name="message"]').val('')
@modal.$('form').submit()
expect(@modal.$('textarea[name="message"]').closest('.form-group').hasClass('has-error')).toBe(true)
describe 'submit form', ->
beforeEach ->
@modal.$('form').submit()
it 'disables inputs', ->
for el in @modal.$('button, input, textarea')
expect($(el).is(':disabled')).toBe(true)
describe 'failed contact', ->
beforeEach ->
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 500})
it 'shows an error', ->
expect(@modal.$('.alert-danger').length).toBe(1)
describe 'successful contact', ->
beforeEach ->
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({status: 200, responseText: '{}'})
it 'shows a success message', ->
expect(@modal.$('.alert-success').length).toBe(1)
describe 'submit button', ->
it 'is disabled until one of the inputs changes', ->
expect(@modal.$('#submit-btn').is(':disabled')).toBe(true)
@modal.$('input[name="email"]').trigger('change')
expect(@modal.$('#submit-btn').is(':disabled')).toBe(false)

View file

@ -1,58 +1,68 @@
ActivateLicensesModal = require 'views/courses/ActivateLicensesModal'
Classrooms = require 'collections/Classrooms'
Courses = require 'collections/Courses'
Levels = require 'collections/Levels'
Prepaids = require 'collections/Prepaids'
Users = require 'collections/Users'
forms = require 'core/forms'
factories = require 'test/app/factories'
# Needs some fixing
xdescribe 'ActivateLicensesModal', ->
@modal = null
me = require 'test/app/fixtures/teacher'
prepaids = require 'test/app/fixtures/prepaids'
classrooms = require 'test/app/fixtures/classrooms/unarchived-classrooms'
users = require 'test/app/fixtures/students'
responses = {
'/db/prepaid': prepaids.toJSON()
'/db/classroom': classrooms.toJSON()
# '/members': users.toJSON() # TODO: Respond with different ones for different classrooms
}
makeModal = (options) ->
(done) ->
@selectedUsers = new Users(@users.models.slice(0,(options?.numSelected or 3)))
@modal = new ActivateLicensesModal({
@classroom, @users, @selectedUsers
describe 'ActivateLicensesModal', ->
beforeEach (done) ->
@members = new Users(_.times(4, (i) -> factories.makeUser()))
@classrooms = new Classrooms([
factories.makeClassroom({}, { @members })
factories.makeClassroom()
])
selectedUsers = new Users(@members.slice(0,3))
options = _.extend({}, {
classroom: @classrooms.first(), @classrooms, users: @members, selectedUsers
}, options)
@modal = new ActivateLicensesModal(options)
@prepaidThatExpiresSooner = factories.makePrepaid({maxRedeemers: 1, endDate: moment().add(1, 'month').toISOString()})
@prepaidThatExpiresLater = factories.makePrepaid({maxRedeemers: 1, endDate: moment().add(2, 'months').toISOString()})
prepaids = new Prepaids([
# empty
factories.makePrepaid({maxRedeemers: 0, endDate: moment().add(1, 'day').toISOString()})
# expired
factories.makePrepaid({maxRedeemers: 10, endDate: moment().subtract(1, 'day').toISOString()})
# pending
factories.makePrepaid({
maxRedeemers: 100
startDate: moment().add(1, 'month').toISOString()
endDate: moment().add(2, 'months').toISOString()
})
jasmine.Ajax.requests.sendResponses(responses)
_.filter(jasmine.Ajax.requests.all().slice(), (request) ->
/\/db\/classroom\/.*\/members/.test(request.url) and request.readyState < 4
).forEach (request) ->
request.respondWith(users.toJSON)
# debugger
jasmine.demoModal(@modal)
_.defer done
beforeEach ->
@classroom = classrooms.get('active-classroom')
@users = require 'test/app/fixtures/students'
afterEach ->
@modal.stopListening()
# these should be used
@prepaidThatExpiresSooner
@prepaidThatExpiresLater
])
@modal.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: prepaids.stringify() })
@modal.classrooms.fakeRequests[0].respondWith({
status: 200
responseText: @classrooms.stringify()
})
@modal.classrooms.first().users.fakeRequests[0].respondWith({
status: 200
responseText: @members.stringify()
})
jasmine.demoModal(@modal)
_.defer done
describe 'the class dropdown', ->
beforeEach makeModal()
it 'contains an All Students option', ->
expect(@modal.$('select option:last-child').data('i18n')).toBe('teacher.all_students')
# punted indefinitely
xit 'should contain an All Students option', ->
expect(@modal.$('select option:last-child').html()).toBe('All Students')
it 'displays the current classname', ->
expect(@modal.$('option:selected').html()).toBe(@classrooms.first().get('name'))
it 'should display the current classname', ->
expect(@modal.$('option:selected').html()).toBe('Teacher Zero\'s Classroomiest Classroom')
it 'should contain all of the teacher\'s classes'
it 'shouldn\'t contain anyone else\'s classrooms'
it 'contains all of the teacher\'s classes', ->
expect(@modal.$('select option').length).toBe(3) # including 'All Students' options
describe 'the checklist of students', ->
it 'should separate the unenrolled from the enrolled students'
@ -63,21 +73,35 @@ xdescribe 'ActivateLicensesModal', ->
describe 'the credits availble count', ->
beforeEach makeModal()
it 'should match the number of unused prepaids', ->
expect(@modal.$('#total-available').html()).toBe('2')
describe 'the Enroll button', ->
beforeEach makeModal()
it 'should show the number of selected students', ->
expect(@modal.$('#total-selected-span').html()).toBe('3')
it 'should fire off one request when clicked'
describe 'when the teacher has enough enrollments', ->
beforeEach makeModal({ numSelected: 2 })
beforeEach ->
selected = @modal.state.get('selectedUsers')
selected.remove(selected.first())
it 'should be enabled', ->
expect(@modal.$('#activate-licenses-btn').hasClass('disabled')).toBe(false)
describe 'when clicked', ->
beforeEach ->
@modal.$('form').submit()
it 'enrolls the selected students with the soonest-to-expire, available prepaid', ->
request = jasmine.Ajax.requests.mostRecent()
if request.url.indexOf(@prepaidThatExpiresSooner.id) is -1
fail('The first prepaid should be the prepaid that expires sooner')
request.respondWith({ status: 200, responseText: '{ "redeemers": [{}] }' })
request = jasmine.Ajax.requests.mostRecent()
if request.url.indexOf(@prepaidThatExpiresLater.id) is -1
fail('The second prepaid should be the prepaid that expires later')
describe 'when the teacher doesn\'t have enough enrollments', ->
it 'should be disabled', ->

View file

@ -25,7 +25,16 @@ describe 'TeacherClassView', ->
me = factories.makeUser({})
@courses = new Courses([factories.makeCourse({name: 'First Course'}), factories.makeCourse({name: 'Second Course'})])
@students = new Users(_.times(2, -> factories.makeUser()))
available = factories.makePrepaid()
expired = factories.makePrepaid({endDate: moment().subtract(1, 'day').toISOString()})
@students = new Users([
factories.makeUser({name: 'Abner'})
factories.makeUser({name: 'Abigail'})
factories.makeUser({name: 'Abby'}, {prepaid: available})
factories.makeUser({name: 'Ben'}, {prepaid: available})
factories.makeUser({name: 'Ned'}, {prepaid: expired})
factories.makeUser({name: 'Ebner'}, {prepaid: expired})
])
@levels = new Levels(_.times(2, -> factories.makeLevel()))
@classroom = factories.makeClassroom({}, { @courses, members: @students, levels: [@levels, new Levels()] })
@courseInstances = new CourseInstances([
@ -42,7 +51,7 @@ describe 'TeacherClassView', ->
{level, creator: @finishedStudent})
)
sessions.push(factories.makeLevelSession(
{state: {complete: false}},
{state: {complete: true}},
{level: @levels.first(), creator: @unfinishedStudent})
)
@levelSessions = new LevelSessions(sessions)
@ -66,6 +75,9 @@ describe 'TeacherClassView', ->
# it "shows the classroom's join code"
describe 'the Students tab', ->
beforeEach ->
@view.state.set('activeTab', '#students-tab')
# it 'shows all of the students'
# it 'sorts correctly by Name'
# it 'sorts correctly by Progress'
@ -89,7 +101,37 @@ describe 'TeacherClassView', ->
# it 'still shows the correct Course Overview progress'
#
describe 'the Enrollment Status tab', ->
beforeEach ->
@view.state.set('activeTab', '#enrollment-status-tab')
describe 'Enroll button', ->
it 'calls enrollStudents with that user when clicked', ->
spyOn(@view, 'enrollStudents')
@view.$('.enroll-student-button:first').click()
expect(@view.enrollStudents).toHaveBeenCalled()
users = @view.enrollStudents.calls.argsFor(0)[0]
expect(users.size()).toBe(1)
expect(users.first().id).toBe(@view.students.first().id)
describe 'Revoke button', ->
it 'opens a confirm modal once clicked', ->
spyOn(window, 'confirm').and.returnValue(true)
@view.$('.revoke-student-button:first').click()
expect(window.confirm).toHaveBeenCalled()
describe 'once the prepaid is successfully revoked', ->
beforeEach ->
spyOn(window, 'confirm').and.returnValue(true)
button = @view.$('.revoke-student-button:first')
@revokedUser = @view.students.get(button.data('user-id'))
@view.$('.revoke-student-button:first').click()
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({
status: 200
responseText: '{}'
})
it 'updates the user and rerenders the page', ->
if @view.$(".enroll-student-button[data-user-id='#{@revokedUser.id}']").length isnt 1
fail('Could not find enroll student button for user whose enrollment was revoked')