Merge branch 'master' into production

This commit is contained in:
Matt Lott 2016-06-27 15:17:20 -07:00
commit 152e468865
26 changed files with 508 additions and 151 deletions

View file

@ -325,3 +325,48 @@ module.exports.capitalLanguages = capitalLanguages =
'python': 'Python' 'python': 'Python'
'java': 'Java' 'java': 'Java'
'lua': 'Lua' 'lua': 'Lua'
module.exports.createLevelNumberMap = (levels) ->
levelNumberMap = {}
practiceLevelTotalCount = 0
practiceLevelCurrentCount = 0
for level, i in levels
levelNumber = i - practiceLevelTotalCount + 1
if level.practice
levelNumber = i - practiceLevelTotalCount + String.fromCharCode('a'.charCodeAt(0) + practiceLevelCurrentCount)
practiceLevelTotalCount++
practiceLevelCurrentCount++
else
practiceLevelCurrentCount = 0
levelNumberMap[level.key] = levelNumber
levelNumberMap
module.exports.findNextLevel = (levels, currentIndex, needsPractice) ->
# levels = [{practice: true/false, complete: true/false}]
index = currentIndex
index++
if needsPractice
if levels[currentIndex].practice or index < levels.length and levels[index].practice
# Needs practice, on practice or next practice, choose next incomplete level
# May leave earlier practice levels incomplete and reach end of course
index++ while index < levels.length and levels[index].complete
else
# Needs practice, on required, next required, choose first incomplete level of previous practice chain
index--
index-- while index >= 0 and not levels[index].practice
if index >= 0
index-- while index >= 0 and levels[index].practice
if index >= 0
index++
index++ while index < levels.length and levels[index].practice and levels[index].complete
if levels[index].practice and not levels[index].complete
return index
index = currentIndex + 1
index++ while index < levels.length and levels[index].complete
else
# No practice needed, next required incomplete level
index++ while index < levels.length and (levels[index].practice or levels[index].complete)
index
module.exports.needsPractice = (playtime=0, threshold=2) ->
playtime / 60 > threshold

View file

@ -295,6 +295,7 @@
saving: "Saving..." saving: "Saving..."
sending: "Sending..." sending: "Sending..."
send: "Send" send: "Send"
type: "Type"
cancel: "Cancel" cancel: "Cancel"
save: "Save" save: "Save"
publish: "Publish" publish: "Publish"

View file

@ -3,6 +3,7 @@ schema = require 'schemas/models/campaign.schema'
Level = require 'models/Level' Level = require 'models/Level'
Levels = require 'collections/Levels' Levels = require 'collections/Levels'
CocoCollection = require 'collections/CocoCollection' CocoCollection = require 'collections/CocoCollection'
utils = require '../core/utils'
module.exports = class Campaign extends CocoModel module.exports = class Campaign extends CocoModel
@className: 'Campaign' @className: 'Campaign'
@ -23,3 +24,11 @@ module.exports = class Campaign extends CocoModel
levels.comparator = 'campaignIndex' levels.comparator = 'campaignIndex'
levels.sort() levels.sort()
return levels return levels
getLevelNumber: (levelID, defaultNumber) ->
unless @levelNumberMap
levels = []
for level in @getLevels().models when level.get('original')
levels.push({key: level.get('original'), practice: level.get('practice') ? false})
@levelNumberMap = utils.createLevelNumberMap(levels)
@levelNumberMap[levelID] ? defaultNumber

View file

@ -1,17 +1,17 @@
CocoModel = require './CocoModel' CocoModel = require './CocoModel'
schema = require 'schemas/models/classroom.schema' schema = require 'schemas/models/classroom.schema'
utils = require 'core/utils' utils = require '../core/utils'
User = require 'models/User' User = require 'models/User'
module.exports = class Classroom extends CocoModel module.exports = class Classroom extends CocoModel
@className: 'Classroom' @className: 'Classroom'
@schema: schema @schema: schema
urlRoot: '/db/classroom' urlRoot: '/db/classroom'
initialize: () -> initialize: () ->
@listenTo @, 'change:aceConfig', @capitalizeLanguageName @listenTo @, 'change:aceConfig', @capitalizeLanguageName
super(arguments...) super(arguments...)
parse: (obj) -> parse: (obj) ->
if obj._id if obj._id
# It's just the classroom object # It's just the classroom object
@ -20,7 +20,7 @@ module.exports = class Classroom extends CocoModel
# It's a compound response with other stuff too # It's a compound response with other stuff too
@owner = new User(obj.owner) @owner = new User(obj.owner)
return obj.data return obj.data
capitalizeLanguageName: -> capitalizeLanguageName: ->
language = @get('aceConfig')?.language language = @get('aceConfig')?.language
@capitalLanguage = utils.capitalLanguages[language] @capitalLanguage = utils.capitalLanguages[language]
@ -35,7 +35,7 @@ module.exports = class Classroom extends CocoModel
} }
_.extend options, opts _.extend options, opts
@fetch(options) @fetch(options)
fetchByCode: (code, opts) -> fetchByCode: (code, opts) ->
options = { options = {
url: _.result(@, 'url') url: _.result(@, 'url')
@ -43,7 +43,17 @@ module.exports = class Classroom extends CocoModel
} }
_.extend options, opts _.extend options, opts
@fetch(options) @fetch(options)
getLevelNumber: (levelID, defaultNumber) ->
unless @levelNumberMap
@levelNumberMap = {}
for course in @get('courses') ? []
levels = []
for level in course.levels when level.original
levels.push({key: level.original, practice: level.practice ? false})
_.assign(@levelNumberMap, utils.createLevelNumberMap(levels))
@levelNumberMap[levelID] ? defaultNumber
removeMember: (userID, opts) -> removeMember: (userID, opts) ->
options = { options = {
url: _.result(@, 'url') + '/members' url: _.result(@, 'url') + '/members'
@ -62,7 +72,7 @@ module.exports = class Classroom extends CocoModel
success: => @trigger 'save-password:success' success: => @trigger 'save-password:success'
error: (response) => @trigger 'save-password:error', response.responseJSON error: (response) => @trigger 'save-password:error', response.responseJSON
} }
getLevels: (options={}) -> getLevels: (options={}) ->
# options: courseID, withoutLadderLevels # options: courseID, withoutLadderLevels
Levels = require 'collections/Levels' Levels = require 'collections/Levels'
@ -77,7 +87,7 @@ module.exports = class Classroom extends CocoModel
if options.withoutLadderLevels if options.withoutLadderLevels
levels.remove(levels.filter((level) -> level.isLadder())) levels.remove(levels.filter((level) -> level.isLadder()))
return levels return levels
getLadderLevel: (courseID) -> getLadderLevel: (courseID) ->
Levels = require 'collections/Levels' Levels = require 'collections/Levels'
courses = @get('courses') courses = @get('courses')
@ -88,30 +98,59 @@ module.exports = class Classroom extends CocoModel
statsForSessions: (sessions, courseID) -> statsForSessions: (sessions, courseID) ->
return null unless sessions return null unless sessions
stats = {}
sessions = sessions.models or sessions sessions = sessions.models or sessions
arena = @getLadderLevel(courseID) arena = @getLadderLevel(courseID)
levels = @getLevels({courseID: courseID, withoutLadderLevels: true}) courseLevels = @getLevels({courseID: courseID, withoutLadderLevels: true})
levelOriginals = levels.pluck('original') levelSessionMap = {}
completeSessionOriginals = (session.get('level').original for session in sessions when session.get('state').complete) levelSessionMap[session.get('level').original] = session for session in sessions
incompleteSessionOriginals = (session.get('level').original for session in sessions when not session.get('state').complete) currentIndex = -1
levelsLeft = _.size(_.difference(levelOriginals, completeSessionOriginals)) lastStarted = null
next = _.find levels.models, (level) -> level.get('original') not in completeSessionOriginals levelsTotal = 0
lastPlayed = _.find levels.models, (level) -> level.get('original') in incompleteSessionOriginals levelsLeft = 0
stats.levels = { lastPlayed = null
size: levels.size() playtime = 0
left: levelsLeft levels = []
done: levelsLeft is 0 for level, index in courseLevels.models
numDone: levels.size() - levelsLeft levelsTotal++ unless level.get('practice')
pctDone: (100 * (levels.size() - levelsLeft) / levels.size()).toFixed(1) + '%' complete = false
lastPlayed: lastPlayed if session = levelSessionMap[level.get('original')]
next: next complete = session.get('state').complete ? false
first: levels.first() playtime += session.get('playtime') ? 0
arena: arena lastPlayed = level
} if complete
sum = (nums) -> _.reduce(nums, (s, num) -> s + num) or 0 currentIndex = index
stats.playtime = sum((session.get('playtime') or 0 for session in sessions)) else
return stats lastStarted = level
levelsLeft++ unless level.get('practice')
else if not level.get('practice')
levelsLeft++
levels.push
practice: level.get('practice') ? false
complete: complete
lastPlayed = lastStarted ? lastPlayed
needsPractice = false
nextIndex = 0
if currentIndex >= 0
currentLevel = courseLevels.models[currentIndex]
currentPlaytime = levelSessionMap[currentLevel.get('original')]?.get('playtime') ? 0
needsPractice = utils.needsPractice(currentPlaytime, currentLevel.get('practiceThresholdMinutes'))
nextIndex = utils.findNextLevel(levels, currentIndex, needsPractice)
nextLevel = courseLevels.models[nextIndex]
nextLevel ?= _.find courseLevels.models, (level) -> not levelSessionMap[level.get('original')]?.get('state')?.complete
stats =
levels:
size: levelsTotal
left: levelsLeft
done: levelsLeft is 0
numDone: levelsTotal - levelsLeft
pctDone: (100 * (levelsTotal - levelsLeft) / levelsTotal).toFixed(1) + '%'
lastPlayed: lastPlayed
next: nextLevel
first: courseLevels.first()
arena: arena
playtime: playtime
stats
fetchForCourseInstance: (courseInstanceID, options={}) -> fetchForCourseInstance: (courseInstanceID, options={}) ->
return unless courseInstanceID return unless courseInstanceID

View file

@ -262,9 +262,9 @@ module.exports = class Level extends CocoModel
isLadder: -> isLadder: ->
return @get('type')?.indexOf('ladder') > -1 return @get('type')?.indexOf('ladder') > -1
fetchNextForCourse: ({ levelOriginalID, courseInstanceID, courseID }, options={}) -> fetchNextForCourse: ({ levelOriginalID, courseInstanceID, courseID, sessionID }, options={}) ->
if courseInstanceID if courseInstanceID
options.url = "/db/course_instance/#{courseInstanceID}/levels/#{levelOriginalID}/next" options.url = "/db/course_instance/#{courseInstanceID}/levels/#{levelOriginalID}/sessions/#{sessionID}/next"
else else
options.url = "/db/course/#{courseID}/levels/#{levelOriginalID}/next" options.url = "/db/course/#{courseID}/levels/#{levelOriginalID}/next"
@fetch(options) @fetch(options)

View file

@ -66,6 +66,7 @@ _.extend CampaignSchema.properties, {
original: { type: 'string', format: 'hidden' } original: { type: 'string', format: 'hidden' }
adventurer: { type: 'boolean' } adventurer: { type: 'boolean' }
practice: { type: 'boolean' } practice: { type: 'boolean' }
practiceThresholdMinutes: {type: 'number'}
adminOnly: { type: 'boolean' } adminOnly: { type: 'boolean' }
disableSpaces: { type: ['boolean','number'] } disableSpaces: { type: ['boolean','number'] }
hidesSubmitUntilRun: { type: 'boolean' } hidesSubmitUntilRun: { type: 'boolean' }

View file

@ -23,6 +23,8 @@ _.extend ClassroomSchema.properties,
courses: c.array { title: 'Courses' }, c.object { title: 'Course' }, { courses: c.array { title: 'Courses' }, c.object { title: 'Course' }, {
_id: c.objectId() _id: c.objectId()
levels: c.array { title: 'Levels' }, c.object { title: 'Level' }, { levels: c.array { title: 'Levels' }, c.object { title: 'Level' }, {
practice: {type: 'boolean'}
practiceThresholdMinutes: {type: 'number'}
type: c.shortString() type: c.shortString()
original: c.objectId() original: c.objectId()
name: {type: 'string'} name: {type: 'string'}

View file

@ -32,10 +32,6 @@ block content
td(data-i18n="courses.total_students") td(data-i18n="courses.total_students")
td td
span.spr= _.size(view.classroom.get('members')) span.spr= _.size(view.classroom.get('members'))
span (
span.spr(data-i18n="courses.enrolled_courses")
span= stats.enrolledUsers
span )
tr tr
td(data-i18n="courses.average_time") td(data-i18n="courses.average_time")
td= stats.averagePlaytime td= stats.averagePlaytime
@ -108,16 +104,17 @@ block content
.progress .progress
each trimModel in levels.models each trimModel in levels.models
- var level = view.levels.get(trimModel.get('original')); // get the level loaded through the db - var level = view.levels.get(trimModel.get('original')); // get the level loaded through the db
- var levelNumber = view.classroom.getLevelNumber(level.get('original'), i + 1)
- i++ - i++
- var session = sessionMap[level.get('original')]; - var session = sessionMap[level.get('original')];
a(href=view.getLevelURL(level, course, courseInstance, session)) a(href=view.getLevelURL(level, course, courseInstance, session))
- var content = view.levelPopoverContent(level, session, i); - var content = view.levelPopoverContent(level, session, levelNumber);
if session && session.get('state') && session.get('state').complete if session && session.get('state') && session.get('state').complete
.progress-bar.progress-bar-complete(style=css, data-content=content, data-toggle='popover')= i .progress-bar.progress-bar-complete(style=css, data-content=content, data-toggle='popover')= levelNumber
else if session else if session
.progress-bar.progress-bar-started(style=css, data-content=content, data-toggle='popover')= i .progress-bar.progress-bar-started(style=css, data-content=content, data-toggle='popover')= levelNumber
else else
.progress-bar.progress-bar-default(style=css, data-content=content, data-toggle='popover')= i .progress-bar.progress-bar-default(style=css, data-content=content, data-toggle='popover')= levelNumber
else if paidFor else if paidFor
.text-center .text-center
button.enable-btn.btn.btn-info.btn-sm.text-uppercase(data-user-id=user.id, data-course-instance-cid=courseInstance.cid) button.enable-btn.btn.btn-info.btn-sm.text-uppercase(data-user-id=user.id, data-course-instance-cid=courseInstance.cid)

View file

@ -88,6 +88,7 @@ block content
tr tr
th th
th(data-i18n="clans.status") th(data-i18n="clans.status")
th(data-i18n="common.type")
th(data-i18n="resources.level") th(data-i18n="resources.level")
th(data-i18n="courses.concepts") th(data-i18n="courses.concepts")
tbody tbody
@ -97,6 +98,7 @@ block content
- var levelCount = 0; - var levelCount = 0;
each level in view.levels.models each level in view.levels.models
- var levelStatus = null; - var levelStatus = null;
- var levelNumber = view.classroom.getLevelNumber(level.get('original'), ++levelCount);
if view.userLevelStateMap[me.id] if view.userLevelStateMap[me.id]
- levelStatus = view.userLevelStateMap[me.id][level.get('original')] - levelStatus = view.userLevelStateMap[me.id][level.get('original')]
tr tr
@ -107,10 +109,8 @@ block content
td td
if view.userLevelStateMap[me.id] if view.userLevelStateMap[me.id]
div= view.userLevelStateMap[me.id][level.get('original')] div= view.userLevelStateMap[me.id][level.get('original')]
- previousLevelCompleted = view.userLevelStateMap[me.id][level.get('original')] === 'complete' td #{level.get('practice') ? 'practice' : 'required'}
else td #{levelNumber}. #{level.get('name').replace('Course: ', '')}
- previousLevelCompleted = false
td= ++levelCount + '. ' + level.get('name').replace('Course: ', '')
td td
if view.levelConceptMap[level.get('original')] if view.levelConceptMap[level.get('original')]
each concept in view.course.get('concepts') each concept in view.course.get('concepts')
@ -118,3 +118,8 @@ block content
span.spr.concept(data-i18n="concepts." + concept) span.spr.concept(data-i18n="concepts." + concept)
if level.get('original') === lastLevelCompleted if level.get('original') === lastLevelCompleted
- passedLastCompletedLevel = true - passedLastCompletedLevel = true
if !level.get('practice')
if view.userLevelStateMap[me.id]
- previousLevelCompleted = view.userLevelStateMap[me.id][level.get('original')] === 'complete'
else
- previousLevelCompleted = false

View file

@ -309,7 +309,8 @@ mixin courseOverview
.course-overview-progress .course-overview-progress
each level, index in levels each level, index in levels
- var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level }) - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level })
+allStudentsLevelProgressDot(progress, level, index+1) - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1)
+allStudentsLevelProgressDot(progress, level, levelNumber)
mixin studentLevelsRow(student) mixin studentLevelsRow(student)
.student-levels-row.alternating-background .student-levels-row.alternating-background
@ -321,7 +322,8 @@ mixin studentLevelsRow(student)
- var levels = view.classroom.getLevels({courseID: course.id}).models - var levels = view.classroom.getLevels({courseID: course.id}).models
each level, index in levels each level, index in levels
- var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student }) - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student })
+studentLevelProgressDot(progress, level, index+1, session) - var levelNumber = view.classroom.getLevelNumber(level.get('original'), index + 1)
+studentLevelProgressDot(progress, level, levelNumber, session)
mixin studentCourseProgressDot(progress, levelsTotal, level, label) mixin studentCourseProgressDot(progress, levelsTotal, level, label)
//- TODO: Refactor with TeacherClassesView jade //- TODO: Refactor with TeacherClassesView jade

View file

@ -56,12 +56,12 @@ block content
| : | :
select.level-select.form-control select.level-select.form-control
if view.campaigns.loaded if view.campaigns.loaded
each level, levelIndex in view.campaigns.get(course.get('campaignID')).getLevels().models - var campaign = view.campaigns.get(course.get('campaignID'))
if level.get('practice') each level, levelIndex in campaign.getLevels().models
- continue; - var levelNumber = campaign.getLevelNumber(level.get('original'), levelIndex + 1)
option(value=level.get('slug')) option(value=level.get('slug'))
span span
= levelIndex + 1 = levelNumber
span.spr span.spr
| . | .
span span

View file

@ -22,7 +22,7 @@ else
.level-name-area .level-name-area
.level-label(data-i18n="play_level.level") .level-label(data-i18n="play_level.level")
.level-name(title=difficultyTitle || "") .level-name(title=difficultyTitle || "")
span= (campaignIndex ? campaignIndex + '. ' : '') + worldName.replace('Course: ', '') span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')}
if levelDifficulty if levelDifficulty
sup.level-difficulty= levelDifficulty sup.level-difficulty= levelDifficulty

View file

@ -17,5 +17,9 @@ if started
//- .small-details //- .small-details
//- i(data-i18n='teacher.click_to_view_progress') //- i(data-i18n='teacher.click_to_view_progress')
else else
.small-details.nowrap
span= levelNumber
span.spr .
span= levelName
span.small-details.nowrap(data-i18n='teacher.no_progress') span.small-details.nowrap(data-i18n='teacher.no_progress')
| No progress | No progress

View file

@ -13,7 +13,7 @@ if completed
.small-details.nowrap .small-details.nowrap
span.spr(data-i18n='teacher.completed') span.spr(data-i18n='teacher.completed')
| Completed | Completed
span= new Date(dateFirstCompleted).toLocaleString() span= new Date(session.get('dateFirstCompleted')).toLocaleString()
+timePlayed +timePlayed
//- .small-details //- .small-details
//- i(data-i18n='teacher.click_to_view_solution') //- i(data-i18n='teacher.click_to_view_solution')
@ -26,11 +26,15 @@ else if started
.small-details.nowrap .small-details.nowrap
span.spr(data-i18n='teacher.last_played') span.spr(data-i18n='teacher.last_played')
| Last played | Last played
span= new Date(lastPlayed).toLocaleString() span= new Date(session.get('changed')).toLocaleString()
+timePlayed +timePlayed
//- .small-details //- .small-details
//- i(data-i18n='teacher.click_to_view_progress') //- i(data-i18n='teacher.click_to_view_progress')
//- | click to view progress //- | click to view progress
else else
.small-details.nowrap
span= levelNumber
span.spr .
span= levelName
span.small-details.nowrap(data-i18n='teacher.no_progress') span.small-details.nowrap(data-i18n='teacher.no_progress')
| No progress | No progress

View file

@ -55,7 +55,7 @@ module.exports = class ClassroomView extends RootView
@ownedClassrooms.fetchMine({data: {project: '_id'}}) @ownedClassrooms.fetchMine({data: {project: '_id'}})
@supermodel.trackCollection(@ownedClassrooms) @supermodel.trackCollection(@ownedClassrooms)
@levels = new Levels() @levels = new Levels()
@levels.fetchForClassroom(classroomID, {data: {project: 'name,slug,original'}}) @levels.fetchForClassroom(classroomID, {data: {project: 'name,original,practice,slug'}})
@levels.on 'add', (model) -> @_byId[model.get('original')] = model # so you can 'get' them @levels.on 'add', (model) -> @_byId[model.get('original')] = model # so you can 'get' them
@supermodel.trackCollection(@levels) @supermodel.trackCollection(@levels)
window.tracker?.trackEvent 'Students Class Loaded', category: 'Students', classroomID: classroomID, ['Mixpanel'] window.tracker?.trackEvent 'Students Class Loaded', category: 'Students', classroomID: classroomID, ['Mixpanel']
@ -177,7 +177,9 @@ module.exports = class ClassroomView extends RootView
stats.averagePlaytime = if playtime and total then moment.duration(playtime / total, "seconds").humanize() else 0 stats.averagePlaytime = if playtime and total then moment.duration(playtime / total, "seconds").humanize() else 0
stats.totalPlaytime = if playtime then moment.duration(playtime, "seconds").humanize() else 0 stats.totalPlaytime = if playtime then moment.duration(playtime, "seconds").humanize() else 0
completeSessions = @sessions.filter (s) -> s.get('state')?.complete levelPracticeMap = {}
levelPracticeMap[level.id] = level.get('practice') ? false for level in @levels.models
completeSessions = @sessions.filter (s) -> s.get('state')?.complete and not levelPracticeMap[s.get('levelID')]
stats.averageLevelsComplete = if @users.size() then (_.size(completeSessions) / @users.size()).toFixed(1) else 'N/A' # ' stats.averageLevelsComplete = if @users.size() then (_.size(completeSessions) / @users.size()).toFixed(1) else 'N/A' # '
stats.totalLevelsComplete = _.size(completeSessions) stats.totalLevelsComplete = _.size(completeSessions)

View file

@ -52,7 +52,7 @@ module.exports = class CourseDetailsView extends RootView
@supermodel.trackRequest(@classroom.fetch()) @supermodel.trackRequest(@classroom.fetch())
levelsLoaded = @supermodel.trackRequest(@levels.fetchForClassroomAndCourse(classroomID, @courseID, { levelsLoaded = @supermodel.trackRequest(@levels.fetchForClassroomAndCourse(classroomID, @courseID, {
data: { project: 'concepts,type,slug,name,original,description' } data: { project: 'concepts,practice,type,slug,name,original,description' }
})) }))
@supermodel.trackRequest($.when(levelsLoaded, sessionsLoaded).then(=> @supermodel.trackRequest($.when(levelsLoaded, sessionsLoaded).then(=>

View file

@ -118,7 +118,7 @@ module.exports = class TeacherClassView extends RootView
@supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID) @supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID)
@levels = new Levels() @levels = new Levels()
@supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}}) @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts,practice'}})
@attachMediatorEvents() @attachMediatorEvents()
window.tracker?.trackEvent 'Teachers Class Loaded', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] window.tracker?.trackEvent 'Teachers Class Loaded', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
@ -449,7 +449,9 @@ module.exports = class TeacherClassView extends RootView
stats.totalPlaytime = if playtime then moment.duration(playtime, "seconds").humanize() else 0 stats.totalPlaytime = if playtime then moment.duration(playtime, "seconds").humanize() else 0
# TODO: Humanize differently ('1 hour' instead of 'an hour') # TODO: Humanize differently ('1 hour' instead of 'an hour')
completeSessions = @classroom.sessions.filter (s) -> s.get('state')?.complete levelPracticeMap = {}
levelPracticeMap[level.id] = level.get('practice') ? false for level in @levels.models
completeSessions = @classroom.sessions.filter (s) -> s.get('state')?.complete and not levelPracticeMap[s.get('levelID')]
stats.averageLevelsComplete = if @students.size() then (_.size(completeSessions) / @students.size()).toFixed(1) else 'N/A' # ' stats.averageLevelsComplete = if @students.size() then (_.size(completeSessions) / @students.size()).toFixed(1) else 'N/A' # '
stats.totalLevelsComplete = _.size(completeSessions) stats.totalLevelsComplete = _.size(completeSessions)

View file

@ -2,6 +2,10 @@ CocoView = require 'views/core/CocoView'
template = require 'templates/play/level/control_bar' template = require 'templates/play/level/control_bar'
{me} = require 'core/auth' {me} = require 'core/auth'
Campaign = require 'models/Campaign'
Classroom = require 'models/Classroom'
Course = require 'models/Course'
CourseInstance = require 'models/CourseInstance'
GameMenuModal = require 'views/play/menu/GameMenuModal' GameMenuModal = require 'views/play/menu/GameMenuModal'
RealTimeModel = require 'models/RealTimeModel' RealTimeModel = require 'models/RealTimeModel'
RealTimeCollection = require 'collections/RealTimeCollection' RealTimeCollection = require 'collections/RealTimeCollection'
@ -28,6 +32,7 @@ module.exports = class ControlBarView extends CocoView
'click #control-bar-sign-up-button': 'onClickSignupButton' 'click #control-bar-sign-up-button': 'onClickSignupButton'
constructor: (options) -> constructor: (options) ->
@supermodel = options.supermodel
@courseID = options.courseID @courseID = options.courseID
@courseInstanceID = options.courseInstanceID @courseInstanceID = options.courseInstanceID
@ -38,6 +43,26 @@ module.exports = class ControlBarView extends CocoView
@levelID = @levelSlug or @level.id @levelID = @levelSlug or @level.id
@spectateGame = options.spectateGame ? false @spectateGame = options.spectateGame ? false
@observing = options.session.get('creator') isnt me.id @observing = options.session.get('creator') isnt me.id
@levelNumber = ''
if @level.get('type') is 'course' and @level.get('campaignIndex')?
@levelNumber = @level.get('campaignIndex') + 1
if @courseInstanceID
@courseInstance = new CourseInstance(_id: @courseInstanceID)
jqxhr = @courseInstance.fetch()
@supermodel.trackRequest(jqxhr)
new Promise(jqxhr.then).then(=>
@classroom = new Classroom(_id: @courseInstance.get('classroomID'))
@supermodel.trackRequest @classroom.fetch()
)
else if @courseID
@course = new Course(_id: @courseID)
jqxhr = @course.fetch()
@supermodel.trackRequest(jqxhr)
new Promise(jqxhr.then).then(=>
@campaign = new Campaign(_id: @course.get('campaignID'))
@supermodel.trackRequest(@campaign.fetch())
)
super options super options
if @level.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin() if @level.get('type') in ['hero-ladder', 'course-ladder'] and me.isAdmin()
@isMultiplayerLevel = true @isMultiplayerLevel = true
@ -45,6 +70,13 @@ module.exports = class ControlBarView extends CocoView
if @level.get 'replayable' if @level.get 'replayable'
@listenTo @session, 'change-difficulty', @onSessionDifficultyChanged @listenTo @session, 'change-difficulty', @onSessionDifficultyChanged
onLoaded: ->
if @classroom
@levelNumber = @classroom.getLevelNumber(@level.get('original'), @levelNumber)
else if @campaign
@levelNumber = @campaign.getLevelNumber(@level.get('original'), @levelNumber)
super()
setBus: (@bus) -> setBus: (@bus) ->
onPlayerStatesChanged: (e) -> onPlayerStatesChanged: (e) ->
@ -62,7 +94,6 @@ module.exports = class ControlBarView extends CocoView
getRenderData: (c={}) -> getRenderData: (c={}) ->
super c super c
c.worldName = @worldName c.worldName = @worldName
c.campaignIndex = @level.get('campaignIndex') + 1 if @level.get('type') is 'course' and @level.get('campaignIndex')? # TODO: support 'game-dev' levels in courses
c.multiplayerEnabled = @session.get('multiplayer') c.multiplayerEnabled = @session.get('multiplayer')
c.ladderGame = @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder'] c.ladderGame = @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder']
if c.isMultiplayerLevel = @isMultiplayerLevel if c.isMultiplayerLevel = @isMultiplayerLevel

View file

@ -43,7 +43,12 @@ module.exports = class CourseVictoryModal extends ModalView
@playSound 'victory' @playSound 'victory'
@nextLevel = new Level() @nextLevel = new Level()
@nextLevelRequest = @supermodel.trackRequest @nextLevel.fetchNextForCourse({ levelOriginalID: @level.get('original'), @courseInstanceID, @courseID }) @nextLevelRequest = @supermodel.trackRequest(@nextLevel.fetchNextForCourse({
levelOriginalID: @level.get('original')
@courseInstanceID
@courseID
sessionID: @session.id
}))
@course = options.course @course = options.course
if @courseID and not @course if @courseID and not @course

View file

@ -151,12 +151,10 @@ module.exports =
courseData = { _id: course._id, levels: [] } courseData = { _id: course._id, levels: [] }
campaign = campaignMap[course.get('campaignID').toString()] campaign = campaignMap[course.get('campaignID').toString()]
levels = _.values(campaign.get('levels')) levels = _.values(campaign.get('levels'))
# TODO: remove practice filter after classroom Ux supports practice levels
levels = _.reject(levels, {'practice': true})
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')) _.extend(levelData, _.pick(level, 'type', 'slug', 'name', 'practice', 'practiceThresholdMinutes'))
courseData.levels.push(levelData) courseData.levels.push(levelData)
coursesData.push(courseData) coursesData.push(courseData)
classroom.set('courses', coursesData) classroom.set('courses', coursesData)

View file

@ -9,8 +9,10 @@ Classroom = require '../models/Classroom'
Course = require '../models/Course' Course = require '../models/Course'
User = require '../models/User' User = require '../models/User'
Level = require '../models/Level' Level = require '../models/Level'
LevelSession = require '../models/LevelSession'
parse = require '../commons/parse' parse = require '../commons/parse'
{objectIdFromTimestamp} = require '../lib/utils' {objectIdFromTimestamp} = require '../lib/utils'
utils = require '../../app/core/utils'
Prepaid = require '../models/Prepaid' Prepaid = require '../models/Prepaid'
module.exports = module.exports =
@ -21,93 +23,103 @@ module.exports =
userIDs = req.body.userIDs userIDs = req.body.userIDs
else else
throw new errors.UnprocessableEntity('Must provide userID or userIDs') throw new errors.UnprocessableEntity('Must provide userID or userIDs')
for userID in userIDs for userID in userIDs
unless _.all userIDs, database.isID unless _.all userIDs, database.isID
throw new errors.UnprocessableEntity('Invalid list of user IDs') throw new errors.UnprocessableEntity('Invalid list of user IDs')
courseInstance = yield database.getDocFromHandle(req, CourseInstance) courseInstance = yield database.getDocFromHandle(req, CourseInstance)
if not courseInstance if not courseInstance
throw new errors.NotFound('Course Instance not found.') throw new errors.NotFound('Course Instance not found.')
classroom = yield Classroom.findById courseInstance.get('classroomID') classroom = yield Classroom.findById courseInstance.get('classroomID')
if not classroom if not classroom
throw new errors.NotFound('Classroom not found.') throw new errors.NotFound('Classroom not found.')
classroomMembers = (userID.toString() for userID in classroom.get('members')) classroomMembers = (userID.toString() for userID in classroom.get('members'))
unless _.all(userIDs, (userID) -> _.contains classroomMembers, userID) unless _.all(userIDs, (userID) -> _.contains classroomMembers, userID)
throw new errors.Forbidden('Users must be members of classroom') throw new errors.Forbidden('Users must be members of classroom')
ownsClassroom = classroom.get('ownerID').equals(req.user._id) ownsClassroom = classroom.get('ownerID').equals(req.user._id)
addingSelf = userIDs.length is 1 and userIDs[0] is req.user.id addingSelf = userIDs.length is 1 and userIDs[0] is req.user.id
unless ownsClassroom or addingSelf unless ownsClassroom or addingSelf
throw new errors.Forbidden('You must own the classroom to add members') throw new errors.Forbidden('You must own the classroom to add members')
# Only the enrolled users # Only the enrolled users
users = yield User.find({ _id: { $in: userIDs }}).select('coursePrepaid coursePrepaidID') # TODO: remove coursePrepaidID once migrated users = yield User.find({ _id: { $in: userIDs }}).select('coursePrepaid coursePrepaidID') # TODO: remove coursePrepaidID once migrated
usersAreEnrolled = _.all((user.isEnrolled() for user in users)) usersAreEnrolled = _.all((user.isEnrolled() for user in users))
course = yield Course.findById courseInstance.get('courseID') course = yield Course.findById courseInstance.get('courseID')
throw new errors.NotFound('Course referenced by course instance not found') unless course throw new errors.NotFound('Course referenced by course instance not found') unless course
if not (course.get('free') or usersAreEnrolled) if not (course.get('free') or usersAreEnrolled)
throw new errors.PaymentRequired('Cannot add users to a course instance until they are added to a prepaid') throw new errors.PaymentRequired('Cannot add users to a course instance until they are added to a prepaid')
userObjectIDs = (mongoose.Types.ObjectId(userID) for userID in userIDs) userObjectIDs = (mongoose.Types.ObjectId(userID) for userID in userIDs)
courseInstance = yield CourseInstance.findByIdAndUpdate( courseInstance = yield CourseInstance.findByIdAndUpdate(
courseInstance._id, courseInstance._id,
{ $addToSet: { members: { $each: userObjectIDs } } } { $addToSet: { members: { $each: userObjectIDs } } }
{ new: true } { new: true }
) )
userUpdateResult = yield User.update( userUpdateResult = yield User.update(
{ _id: { $in: userObjectIDs } }, { _id: { $in: userObjectIDs } },
{ $addToSet: { courseInstances: courseInstance._id } } { $addToSet: { courseInstances: courseInstance._id } }
) )
res.status(200).send(courseInstance.toObject({ req })) res.status(200).send(courseInstance.toObject({ req }))
fetchNextLevel: wrap (req, res) -> fetchNextLevel: wrap (req, res) ->
levelOriginal = req.params.levelOriginal levelOriginal = req.params.levelOriginal
if not database.isID(levelOriginal) unless database.isID(levelOriginal) then throw new errors.UnprocessableEntity('Invalid level original ObjectId')
throw new errors.UnprocessableEntity('Invalid level original ObjectId') sessionID = req.params.sessionID
unless database.isID(sessionID) then throw new errors.UnprocessableEntity('Invalid session ObjectId')
courseInstance = yield database.getDocFromHandle(req, CourseInstance) courseInstance = yield database.getDocFromHandle(req, CourseInstance)
if not courseInstance unless courseInstance then throw new errors.NotFound('Course Instance not found.')
throw new errors.NotFound('Course Instance not found.')
courseID = courseInstance.get('courseID')
classroom = yield Classroom.findById courseInstance.get('classroomID') classroom = yield Classroom.findById courseInstance.get('classroomID')
if not classroom unless classroom then throw new errors.NotFound('Classroom not found.')
throw new errors.NotFound('Classroom not found.') currentLevel = yield Level.findOne({original: mongoose.Types.ObjectId(levelOriginal)}, {practiceThresholdMinutes: 1, type: 1})
unless currentLevel then throw new errors.NotFound('Current level not found.')
nextLevelOriginal = null
foundLevelOriginal = false courseID = courseInstance.get('courseID')
for course in classroom.get('courses') or [] courseLevels = []
if not courseID.equals(course._id) courseLevels = course.levels for course in classroom.get('courses') or [] when courseID.equals(course._id)
continue
for level, index in course.levels # Get level completions and playtime
if level.original.toString() is levelOriginal currentLevelSession = null
foundLevelOriginal = true levelIDs = (level.original.toString() for level in courseLevels)
nextLevelOriginal = course.levels[index+1]?.original query = {$and: [{creator: req.user.id}, {'level.original': {$in: levelIDs}}]}
break levelSessions = yield LevelSession.find(query, {level: 1, playtime: 1, state: 1})
levelCompleteMap = {}
if not foundLevelOriginal for levelSession in levelSessions
throw new errors.NotFound('Level original ObjectId not found in Classroom courses') currentLevelSession = levelSession if levelSession.id is sessionID
levelCompleteMap[levelSession.get('level')?.original] = levelSession.get('state')?.complete
if not nextLevelOriginal unless currentLevelSession then throw new errors.NotFound('Level session not found.')
return res.status(200).send({}) needsPractice = utils.needsPractice(currentLevelSession.get('playtime'), currentLevel.get('practiceThresholdMinutes'))
# Find next level
levels = []
currentIndex = -1
for level, index in courseLevels
currentIndex = index if level.original.toString() is levelOriginal
levels.push
practice: level.practice ? false
complete: levelCompleteMap[level.original?.toString()] or currentIndex is index
unless currentIndex >=0 then throw new errors.NotFound('Level original ObjectId not found in Classroom courses')
nextLevelIndex = utils.findNextLevel(levels, currentIndex, needsPractice)
nextLevelOriginal = courseLevels[nextLevelIndex]?.original
unless nextLevelOriginal then return res.status(200).send({})
# Return full Level object
dbq = Level.findOne({original: mongoose.Types.ObjectId(nextLevelOriginal)}) dbq = Level.findOne({original: mongoose.Types.ObjectId(nextLevelOriginal)})
dbq.sort({ 'version.major': -1, 'version.minor': -1 }) dbq.sort({ 'version.major': -1, 'version.minor': -1 })
dbq.select(parse.getProjectFromReq(req)) dbq.select(parse.getProjectFromReq(req))
level = yield dbq level = yield dbq
level = level.toObject({req: req}) level = level.toObject({req: req})
res.status(200).send(level) res.status(200).send(level)
fetchClassroom: wrap (req, res) -> fetchClassroom: wrap (req, res) ->
courseInstance = yield database.getDocFromHandle(req, CourseInstance) courseInstance = yield database.getDocFromHandle(req, CourseInstance)
if not courseInstance if not courseInstance
@ -126,25 +138,25 @@ module.exports =
res.status(200).send(classroom) res.status(200).send(classroom)
fetchRecent: wrap (req, res) -> fetchRecent: wrap (req, res) ->
query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]} query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]}
query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay? query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay?
query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay? query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay?
courseInstances = yield CourseInstance.find(query, {courseID: 1, members: 1, ownerID: 1}) courseInstances = yield CourseInstance.find(query, {courseID: 1, members: 1, ownerID: 1})
userIDs = [] userIDs = []
for courseInstance in courseInstances for courseInstance in courseInstances
if members = courseInstance.get('members') if members = courseInstance.get('members')
userIDs.push(userID) for userID in members userIDs.push(userID) for userID in members
users = yield User.find({_id: {$in: userIDs}}, {coursePrepaid: 1, coursePrepaidID: 1}) users = yield User.find({_id: {$in: userIDs}}, {coursePrepaid: 1, coursePrepaidID: 1})
prepaidIDs = [] prepaidIDs = []
for user in users for user in users
if prepaidID = user.get('coursePrepaid') if prepaidID = user.get('coursePrepaid')
prepaidIDs.push(prepaidID._id) prepaidIDs.push(prepaidID._id)
prepaids = yield Prepaid.find({_id: {$in: prepaidIDs}}, {properties: 1}) prepaids = yield Prepaid.find({_id: {$in: prepaidIDs}}, {properties: 1})
res.send({ res.send({
courseInstances: (courseInstance.toObject({req: req}) for courseInstance in courseInstances) courseInstances: (courseInstance.toObject({req: req}) for courseInstance in courseInstances)
students: (user.toObject({req: req}) for user in users) students: (user.toObject({req: req}) for user in users)

View file

@ -55,6 +55,7 @@ module.exports =
classroomID = courseInstance.get('classroomID') classroomID = courseInstance.get('classroomID')
continue unless classroomID continue unless classroomID
classroom = classroomMap[classroomID.toString()] classroom = classroomMap[classroomID.toString()]
continue unless classroom
courseID = courseInstance.get('courseID') courseID = courseInstance.get('courseID')
classroomCourse = _.find(classroom.get('courses'), (c) -> c._id.equals(courseID)) classroomCourse = _.find(classroom.get('courses'), (c) -> c._id.equals(courseID))
for courseLevel in classroomCourse.levels for courseLevel in classroomCourse.levels

View file

@ -81,7 +81,7 @@ module.exports.setup = (app) ->
app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel) app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel)
app.post('/db/course_instance/-/recent', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchRecent) app.post('/db/course_instance/-/recent', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchRecent)
app.get('/db/course_instance/:handle/levels/:levelOriginal/next', mw.courseInstances.fetchNextLevel) app.get('/db/course_instance/:handle/levels/:levelOriginal/sessions/:sessionID/next', mw.courseInstances.fetchNextLevel)
app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers) app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers)
app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom) app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom)

View file

@ -144,16 +144,6 @@ describe 'POST /db/classroom', ->
expect(classroom.get('courses')[0].levels[0].name).toBe('Level A') expect(classroom.get('courses')[0].levels[0].name).toBe('Level A')
done() done()
it 'makes a copy of the list of all non-practice levels in all courses', utils.wrap (done) ->
teacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(teacher)
data = { name: 'tmp Classroom 2' }
[res, body] = yield request.postAsync {uri: classroomsURL, json: data }
classroom = yield Classroom.findById(res.body._id)
# console.log(JSON.stringify(classroom.get('courses')[0], null, 2));
expect(classroom.get('courses')[0].levels.length).toEqual(2)
done()
describe 'GET /db/classroom/:handle/levels', -> describe 'GET /db/classroom/:handle/levels', ->
beforeEach utils.wrap (done) -> beforeEach utils.wrap (done) ->

View file

@ -9,6 +9,7 @@ User = require '../../../server/models/User'
Classroom = require '../../../server/models/Classroom' Classroom = require '../../../server/models/Classroom'
Campaign = require '../../../server/models/Campaign' Campaign = require '../../../server/models/Campaign'
Level = require '../../../server/models/Level' Level = require '../../../server/models/Level'
LevelSession = require '../../../server/models/LevelSession'
Prepaid = require '../../../server/models/Prepaid' Prepaid = require '../../../server/models/Prepaid'
request = require '../request' request = require '../request'
moment = require 'moment' moment = require 'moment'
@ -48,7 +49,7 @@ describe 'POST /db/course_instance', ->
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(body.classroomID).toBeDefined() expect(body.classroomID).toBeDefined()
done() done()
it 'returns the same CourseInstance if you POST twice', utils.wrap (done) -> it 'returns the same CourseInstance if you POST twice', utils.wrap (done) ->
data = { data = {
name: 'Some Name' name: 'Some Name'
@ -167,7 +168,7 @@ describe 'POST /db/course_instance/:id/members', ->
[res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}}
expect(res.statusCode).toBe(402) expect(res.statusCode).toBe(402)
done() done()
it 'works if the course is not free and the user is enrolled', utils.wrap (done) -> it 'works if the course is not free and the user is enrolled', utils.wrap (done) ->
@course.set('free', false) @course.set('free', false)
yield @course.save() yield @course.save()
@ -187,12 +188,12 @@ describe 'POST /db/course_instance/:id/members', ->
[res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}}
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
done() done()
describe 'DELETE /db/course_instance/:id/members', -> describe 'DELETE /db/course_instance/:id/members', ->
beforeEach utils.wrap (done) -> beforeEach utils.wrap (done) ->
utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid]) utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid])
# create, login user # create, login user
@teacher = yield utils.initUser({role: 'teacher'}) @teacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(@teacher) yield utils.loginUser(@teacher)
@ -211,7 +212,7 @@ describe 'DELETE /db/course_instance/:id/members', ->
} }
[res, body] = yield request.postAsync {uri: url, json: data} [res, body] = yield request.postAsync {uri: url, json: data}
@courseInstance = yield CourseInstance.findById res.body._id @courseInstance = yield CourseInstance.findById res.body._id
# add user to course instance # add user to course instance
url = getURL("/db/course_instance/#{@courseInstance.id}/members") url = getURL("/db/course_instance/#{@courseInstance.id}/members")
[res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}}
@ -221,14 +222,14 @@ describe 'DELETE /db/course_instance/:id/members', ->
redeemers: [] redeemers: []
}).save() }).save()
done() done()
it 'removes a member to the given CourseInstance', utils.wrap (done) -> it 'removes a member to the given CourseInstance', utils.wrap (done) ->
url = getURL("/db/course_instance/#{@courseInstance.id}/members") url = getURL("/db/course_instance/#{@courseInstance.id}/members")
[res, body] = yield request.delAsync {uri: url, json: {userID: @student.id}} [res, body] = yield request.delAsync {uri: url, json: {userID: @student.id}}
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(res.body.members.length).toBe(0) expect(res.body.members.length).toBe(0)
done() done()
it 'removes the CourseInstance from the User.courseInstances', utils.wrap (done) -> it 'removes the CourseInstance from the User.courseInstances', utils.wrap (done) ->
url = getURL("/db/course_instance/#{@courseInstance.id}/members") url = getURL("/db/course_instance/#{@courseInstance.id}/members")
user = yield User.findById(@student.id) user = yield User.findById(@student.id)
@ -239,13 +240,14 @@ describe 'DELETE /db/course_instance/:id/members', ->
user = yield User.findById(@student.id) user = yield User.findById(@student.id)
expect(_.size(user.get('courseInstances'))).toBe(0) expect(_.size(user.get('courseInstances'))).toBe(0)
done() done()
describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', -> describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', ->
beforeEach utils.wrap (done) -> beforeEach utils.wrap (done) ->
yield utils.clearModels [User, Classroom, Course, Level, Campaign] yield utils.clearModels [User, Classroom, Course, Level, Campaign]
admin = yield utils.initAdmin() admin = yield utils.initAdmin()
yield utils.loginUser(admin) yield utils.loginUser(admin)
teacher = yield utils.initUser({role: 'teacher'})
levelJSON = { name: 'A', permissions: [{access: 'owner', target: admin.id}], type: 'course' } levelJSON = { name: 'A', permissions: [{access: 'owner', target: admin.id}], type: 'course' }
[res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON}) [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON})
@ -253,12 +255,25 @@ describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', ->
@levelA = yield Level.findById(res.body._id) @levelA = yield Level.findById(res.body._id)
paredLevelA = _.pick(res.body, 'name', 'original', 'type') paredLevelA = _.pick(res.body, 'name', 'original', 'type')
@sessionA = new LevelSession
creator: teacher.id
level: original: @levelA.get('original').toString()
permissions: simplePermissions
state: complete: true
yield @sessionA.save()
levelJSON = { name: 'B', permissions: [{access: 'owner', target: admin.id}], type: 'course' } levelJSON = { name: 'B', permissions: [{access: 'owner', target: admin.id}], type: 'course' }
[res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON}) [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON})
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
@levelB = yield Level.findById(res.body._id) @levelB = yield Level.findById(res.body._id)
paredLevelB = _.pick(res.body, 'name', 'original', 'type') paredLevelB = _.pick(res.body, 'name', 'original', 'type')
@sessionB = new LevelSession
creator: teacher.id
level: original: @levelB.get('original').toString()
permissions: simplePermissions
yield @sessionB.save()
levelJSON = { name: 'C', permissions: [{access: 'owner', target: admin.id}], type: 'course' } levelJSON = { name: 'C', permissions: [{access: 'owner', target: admin.id}], type: 'course' }
[res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON}) [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSON})
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
@ -282,14 +297,13 @@ describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', ->
@courseB = Course({name: 'Course B', campaignID: @campaignB._id}) @courseB = Course({name: 'Course B', campaignID: @campaignB._id})
yield @courseB.save() yield @courseB.save()
teacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(teacher) yield utils.loginUser(teacher)
data = { name: 'Classroom 1' } data = { name: 'Classroom 1' }
classroomsURL = getURL('/db/classroom') classroomsURL = getURL('/db/classroom')
[res, body] = yield request.postAsync {uri: classroomsURL, json: data } [res, body] = yield request.postAsync {uri: classroomsURL, json: data }
expect(res.statusCode).toBe(201) expect(res.statusCode).toBe(201)
@classroom = yield Classroom.findById(res.body._id) @classroom = yield Classroom.findById(res.body._id)
url = getURL('/db/course_instance') url = getURL('/db/course_instance')
dataA = { name: 'Some Name', courseID: @courseA.id, classroomID: @classroom.id } dataA = { name: 'Some Name', courseID: @courseA.id, classroomID: @classroom.id }
@ -301,27 +315,27 @@ describe 'GET /db/course_instance/:handle/levels/:levelOriginal/next', ->
[res, body] = yield request.postAsync {uri: url, json: dataB} [res, body] = yield request.postAsync {uri: url, json: dataB}
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
@courseInstanceB = yield CourseInstance.findById(res.body._id) @courseInstanceB = yield CourseInstance.findById(res.body._id)
done() done()
it 'returns the next level for the course in the linked classroom', utils.wrap (done) -> it 'returns the next level for the course in the linked classroom', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelA.id}/next"), json: true } [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelA.id}/sessions/#{@sessionA.id}/next"), json: true }
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(res.body.original).toBe(@levelB.original.toString()) expect(res.body.original).toBe(@levelB.original.toString())
done() done()
it 'returns empty object if the given level is the last level in its course', utils.wrap (done) -> it 'returns empty object if the given level is the last level in its course', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelB.id}/next"), json: true } [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceA.id}/levels/#{@levelB.id}/sessions/#{@sessionB.id}/next"), json: true }
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(res.body).toEqual({}) expect(res.body).toEqual({})
done() done()
it 'returns 404 if the given level is not in the course instance\'s course', utils.wrap (done) -> it 'returns 404 if the given level is not in the course instance\'s course', utils.wrap (done) ->
[res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceB.id}/levels/#{@levelA.id}/next"), json: true } [res, body] = yield request.getAsync { uri: utils.getURL("/db/course_instance/#{@courseInstanceB.id}/levels/#{@levelA.id}/sessions/#{@sessionA.id}/next"), json: true }
expect(res.statusCode).toBe(404) expect(res.statusCode).toBe(404)
done() done()
describe 'GET /db/course_instance/:handle/classroom', -> describe 'GET /db/course_instance/:handle/classroom', ->
beforeEach utils.wrap (done) -> beforeEach utils.wrap (done) ->
@ -362,9 +376,9 @@ describe 'GET /db/course_instance/:handle/classroom', ->
done() done()
describe 'POST /db/course_instance/-/recent', -> describe 'POST /db/course_instance/-/recent', ->
url = getURL('/db/course_instance/-/recent') url = getURL('/db/course_instance/-/recent')
beforeEach utils.wrap (done) -> beforeEach utils.wrap (done) ->
yield utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid, Campaign, Level]) yield utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid, Campaign, Level])
@teacher = yield utils.initUser({role: 'teacher'}) @teacher = yield utils.initUser({role: 'teacher'})
@ -405,9 +419,9 @@ describe 'POST /db/course_instance/-/recent', ->
endDay = moment().subtract(1, 'day').format('YYYY-MM-DD') endDay = moment().subtract(1, 'day').format('YYYY-MM-DD')
[res, body] = yield request.postAsync(url, { json: { startDay, endDay } }) [res, body] = yield request.postAsync(url, { json: { startDay, endDay } })
expect(res.body.courseInstances.length).toBe(0) expect(res.body.courseInstances.length).toBe(0)
done() done()
it 'returns 403 if not an admin', utils.wrap (done) -> it 'returns 403 if not an admin', utils.wrap (done) ->
yield utils.loginUser(@teacher) yield utils.loginUser(@teacher)
[res, body] = yield request.postAsync(url, { json: true }) [res, body] = yield request.postAsync(url, { json: true })

View file

@ -1,5 +1,5 @@
describe 'Utility library', -> describe 'Utility library', ->
util = require 'core/utils' utils = require 'core/utils'
describe 'i18n', -> describe 'i18n', ->
beforeEach -> beforeEach ->
@ -25,24 +25,217 @@ describe 'Utility library', ->
'text': 'Godagens, trollkarl! Kommit för att öva? Nå, låt oss börja...' 'text': 'Godagens, trollkarl! Kommit för att öva? Nå, låt oss börja...'
it 'i18n should find a valid target string', -> it 'i18n should find a valid target string', ->
expect(util.i18n(this.fixture1, 'text', 'sv')).toEqual(this.fixture1.i18n['sv'].text) expect(utils.i18n(this.fixture1, 'text', 'sv')).toEqual(this.fixture1.i18n['sv'].text)
expect(util.i18n(this.fixture1, 'text', 'es-ES')).toEqual(this.fixture1.i18n['es-ES'].text) expect(utils.i18n(this.fixture1, 'text', 'es-ES')).toEqual(this.fixture1.i18n['es-ES'].text)
it 'i18n picks the correct fallback for a specific language', -> it 'i18n picks the correct fallback for a specific language', ->
expect(util.i18n(this.fixture1, 'text', 'fr-be')).toEqual(this.fixture1.i18n['fr'].text) expect(utils.i18n(this.fixture1, 'text', 'fr-be')).toEqual(this.fixture1.i18n['fr'].text)
it 'i18n picks the correct fallback', -> it 'i18n picks the correct fallback', ->
expect(util.i18n(this.fixture1, 'text', 'nl')).toEqual(this.fixture1.i18n['en'].text) expect(utils.i18n(this.fixture1, 'text', 'nl')).toEqual(this.fixture1.i18n['en'].text)
expect(util.i18n(this.fixture1, 'text', 'nl', 'de')).toEqual(this.fixture1.i18n['de'].text) expect(utils.i18n(this.fixture1, 'text', 'nl', 'de')).toEqual(this.fixture1.i18n['de'].text)
it 'i18n falls back to the default text, even for other targets (like blurb)', -> it 'i18n falls back to the default text, even for other targets (like blurb)', ->
delete this.fixture1.i18n['en'] delete this.fixture1.i18n['en']
expect(util.i18n(this.fixture1, 'text', 'en')).toEqual(this.fixture1.text) expect(utils.i18n(this.fixture1, 'text', 'en')).toEqual(this.fixture1.text)
expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(this.fixture1.blurb) expect(utils.i18n(this.fixture1, 'blurb', 'en')).toEqual(this.fixture1.blurb)
delete this.fixture1.blurb delete this.fixture1.blurb
expect(util.i18n(this.fixture1, 'blurb', 'en')).toEqual(null) expect(utils.i18n(this.fixture1, 'blurb', 'en')).toEqual(null)
it 'i18n can fall forward if a general language is not found', -> it 'i18n can fall forward if a general language is not found', ->
expect(util.i18n(this.fixture1, 'text', 'pt')).toEqual(this.fixture1.i18n['pt-BR'].text) expect(utils.i18n(this.fixture1, 'text', 'pt')).toEqual(this.fixture1.i18n['pt-BR'].text)
describe 'Miscellaneous utility', -> describe 'createLevelNumberMap', ->
it 'returns correct map for r', ->
levels = [
{key: 1, practice: false}
]
levelNumberMap = utils.createLevelNumberMap(levels)
expect((val.toString() for key, val of levelNumberMap)).toEqual(['1'])
it 'returns correct map for r r', ->
levels = [
{key: 1, practice: false}
{key: 2, practice: false}
]
levelNumberMap = utils.createLevelNumberMap(levels)
expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '2'])
it 'returns correct map for p', ->
levels = [
{key: 1, practice: true}
]
levelNumberMap = utils.createLevelNumberMap(levels)
expect((val.toString() for key, val of levelNumberMap)).toEqual(['0a'])
it 'returns correct map for r p r', ->
levels = [
{key: 1, practice: false}
{key: 2, practice: true}
{key: 3, practice: false}
]
levelNumberMap = utils.createLevelNumberMap(levels)
expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '1a', '2'])
it 'returns correct map for r p p p', ->
levels = [
{key: 1, practice: false}
{key: 2, practice: true}
{key: 3, practice: true}
{key: 4, practice: true}
]
levelNumberMap = utils.createLevelNumberMap(levels)
expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '1a', '1b', '1c'])
it 'returns correct map for r p p p r p p r r p r', ->
levels = [
{key: 1, practice: false}
{key: 2, practice: true}
{key: 3, practice: true}
{key: 4, practice: true}
{key: 5, practice: false}
{key: 6, practice: true}
{key: 7, practice: true}
{key: 8, practice: false}
{key: 9, practice: false}
{key: 10, practice: true}
{key: 11, practice: false}
]
levelNumberMap = utils.createLevelNumberMap(levels)
expect((val.toString() for key, val of levelNumberMap)).toEqual(['1', '1a', '1b', '1c', '2', '2a', '2b', '3', '4', '4a', '5'])
describe 'findNextlevel', ->
describe 'when no practice needed', ->
needsPractice = false
it 'returns next level when rc* p', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: false}
]
expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(2)
done()
it 'returns next level when pc* p r', (done) ->
levels = [
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: false}
]
expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(2)
done()
it 'returns next level when pc* p p', (done) ->
levels = [
{practice: true, complete: true}
{practice: true, complete: false}
{practice: true, complete: false}
]
expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(3)
done()
it 'returns next level when rc* p rc', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
]
expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(3)
done()
describe 'when needs practice', ->
needsPractice = true
it 'returns next level when rc* p', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: false}
]
expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(1)
done()
it 'returns next level when rc* rc', (done) ->
levels = [
{practice: false, complete: true}
{practice: false, complete: true}
]
expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(2)
done()
it 'returns next level when rc p rc*', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
]
expect(utils.findNextLevel(levels, 2, needsPractice)).toEqual(1)
done()
it 'returns next level when rc pc p rc*', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
]
expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(2)
done()
it 'returns next level when rc pc p rc* p', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
{practice: true, complete: false}
]
expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(4)
done()
it 'returns next level when rc pc p rc* pc', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
{practice: true, complete: true}
]
expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5)
done()
it 'returns next level when rc pc p rc* pc p', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
]
expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5)
done()
it 'returns next level when rc pc p rc* pc r', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
{practice: true, complete: true}
{practice: false, complete: false}
]
expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5)
done()
it 'returns next level when rc pc p rc* pc p r', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: false}
{practice: false, complete: false}
]
expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(5)
done()
it 'returns next level when rc pc pc rc* r p', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: true, complete: true}
{practice: false, complete: true}
{practice: false, complete: false}
{practice: true, complete: false}
]
expect(utils.findNextLevel(levels, 3, needsPractice)).toEqual(4)
done()
it 'returns next level when rc* pc rc', (done) ->
levels = [
{practice: false, complete: true}
{practice: true, complete: true}
{practice: false, complete: true}
]
expect(utils.findNextLevel(levels, 0, needsPractice)).toEqual(3)
done()