Teacher trial subscription form

Add a teacher survey form for applying for a free trial subscription
for evaluation purposes.
Add an admin trial requests review page, where admins can approve/deny
requests.
This commit is contained in:
Matt Lott 2015-06-05 06:48:09 -07:00
parent d0f9456024
commit d7cddcb136
22 changed files with 620 additions and 22 deletions

View file

@ -38,6 +38,7 @@ module.exports = class CocoRouter extends Backbone.Router
'admin/level-sessions': go('admin/LevelSessionsView')
'admin/users': go('admin/UsersView')
'admin/base': go('admin/BaseView')
'admin/trial-requests': go('admin/TrialRequestsView')
'admin/user-code-problems': go('admin/UserCodeProblemsView')
'admin/pending-patches': go('admin/PendingPatchesView')
@ -109,6 +110,7 @@ module.exports = class CocoRouter extends Backbone.Router
'preview': go('HomeView')
'teachers': go('TeachersView')
'teachers/freetrial': go('TeachersFreeTrialView')
'test(/*subpath)': go('TestView')

View file

@ -600,8 +600,9 @@
free_1: "There are 80+ FREE levels which cover every concept." # {change}
free_2: "A monthly subscription provides access to video tutorials and extra practice levels."
teacher_subs_title: "Teachers get free subscriptions!"
teacher_subs_1: "Please contact"
teacher_subs_2: "to set up a free monthly subscription."
teacher_subs_1: "Please fill out our" # {change}
teacher_subs_2: "Teacher Survey" # {change}
teacher_subs_3: "to set up your subscription."
sub_includes_title: "What is included in the subscription?"
sub_includes_1: "In addition to the 80+ basic levels, students with a monthly subscription get access to these additional features:"
sub_includes_2: "60+ practice levels"

View file

@ -0,0 +1,7 @@
CocoModel = require './CocoModel'
schema = require 'schemas/models/trial_request.schema'
module.exports = class TrialRequest extends CocoModel
@className: 'TrialRequest'
@schema: schema
urlRoot: '/db/trial.request'

View file

@ -0,0 +1,18 @@
c = require './../schemas'
TrialRequestSchema = c.object {
title: 'Trial request',
required: ['type']
}
_.extend TrialRequestSchema.properties,
applicant: c.objectId(links: [{rel: 'extra', href: '/db/user/{($)}'}])
prepaidCode: c.objectId()
reviewDate: c.date({readOnly: true})
reviewer: c.objectId(links: [{rel: 'extra', href: '/db/user/{($)}'}])
properties: {type: 'object', description: 'Data specific to this request.'}
status: {type: 'string', 'enum': ['submitted', 'approved', 'denied']}
type: {type: 'string', 'enum': ['subscription']}
c.extendBasicProperties TrialRequestSchema, 'TrialRequest'
module.exports = TrialRequestSchema

View file

@ -0,0 +1,16 @@
#admin-trial-requests-view
#site-content-area
width: 100%
.btn-deny
float: right
.status-cell
width: 120px
td.created
min-width: 90px
td.reviewed
min-width: 90px

View file

@ -0,0 +1,17 @@
#teachers-free-trial-view
.input-email-address
width: 40%
.input-location
width: 40%
.input-heard-about
width: 100%
.thanks-submit
display: none
.error-message
display: none
color: red

View file

@ -25,16 +25,18 @@ block content
h4(data-i18n="admin.av_entities_sub_title") Entities
ul
li
a(href="/admin/users", data-i18n="admin.av_entities_users_url") Users
li
a(href="/admin/level-sessions", data-i18n="admin.av_entities_active_instances_url") Active Instances
li
a(href="/admin/employers", data-i18n="admin.av_entities_employer_list_url") Employer List
li
a(href="/admin/candidates", data-i18n="admin.av_entities_candidates_list_url") Candidate List
li
a(href="/admin/employers", data-i18n="admin.av_entities_employer_list_url") Employer List
li
a(href="/admin/trial-requests") Trial Requests
li
a(href="/admin/user-code-problems", data-i18n="admin.av_entities_user_code_problems_list_url") User Code Problems List
li
a(href="/admin/users", data-i18n="admin.av_entities_users_url") Users
h4(data-i18n="admin.av_other_sub_title") Other

View file

@ -0,0 +1,52 @@
extends /templates/base
block content
if !me.isAdmin()
div You must be logged in as an admin to view this page.
else
h2 Trial Requests
if !trialRequests || trialRequests.length < 1
h4 Fetching trial requests...
else
table.table.table-condensed
thead
tr
th Created
th Reviewed
th Applicant
th Location
th Age
th Students
th How Found
th Status
tbody
- var numReviewed = 0
- var maxReviewedShown = 100
each trialRequest in trialRequests
if trialRequest.get('status') !== 'submitted'
- numReviewed++
if numReviewed > maxReviewedShown
- break
tr
td.created= new Date(parseInt(trialRequest.get('_id').substring(0, 8), 16) * 1000).toISOString().substring(0, 10)
td.reviewed
if trialRequest.get('reviewDate')
span= trialRequest.get('reviewDate').substring(0, 10)
td
a(href="/user/#{trialRequest.get('applicant')}")= trialRequest.get('properties').email
td= trialRequest.get('properties').location
td= trialRequest.get('properties').age
td= trialRequest.get('properties').numStudents
td= trialRequest.get('properties').heardAbout
td.status-cell
if trialRequest.get('status') === 'submitted'
button.btn.btn-xs.btn-success.btn-approve(data-trial-request=trialRequest) Approve
button.btn.btn-xs.btn-danger.btn-deny(data-trial-request=trialRequest) Deny
else if trialRequest.get('prepaidCode')
span= trialRequest.get('prepaidCode')
else
span= trialRequest.get('status')
div *Currently assumes all trial requests of type 'subscription'

View file

@ -0,0 +1,71 @@
extends /templates/base
block content
h2 Teacher Survey
if me.isAnonymous()
p You must be logged in to apply. Please create an account or log in from the menu above.
else if fetchingData
h4 Retrieving information...
else if existingRequests.length > 0
if existingRequests[0].get('status') === 'submitted'
p
span.spr Your application for a free trial subscription is being
strong reviewed
else if existingRequests[0].get('status') === 'approved'
p
span.spr Your application for a free trial subscription was
strong.spr approved
span.spr . Further instructions have been sent to
strong= existingRequests[0].get('properties').email
else
p
span.spr Your application for a free trial subscription has been
strong.spr denied
p
span.spr Please contact
span.spr
a(href='mailto:team@codecombat.com') team@codecombat.com
span if you have further questions.
else
p
span.spr We offer free subscriptions to teachers for evaluation purposes. You can find more information on our
a.spr(href='/teachers') teachers
span page.
p Please fill out this quick survey and well email you setup instructions.
p.container-email-address
label.control-label Email Address
br
input.control-label.input-email-address(type='text', value=email)
p.container-location
label.control-label Name of School, City
br
input.control-label.input-location(type='text')
p.container-age
label.control-label How old are your students?
div
input(type="radio", name="age", value="Under 14")
span.spl Under 14
div
input(type="radio", name="age", value="14-17")
span.spl 14-17
div
input(type="radio", name="age", value="18+")
span.spl 18+
div
input(type="radio", name="age", value='other')
span.spl.spr Other:
input.spr.input-age-other(type='text')
p.container-num-students
label.control-label How many students do you teach?
br
input.control-label.input-num-students(type='text')
p.container-heard-about
label.control-label How did you hear about CodeCombat?
br
textarea.control-label.input-heard-about(rows=4)
p.error-message Please fill out all fields.
p
button.btn.btn-default.submit-button Submit
p.thanks-submit Thanks! We'll send you setup instructions shortly.

View file

@ -19,10 +19,9 @@ block content
h3.teachers-title(data-i18n="teachers.teacher_subs_title")
p
span(data-i18n="teachers.teacher_subs_1")
span.spr.spl
a(href='mailto:team@codecombat.com?subject=Free%20Teacher%20Subscription') team@codecombat.com
span.spr.spl(data-i18n="teachers.teacher_subs_2")
span.spr(data-i18n="teachers.teacher_subs_1")
a.spr(href='/teachers/freetrial', data-i18n="teachers.teacher_subs_2")
span.spr(data-i18n="teachers.teacher_subs_3")
h3(data-i18n="teachers.sub_includes_title")
p(data-i18n="teachers.sub_includes_1")

View file

@ -0,0 +1,85 @@
RootView = require 'views/core/RootView'
template = require 'templates/teachers-free-trial'
CocoCollection = require 'collections/CocoCollection'
TrialRequest = require 'models/TrialRequest'
# TODO: distinguish between this type of existing trial requests and others
module.exports = class TeachersFreeTrialView extends RootView
id: 'teachers-free-trial-view'
template: template
events:
'click .submit-button': 'onClickSubmit'
constructor: (options) ->
super options
@email = me.get('email')
@refreshData()
getRenderData: ->
context = super()
context.email = @email
context.existingRequests = @existingRequests.models
context.fetchingData = @fetchingData
context
refreshData: ->
@fetchingData = true
@existingRequests = new CocoCollection([], { url: '/db/trial.request/-/own', model: TrialRequest, comparator: '_id' })
@listenToOnce @existingRequests, 'sync', =>
@fetchingData = false
@render?()
@supermodel.loadCollection(@existingRequests, 'own_trial_requests', {cache: false})
onClickSubmit: (e) ->
email = $('.input-email-address').val()
location = $('.input-location').val()
age = $('input[name=age]:checked').val()
age = $('.input-age-other').val() if age is 'other'
numStudents = $('.input-num-students').val()
heardAbout = $('.input-heard-about').val()
# Validate input
$('.container-email-address').removeClass('has-error')
$('.container-location').removeClass('has-error')
$('.container-age').removeClass('has-error')
$('.container-num-students').removeClass('has-error')
$('.container-heard-about').removeClass('has-error')
$('.error-message').hide()
emailPattern = /^([\w.-]+)@([\w.-]+)\.([a-zA-Z.]{2,6})$/i
unless email?.match(emailPattern)
$('.container-email-address').addClass('has-error')
$('.error-message').show()
return
unless location
$('.container-location').addClass('has-error')
$('.error-message').show()
return
unless age
$('.container-age').addClass('has-error')
$('.error-message').show()
return
unless numStudents
$('.container-num-students').addClass('has-error')
$('.error-message').show()
return
unless heardAbout
$('.container-heard-about').addClass('has-error')
$('.error-message').show()
return
# Save trial request
trialRequest = new TrialRequest
type: 'subscription'
properties:
email: email
location: location
age: age
numStudents: numStudents
heardAbout: heardAbout
trialRequest.save {},
error: (model, response, options) =>
console.error 'Error saving trial request', response
success: (model, response, options) =>
@refreshData()

View file

@ -0,0 +1,66 @@
RootView = require 'views/core/RootView'
template = require 'templates/admin/trial-requests'
CocoCollection = require 'collections/CocoCollection'
TrialRequest = require 'models/TrialRequest'
module.exports = class TrialRequestsView extends RootView
id: 'admin-trial-requests-view'
template: template
events:
'click .btn-approve': 'onClickApprove'
'click .btn-deny': 'onClickDeny'
constructor: (options) ->
super options
if me.isAdmin()
sortRequests = (a, b) ->
statusA = a.get('status')
statusB = b.get('status')
if statusA is 'submitted' and statusB is 'submitted'
if a.get('_id') < b.get('_id')
-1
else
1
else if statusA is 'submitted'
-1
else if statusB is 'submitted'
1
else if not b.get('reviewDate') or a.get('reviewDate') > b.get('reviewDate')
-1
else
1
@trialRequests = new CocoCollection([], { url: '/db/trial.request', model: TrialRequest, comparator: sortRequests })
@supermodel.loadCollection(@trialRequests, 'trial-requests', {cache: false})
getRenderData: ->
context = super()
context.trialRequests = @trialRequests?.models ? []
context
onClickApprove: (e) ->
trialRequestData = $(e.target).data('trial-request')
trialRequest = _.find @trialRequests.models, (a) -> a.get('_id') is trialRequestData._id
unless trialRequest
console.error 'Could not find trial request model for', trialRequestData
return
trialRequest.set('status', 'approved')
trialRequest.patch
error: (model, response, options) =>
console.error 'Error patching trial request', response
success: (model, response, options) =>
@render?()
onClickDeny: (e) ->
trialRequestData = $(e.target).data('trial-request')
trialRequest = _.find @trialRequests.models, (a) -> a.get('_id') is trialRequestData._id
unless trialRequest
console.error 'Could not find trial request model for', trialRequestData
return
return unless window.confirm("Deny #{trialRequest.get('properties').email}?")
trialRequest.set('status', 'denied')
trialRequest.patch
error: (model, response, options) =>
console.error 'Error patching trial request', response
success: (model, response, options) =>
@render?()

View file

@ -28,7 +28,6 @@ ClanHandler = class ClanHandler extends Handler
false
makeNewInstance: (req) ->
userName = req.user.get('name') ? 'Anoner'
instance = super(req)
instance.set 'ownerID', req.user._id
instance.set 'members', [req.user._id]

View file

@ -25,6 +25,7 @@ module.exports.handlers =
'poll': 'polls/poll_handler'
'prepaid': 'prepaids/prepaid_handler'
'subscription': 'payments/subscription_handler'
'trial_request': 'trial_requests/trial_request_handler'
'user_polls_record': 'polls/user_polls_record_handler'
module.exports.routes =

View file

@ -2,4 +2,13 @@ mongoose = require 'mongoose'
config = require '../../server_config'
PrepaidSchema = new mongoose.Schema {}, {strict: false, minimize: false,read:config.mongo.readpref}
PrepaidSchema.statics.generateNewCode = (done) ->
tryCode = ->
code = _.sample("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 8).join('')
Prepaid.findOne code: code, (err, prepaid) ->
return done() if err
return done(code) unless prepaid
tryCode()
tryCode()
module.exports = Prepaid = mongoose.model('prepaid', PrepaidSchema)

View file

@ -20,7 +20,7 @@ PrepaidHandler = class PrepaidHandler extends Handler
createPrepaid: (req, res) ->
return @sendForbiddenError(res) unless @hasAccess(req)
return @sendForbiddenError(res) unless req.body.type is 'subscription'
@generateNewCode (code) =>
Prepaid.generateNewCode (code) =>
return @sendDatabaseError(res, 'Database error.') unless code
prepaid = new Prepaid
creator: req.user.id
@ -33,13 +33,4 @@ PrepaidHandler = class PrepaidHandler extends Handler
return @sendDatabaseError(res, err) if err
@sendSuccess(res, prepaid.toObject())
generateNewCode: (done) ->
tryCode = ->
code = _.sample("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 8).join('')
Prepaid.findOne code: code, (err, prepaid) ->
return if err
return done(code) unless prepaid
tryCode()
tryCode()
module.exports = new PrepaidHandler()

View file

@ -11,6 +11,7 @@ if config.unittest
module.exports.api.send = ->
module.exports.templates =
parent_subscribe_email: 'tem_2APERafogvwKhmcnouigud'
setup_free_sub_email: 'tem_sqdvLCZRwoDQc6jAf5RrQE'
share_progress_email: 'tem_VHE3ihhGmVa3727qds9zY8'
welcome_email: 'utnGaBHuSU4Hmsi7qrAypU'
ladder_update_email: 'JzaZxf39A4cKMxpPZUfWy4'

View file

@ -0,0 +1,59 @@
log = require 'winston'
mongoose = require 'mongoose'
config = require '../../server_config'
hipchat = require '../hipchat'
sendwithus = require '../sendwithus'
Prepaid = require '../prepaids/Prepaid'
jsonSchema = require '../../app/schemas/models/trial_request.schema'
TrialRequestSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:config.mongo.readpref}
TrialRequestSchema.pre 'save', (next) ->
return next() unless @get('status') is 'approved'
Prepaid.generateNewCode (code) =>
unless code
log.error "Trial request pre save prepaid gen new code failure"
return next()
prepaid = new Prepaid
creator: @get('reviewer')
type: 'subscription'
status: 'active'
code: code
properties:
couponID: 'free'
prepaid.save (err) =>
if err
log.error "Trial request prepaid creation error: #{err}"
@set('prepaidCode', code)
next()
TrialRequestSchema.post 'save', (doc) ->
if doc.get('status') is 'submitted'
msg = "http://codecombat.com/admin/trial-requests #{doc.get('type')} request submitted by #{doc.get('properties').email}"
hipchat.sendHipChatMessage msg, ['tower']
else if doc.get('status') is 'approved'
ppc = doc.get('prepaidCode')
unless ppc
log.error 'Trial request post save no ppc'
return
emailParams =
recipient:
address: doc.get('properties')?.email
email_id: sendwithus.templates.setup_free_sub_email
email_data:
url: "https://codecombat.com/account/subscription?_ppc=#{ppc}";
sendwithus.api.send emailParams, (err, result) =>
log.error "sendwithus trial request approved error: #{err}, result: #{result}" if err
TrialRequestSchema.statics.privateProperties = []
TrialRequestSchema.statics.editableProperties = [
'prepaidCode'
'properties'
'reviewDate'
'reviewer'
'status'
'type'
]
TrialRequestSchema.statics.jsonSchema = jsonSchema
module.exports = TrialRequest = mongoose.model 'trial.request', TrialRequestSchema, 'trial.requests'

View file

@ -0,0 +1,40 @@
async = require 'async'
log = require 'winston'
mongoose = require 'mongoose'
Handler = require '../commons/Handler'
TrialRequest = require './TrialRequest'
TrialRequestHandler = class TrialRequestHandler extends Handler
modelClass: TrialRequest
jsonSchema: require '../../app/schemas/models/trial_request.schema'
hasAccess: (req) ->
req.method in ['POST'] or req.user?.isAdmin()
hasAccessToDocument: (req, document, method=null) ->
return false unless document?
return true if req.user?.isAdmin()
false
makeNewInstance: (req) ->
instance = super(req)
instance.set 'applicant', req.user._id
instance.set 'status', 'submitted'
instance
put: (req, res, id) ->
req.body.reviewDate = new Date()
req.body.reviewer = req.user.get('_id')
super(req, res, id)
getByRelationship: (req, res, args...) ->
return @getOwn(req, res) if args[1] is 'own'
super(arguments...)
getOwn: (req, res) ->
return @sendForbiddenError(res) unless req.user?
TrialRequest.find {applicant: req.user.get('_id')}, (err, documents) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, documents)
module.exports = new TrialRequestHandler()

View file

@ -41,6 +41,7 @@ models_path = [
'../../server/achievements/EarnedAchievement'
'../../server/payments/Payment'
'../../server/prepaids/Prepaid'
'../../server/trial_requests/TrialRequest'
]
for m in models_path

View file

@ -4,7 +4,6 @@ utils = require '../../../app/core/utils' # Must come after require /common
mongoose = require 'mongoose'
describe 'Clans', ->
stripe = require('stripe')(config.stripe.secretKey)
clanURL = getURL('/db/clan')
userURL = getURL('/db/user')

View file

@ -0,0 +1,162 @@
require '../common'
describe 'Trial Requests', ->
URL = getURL('/db/trial.request')
ownURL = getURL('/db/trial.request/-/own')
createTrialRequest = (user, type, properties, done) ->
requestBody =
type: type
properties: properties
request.post {uri: URL, json: requestBody }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(body.type).toEqual(type)
expect(body.properties).toEqual(properties)
expect(body.applicant).toEqual(user.id)
expect(body.status).toEqual('submitted')
TrialRequest.findById body._id, (err, doc) ->
expect(err).toBeNull()
expect(doc.get('type')).toEqual(type)
expect(doc.get('properties')).toEqual(properties)
expect(doc.get('applicant')).toEqual(user._id)
expect(doc.get('status')).toEqual('submitted')
done(doc)
it 'Clear database', (done) ->
clearModels [User, TrialRequest], (err) ->
throw err if err
done()
it 'Create trial request', (done) ->
loginNewUser (user) ->
properties =
email: user.get('email')
location: 'SF, CA'
age: '14-17'
numStudents: 14
heardAbout: 'magical interwebs'
createTrialRequest user, 'subscription', properties, (trialRequest) ->
done()
it 'Get trial requests, non-admin', (done) ->
loginNewUser (user) ->
properties =
email: user.get('email')
location: 'SF, CA'
age: '14-17'
numStudents: 14
heardAbout: 'magical interwebs'
createTrialRequest user, 'subscription', properties, (trialRequest) ->
request.get URL, (err, res, body) ->
expect(res.statusCode).toEqual(403)
done()
it 'Get trial requests, admin', (done) ->
loginNewUser (user) ->
properties =
email: user.get('email')
location: 'SF, CA'
age: '14-17'
numStudents: 14
heardAbout: 'magical interwebs'
createTrialRequest user, 'subscription', properties, (trialRequest) ->
loginNewUser (admin) ->
admin.set('permissions', ['admin'])
admin.save (err, user) ->
request.get URL, (err, res, body) ->
expect(res.statusCode).toEqual(200)
expect(body.length).toBeGreaterThan(0)
done()
it 'Get own trial requests', (done) ->
loginNewUser (user) ->
properties =
email: user.get('email')
location: 'SF, CA'
age: '14-17'
numStudents: 14
heardAbout: 'magical interwebs'
createTrialRequest user, 'subscription', properties, (trialRequest) ->
request.get ownURL, (err, res, body) ->
expect(res.statusCode).toEqual(200)
ownRequests = JSON.parse(body)
expect(ownRequests.length).toEqual(1)
expect(ownRequests[0]._id).toEqual(trialRequest.id)
done()
it 'Get non-owned trial request, non-admin', (done) ->
loginNewUser (user) ->
properties =
email: user.get('email')
location: 'SF, CA'
age: '14-17'
numStudents: 14
heardAbout: 'magical interwebs'
createTrialRequest user, 'subscription', properties, (trialRequest) ->
loginNewUser (user2) ->
request.get URL + "/#{trialRequest.id}", (err, res, body) ->
expect(res.statusCode).toEqual(403)
done()
it 'Approve trial request', (done) ->
loginNewUser (user) ->
properties =
email: user.get('email')
location: 'SF, CA'
age: '14-17'
numStudents: 14
heardAbout: 'magical interwebs'
createTrialRequest user, 'subscription', properties, (trialRequest) ->
loginNewUser (admin) ->
admin.set('permissions', ['admin'])
admin.save (err, user) ->
requestBody = trialRequest.toObject()
requestBody.status = 'approved'
request.put {uri: URL, json: requestBody }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(body.status).toEqual('approved')
expect(body.reviewDate).toBeDefined()
expect(new Date(body.reviewDate)).toBeLessThan(new Date())
expect(body.reviewer).toEqual(admin.id)
expect(body.prepaidCode).toBeDefined()
TrialRequest.findById body._id, (err, doc) ->
expect(err).toBeNull()
expect(doc.get('status')).toEqual('approved')
expect(doc.get('reviewDate')).toBeDefined()
expect(new Date(doc.get('reviewDate'))).toBeLessThan(new Date())
expect(doc.get('reviewer')).toEqual(admin._id)
expect(doc.get('prepaidCode')).toBeDefined()
done()
it 'Deny trial request', (done) ->
loginNewUser (user) ->
properties =
email: user.get('email')
location: 'SF, CA'
age: '14-17'
numStudents: 14
heardAbout: 'magical interwebs'
createTrialRequest user, 'subscription', properties, (trialRequest) ->
loginNewUser (admin) ->
admin.set('permissions', ['admin'])
admin.save (err, user) ->
requestBody = trialRequest.toObject()
requestBody.status = 'denied'
request.put {uri: URL, json: requestBody }, (err, res, body) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
expect(body.status).toEqual('denied')
expect(body.reviewDate).toBeDefined()
expect(new Date(body.reviewDate)).toBeLessThan(new Date())
expect(body.reviewer).toEqual(admin.id)
expect(body.prepaidCode).not.toBeDefined()
TrialRequest.findById body._id, (err, doc) ->
expect(err).toBeNull()
expect(doc.get('status')).toEqual('denied')
expect(doc.get('reviewDate')).toBeDefined()
expect(new Date(doc.get('reviewDate'))).toBeLessThan(new Date())
expect(doc.get('reviewer')).toEqual(admin._id)
expect(doc.get('prepaidCode')).not.toBeDefined()
done()