Merge branch 'master' into game-dev-levels

This commit is contained in:
Scott Erickson 2016-07-14 15:50:04 -07:00
commit 61caf3dcd3
43 changed files with 521 additions and 474 deletions

View file

@ -56,6 +56,7 @@ so we can accept your pull requests. It is easy.
![Josh Callebaut](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Josh%20Callebaut/josh_callebaut_100.png "Josh Callebaut") ![Josh Callebaut](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Josh%20Callebaut/josh_callebaut_100.png "Josh Callebaut")
![Michael Schmatz](http://codecombat.com/images/pages/about/michael_small.png "Michael Schmatz") ![Michael Schmatz](http://codecombat.com/images/pages/about/michael_small.png "Michael Schmatz")
![Josh Lee](http://codecombat.com/images/pages/about/josh_small.png "Josh Lee") ![Josh Lee](http://codecombat.com/images/pages/about/josh_small.png "Josh Lee")
![Dan TDM](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Dan_TDM/dan_tdm_100.png "Dan TDM")
![Alex Cotsarelis](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Cotsarelis/alex_100.png "Alex Cotsarelis") ![Alex Cotsarelis](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Cotsarelis/alex_100.png "Alex Cotsarelis")
![Alex Crooks](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Crooks/alex_100.png "Alex Crooks") ![Alex Crooks](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Crooks/alex_100.png "Alex Crooks")
![Alexandru Caciulescu](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alexandru%20Caciulescu/alexandru_100.png "Alexandru Caciulescu") ![Alexandru Caciulescu](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alexandru%20Caciulescu/alexandru_100.png "Alexandru Caciulescu")

View file

@ -5,12 +5,6 @@ module.exports = class LevelSessionCollection extends CocoCollection
url: '/db/level.session' url: '/db/level.session'
model: LevelSession model: LevelSession
fetchMineForCourseInstance: (courseInstanceID, options) ->
options = _.extend({
url: "/db/course_instance/#{courseInstanceID}/my-course-level-sessions"
}, options)
@fetch(options)
fetchForCourseInstance: (courseInstanceID, options) -> fetchForCourseInstance: (courseInstanceID, options) ->
options = _.extend({ options = _.extend({
url: "/db/course_instance/#{courseInstanceID}/my-course-level-sessions" url: "/db/course_instance/#{courseInstanceID}/my-course-level-sessions"

View file

@ -488,7 +488,7 @@ module.exports = class LevelLoader extends CocoClass
@world.difficulty = @session?.get('state')?.difficulty ? 0 @world.difficulty = @session?.get('state')?.difficulty ? 0
if @observing if @observing
@world.difficulty = Math.max 0, @world.difficulty - 1 # Show the difficulty they won, not the next one. @world.difficulty = Math.max 0, @world.difficulty - 1 # Show the difficulty they won, not the next one.
serializedLevel = @level.serialize(@supermodel, @session, @opponentSession) serializedLevel = @level.serialize {@supermodel, @session, @opponentSession, @headless, @sessionless}
@world.loadFromLevel serializedLevel, false @world.loadFromLevel serializedLevel, false
console.log 'World has been initialized from level loader.' if LOG console.log 'World has been initialized from level loader.' if LOG

View file

@ -226,7 +226,7 @@ module.exports = class Simulator extends CocoClass
@levelLoader = null @levelLoader = null
setupGod: -> setupGod: ->
@god.setLevel @level.serialize(@supermodel, @session, @otherSession) @god.setLevel @level.serialize {@supermodel, @session, @otherSession, headless: true, sessionless: false}
@god.setLevelSessionIDs (session.sessionID for session in @task.getSessions()) @god.setLevelSessionIDs (session.sessionID for session in @task.getSessions())
@god.setWorldClassMap @world.classMap @god.setWorldClassMap @world.classMap
@god.setGoalManager new GoalManager @world, @level.get('goals'), null, {headless: true} @god.setGoalManager new GoalManager @world, @level.get('goals'), null, {headless: true}

View file

@ -12,7 +12,8 @@ module.exports = class Level extends CocoModel
urlRoot: '/db/level' urlRoot: '/db/level'
editableByArtisans: true editableByArtisans: true
serialize: (supermodel, session, otherSession, cached=false) -> serialize: (options) ->
{supermodel, session, otherSession, @headless, @sessionless, cached=false} = options
o = @denormalize supermodel, session, otherSession # hot spot to optimize o = @denormalize supermodel, session, otherSession # hot spot to optimize
# Figure out Components # Figure out Components
@ -146,7 +147,7 @@ module.exports = class Level extends CocoModel
levelThang.components.push placeholderComponent levelThang.components.push placeholderComponent
# Load the user's chosen hero AFTER getting stats from default char # Load the user's chosen hero AFTER getting stats from default char
if /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['course'] if /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['course'] and not @headless and not @sessionless
heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain
levelThang.thangType = heroThangType if heroThangType levelThang.thangType = heroThangType if heroThangType

View file

@ -25,6 +25,7 @@ _.extend ClassroomSchema.properties,
levels: c.array { title: 'Levels' }, c.object { title: 'Level' }, { levels: c.array { title: 'Levels' }, c.object { title: 'Level' }, {
practice: {type: 'boolean'} practice: {type: 'boolean'}
practiceThresholdMinutes: {type: 'number'} practiceThresholdMinutes: {type: 'number'}
shareable: {type: 'boolean'}
type: c.shortString() type: c.shortString()
original: c.objectId() original: c.objectId()
name: {type: 'string'} name: {type: 'string'}

View file

@ -267,7 +267,7 @@ LevelSchema = c.object {
victory: {} victory: {}
type: 'hero' type: 'hero'
goals: [ goals: [
{id: 'ogres-die', name: 'Ogres must die.', killThangs: ['ogres'], worldEndsAfter: 3} {id: 'ogres-die', name: 'Defeat the ogres.', killThangs: ['ogres'], worldEndsAfter: 3}
{id: 'humans-survive', name: 'Your hero must survive.', saveThangs: ['Hero Placeholder'], howMany: 1, worldEndsAfter: 3, hiddenGoal: true} {id: 'humans-survive', name: 'Your hero must survive.', saveThangs: ['Hero Placeholder'], howMany: 1, worldEndsAfter: 3, hiddenGoal: true}
] ]
concepts: ['basic_syntax'] concepts: ['basic_syntax']
@ -325,6 +325,7 @@ _.extend LevelSchema.properties,
replayable: {type: 'boolean', title: 'Replayable', description: 'Whether this (hero) level infinitely scales up its difficulty and can be beaten over and over for greater rewards.'} replayable: {type: 'boolean', title: 'Replayable', description: 'Whether this (hero) level infinitely scales up its difficulty and can be beaten over and over for greater rewards.'}
buildTime: {type: 'number', description: 'How long it has taken to build this level.'} buildTime: {type: 'number', description: 'How long it has taken to build this level.'}
practice: { type: 'boolean' } practice: { type: 'boolean' }
shareable: { type: 'boolean', title: 'Shareable' }
practiceThresholdMinutes: {type: 'number', description: 'Players with larger playtimes may be directed to a practice level.'} practiceThresholdMinutes: {type: 'number', description: 'Players with larger playtimes may be directed to a practice level.'}
# Admin flags # Admin flags

View file

@ -15,6 +15,16 @@
.form-group .form-group
text-align: left text-align: left
margin: 0
input
max-height: 5vh
.form-container
> .form-group, > .row
max-height: 84px
flex-grow: 1
align-self: flex-start
.btn-illustrated img .btn-illustrated img
// Undo previous opacity-toggling hover behavior // Undo previous opacity-toggling hover behavior

View file

@ -1,8 +1,16 @@
@import "app/styles/style-flat-variables" @import "app/styles/style-flat-variables"
#choose-account-type-view #choose-account-type-view
.choose-type-title
display: flex
flex-direction: column
flex-grow: 0.5
justify-content: flex-end
h4
padding-bottom: 10px
.path-cards .path-cards
margin-top: 15px
display: flex display: flex
.path-card ~ .path-card .path-card ~ .path-card
@ -13,7 +21,8 @@
flex-direction: column flex-direction: column
justify-content: space-between justify-content: space-between
width: 235px width: 235px
min-height: 340px height: 340px
max-height: 42vh
border-style: solid border-style: solid
border-width: thin border-width: thin
border-radius: 5px border-radius: 5px
@ -35,6 +44,7 @@
align-items: center align-items: center
justify-content: center justify-content: center
height: 50px height: 50px
max-height: 5vh
color: white color: white
font-weight: bold font-weight: bold
text-align: center text-align: center
@ -43,7 +53,8 @@
flex-grow: 1 flex-grow: 1
display: flex display: flex
flex-direction: column flex-direction: column
margin: 50px 20px 0 justify-content: center
margin: 0 20px
ul ul
align-self: center align-self: center
@ -55,14 +66,26 @@
left: -5px left: -5px
.card-footer .card-footer
margin: 20px margin: 0 20px 20px
min-height: 62px
display: flex
flex-direction: column
justify-content: flex-end
.individual-section .individual-section
margin-top: 50px display: flex
flex-grow: 1
flex-direction: column
align-items: center
justify-content: center
max-width: 425px max-width: 425px
.individual-title .individual-title
font-weight: bold font-weight: bold
.individual-description
margin: 0
flex-grow: 0.2
.text-h6 .text-h6
color: white color: white

View file

@ -17,6 +17,7 @@
display: flex display: flex
flex-direction: column flex-direction: column
height: 850px height: 850px
max-height: 90vh
width: 850px width: 850px
text-align: center text-align: center
padding: 0 padding: 0
@ -44,6 +45,7 @@
align-items: center align-items: center
justify-content: flex-end justify-content: flex-end
height: 100px height: 100px
max-height: 10.5vh
padding: 0 padding: 0
background-color: $navy background-color: $navy

View file

@ -267,9 +267,9 @@ block content
h1 Active Users h1 Active Users
if view.activeUsers.length > 0 if view.activeUsers.length > 0
- var eventNames = []; - var eventNames = [];
each count, event in view.activeUsers[0].events each event in view.activeUserEventNames
if event.indexOf('classroom') >= 0 if event.indexOf('classroom') >= 0
- eventNames.push(event) - eventNames.push(event);
- eventNames.sort(function (a, b) {return a.localeCompare(b);}); - eventNames.sort(function (a, b) {return a.localeCompare(b);});
table.table.table-striped.table-condensed table.table.table-striped.table-condensed
tr tr
@ -322,9 +322,9 @@ block content
h1 Active Users h1 Active Users
if view.activeUsers.length > 0 if view.activeUsers.length > 0
- var eventNames = []; - var eventNames = [];
each count, event in view.activeUsers[0].events each event in view.activeUserEventNames
if event.indexOf('campaign') >= 0 if event.indexOf('campaign') >= 0
- eventNames.push(event) - eventNames.push(event);
- eventNames.sort(function (a, b) {return a.localeCompare(b);}); - eventNames.sort(function (a, b) {return a.localeCompare(b);});
table.table.table-striped.table-condensed table.table.table-striped.table-condensed
tr tr
@ -346,27 +346,13 @@ block content
h1 Active Users h1 Active Users
if view.activeUsers.length > 0 if view.activeUsers.length > 0
- var eventNames = [];
each count, event in view.activeUsers[0].events
- eventNames.push(event)
- eventNames.sort(function (a, b) {
- if (a.indexOf('campaign') == b.indexOf('campaign') || a.indexOf('classroom') == b.indexOf('classroom')) {
- return a.localeCompare(b);
- }
- else if (a.indexOf('campaign') > b.indexOf('campaign')) {
- return 1;
- }
- else {
- return -1;
- }
- });
table.table.table-striped.table-condensed table.table.table-striped.table-condensed
tr tr
th(style='min-width:85px;') Day th(style='min-width:85px;') Day
each eventName in eventNames each eventName in view.activeUserEventNames
th= eventName th= eventName
each activeUser in view.activeUsers each activeUser in view.activeUsers
tr tr
td= activeUser.day td= activeUser.day
each eventName in eventNames each eventName in view.activeUserEventNames
td= activeUser.events[eventName] || 0 td= activeUser.events[eventName] || 0

View file

@ -1,5 +1,5 @@
.modal-body-content .modal-body-content
h4 h4.choose-type-title
span(data-i18n="signup.choose_type") span(data-i18n="signup.choose_type")
.path-cards .path-cards
.path-card.navy .path-card.navy

View file

@ -10,7 +10,7 @@
else else
p(data-i18n="signup.confirm_individual_blurb") p(data-i18n="signup.confirm_individual_blurb")
.signup-info-box-wrapper.m-y-3 .signup-info-box-wrapper
.text-burgandy(data-i18n="signup.write_this_down") .text-burgandy(data-i18n="signup.write_this_down")
.signup-info-box.text-center .signup-info-box.text-center
if me.get('name') if me.get('name')
@ -32,4 +32,4 @@
span(data-i18n="general.email") span(data-i18n="general.email")
| : #{me.get('email')} | : #{me.get('email')}
button#start-btn.btn.btn-navy.btn-lg.m-y-3(data-i18n="signup.start_playing") button#start-btn.btn.btn-navy.btn-lg.m-t-3(data-i18n="signup.start_playing")

View file

@ -45,7 +45,7 @@ form.modal-body.segment-check
option(value='',data-i18n="calendar.day") option(value='',data-i18n="calendar.day")
for day in _.range(1,32) for day in _.range(1,32)
option(selected=(day == view.signupState.get('birthdayDay'))) #{day} option(selected=(day == view.signupState.get('birthdayDay'))) #{day}
select#birthday-year-input.input-large.form-control(name="birthdayYear", style="width: 90px;") select#birthday-year-input.input-large.form-control(name="birthdayYear", style="width: 90px; float: left")
option(value='',data-i18n="calendar.year") option(value='',data-i18n="calendar.year")
- var thisYear = new Date().getFullYear() - var thisYear = new Date().getFullYear()
for year in _.range(thisYear, thisYear - 100, -1) for year in _.range(thisYear, thisYear - 100, -1)

View file

@ -182,7 +182,8 @@ mixin studentsTab
th.checkbox-col.select-all.small.text-center th.checkbox-col.select-all.small.text-center
span(data-i18n="teacher.select_all") span(data-i18n="teacher.select_all")
.checkbox-flat .checkbox-flat
input(type='checkbox' id='checkbox-all-students') - var allStudentsChecked = _.all(state.get('checkboxStates'))
input(type='checkbox', id='checkbox-all-students', checked=allStudentsChecked)
label.checkmark(for='checkbox-all-students') label.checkmark(for='checkbox-all-students')
th th
+sortButtons +sortButtons
@ -201,7 +202,7 @@ mixin studentRow(student)
tr.student-row.alternating-background tr.student-row.alternating-background
td.checkbox-col.student-checkbox td.checkbox-col.student-checkbox
.checkbox-flat .checkbox-flat
input(type='checkbox' id='checkbox-student-' + student.id, data-student-id=student.id) input(type='checkbox' id='checkbox-student-' + student.id, data-student-id=student.id, checked=state.get('checkboxStates')[student.id])
label.checkmark(for='checkbox-student-' + student.id) label.checkmark(for='checkbox-student-' + student.id)
td.student-info-col td.student-info-col
.student-info .student-info

View file

@ -132,7 +132,8 @@ block outer_content
div.tab-pane#editor-level-tasks-tab-view div.tab-pane#editor-level-tasks-tab-view
div.tab-pane#editor-level-patches div.tab-pane#editor-level-patches.nano
.nano-content
.patches-view .patches-view
div.tab-pane#related-achievements-view div.tab-pane#related-achievements-view

View file

@ -73,6 +73,7 @@ module.exports = class AnalyticsView extends RootView
# Add campaign/classroom DAU 30-day averages and daily totals # Add campaign/classroom DAU 30-day averages and daily totals
campaignDauTotals = [] campaignDauTotals = []
classroomDauTotals = [] classroomDauTotals = []
eventMap = {}
for entry in @activeUsers for entry in @activeUsers
day = entry.day day = entry.day
campaignDauTotal = 0 campaignDauTotal = 0
@ -82,18 +83,31 @@ module.exports = class AnalyticsView extends RootView
campaignDauTotal += count campaignDauTotal += count
else if event.indexOf('DAU classroom') >= 0 else if event.indexOf('DAU classroom') >= 0
classroomDauTotal += count classroomDauTotal += count
eventMap[event] = true
entry.events['DAU campaign total'] = campaignDauTotal entry.events['DAU campaign total'] = campaignDauTotal
eventMap['DAU campaign total'] = true
campaignDauTotals.unshift(campaignDauTotal) campaignDauTotals.unshift(campaignDauTotal)
campaignDauTotals.pop() while campaignDauTotals.length > 30 campaignDauTotals.pop() while campaignDauTotals.length > 30
if campaignDauTotals.length is 30 if campaignDauTotals.length is 30
entry.events['DAU campaign 30-day average'] = Math.round(_.reduce(campaignDauTotals, (a, b) -> a + b) / 30) entry.events['DAU campaign 30-day average'] = Math.round(_.reduce(campaignDauTotals, (a, b) -> a + b) / 30)
eventMap['DAU campaign 30-day average'] = true
entry.events['DAU classroom total'] = classroomDauTotal entry.events['DAU classroom total'] = classroomDauTotal
eventMap['DAU classroom total'] = true
classroomDauTotals.unshift(classroomDauTotal) classroomDauTotals.unshift(classroomDauTotal)
classroomDauTotals.pop() while classroomDauTotals.length > 30 classroomDauTotals.pop() while classroomDauTotals.length > 30
if classroomDauTotals.length is 30 if classroomDauTotals.length is 30
entry.events['DAU classroom 30-day average'] = Math.round(_.reduce(classroomDauTotals, (a, b) -> a + b) / 30) entry.events['DAU classroom 30-day average'] = Math.round(_.reduce(classroomDauTotals, (a, b) -> a + b) / 30)
eventMap['DAU classroom 30-day average'] = true
@activeUsers.sort (a, b) -> b.day.localeCompare(a.day) @activeUsers.sort (a, b) -> b.day.localeCompare(a.day)
@activeUserEventNames = Object.keys(eventMap)
@activeUserEventNames.sort (a, b) ->
if a.indexOf('campaign') is b.indexOf('campaign') or a.indexOf('classroom') is b.indexOf('classroom')
a.localeCompare(b)
else if a.indexOf('campaign') > b.indexOf('campaign')
1
else
-1
@updateAllKPIChartData() @updateAllKPIChartData()
@updateActiveUsersChartData() @updateActiveUsersChartData()
@ -183,7 +197,7 @@ module.exports = class AnalyticsView extends RootView
@supermodel.addRequestResource({ @supermodel.addRequestResource({
url: '/db/prepaid/-/courses' url: '/db/prepaid/-/courses'
method: 'POST' method: 'POST'
data: {project: {maxRedeemers: 1, properties: 1, redeemers: 1}} data: {project: {endDate: 1, maxRedeemers: 1, properties: 1, redeemers: 1}}
success: (prepaids) => success: (prepaids) =>
paidDayMaxMap = {} paidDayMaxMap = {}
paidDayRedeemedMap = {} paidDayRedeemedMap = {}
@ -201,14 +215,13 @@ module.exports = class AnalyticsView extends RootView
redeemDay = redeemer.date.substring(0, 10) redeemDay = redeemer.date.substring(0, 10)
trialDayRedeemedMap[redeemDay] ?= 0 trialDayRedeemedMap[redeemDay] ?= 0
trialDayRedeemedMap[redeemDay]++ trialDayRedeemedMap[redeemDay]++
else else if not prepaid.endDate? or new Date(prepaid.endDate) > new Date()
paidDayMaxMap[day] ?= 0 paidDayMaxMap[day] ?= 0
paidDayMaxMap[day] += prepaid.maxRedeemers paidDayMaxMap[day] += prepaid.maxRedeemers
for redeemer in prepaid.redeemers for redeemer in prepaid.redeemers
redeemDay = redeemer.date.substring(0, 10) redeemDay = redeemer.date.substring(0, 10)
paidDayRedeemedMap[redeemDay] ?= 0 paidDayRedeemedMap[redeemDay] ?= 0
paidDayRedeemedMap[redeemDay]++ paidDayRedeemedMap[redeemDay]++
@dayEnrollmentsMap = {} @dayEnrollmentsMap = {}
@paidCourseTotalEnrollments = [] @paidCourseTotalEnrollments = []
for day, count of paidDayMaxMap for day, count of paidDayMaxMap
@ -368,6 +381,9 @@ module.exports = class AnalyticsView extends RootView
# Trim points preceding days # Trim points preceding days
if points.length and days.length and points[0].day.localeCompare(days[0]) < 0 if points.length and days.length and points[0].day.localeCompare(days[0]) < 0
if points[points.length - 1].day.localeCompare(days[0]) < 0
points = []
else
for point, i in points for point, i in points
if point.day.localeCompare(days[0]) >= 0 if point.day.localeCompare(days[0]) >= 0
points.splice(0, i) points.splice(0, i)
@ -375,7 +391,7 @@ module.exports = class AnalyticsView extends RootView
# Ensure points for each day # Ensure points for each day
for day, i in days for day, i in days
if points.length <= i or points[i].day isnt day if points.length <= i or points[i]?.day isnt day
prevY = if i > 0 then points[i - 1].y else 0.0 prevY = if i > 0 then points[i - 1].y else 0.0
points.splice i, 0, points.splice i, 0,
day: day day: day
@ -648,16 +664,14 @@ module.exports = class AnalyticsView extends RootView
dailyMax = 0 dailyMax = 0
data = [] data = []
total = 0
for entry in @paidCourseTotalEnrollments for entry in @paidCourseTotalEnrollments
total += entry.count
data.push data.push
day: entry.day day: entry.day
value: total value: entry.count
points = @createLineChartPoints(days, data) points = @createLineChartPoints(days, data)
@enrollmentsChartLines.push @enrollmentsChartLines.push
points: points points: points
description: 'Total paid enrollments issued' description: 'Paid enrollments issued'
lineColor: @lineColors[colorIndex++ % @lineColors.length] lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1 strokeWidth: 1
min: 0 min: 0
@ -666,16 +680,14 @@ module.exports = class AnalyticsView extends RootView
dailyMax = _.max([dailyMax, _.max(points, 'y').y]) dailyMax = _.max([dailyMax, _.max(points, 'y').y])
data = [] data = []
total = 0
for entry in @paidCourseRedeemedEnrollments for entry in @paidCourseRedeemedEnrollments
total += entry.count
data.push data.push
day: entry.day day: entry.day
value: total value: entry.count
points = @createLineChartPoints(days, data) points = @createLineChartPoints(days, data)
@enrollmentsChartLines.push @enrollmentsChartLines.push
points: points points: points
description: 'Total paid enrollments redeemed' description: 'Paid enrollments redeemed'
lineColor: @lineColors[colorIndex++ % @lineColors.length] lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1 strokeWidth: 1
min: 0 min: 0
@ -684,16 +696,14 @@ module.exports = class AnalyticsView extends RootView
dailyMax = _.max([dailyMax, _.max(points, 'y').y]) dailyMax = _.max([dailyMax, _.max(points, 'y').y])
data = [] data = []
total = 0
for entry in @trialCourseTotalEnrollments for entry in @trialCourseTotalEnrollments
total += entry.count
data.push data.push
day: entry.day day: entry.day
value: total value: entry.count
points = @createLineChartPoints(days, data) points = @createLineChartPoints(days, data, true)
@enrollmentsChartLines.push @enrollmentsChartLines.push
points: points points: points
description: 'Total trial enrollments issued' description: 'Trial enrollments issued'
lineColor: @lineColors[colorIndex++ % @lineColors.length] lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1 strokeWidth: 1
min: 0 min: 0
@ -702,16 +712,14 @@ module.exports = class AnalyticsView extends RootView
dailyMax = _.max([dailyMax, _.max(points, 'y').y]) dailyMax = _.max([dailyMax, _.max(points, 'y').y])
data = [] data = []
total = 0
for entry in @trialCourseRedeemedEnrollments for entry in @trialCourseRedeemedEnrollments
total += entry.count
data.push data.push
day: entry.day day: entry.day
value: total value: entry.count
points = @createLineChartPoints(days, data) points = @createLineChartPoints(days, data)
@enrollmentsChartLines.push @enrollmentsChartLines.push
points: points points: points
description: 'Total trial enrollments redeemed' description: 'Trial enrollments redeemed'
lineColor: @lineColors[colorIndex++ % @lineColors.length] lineColor: @lineColors[colorIndex++ % @lineColors.length]
strokeWidth: 1 strokeWidth: 1
min: 0 min: 0

View file

@ -23,6 +23,7 @@ module.exports = class MainAdminView extends RootView
'submit #user-search-form': 'onSubmitUserSearchForm' 'submit #user-search-form': 'onSubmitUserSearchForm'
'click #stop-spying-btn': 'onClickStopSpyingButton' 'click #stop-spying-btn': 'onClickStopSpyingButton'
'click #increment-button': 'incrementUserAttribute' 'click #increment-button': 'incrementUserAttribute'
'click .user-spy-button': 'onClickUserSpyButton'
'click #user-search-result': 'onClickUserSearchResult' 'click #user-search-result': 'onClickUserSearchResult'
'click #create-free-sub-btn': 'onClickFreeSubLink' 'click #create-free-sub-btn': 'onClickFreeSubLink'
'click #terminal-create': 'onClickTerminalSubLink' 'click #terminal-create': 'onClickTerminalSubLink'
@ -31,31 +32,10 @@ module.exports = class MainAdminView extends RootView
getTitle: -> return $.i18n.t('account_settings.admin') getTitle: -> return $.i18n.t('account_settings.admin')
initialize: -> initialize: ->
@campaigns = new Campaigns()
@courses = new CocoCollection([], { url: "/db/course", model: Course})
if window.amActually if window.amActually
@amActually = new User({_id: window.amActually}) @amActually = new User({_id: window.amActually})
@amActually.fetch() @amActually.fetch()
@supermodel.trackModel(@amActually) @supermodel.trackModel(@amActually)
if me.isAdmin()
@supermodel.trackRequest @campaigns.fetchByType('course', { data: { project: 'levels' } })
@supermodel.loadCollection(@courses, 'courses')
super()
onLoaded: ->
campaignCourseIndexMap = {}
for course, index in @courses.models
campaignCourseIndexMap[course.get('campaignID')] = index + 1
@courseLevels = []
for campaign in @campaigns.models
continue unless campaignCourseIndexMap[campaign.id]
for levelID, level of campaign.get('levels')
@courseLevels.push({
levelID
slug: level.slug
courseIndex: campaignCourseIndexMap[campaign.id]
})
super() super()
onClickStopSpyingButton: -> onClickStopSpyingButton: ->
@ -80,6 +60,18 @@ module.exports = class MainAdminView extends RootView
errors.showNotyNetworkError(arguments...) errors.showNotyNetworkError(arguments...)
}) })
onClickUserSpyButton: (e) ->
e.stopPropagation()
userID = $(e.target).closest('tr').data('user-id')
button = $(e.currentTarget)
forms.disableSubmit(button)
me.spy(userID, {
success: -> window.location.reload()
error: ->
forms.enableSubmit(button)
errors.showNotyNetworkError(arguments...)
})
onSubmitUserSearchForm: (e) -> onSubmitUserSearchForm: (e) ->
e.preventDefault() e.preventDefault()
searchValue = @$el.find('#user-search').val() searchValue = @$el.find('#user-search').val()
@ -97,7 +89,7 @@ module.exports = class MainAdminView extends RootView
forms.enableSubmit(@$('#user-search-button')) forms.enableSubmit(@$('#user-search-button'))
result = '' result = ''
if users.length if users.length
result = ("<tr data-user-id='#{user._id}'><td><code>#{user._id}</code></td><td>#{_.escape(user.name or 'Anonymous')}</td><td>#{_.escape(user.email)}</td></tr>" for user in users) result = ("<tr data-user-id='#{user._id}'><td><code>#{user._id}</code></td><td>#{_.escape(user.name or 'Anonymous')}</td><td>#{_.escape(user.email)}</td><td><button class='user-spy-button'>Spy</button></td></tr>" for user in users)
result = "<table class=\"table\">#{result.join('\n')}</table>" result = "<table class=\"table\">#{result.join('\n')}</table>"
@$el.find('#user-search-result').html(result) @$el.find('#user-search-result').html(result)
@ -157,42 +149,31 @@ module.exports = class MainAdminView extends RootView
@supermodel.addRequestResource('create_prepaid', options, 0).load() @supermodel.addRequestResource('create_prepaid', options, 0).load()
onClickExportProgress: -> onClickExportProgress: ->
return unless @courseLevels?.length > 0
$('.classroom-progress-csv').prop('disabled', true) $('.classroom-progress-csv').prop('disabled', true)
classCode = $('.classroom-progress-class-code').val() classCode = $('.classroom-progress-class-code').val()
classroom = null
courseLevels = []
sessions = null
users = null
userMap = {} userMap = {}
new Promise((resolve, reject) => Promise.resolve(new Classroom().fetchByCode(classCode))
new Classroom().fetchByCode(classCode, { .then (model) =>
success: resolve classroom = new Classroom({ _id: model.data._id })
error: (model, response, options) => reject(response) Promise.resolve(classroom.fetch())
}) .then (model) =>
) for course, index in classroom.get('courses')
.then (classroom) => for level in course.levels
new Promise((resolve, reject) => courseLevels.push
new Classroom({ _id: classroom.id }).fetch({ courseIndex: index + 1
success: resolve levelID: level.original
error: (model, response, options) => reject(response) slug: level.slug
}) users = new Users()
) Promise.resolve($.when(users.fetchForClassroom(classroom)...))
.then (classroom) => .then (models) =>
new Promise((resolve, reject) =>
new Users().fetchForClassroom(classroom, {
success: (models, response, options) =>
resolve([classroom, models]) if models?.loaded
error: (models, response, options) => reject(response)
})
)
.then ([classroom, users]) =>
userMap[user.id] = user for user in users.models userMap[user.id] = user for user in users.models
new Promise((resolve, reject) => sessions = new LevelSessions()
new LevelSessions().fetchForAllClassroomMembers(classroom, { Promise.resolve($.when(sessions.fetchForAllClassroomMembers(classroom)...))
success: (models, response, options) => .then (models) =>
resolve(models) if models?.loaded
error: (models, response, options) => reject(response)
})
)
.then (sessions) =>
userLevelPlaytimeMap = {} userLevelPlaytimeMap = {}
for session in sessions.models for session in sessions.models
continue unless session.get('state')?.complete continue unless session.get('state')?.complete
@ -205,7 +186,7 @@ module.exports = class MainAdminView extends RootView
userPlaytimes = [] userPlaytimes = []
for userID, user of userMap for userID, user of userMap
playtimes = [user.get('name') ? 'Anonymous'] playtimes = [user.get('name') ? 'Anonymous']
for level in @courseLevels for level in courseLevels
if userLevelPlaytimeMap[userID]?[level.levelID]? if userLevelPlaytimeMap[userID]?[level.levelID]?
rawSeconds = parseInt(userLevelPlaytimeMap[userID][level.levelID]) rawSeconds = parseInt(userLevelPlaytimeMap[userID][level.levelID])
hours = Math.floor(rawSeconds / 60 / 60) hours = Math.floor(rawSeconds / 60 / 60)
@ -222,7 +203,7 @@ module.exports = class MainAdminView extends RootView
columnLabels = "Username" columnLabels = "Username"
currentLevel = 1 currentLevel = 1
lastCourseIndex = 1 lastCourseIndex = 1
for level in @courseLevels for level in courseLevels
unless level.courseIndex is lastCourseIndex unless level.courseIndex is lastCourseIndex
currentLevel = 1 currentLevel = 1
lastCourseIndex = level.courseIndex lastCourseIndex = level.courseIndex
@ -238,3 +219,4 @@ module.exports = class MainAdminView extends RootView
.catch (error) -> .catch (error) ->
$('.classroom-progress-csv').prop('disabled', false) $('.classroom-progress-csv').prop('disabled', false)
console.error error console.error error
throw error

View file

@ -28,6 +28,7 @@ module.exports = class BasicInfoView extends CocoView
events: events:
'change input[name="email"]': 'onChangeEmail' 'change input[name="email"]': 'onChangeEmail'
'change input[name="name"]': 'onChangeName' 'change input[name="name"]': 'onChangeName'
'change input[name="password"]': 'onChangePassword'
'click .back-button': 'onClickBackButton' 'click .back-button': 'onClickBackButton'
'submit form': 'onSubmitForm' 'submit form': 'onSubmitForm'
'click .use-suggested-name-link': 'onClickUseSuggestedNameLink' 'click .use-suggested-name-link': 'onClickUseSuggestedNameLink'
@ -51,7 +52,14 @@ module.exports = class BasicInfoView extends CocoView
@listenTo @signupState, 'change:facebookEnabled', -> @renderSelectors('.auth-network-logins') @listenTo @signupState, 'change:facebookEnabled', -> @renderSelectors('.auth-network-logins')
@listenTo @signupState, 'change:gplusEnabled', -> @renderSelectors('.auth-network-logins') @listenTo @signupState, 'change:gplusEnabled', -> @renderSelectors('.auth-network-logins')
onChangeEmail: -> # These values are passed along to AuthModal if the user clicks "Sign In" (handled by CreateAccountModal)
updateAuthModalInitialValues: (values) ->
@signupState.set {
authModalInitialValues: _.merge @signupState.get('authModalInitialValues'), values
}, { silent: true }
onChangeEmail: (e) ->
@updateAuthModalInitialValues { email: @$(e.currentTarget).val() }
@checkEmail() @checkEmail()
checkEmail: -> checkEmail: ->
@ -85,7 +93,8 @@ module.exports = class BasicInfoView extends CocoView
}) })
return @state.get('checkEmailPromise') return @state.get('checkEmailPromise')
onChangeName: -> onChangeName: (e) ->
@updateAuthModalInitialValues { name: @$(e.currentTarget).val() }
@checkName() @checkName()
checkName: -> checkName: ->
@ -122,6 +131,9 @@ module.exports = class BasicInfoView extends CocoView
return @state.get('checkNamePromise') return @state.get('checkNamePromise')
onChangePassword: (e) ->
@updateAuthModalInitialValues { password: @$(e.currentTarget).val() }
checkBasicInfo: (data) -> checkBasicInfo: (data) ->
# TODO: Move this to somewhere appropriate # TODO: Move this to somewhere appropriate
tv4.addFormat({ tv4.addFormat({

View file

@ -62,6 +62,7 @@ module.exports = class CreateAccountModal extends ModalView
gplusEnabled: application.gplusHandler.apiLoaded gplusEnabled: application.gplusHandler.apiLoaded
classCode classCode
birthday: new Date('') # so that birthday.getTime() is NaN birthday: new Date('') # so that birthday.getTime() is NaN
authModalInitialValues: {}
} }
{ startOnPath } = options { startOnPath } = options
@ -112,5 +113,4 @@ module.exports = class CreateAccountModal extends ModalView
document.location.reload() document.location.reload()
onClickLoginLink: -> onClickLoginLink: ->
# TODO: Make sure the right information makes its way into the state. @openModalView(new AuthModal({ initialValues: @signupState.get('authModalInitialValues') }))
@openModalView(new AuthModal({ initialValues: @signupState.pick(['email', 'name', 'password']) }))

View file

@ -11,7 +11,7 @@ module.exports = class SegmentCheckView extends CocoView
events: events:
'click .back-to-account-type': -> @trigger 'nav-back' 'click .back-to-account-type': -> @trigger 'nav-back'
'input .class-code-input': 'onInputClassCode' 'input .class-code-input': 'onInputClassCode'
'input .birthday-form-group': 'onInputBirthday' 'change .birthday-form-group': 'onInputBirthday'
'submit form.segment-check': 'onSubmitSegmentCheck' 'submit form.segment-check': 'onSubmitSegmentCheck'
'click .individual-path-button': -> @trigger 'choose-path', 'individual' 'click .individual-path-button': -> @trigger 'choose-path', 'individual'

View file

@ -56,6 +56,7 @@ module.exports = class TeacherClassView extends RootView
assigningToNobody: false assigningToNobody: false
assigningToUnenrolled: false assigningToUnenrolled: false
selectedCourse: undefined selectedCourse: undefined
checkboxStates: {}
classStats: classStats:
averagePlaytime: "" averagePlaytime: ""
totalPlaytime: "" totalPlaytime: ""
@ -145,6 +146,10 @@ module.exports = class TeacherClassView extends RootView
classStats = @calculateClassStats() classStats = @calculateClassStats()
@state.set classStats: classStats if classStats @state.set classStats: classStats if classStats
@state.set students: @students @state.set students: @students
checkboxStates = {}
for student in @students.models
checkboxStates[student.id] = @state.get('checkboxStates')[student.id] or false
@state.set { checkboxStates }
@listenTo @students, 'sort', -> @listenTo @students, 'sort', ->
@state.set students: @students @state.set students: @students
@listenTo @, 'course-select:change', ({ selectedCourse }) -> @listenTo @, 'course-select:change', ({ selectedCourse }) ->
@ -291,8 +296,7 @@ module.exports = class TeacherClassView extends RootView
@trigger 'course-select:change', { selectedCourse: @courses.get($(e.currentTarget).val()) } @trigger 'course-select:change', { selectedCourse: @courses.get($(e.currentTarget).val()) }
getSelectedStudentIDs: -> getSelectedStudentIDs: ->
@$('.student-row .checkbox-flat input:checked').map (index, checkbox) -> Object.keys(_.pick @state.get('checkboxStates'), (checked) -> checked)
$(checkbox).data('student-id')
ensureInstance: (courseID) -> ensureInstance: (courseID) ->
@ -304,7 +308,7 @@ module.exports = class TeacherClassView extends RootView
window.tracker?.trackEvent $(e.currentTarget).data('event-action'), category: 'Teachers', classroomID: @classroom.id, userID: userID, ['Mixpanel'] window.tracker?.trackEvent $(e.currentTarget).data('event-action'), category: 'Teachers', classroomID: @classroom.id, userID: userID, ['Mixpanel']
onClickBulkEnroll: -> onClickBulkEnroll: ->
userIDs = @getSelectedStudentIDs().toArray() userIDs = @getSelectedStudentIDs()
selectedUsers = new Users(@students.get(userID) for userID in userIDs) selectedUsers = new Users(@students.get(userID) for userID in userIDs)
@enrollStudents(selectedUsers) @enrollStudents(selectedUsers)
window.tracker?.trackEvent 'Teachers Class Students Enroll Selected', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] window.tracker?.trackEvent 'Teachers Class Students Enroll Selected', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
@ -385,10 +389,9 @@ module.exports = class TeacherClassView extends RootView
onClickBulkAssign: -> onClickBulkAssign: ->
courseID = @$('.bulk-course-select').val() courseID = @$('.bulk-course-select').val()
selectedIDs = @getSelectedStudentIDs() selectedIDs = @getSelectedStudentIDs()
members = selectedIDs.filter((index, userID) => members = selectedIDs.filter (userID) =>
user = @students.get(userID) user = @students.get(userID)
user.isEnrolled() user.isEnrolled()
).toArray()
assigningToUnenrolled = _.any selectedIDs, (userID) => assigningToUnenrolled = _.any selectedIDs, (userID) =>
not @students.get(userID).isEnrolled() not @students.get(userID).isEnrolled()
assigningToNobody = selectedIDs.length is 0 assigningToNobody = selectedIDs.length is 0
@ -417,23 +420,22 @@ module.exports = class TeacherClassView extends RootView
onClickSelectAll: (e) -> onClickSelectAll: (e) ->
e.preventDefault() e.preventDefault()
checkboxes = @$('.student-checkbox input') checkboxStates = _.clone @state.get('checkboxStates')
if _.all(checkboxes, 'checked') if _.all(checkboxStates)
@$('.select-all input').prop('checked', false) for studentID of checkboxStates
checkboxes.prop('checked', false) checkboxStates[studentID] = false
else else
@$('.select-all input').prop('checked', true) for studentID of checkboxStates
checkboxes.prop('checked', true) checkboxStates[studentID] = true
null @state.set { checkboxStates }
onClickStudentCheckbox: (e) -> onClickStudentCheckbox: (e) ->
e.preventDefault() e.preventDefault()
# $(e.target).$()
checkbox = $(e.currentTarget).find('input') checkbox = $(e.currentTarget).find('input')
checkbox.prop('checked', not checkbox.prop('checked')) studentID = checkbox.data('student-id')
# checkboxes.prop('checked', false) checkboxStates = _.clone @state.get('checkboxStates')
checkboxes = @$('.student-checkbox input') checkboxStates[studentID] = not checkboxStates[studentID]
@$('.select-all input').prop('checked', _.all(checkboxes, 'checked')) @state.set { checkboxStates }
calculateClassStats: -> calculateClassStats: ->
return {} unless @classroom.sessions?.loaded and @students.loaded return {} unless @classroom.sessions?.loaded and @students.loaded

View file

@ -16,6 +16,7 @@ module.exports = class SettingsTabView extends CocoView
'name', 'description', 'documentation', 'nextLevel', 'background', 'victory', 'i18n', 'icon', 'goals', 'name', 'description', 'documentation', 'nextLevel', 'background', 'victory', 'i18n', 'icon', 'goals',
'type', 'terrain', 'showsGuide', 'banner', 'employerDescription', 'loadingTip', 'requiresSubscription', 'type', 'terrain', 'showsGuide', 'banner', 'employerDescription', 'loadingTip', 'requiresSubscription',
'helpVideos', 'replayable', 'scoreTypes', 'concepts', 'picoCTFProblem', 'practice', 'practiceThresholdMinutes' 'helpVideos', 'replayable', 'scoreTypes', 'concepts', 'picoCTFProblem', 'practice', 'practiceThresholdMinutes'
'shareable'
] ]
subscriptions: subscriptions:

View file

@ -595,7 +595,7 @@ module.exports = class ThangsTabView extends CocoView
@level.set 'thangs', thangs @level.set 'thangs', thangs
return if @editThangView return if @editThangView
return if skipSerialization return if skipSerialization
serializedLevel = @level.serialize @supermodel, null, null, true serializedLevel = @level.serialize {@supermodel, session: null, otherSession: null, headless: false, sessionless: true, cached: true}
try try
@world.loadFromLevel serializedLevel, false @world.loadFromLevel serializedLevel, false
catch error catch error

View file

@ -25,9 +25,9 @@ module.exports = class VerifierTest extends CocoClass
@loadStartTime = new Date() @loadStartTime = new Date()
@god = new God maxAngels: 1, headless: true @god = new God maxAngels: 1, headless: true
@levelLoader = new LevelLoader supermodel: @supermodel, levelID: @levelID, headless: true, fakeSessionConfig: {codeLanguage: @language, callback: @configureSession} @levelLoader = new LevelLoader supermodel: @supermodel, levelID: @levelID, headless: true, fakeSessionConfig: {codeLanguage: @language, callback: @configureSession}
@listenToOnce @levelLoader, 'world-necessities-loaded', @onWorldNecessitiesLoaded @listenToOnce @levelLoader, 'world-necessities-loaded', -> _.defer @onWorldNecessitiesLoaded
onWorldNecessitiesLoaded: -> onWorldNecessitiesLoaded: =>
# Called when we have enough to build the world, but not everything is loaded # Called when we have enough to build the world, but not everything is loaded
@grabLevelLoaderData() @grabLevelLoaderData()
@ -62,7 +62,7 @@ module.exports = class VerifierTest extends CocoClass
@solution = @levelLoader.session.solution @solution = @levelLoader.session.solution
setupGod: -> setupGod: ->
@god.setLevel @level.serialize @supermodel, @session @god.setLevel @level.serialize {@supermodel, @session, otherSession: null, headless: true, sessionless: false}
@god.setLevelSessionIDs [@session.id] @god.setLevelSessionIDs [@session.id]
@god.setWorldClassMap @world.classMap @god.setWorldClassMap @world.classMap
@god.lastFlagHistory = @session.get('state').flagHistory @god.lastFlagHistory = @session.get('state').flagHistory
@ -122,8 +122,10 @@ module.exports = class VerifierTest extends CocoClass
setTimeout @cleanup, 100 setTimeout @cleanup, 100
cleanup: => cleanup: =>
if @levelLoader
@stopListening @levelLoader
@levelLoader.destroy()
if @god if @god
@stopListening @god @stopListening @god
@god.destroy() @god.destroy()
@world = null @world = null

View file

@ -42,6 +42,7 @@ module.exports = class LadderView extends RootView
initialize: (options, @levelID, @leagueType, @leagueID) -> initialize: (options, @levelID, @leagueType, @leagueID) ->
@level = @supermodel.loadModel(new Level(_id: @levelID)).model @level = @supermodel.loadModel(new Level(_id: @levelID)).model
@level.once 'sync', => @level.once 'sync', =>
return if @destroyed
@levelDescription = marked(@level.get('description')) if @level.get('description') @levelDescription = marked(@level.get('description')) if @level.get('description')
@teams = teamDataFromLevel @level @teams = teamDataFromLevel @level
@sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model @sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model

View file

@ -69,7 +69,7 @@ module.exports = class SpectateLevelView extends RootView
@load() @load()
setLevel: (@level, @supermodel) -> setLevel: (@level, @supermodel) ->
serializedLevel = @level.serialize @supermodel, @session, @otherSession serializedLevel = @level.serialize {@supermodel, @session, @otherSession, headless: false, sessionless: false}
@god?.setLevel serializedLevel @god?.setLevel serializedLevel
if @world if @world
@world.loadFromLevel serializedLevel, false @world.loadFromLevel serializedLevel, false
@ -106,7 +106,7 @@ module.exports = class SpectateLevelView extends RootView
#at this point, all requisite data is loaded, and sessions are not denormalized #at this point, all requisite data is loaded, and sessions are not denormalized
team = @world.teamForPlayer(0) team = @world.teamForPlayer(0)
@loadOpponentTeam(team) @loadOpponentTeam(team)
@god.setLevel @level.serialize @supermodel, @session, @otherSession @god.setLevel @level.serialize {@supermodel, @session, @otherSession, headless: false, sessionless: false}
@god.setLevelSessionIDs if @otherSession then [@session.id, @otherSession.id] else [@session.id] @god.setLevelSessionIDs if @otherSession then [@session.id, @otherSession.id] else [@session.id]
@god.setWorldClassMap @world.classMap @god.setWorldClassMap @world.classMap
@setTeam team @setTeam team

View file

@ -128,7 +128,7 @@ module.exports = class PlayLevelView extends RootView
@supermodel.collections = givenSupermodel.collections @supermodel.collections = givenSupermodel.collections
@supermodel.shouldSaveBackups = givenSupermodel.shouldSaveBackups @supermodel.shouldSaveBackups = givenSupermodel.shouldSaveBackups
serializedLevel = @level.serialize @supermodel, @session, @otherSession serializedLevel = @level.serialize {@supermodel, @session, @otherSession, headless: false, sessionless: false}
@god?.setLevel serializedLevel @god?.setLevel serializedLevel
if @world if @world
@world.loadFromLevel serializedLevel, false @world.loadFromLevel serializedLevel, false
@ -244,7 +244,7 @@ module.exports = class PlayLevelView extends RootView
@session.set 'multiplayer', false @session.set 'multiplayer', false
setupGod: -> setupGod: ->
@god.setLevel @level.serialize @supermodel, @session, @otherSession @god.setLevel @level.serialize {@supermodel, @session, @otherSession, headless: false, sessionless: false}
@god.setLevelSessionIDs if @otherSession then [@session.id, @otherSession.id] else [@session.id] @god.setLevelSessionIDs if @otherSession then [@session.id, @otherSession.id] else [@session.id]
@god.setWorldClassMap @world.classMap @god.setWorldClassMap @world.classMap

View file

@ -1,15 +1,9 @@
ModalView = require 'views/core/ModalView' ModalView = require 'views/core/ModalView'
template = require 'templates/play/level/modal/course-victory-modal' template = require 'templates/play/level/modal/course-victory-modal'
Achievements = require 'collections/Achievements'
Level = require 'models/Level' Level = require 'models/Level'
Course = require 'models/Course' Course = require 'models/Course'
ThangType = require 'models/ThangType'
ThangTypes = require 'collections/ThangTypes'
LevelSessions = require 'collections/LevelSessions' LevelSessions = require 'collections/LevelSessions'
EarnedAchievement = require 'models/EarnedAchievement'
LocalMongo = require 'lib/LocalMongo'
ProgressView = require './ProgressView' ProgressView = require './ProgressView'
NewItemView = require './NewItemView'
Classroom = require 'models/Classroom' Classroom = require 'models/Classroom'
utils = require 'core/utils' utils = require 'core/utils'
@ -18,7 +12,6 @@ module.exports = class CourseVictoryModal extends ModalView
template: template template: template
closesOnClickOutside: false closesOnClickOutside: false
initialize: (options) -> initialize: (options) ->
@courseID = options.courseID @courseID = options.courseID
@courseInstanceID = options.courseInstanceID @courseInstanceID = options.courseInstanceID
@ -26,20 +19,10 @@ module.exports = class CourseVictoryModal extends ModalView
@session = options.session @session = options.session
@level = options.level @level = options.level
@newItems = new ThangTypes()
@newHeroes = new ThangTypes()
if @courseInstanceID if @courseInstanceID
@classroom = new Classroom() @classroom = new Classroom()
@supermodel.trackRequest(@classroom.fetchForCourseInstance(@courseInstanceID)) @supermodel.trackRequest(@classroom.fetchForCourseInstance(@courseInstanceID))
@achievements = options.achievements
if not @achievements
@achievements = new Achievements()
@achievements.fetchRelatedToLevel(@session.get('level').original)
@achievements = @supermodel.loadCollection(@achievements, 'achievements').model
@listenToOnce @achievements, 'sync', @onAchievementsLoaded
else
@onAchievementsLoaded()
@playSound 'victory' @playSound 'victory'
@nextLevel = new Level() @nextLevel = new Level()
@ -68,69 +51,12 @@ module.exports = class CourseVictoryModal extends ModalView
return return
super(arguments...) super(arguments...)
onAchievementsLoaded: ->
@achievements.models = _.filter @achievements.models, (m) -> not m.get('query')?.ladderAchievementDifficulty # Don't show higher AI difficulty achievements
itemOriginals = []
heroOriginals = []
achievementIDs = []
for achievement in @achievements.models
rewards = achievement.get('rewards') or {}
heroOriginals.push rewards.heroes or []
itemOriginals.push rewards.items or []
achievement.completed = LocalMongo.matchesQuery(@session.attributes, achievement.get('query'))
achievementIDs.push(achievement.id) if achievement.completed
itemOriginals = _.uniq _.flatten itemOriginals
heroOriginals = _.uniq _.flatten heroOriginals
#project = ['original', 'rasterIcon', 'name', 'soundTriggers', 'i18n'] # This is what we need, but the PlayHeroesModal needs more, and so we load more to fill up the supermodel.
project = ['original', 'rasterIcon', 'name', 'slug', 'soundTriggers', 'featureImages', 'gems', 'heroClass', 'description', 'components', 'extendedName', 'unlockLevelName', 'i18n']
for [newThangTypeCollection, originals] in [[@newItems, itemOriginals], [@newHeroes, heroOriginals]]
for original in originals
thang= new ThangType()
thang.url = "/db/thang.type/#{original}/version"
thang.project = project
@supermodel.loadModel(thang)
newThangTypeCollection.add(thang)
@newEarnedAchievements = []
for achievement in @achievements.models
continue unless achievement.completed
ea = new EarnedAchievement({
collection: achievement.get('collection')
triggeredBy: @session.id
achievement: achievement.id
})
if me.isSessionless()
@newEarnedAchievements.push ea
else
ea.save()
# Can't just add models to supermodel because each ea has the same url
ea.sr = @supermodel.addSomethingResource(ea.cid)
@newEarnedAchievements.push ea
@listenToOnce ea, 'sync', (model) ->
model.sr.markLoaded()
if _.all((ea.id for ea in @newEarnedAchievements))
unless me.loading
@supermodel.loadModel(me, {cache: false})
@newEarnedAchievementsResource.markLoaded()
unless me.isSessionless()
# have to use a something resource because addModelResource doesn't handle models being upserted/fetched via POST like we're doing here
@newEarnedAchievementsResource = @supermodel.addSomethingResource('earned achievements') if @newEarnedAchievements.length
onLoaded: -> onLoaded: ->
super() super()
@views = [] @views = []
# TODO: Add main victory view @levelSessions?.remove(@session)
# TODO: Add level up view @levelSessions?.add(@session)
# TODO: Add new hero view?
for newItem in @newItems.models
@views.push(new NewItemView({item: newItem}))
progressView = new ProgressView({ progressView = new ProgressView({
level: @level level: @level
nextLevel: @nextLevel nextLevel: @nextLevel

View file

@ -275,8 +275,7 @@ module.exports = class SpellView extends CocoView
e.editor.execCommand 'gotolineend' e.editor.execCommand 'gotolineend'
return true return true
if me.level() < 20 or aceConfig.indentGuides # Add visual indent guides
# Add visual ident guides
language = @spell.language language = @spell.language
ensureLineStartsBlock = (line) -> ensureLineStartsBlock = (line) ->
return false unless language is "python" return false unless language is "python"

View file

@ -6,51 +6,49 @@
// Usage: // Usage:
// mongo <address>:<port>/<database> <script file> -u <username> -p <password> // mongo <address>:<port>/<database> <script file> -u <username> -p <password>
try { // TODO: Does not handle course prepaid updates on a user
var logDB = new Mongo("localhost").getDB("analytics") // TODO: Does not handle class membership changes
var scriptStartTime = new Date();
var analyticsStringCache = {};
var minClassSize = 12; // TODO: Investigate abrupt trial drop off at 4/1/16. Showed up when fixing coursePrepaid.
var minActiveCount = 6;
var eventNamePaid = 'Active classes paid'; var analyticsDB = new Mongo("localhost").getDB("analytics")
var eventNameTrial = 'Active classes trial'; var scriptStartTime = new Date();
var eventNameFree = 'Active classes free'; var analyticsStringCache = {};
var numDays = 40; var minClassSize = 12;
var daysInMonth = 30; var minActiveCount = 6;
var startDay = new Date(); var eventNamePaid = 'Active classes paid';
var today = startDay.toISOString().substr(0, 10); var eventNameTrial = 'Active classes trial';
startDay.setUTCDate(startDay.getUTCDate() - numDays); var eventNameFree = 'Active classes free';
startDay = startDay.toISOString().substr(0, 10);
log("Today is " + today); var numDays = 40;
log("Start day is " + startDay); var daysInMonth = 30;
log("Getting active class counts.."); var startDay = new Date();
var activeClassCounts = getActiveClassCounts(startDay); var today = startDay.toISOString().substr(0, 10);
// printjson(activeClassCounts); startDay.setUTCDate(startDay.getUTCDate() - numDays);
log("Inserting active class counts.."); startDay = startDay.toISOString().substr(0, 10);
for (var event in activeClassCounts) {
log("Today is " + today);
log("Start day is " + startDay);
log("Getting active class counts..");
var activeClassCounts = getActiveClassCounts(startDay);
// printjson(activeClassCounts);
// log("Inserting active class counts..");
for (var event in activeClassCounts) {
for (var day in activeClassCounts[event]) { for (var day in activeClassCounts[event]) {
if (today === day) continue; // Never save data for today because it's incomplete if (today === day) continue; // Never save data for today because it's incomplete
// print(event, day, activeClassCounts[event][day]); // print(event, day, activeClassCounts[event][day]);
insertEventCount(event, day, activeClassCounts[event][day]); insertEventCount(event, day, activeClassCounts[event][day]);
} }
} }
log("Script runtime: " + (new Date() - scriptStartTime)); log("Script runtime: " + (new Date() - scriptStartTime));
}
catch(err) {
log("ERROR: " + err);
printjson(err);
}
function getActiveClassCounts(startDay) { function getActiveClassCounts(startDay) {
// Tally active classes per day, for paid, trial, and free // Tally active classes per day, for paid, trial, and free
// TODO: does not handle class membership changes
if (!startDay) return {}; if (!startDay) return {};
@ -60,7 +58,7 @@ function getActiveClassCounts(startDay) {
// paid: at least one paid member // paid: at least one paid member
// trial: not paid, at least one trial member // trial: not paid, at least one trial member
// free: not paid, not free trial // free: not paid, not free trial
// user.coursePrepaidID set means access to paid courses // user.coursePrepaidID or user.coursePrepaid set means access to paid courses
// prepaid.properties.trialRequestID means access was via trial // prepaid.properties.trialRequestID means access was via trial
// Find classroom users // Find classroom users
@ -86,30 +84,46 @@ function getActiveClassCounts(startDay) {
} }
} }
} }
// log("DEBUG: Classroom users: " + classroomUserIDs.length);
log("Find user types.."); log("Find user types..");
var userEventMap = {}; var userEventEndDateMap = {};
var prepaidUsersMap = {}; var prepaidUsersMap = {};
var prepaidIDs = []; var prepaidIDs = [];
cursor = db.users.find({_id: {$in: classroomUserObjectIds}}, {coursePrepaidID: 1}); cursor = db.users.find({_id: {$in: classroomUserObjectIds}}, {coursePrepaid: 1, coursePrepaidID: 1});
while (cursor.hasNext()) { while (cursor.hasNext()) {
doc = cursor.next(); doc = cursor.next();
userEventEndDateMap[doc._id.valueOf()] = {};
userEventEndDateMap[doc._id.valueOf()][eventNameFree] = new Date();
if (doc.coursePrepaid) {
if (!doc.coursePrepaid.endDate) throw new Error("No endDate for new prepaid " + doc._id.valuOf());
userEventEndDateMap[doc._id.valueOf()][eventNamePaid] = new Date(doc.coursePrepaid.endDate);
if (!prepaidUsersMap[doc.coursePrepaid._id.valueOf()]) prepaidUsersMap[doc.coursePrepaid._id.valueOf()] = [];
prepaidUsersMap[doc.coursePrepaid._id.valueOf()].push(doc._id.valueOf());
prepaidIDs.push(doc.coursePrepaid._id);
}
if (doc.coursePrepaidID) { if (doc.coursePrepaidID) {
userEventMap[doc._id.valueOf()] = eventNamePaid; if (!userEventEndDateMap[doc._id.valueOf()][eventNamePaid]) {
userEventEndDateMap[doc._id.valueOf()][eventNamePaid] = new Date();
}
if (!prepaidUsersMap[doc.coursePrepaidID.valueOf()]) prepaidUsersMap[doc.coursePrepaidID.valueOf()] = []; if (!prepaidUsersMap[doc.coursePrepaidID.valueOf()]) prepaidUsersMap[doc.coursePrepaidID.valueOf()] = [];
prepaidUsersMap[doc.coursePrepaidID.valueOf()].push(doc._id.valueOf()); prepaidUsersMap[doc.coursePrepaidID.valueOf()].push(doc._id.valueOf());
prepaidIDs.push(doc.coursePrepaidID); prepaidIDs.push(doc.coursePrepaidID);
} }
else {
userEventMap[doc._id.valueOf()] = eventNameFree;
} }
} cursor = db.prepaids.find({_id: {$in: prepaidIDs}}, {endDate: 1, properties: 1});
cursor = db.prepaids.find({_id: {$in: prepaidIDs}}, {properties: 1});
while (cursor.hasNext()) { while (cursor.hasNext()) {
doc = cursor.next(); doc = cursor.next();
if (doc.properties && doc.properties.trialRequestID) { if (doc.properties && doc.properties.trialRequestID) {
var endDate = new Date();
if (doc.endDate) {
endDate = new Date(doc.endDate);
}
else if (doc.properties.endDate) {
endDate = new Date(doc.properties.endDate);
}
for (var i = 0; i < prepaidUsersMap[doc._id.valueOf()].length; i++) { for (var i = 0; i < prepaidUsersMap[doc._id.valueOf()].length; i++) {
userEventMap[prepaidUsersMap[doc._id.valueOf()][i]] = eventNameTrial; userEventEndDateMap[prepaidUsersMap[doc._id.valueOf()][i]][eventNameTrial] = endDate;
} }
} }
} }
@ -121,18 +135,22 @@ function getActiveClassCounts(startDay) {
var endDate = ISODate(startDay + "T00:00:00.000Z"); var endDate = ISODate(startDay + "T00:00:00.000Z");
var todayDate = new Date(new Date().toISOString().substring(0, 10)); var todayDate = new Date(new Date().toISOString().substring(0, 10));
var startObj = objectIdWithTimestamp(startDate); var startObj = objectIdWithTimestamp(startDate);
// Batch size test times: 10k 427005, 5k 361361, 1k 799068, 2k 791521
var batchSize = 5000;
for (var j = 0; j < classroomUserIDs.length / batchSize + 1; j++) {
// log("DEBUG: Fetching classroom events batch " + (j * batchSize) + " " + (j * batchSize + batchSize));
var queryParams = {$and: [ var queryParams = {$and: [
{_id: {$gte: startObj}}, {_id: {$gte: startObj}},
{user: {$in: classroomUserIDs}}, {user: {$in: classroomUserIDs.slice(j * batchSize, j * batchSize + batchSize)}},
{event: 'Started Level'} {event: 'Started Level'}
]}; ]};
cursor = logDB['log'].find(queryParams, {user: 1}); cursor = analyticsDB['log'].find(queryParams, {user: 1});
while (cursor.hasNext()) { while (cursor.hasNext()) {
doc = cursor.next(); doc = cursor.next();
if (!userPlayedMap[doc.user]) userPlayedMap[doc.user] = []; if (!userPlayedMap[doc.user]) userPlayedMap[doc.user] = [];
userPlayedMap[doc.user].push(doc._id.getTimestamp()); userPlayedMap[doc.user].push(doc._id.getTimestamp());
} }
// printjson(userPlayedMap); }
log("Calculate number of active members per classroom per day per event type.."); log("Calculate number of active members per classroom per day per event type..");
var classDayTypeMap = {}; var classDayTypeMap = {};
@ -159,7 +177,19 @@ function getActiveClassCounts(startDay) {
if (userPlayedMap[member]) { if (userPlayedMap[member]) {
for (var k = 0; k < userPlayedMap[member].length; k++) { for (var k = 0; k < userPlayedMap[member].length; k++) {
if (userPlayedMap[member][k] > startDate && userPlayedMap[member][k] <= endDate) { if (userPlayedMap[member][k] > startDate && userPlayedMap[member][k] <= endDate) {
classDayTypeMap[classroom][endDay][userEventMap[member]]++; if (userEventEndDateMap[member][eventNameTrial] > endDate) {
classDayTypeMap[classroom][endDay][eventNameTrial]++;
}
else if (userEventEndDateMap[member][eventNamePaid] > endDate) {
classDayTypeMap[classroom][endDay][eventNamePaid]++;
}
else if (userEventEndDateMap[member][eventNameFree] > endDate) {
classDayTypeMap[classroom][endDay][eventNameFree]++;
}
else {
print("ERROR: no event for " + member);
printjson(userEventEndDateMap[member]);
}
break; break;
} }
} }

View file

@ -8,6 +8,8 @@
// Usage: // Usage:
// mongo <address>:<port>/<database> <script file> -u <username> -p <password> // mongo <address>:<port>/<database> <script file> -u <username> -p <password>
// TODO: classroom paid active users before 4/13/16 not correct
try { try {
var logDB = new Mongo("localhost").getDB("analytics") var logDB = new Mongo("localhost").getDB("analytics")
var scriptStartTime = new Date(); var scriptStartTime = new Date();
@ -129,32 +131,47 @@ function getActiveUserCounts(startDay, endDay, activeUserEvents) {
log("Classroom user count: " + classroomUserObjectIds.length); log("Classroom user count: " + classroomUserObjectIds.length);
// Classrooms free/trial/paid // Classrooms free/trial/paid
// Paid user: user.coursePrepaidID set means access to paid courses // Paid user: user.coursePrepaid or user.coursePrepaidID set means access to paid courses
// Trial user: prepaid.properties.trialRequestID means access was via trial // Trial user: prepaid.properties.trialRequestID means access was via trial
// Free: not paid, not trial // Free: not paid, not trial
log("Finding classroom users free/trial/paid status.."); log("Finding classroom users free/trial/paid status..");
var classroomUserEventMap = {}; var classroomUserEventEndDateMap = {};
var prepaidUsersMap = {}; var prepaidUsersMap = {};
var prepaidIDs = []; var prepaidIDs = [];
cursor = db.users.find({_id: {$in: classroomUserObjectIds}}, {coursePrepaidID: 1}); cursor = db.users.find({_id: {$in: classroomUserObjectIds}}, {coursePrepaid: 1, coursePrepaidID: 1});
while (cursor.hasNext()) { while (cursor.hasNext()) {
doc = cursor.next(); doc = cursor.next();
classroomUserEventEndDateMap[doc._id.valueOf()] = {};
classroomUserEventEndDateMap[doc._id.valueOf()]['DAU classroom free'] = new Date();
if (doc.coursePrepaid) {
if (!doc.coursePrepaid.endDate) throw new Error("No endDate for new prepaid " + doc._id.valuOf());
classroomUserEventEndDateMap[doc._id.valueOf()]['DAU classroom paid'] = new Date(doc.coursePrepaid.endDate);
if (!prepaidUsersMap[doc.coursePrepaid._id.valueOf()]) prepaidUsersMap[doc.coursePrepaid._id.valueOf()] = [];
prepaidUsersMap[doc.coursePrepaid._id.valueOf()].push(doc._id.valueOf());
prepaidIDs.push(doc.coursePrepaid._id);
}
if (doc.coursePrepaidID) { if (doc.coursePrepaidID) {
classroomUserEventMap[doc._id.valueOf()] = 'DAU classroom paid'; if (!classroomUserEventEndDateMap[doc._id.valueOf()]['DAU classroom paid']) {
classroomUserEventEndDateMap[doc._id.valueOf()]['DAU classroom paid'] = new Date();
}
if (!prepaidUsersMap[doc.coursePrepaidID.valueOf()]) prepaidUsersMap[doc.coursePrepaidID.valueOf()] = []; if (!prepaidUsersMap[doc.coursePrepaidID.valueOf()]) prepaidUsersMap[doc.coursePrepaidID.valueOf()] = [];
prepaidUsersMap[doc.coursePrepaidID.valueOf()].push(doc._id.valueOf()); prepaidUsersMap[doc.coursePrepaidID.valueOf()].push(doc._id.valueOf());
prepaidIDs.push(doc.coursePrepaidID); prepaidIDs.push(doc.coursePrepaidID);
} }
else {
classroomUserEventMap[doc._id.valueOf()] = 'DAU classroom free';
}
} }
cursor = db.prepaids.find({_id: {$in: prepaidIDs}}, {properties: 1}); cursor = db.prepaids.find({_id: {$in: prepaidIDs}}, {properties: 1});
while (cursor.hasNext()) { while (cursor.hasNext()) {
doc = cursor.next(); doc = cursor.next();
if (doc.properties && doc.properties.trialRequestID) { if (doc.properties && doc.properties.trialRequestID) {
var endDate = new Date();
if (doc.endDate) {
endDate = new Date(doc.endDate);
}
else if (doc.properties.endDate) {
endDate = new Date(doc.properties.endDate);
}
for (var i = 0; i < prepaidUsersMap[doc._id.valueOf()].length; i++) { for (var i = 0; i < prepaidUsersMap[doc._id.valueOf()].length; i++) {
classroomUserEventMap[prepaidUsersMap[doc._id.valueOf()][i]] = 'DAU classroom trial'; classroomUserEventEndDateMap[prepaidUsersMap[doc._id.valueOf()][i]]['DAU classroom trial'] = endDate;
} }
} }
} }
@ -199,7 +216,22 @@ function getActiveUserCounts(startDay, endDay, activeUserEvents) {
var userDayEventMap = {} var userDayEventMap = {}
for (day in dayUserActiveMap) { for (day in dayUserActiveMap) {
for (var user in dayUserActiveMap[day]) { for (var user in dayUserActiveMap[day]) {
var event = classroomUserEventMap[user] || (dayCampaignUserPaidMap[day] && dayCampaignUserPaidMap[day][user] ? 'DAU campaign paid' : 'DAU campaign free'); var event = null;
var endDate = new Date(day + "T00:00:00.000Z");
if (classroomUserEventEndDateMap[user]) {
if (classroomUserEventEndDateMap[user]['DAU classroom trial'] > endDate) {
event = 'DAU classroom trial';
}
else if (classroomUserEventEndDateMap[user]['DAU classroom paid'] > endDate) {
event = 'DAU classroom paid';
}
else if (classroomUserEventEndDateMap[user]['DAU classroom free'] > endDate) {
event = 'DAU classroom free';
}
}
if (!event) {
event = dayCampaignUserPaidMap[day] && dayCampaignUserPaidMap[day][user] ? 'DAU campaign paid' : 'DAU campaign free';
}
dailyEventNames[event] = true; dailyEventNames[event] = true;
if (!activeUsersCounts[day]) activeUsersCounts[day] = {}; if (!activeUsersCounts[day]) activeUsersCounts[day] = {};
if (!activeUsersCounts[day][event]) activeUsersCounts[day][event] = 0; if (!activeUsersCounts[day][event]) activeUsersCounts[day][event] = 0;

View file

@ -1,7 +1,7 @@
// Follow up on Close.io leads // Follow up on Close.io leads
'use strict'; 'use strict';
if (process.argv.length !== 7) { if (process.argv.length !== 8) {
log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <mongo connection Url>"); log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <mongo connection Url>");
process.exit(); process.exit();
} }
@ -20,8 +20,8 @@ const demoRequestEmailTemplatesAuto2 = ['tmpl_HJ5zebh1SqC1QydDto05VPUMu4F7i5M35L
const scriptStartTime = new Date(); const scriptStartTime = new Date();
const closeIoApiKey = process.argv[2]; const closeIoApiKey = process.argv[2];
const closeIoMailApiKeys = [process.argv[3], process.argv[4], process.argv[5]]; // Automatic mails sent as API owners const closeIoMailApiKeys = [process.argv[3], process.argv[4], process.argv[5], process.argv[6]]; // Automatic mails sent as API owners
const mongoConnUrl = process.argv[6]; const mongoConnUrl = process.argv[7];
const MongoClient = require('mongodb').MongoClient; const MongoClient = require('mongodb').MongoClient;
const async = require('async'); const async = require('async');
const request = require('request'); const request = require('request');

View file

@ -1,8 +1,8 @@
// Upsert new lead data into Close.io // Upsert new lead data into Close.io
'use strict'; 'use strict';
if (process.argv.length !== 9) { if (process.argv.length !== 10) {
log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <Close.io EU mail API key> <Intercom 'App ID:API key'> <mongo connection Url>"); log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <Close.io mail API key4> <Close.io EU mail API key> <Intercom 'App ID:API key'> <mongo connection Url>");
process.exit(); process.exit();
} }
@ -64,18 +64,22 @@ const closeIoMailApiKeys = [
}, },
{ {
apiKey: process.argv[4], apiKey: process.argv[4],
weight: .25 weight: .20
}, },
{ {
apiKey: process.argv[5], apiKey: process.argv[5],
weight: .05 weight: .05
}, },
{
apiKey: process.argv[6],
weight: .05
},
]; ];
const closeIoEuMailApiKey = process.argv[6]; const closeIoEuMailApiKey = process.argv[7];
const intercomAppIdApiKey = process.argv[7]; const intercomAppIdApiKey = process.argv[8];
const intercomAppId = intercomAppIdApiKey.split(':')[0]; const intercomAppId = intercomAppIdApiKey.split(':')[0];
const intercomApiKey = intercomAppIdApiKey.split(':')[1]; const intercomApiKey = intercomAppIdApiKey.split(':')[1];
const mongoConnUrl = process.argv[8]; const mongoConnUrl = process.argv[9];
const MongoClient = require('mongodb').MongoClient; const MongoClient = require('mongodb').MongoClient;
const async = require('async'); const async = require('async');
const countryData = require('country-data'); const countryData = require('country-data');

View file

@ -142,13 +142,13 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
CourseInstance.findById courseInstanceID, (err, courseInstance) => CourseInstance.findById courseInstanceID, (err, courseInstance) =>
return @sendDatabaseError(res, err) if err return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless courseInstance return @sendNotFoundError(res) unless courseInstance
Course.findById courseInstance.get('courseID'), (err, course) => Classroom.findById courseInstance.get('classroomID'), (err, classroom) =>
return @sendDatabaseError(res, err) if err return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless course return @sendNotFoundError(res) unless classroom
Campaign.findById course.get('campaignID'), (err, campaign) => levelIDs = []
return @sendDatabaseError(res, err) if err for course in classroom.get('courses') when course._id.equals(courseInstance.get('courseID'))
return @sendNotFoundError(res) unless campaign for level in course.levels when not _.contains(level.type, 'ladder')
levelIDs = (levelID for levelID, level of campaign.get('levels') when not _.contains(level.type, 'ladder')) levelIDs.push(level.original + "")
query = {$and: [{creator: req.user.id}, {'level.original': {$in: levelIDs}}]} query = {$and: [{creator: req.user.id}, {'level.original': {$in: levelIDs}}]}
cursor = LevelSession.find(query) cursor = LevelSession.find(query)
cursor = cursor.select(req.query.project) if req.query.project cursor = cursor.select(req.query.project) if req.query.project

View file

@ -39,6 +39,7 @@ LevelHandler = class LevelHandler extends Handler
'requiresSubscription' 'requiresSubscription'
'adventurer' 'adventurer'
'practice' 'practice'
'shareable'
'adminOnly' 'adminOnly'
'disableSpaces' 'disableSpaces'
'hidesSubmitUntilRun' 'hidesSubmitUntilRun'

View file

@ -154,7 +154,7 @@ module.exports =
levels = _.sortBy(levels, 'campaignIndex') levels = _.sortBy(levels, 'campaignIndex')
for level in levels for level in levels
levelData = { original: mongoose.Types.ObjectId(level.original) } levelData = { original: mongoose.Types.ObjectId(level.original) }
_.extend(levelData, _.pick(level, 'type', 'slug', 'name', 'practice', 'practiceThresholdMinutes')) _.extend(levelData, _.pick(level, 'type', 'slug', 'name', 'practice', 'practiceThresholdMinutes', 'shareable'))
courseData.levels.push(levelData) courseData.levels.push(levelData)
coursesData.push(courseData) coursesData.push(courseData)
classroom.set('courses', coursesData) classroom.set('courses', coursesData)

View file

@ -13,6 +13,8 @@ User = require '../models/User'
Classroom = require '../models/Classroom' Classroom = require '../models/Classroom'
facebook = require '../lib/facebook' facebook = require '../lib/facebook'
gplus = require '../lib/gplus' gplus = require '../lib/gplus'
TrialRequest = require '../models/TrialRequest'
log = require 'winston'
module.exports = module.exports =
fetchByGPlusID: wrap (req, res, next) -> fetchByGPlusID: wrap (req, res, next) ->
@ -133,16 +135,7 @@ module.exports =
throw new errors.Conflict('Email already taken') throw new errors.Conflict('Email already taken')
req.user.set({ password, email, anonymous: false }) req.user.set({ password, email, anonymous: false })
try yield module.exports.finishSignup(req, res)
yield req.user.save()
catch e
if e.code is 11000 # Duplicate key error
throw new errors.Conflict('Email already taken')
else
throw e
req.user.sendWelcomeEmail()
res.status(200).send(req.user.toObject({req: req}))
signupWithFacebook: wrap (req, res) -> signupWithFacebook: wrap (req, res) ->
unless req.user.isAnonymous() unless req.user.isAnonymous()
@ -159,16 +152,7 @@ module.exports =
throw new errors.UnprocessableEntity('Invalid facebookAccessToken') throw new errors.UnprocessableEntity('Invalid facebookAccessToken')
req.user.set({ facebookID, email, anonymous: false }) req.user.set({ facebookID, email, anonymous: false })
try yield module.exports.finishSignup(req, res)
yield req.user.save()
catch e
if e.code is 11000 # Duplicate key error
throw new errors.Conflict('Email already taken')
else
throw e
req.user.sendWelcomeEmail()
res.status(200).send(req.user.toObject({req: req}))
signupWithGPlus: wrap (req, res) -> signupWithGPlus: wrap (req, res) ->
unless req.user.isAnonymous() unless req.user.isAnonymous()
@ -186,6 +170,9 @@ module.exports =
throw new errors.UnprocessableEntity('Invalid gplusAccessToken') throw new errors.UnprocessableEntity('Invalid gplusAccessToken')
req.user.set({ gplusID, email, anonymous: false }) req.user.set({ gplusID, email, anonymous: false })
yield module.exports.finishSignup(req, res)
finishSignup: co.wrap (req, res) ->
try try
yield req.user.save() yield req.user.save()
catch e catch e
@ -194,5 +181,21 @@ module.exports =
else else
throw e throw e
# post-successful account signup tasks
req.user.sendWelcomeEmail() req.user.sendWelcomeEmail()
# If person A creates a trial request without creating an account, then person B uses that computer
# to create an account, then person A's trial request is associated with person B's account. To prevent
# this, we check that the signup email matches the trial request email, for every signup. If they do
# not match, the trial request applicant field is cleared, disassociating the trial request from this
# account.
trialRequest = yield TrialRequest.findOne({applicant: req.user._id})
if trialRequest
email = trialRequest.get('properties')?.email or ''
emailLower = email.toLowerCase()
if emailLower and emailLower isnt req.user.get('emailLower')
log.warn('User submitted trial request and created account with different emails. Disassociating trial request.')
yield trialRequest.update({$unset: {applicant: ''}})
res.status(200).send(req.user.toObject({req: req})) res.status(200).send(req.user.toObject({req: req}))

View file

@ -31,6 +31,6 @@ AnalyticsLogEventSchema.statics.logEvent = (user, event, properties={}) ->
unless config.proxy unless config.proxy
analyticsMongoose = mongoose.createConnection() analyticsMongoose = mongoose.createConnection()
analyticsMongoose.open "mongodb://#{config.mongo.analytics_host}:#{config.mongo.analytics_port}/#{config.mongo.analytics_db}", (error) -> analyticsMongoose.open "mongodb://#{config.mongo.analytics_host}:#{config.mongo.analytics_port}/#{config.mongo.analytics_db}", (error) ->
log.warn "Couldnt connect to analytics", error log.error "Couldnt connect to analytics", error if error
module.exports = AnalyticsLogEvent = analyticsMongoose.model('analytics.log.event', AnalyticsLogEventSchema, config.mongo.analytics_collection) module.exports = AnalyticsLogEvent = analyticsMongoose.model('analytics.log.event', AnalyticsLogEventSchema, config.mongo.analytics_collection)

View file

@ -106,4 +106,32 @@ LevelSessionSchema.set('toObject', {
return ret return ret
}) })
module.exports = LevelSession = mongoose.model('level.session', LevelSessionSchema, 'level.sessions') if config.mongo.level_session_replica_string?
levelSessionMongo = mongoose.createConnection()
levelSessionMongo.open config.mongo.level_session_replica_string, (error) ->
if error
log.error "Couldnt connect to session mongo!", error
else
log.info "Connected to seperate level session server with string", config.mongo.level_session_replica_string
else
levelSessionMongo = mongoose
LevelSession = levelSessionMongo.model('level.session', LevelSessionSchema, 'level.sessions')
if config.mongo.level_session_aux_replica_string?
auxLevelSessionMongo = mongoose.createConnection()
auxLevelSessionMongo.open config.mongo.level_session_aux_replica_string, (error) ->
if error
log.error "Couldnt connect to AUX session mongo!", error
else
log.info "Connected to seperate level AUX session server with string", config.mongo.level_session_aux_replica_string
auxLevelSession = auxLevelSessionMongo.model('level.session', LevelSessionSchema, 'level.sessions')
LevelSessionSchema.post 'save', (d) ->
return unless d instanceof LevelSession
o = d.toObject {transform: ((x, r) -> r), virtuals: false}
auxLevelSession.collection.save o, {w:1}, (err, v) ->
log.error err.stack if err
module.exports = LevelSession

View file

@ -26,6 +26,11 @@ config.mongo =
mongoose_tokyo_replica_string: process.env.COCO_MONGO_MONGOOSE_TOKYO_REPLICA_STRING or '' mongoose_tokyo_replica_string: process.env.COCO_MONGO_MONGOOSE_TOKYO_REPLICA_STRING or ''
mongoose_saoPaulo_replica_string : process.env.COCO_MONGO_MONGOOSE_SAOPAULO_REPLICA_STRING or '' mongoose_saoPaulo_replica_string : process.env.COCO_MONGO_MONGOOSE_SAOPAULO_REPLICA_STRING or ''
if process.env.COCO_MONGO_LS_REPLICA_STRING?
config.mongo.level_session_replica_string = process.env.COCO_MONGO_LS_REPLICA_STRING
if process.env.COCO_MONGO_LS_AUX_REPLICA_STRING?
config.mongo.level_session_aux_replica_string = process.env.COCO_MONGO_LS_AUX_REPLICA_STRING
if config.tokyo or config.saoPaulo if config.tokyo or config.saoPaulo

View file

@ -3,6 +3,7 @@ utils = require '../utils'
urlUser = '/db/user' urlUser = '/db/user'
User = require '../../../server/models/User' User = require '../../../server/models/User'
Classroom = require '../../../server/models/Classroom' Classroom = require '../../../server/models/Classroom'
TrialRequest = require '../../../server/models/TrialRequest'
Prepaid = require '../../../server/models/Prepaid' Prepaid = require '../../../server/models/Prepaid'
request = require '../request' request = require '../request'
facebook = require '../../../server/lib/facebook' facebook = require '../../../server/lib/facebook'
@ -707,6 +708,32 @@ describe 'POST /db/user/:handle/signup-with-password', ->
expect(res.statusCode).toBe(409) expect(res.statusCode).toBe(409)
done() done()
it 'disassociates the user from their trial request if the trial request email and signup email do not match', utils.wrap (done) ->
user = yield utils.becomeAnonymous()
trialRequest = yield utils.makeTrialRequest({ properties: { email: 'one@email.com' } })
expect(trialRequest.get('applicant').equals(user._id)).toBe(true)
url = getURL("/db/user/#{user.id}/signup-with-password")
email = 'two@email.com'
json = { email, password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(200)
trialRequest = yield TrialRequest.findById(trialRequest.id)
expect(trialRequest.get('applicant')).toBeUndefined()
done()
it 'does NOT disassociate the user from their trial request if the trial request email and signup email DO match', utils.wrap (done) ->
user = yield utils.becomeAnonymous()
trialRequest = yield utils.makeTrialRequest({ properties: { email: 'one@email.com' } })
expect(trialRequest.get('applicant').equals(user._id)).toBe(true)
url = getURL("/db/user/#{user.id}/signup-with-password")
email = 'one@email.com'
json = { email, password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(200)
trialRequest = yield TrialRequest.findById(trialRequest.id)
expect(trialRequest.get('applicant').equals(user._id)).toBe(true)
done()
describe 'POST /db/user/:handle/signup-with-facebook', -> describe 'POST /db/user/:handle/signup-with-facebook', ->
facebookID = '12345' facebookID = '12345'

View file

@ -1,9 +1,7 @@
Course = require 'models/Course' Course = require 'models/Course'
Level = require 'models/Level' Level = require 'models/Level'
LevelSession = require 'models/LevelSession' LevelSession = require 'models/LevelSession'
Achievements = require 'collections/Achievements'
CourseVictoryModal = require 'views/play/level/modal/CourseVictoryModal' CourseVictoryModal = require 'views/play/level/modal/CourseVictoryModal'
NewItemView = require 'views/play/level/modal/NewItemView'
ProgressView = require 'views/play/level/modal/ProgressView' ProgressView = require 'views/play/level/modal/ProgressView'
factories = require 'test/app/factories' factories = require 'test/app/factories'
@ -21,7 +19,6 @@ describe 'CourseVictoryModal', ->
course: factories.makeCourse() course: factories.makeCourse()
level: level level: level
session: factories.makeLevelSession({ state: { complete: true } }, { level }) session: factories.makeLevelSession({ state: { complete: true } }, { level })
achievements: new Achievements([factories.makeLevelCompleteAchievement({}, {level: level})])
nextLevel: factories.makeLevel() nextLevel: factories.makeLevel()
courseInstanceID: courseInstance.id courseInstanceID: courseInstance.id
courseID: course.id courseID: course.id
@ -31,11 +28,6 @@ describe 'CourseVictoryModal', ->
handleRequests = (modal) -> handleRequests = (modal) ->
requests = jasmine.Ajax.requests.all() requests = jasmine.Ajax.requests.all()
thangRequest = _.find(requests, (r) -> _.string.startsWith(r.url, '/db/thang.type'))
thangRequest?.respondWith({status: 200, responseText: factories.makeThangType().stringify()})
modal.newEarnedAchievements[0].fakeRequests[0].respondWith({
status: 200, responseText: factories.makeEarnedAchievement().stringify()
})
modal.levelSessions.fakeRequests[0].respondWith({ status: 200, responseText: '[]' }) modal.levelSessions.fakeRequests[0].respondWith({ status: 200, responseText: '[]' })
modal.classroom.fakeRequests[0].respondWith({ modal.classroom.fakeRequests[0].respondWith({
status: 200, responseText: factories.makeClassroom().stringify() status: 200, responseText: factories.makeClassroom().stringify()
@ -106,32 +98,3 @@ describe 'CourseVictoryModal', ->
expect(application.router.navigate).toHaveBeenCalled() expect(application.router.navigate).toHaveBeenCalled()
it '(demo)', -> jasmine.demoModal(modal) it '(demo)', -> jasmine.demoModal(modal)
describe 'given a course level with a new item', ->
modal = null
beforeEach (done) ->
options = makeViewOptions()
# insert new item into achievement properties
achievement = options.achievements.first()
rewards = _.cloneDeep(achievement.get('rewards'))
rewards.items = ["53e4108204c00d4607a89f78"]
achievement.set('rewards', rewards)
modal = new CourseVictoryModal(options)
handleRequests(modal)
nextLevelRequest.respondWith({status: 200, responseText: factories.makeLevel().stringify()})
_.defer done
it 'includes a NewItemView when the level rewards a new item', ->
expect(_.size(modal.views)).toBe(2)
expect(modal.views[0] instanceof NewItemView).toBe(true)
it 'continues to the ProgressView when you click the continue button', ->
expect(modal.currentView instanceof NewItemView).toBe(true)
modal.$el.find('#continue-btn').click()
expect(modal.currentView instanceof ProgressView).toBe(true)
it '(demo)', -> jasmine.demoModal(modal)