codecombat/app/views/courses/TeacherClassView.coffee
Matt Lott d72e4eb750 Practice levels Ux and next level algorithm
Update classroom and gameplay Ux to surface practice levels as 3a, 3b,
etc.
Update next level logic to leverage practice levels based on per level
completion playtime thresholds.
Patrol buster and patrol buster A are live for testing.
Fix a few classroom Ux progress hover bubble info bugs.

Closes #3767
2016-06-27 14:05:42 -07:00

470 lines
20 KiB
CoffeeScript

RootView = require 'views/core/RootView'
State = require 'models/State'
template = require 'templates/courses/teacher-class-view'
helper = require 'lib/coursesHelper'
ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal'
InviteToClassroomModal = require 'views/courses/InviteToClassroomModal'
ActivateLicensesModal = require 'views/courses/ActivateLicensesModal'
EditStudentModal = require 'views/teachers/EditStudentModal'
RemoveStudentModal = require 'views/courses/RemoveStudentModal'
Campaigns = require 'collections/Campaigns'
Classroom = require 'models/Classroom'
Classrooms = require 'collections/Classrooms'
Levels = require 'collections/Levels'
LevelSessions = require 'collections/LevelSessions'
User = require 'models/User'
Users = require 'collections/Users'
Course = require 'models/Course'
Courses = require 'collections/Courses'
CourseInstance = require 'models/CourseInstance'
CourseInstances = require 'collections/CourseInstances'
module.exports = class TeacherClassView extends RootView
id: 'teacher-class-view'
template: template
events:
'click .nav-tabs a': 'onClickNavTabLink'
'click .unarchive-btn': 'onClickUnarchive'
'click .edit-classroom': 'onClickEditClassroom'
'click .add-students-btn': 'onClickAddStudents'
'click .edit-student-link': 'onClickEditStudentLink'
'click .sort-button': 'onClickSortButton'
'click #copy-url-btn': 'onClickCopyURLButton'
'click #copy-code-btn': 'onClickCopyCodeButton'
'click .remove-student-link': 'onClickRemoveStudentLink'
'click .assign-student-button': 'onClickAssignStudentButton'
'click .enroll-student-button': 'onClickEnrollStudentButton'
'click .assign-to-selected-students': 'onClickBulkAssign'
'click .enroll-selected-students': 'onClickBulkEnroll'
'click .export-student-progress-btn': 'onClickExportStudentProgress'
'click .select-all': 'onClickSelectAll'
'click .student-checkbox': 'onClickStudentCheckbox'
'keyup #student-search': 'onKeyPressStudentSearch'
'change .course-select, .bulk-course-select': 'onChangeCourseSelect'
getInitialState: ->
{
sortAttribute: 'name'
sortDirection: 1
activeTab: '#' + (Backbone.history.getHash() or 'students-tab')
students: new Users()
classCode: ""
joinURL: ""
errors:
assigningToNobody: false
assigningToUnenrolled: false
selectedCourse: undefined
classStats:
averagePlaytime: ""
totalPlaytime: ""
averageLevelsComplete: ""
totalLevelsComplete: ""
enrolledUsers: ""
}
getTitle: -> return @classroom?.get('name')
initialize: (options, classroomID) ->
super(options)
@singleStudentCourseProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-course'
@singleStudentLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-level'
@allStudentsLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-all-students-single-level'
@debouncedRender = _.debounce @render
@state = new State(@getInitialState())
@updateHash @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab)
@classroom = new Classroom({ _id: classroomID })
@supermodel.trackRequest @classroom.fetch()
@onKeyPressStudentSearch = _.debounce(@onKeyPressStudentSearch, 200)
@students = new Users()
@listenTo @classroom, 'sync', ->
jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true)
@supermodel.trackRequests jqxhrs
@classroom.sessions = new LevelSessions()
requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom)
@supermodel.trackRequests(requests)
@students.comparator = (student1, student2) =>
dir = @state.get('sortDirection')
value = @state.get('sortValue')
if value is 'name'
return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir)
if value is 'progress'
# TODO: I would like for this to be in the Level model,
# but it doesn't know about its own courseNumber.
level1 = student1.latestCompleteLevel
level2 = student2.latestCompleteLevel
return -dir if not level1
return dir if not level2
return dir * (level1.courseNumber - level2.courseNumber or level1.levelNumber - level2.levelNumber)
if value is 'status'
statusMap = { expired: 0, 'not-enrolled': 1, enrolled: 2 }
diff = statusMap[student1.prepaidStatus()] - statusMap[student2.prepaidStatus()]
return dir * diff if diff
return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir)
@courses = new Courses()
@supermodel.trackRequest @courses.fetch()
@courseInstances = new CourseInstances()
@supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID)
@levels = new Levels()
@supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts,practice'}})
@attachMediatorEvents()
window.tracker?.trackEvent 'Teachers Class Loaded', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
attachMediatorEvents: () ->
# Model/Collection events
@listenTo @classroom, 'sync change update', ->
classCode = @classroom.get('codeCamel') or @classroom.get('code')
@state.set {
classCode: classCode
joinURL: document.location.origin + "/courses?_cc=" + classCode
}
@listenTo @courses, 'sync change update', ->
@setCourseMembers() # Is this necessary?
@state.set selectedCourse: @courses.first() unless @state.get('selectedCourse')
@listenTo @courseInstances, 'sync change update', ->
@setCourseMembers()
@listenTo @courseInstances, 'add-members', ->
noty text: $.i18n.t('teacher.assigned'), layout: 'center', type: 'information', killer: true, timeout: 5000
@listenTo @students, 'sync change update add remove reset', ->
# Set state/props of things that depend on students?
# Set specific parts of state based on the models, rather than just dumping the collection there?
@calculateProgressAndLevels()
classStats = @calculateClassStats()
@state.set classStats: classStats if classStats
@state.set students: @students
@listenTo @students, 'sort', ->
@state.set students: @students
@listenTo @, 'course-select:change', ({ selectedCourse }) ->
@state.set selectedCourse: selectedCourse
setCourseMembers: =>
for course in @courses.models
course.instance = @courseInstances.findWhere({ courseID: course.id, classroomID: @classroom.id })
course.members = course.instance?.get('members') or []
null
onLoaded: ->
@removeDeletedStudents() # TODO: Move this to mediator listeners? For both classroom and students?
@calculateProgressAndLevels()
# render callback setup
@listenTo @courseInstances, 'sync change update', @debouncedRender
@listenTo @state, 'sync change', ->
if _.isEmpty(_.omit(@state.changed, 'searchTerm'))
@renderSelectors('#enrollment-status-table')
else
@debouncedRender()
@listenTo @students, 'sort', @debouncedRender
super()
afterRender: ->
super(arguments...)
$('.progress-dot').each (i, el) ->
dot = $(el)
dot.tooltip({
html: true
container: dot
}).delegate '.tooltip', 'mousemove', ->
dot.tooltip('hide')
calculateProgressAndLevels: ->
return unless @supermodel.progress is 1
# TODO: How to structure this in @state?
for student in @students.models
# TODO: this is a weird hack
studentsStub = new Users([ student ])
student.latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, studentsStub)
earliestIncompleteLevel = helper.calculateEarliestIncomplete(@classroom, @courses, @courseInstances, @students)
latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, @students)
classroomsStub = new Classrooms([ @classroom ])
progressData = helper.calculateAllProgress(classroomsStub, @courses, @courseInstances, @students)
# conceptData: helper.calculateConceptsCovered(classroomsStub, @courses, @campaigns, @courseInstances, @students)
@state.set {
earliestIncompleteLevel
latestCompleteLevel
progressData
classStats: @calculateClassStats()
}
onClickNavTabLink: (e) ->
e.preventDefault()
hash = $(e.target).closest('a').attr('href')
@updateHash(hash)
@state.set activeTab: hash
updateHash: (hash) ->
return if application.testing
window.location.hash = hash
onClickCopyCodeButton: ->
window.tracker?.trackEvent 'Teachers Class Copy Class Code', category: 'Teachers', classroomID: @classroom.id, classCode: @state.get('classCode'), ['Mixpanel']
@$('#join-code-input').val(@state.get('classCode')).select()
@tryCopy()
onClickCopyURLButton: ->
window.tracker?.trackEvent 'Teachers Class Copy Class URL', category: 'Teachers', classroomID: @classroom.id, url: @state.get('joinURL'), ['Mixpanel']
@$('#join-url-input').val(@state.get('joinURL')).select()
@tryCopy()
tryCopy: ->
try
document.execCommand('copy')
catch err
message = 'Oops, unable to copy'
noty text: message, layout: 'topCenter', type: 'error', killer: false
onClickUnarchive: ->
window.tracker?.trackEvent 'Teachers Class Unarchive', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
@classroom.save { archived: false }
onClickEditClassroom: (e) ->
window.tracker?.trackEvent 'Teachers Class Edit Class Started', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
classroom = @classroom
modal = new ClassroomSettingsModal({ classroom: classroom })
@openModalView(modal)
@listenToOnce modal, 'hide', @render
onClickEditStudentLink: (e) ->
window.tracker?.trackEvent 'Teachers Class Students Edit', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
user = @students.get($(e.currentTarget).data('student-id'))
modal = new EditStudentModal({ user, @classroom })
@openModalView(modal)
onClickRemoveStudentLink: (e) ->
user = @students.get($(e.currentTarget).data('student-id'))
modal = new RemoveStudentModal({
classroom: @classroom
user: user
courseInstances: @courseInstances
})
@openModalView(modal)
modal.once 'remove-student', @onStudentRemoved, @
onStudentRemoved: (e) ->
@students.remove(e.user)
window.tracker?.trackEvent 'Teachers Class Students Removed', category: 'Teachers', classroomID: @classroom.id, userID: e.user.id, ['Mixpanel']
onClickAddStudents: (e) =>
window.tracker?.trackEvent 'Teachers Class Add Students', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
modal = new InviteToClassroomModal({ classroom: @classroom })
@openModalView(modal)
@listenToOnce modal, 'hide', @render
removeDeletedStudents: () ->
return unless @classroom.loaded and @students.loaded
_.remove(@classroom.get('members'), (memberID) =>
not @students.get(memberID) or @students.get(memberID)?.get('deleted')
)
true
onClickSortButton: (e) ->
value = $(e.target).val()
if value is @state.get('sortValue')
@state.set('sortDirection', -@state.get('sortDirection'))
else
@state.set({
sortValue: value
sortDirection: 1
})
@students.sort()
onKeyPressStudentSearch: (e) ->
@state.set('searchTerm', $(e.target).val())
onChangeCourseSelect: (e) ->
@trigger 'course-select:change', { selectedCourse: @courses.get($(e.currentTarget).val()) }
getSelectedStudentIDs: ->
@$('.student-row .checkbox-flat input:checked').map (index, checkbox) ->
$(checkbox).data('student-id')
ensureInstance: (courseID) ->
onClickEnrollStudentButton: (e) ->
userID = $(e.currentTarget).data('user-id')
user = @students.get(userID)
selectedUsers = new Users([user])
@enrollStudents(selectedUsers)
window.tracker?.trackEvent $(e.currentTarget).data('event-action'), category: 'Teachers', classroomID: @classroom.id, userID: userID, ['Mixpanel']
onClickBulkEnroll: ->
userIDs = @getSelectedStudentIDs().toArray()
selectedUsers = new Users(@students.get(userID) for userID in userIDs)
@enrollStudents(selectedUsers)
window.tracker?.trackEvent 'Teachers Class Students Enroll Selected', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
enrollStudents: (selectedUsers) ->
modal = new ActivateLicensesModal { @classroom, selectedUsers, users: @students }
@openModalView(modal)
modal.once 'redeem-users', (enrolledUsers) =>
enrolledUsers.each (newUser) =>
user = @students.get(newUser.id)
if user
user.set(newUser.attributes)
null
onClickExportStudentProgress: ->
# TODO: Does not yield .csv download on Safari, and instead opens a new tab with the .csv contents
window.tracker?.trackEvent 'Teachers Class Export CSV', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel']
courseLabels = ""
courseOrder = []
for course, index in @classroom.get('courses')
courseLabels += "CS#{index + 1} Playtime,"
courseOrder.push(course._id)
csvContent = "data:text/csv;charset=utf-8,Username,Email,Total Playtime,#{courseLabels}Concepts\n"
levelCourseMap = {}
for trimCourse in @classroom.get('courses')
for trimLevel in trimCourse.levels
levelCourseMap[trimLevel.original] = @courses.get(trimCourse._id)
for student in @students.models
concepts = []
for trimCourse in @classroom.get('courses')
course = @courses.get(trimCourse._id)
instance = @courseInstances.findWhere({ courseID: course.id, classroomID: @classroom.id })
if instance and instance.hasMember(student)
for trimLevel in trimCourse.levels
level = @levels.findWhere({ original: trimLevel.original })
progress = @state.get('progressData').get({ classroom: @classroom, course: course, level: level, user: student })
concepts.push(level.get('concepts') ? []) if progress?.completed
concepts = _.union(_.flatten(concepts))
conceptsString = _.map(concepts, (c) -> $.i18n.t("concepts." + c)).join(', ')
coursePlaytimeMap = {}
playtime = 0
for session in @classroom.sessions.models when session.get('creator') is student.id
playtime += session.get('playtime') or 0
if courseID = levelCourseMap[session.get('level')?.original]?.id
coursePlaytimeMap[courseID] ?= 0
coursePlaytimeMap[courseID] += session.get('playtime') or 0
playtimeString = if playtime is 0 then "0" else moment.duration(playtime, 'seconds').humanize()
for course in @courses.models
coursePlaytimeMap[course.id] ?= 0
coursePlaytimes = []
for courseID, playtime of coursePlaytimeMap
coursePlaytimes.push
courseID: courseID
playtime: playtime
coursePlaytimes.sort (a, b) ->
return -1 if courseOrder.indexOf(a.courseID) < courseOrder.indexOf(b.courseID)
return 0 if courseOrder.indexOf(a.courseID) is courseOrder.indexOf(b.courseID)
return 1
coursePlaytimesString = ""
for coursePlaytime, index in coursePlaytimes
if coursePlaytime.playtime is 0
coursePlaytimesString += "0,"
else
coursePlaytimesString += "#{moment.duration(coursePlaytime.playtime, 'seconds').humanize()},"
csvContent += "#{student.get('name')},#{student.get('email')},#{playtimeString},#{coursePlaytimesString}\"#{conceptsString}\"\n"
csvContent = csvContent.substring(0, csvContent.length - 1)
encodedUri = encodeURI(csvContent)
window.open(encodedUri)
onClickAssignStudentButton: (e) ->
userID = $(e.currentTarget).data('user-id')
user = @students.get(userID)
members = [userID]
courseID = $(e.currentTarget).data('course-id')
@assignCourse courseID, members
window.tracker?.trackEvent 'Teachers Class Students Assign Selected', category: 'Teachers', classroomID: @classroom.id, courseID: courseID, userID: userID, ['Mixpanel']
onClickBulkAssign: ->
courseID = @$('.bulk-course-select').val()
selectedIDs = @getSelectedStudentIDs()
members = selectedIDs.filter((index, userID) =>
user = @students.get(userID)
user.isEnrolled()
).toArray()
assigningToUnenrolled = _.any selectedIDs, (userID) =>
not @students.get(userID).isEnrolled()
assigningToNobody = selectedIDs.length is 0
@state.set errors: { assigningToNobody, assigningToUnenrolled }
@assignCourse courseID, members
window.tracker?.trackEvent 'Teachers Class Students Assign Selected', category: 'Teachers', classroomID: @classroom.id, courseID: courseID, ['Mixpanel']
# TODO: Move this to the model. Use promises/callbacks?
assignCourse: (courseID, members) ->
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
if courseInstance
courseInstance.addMembers members
else
courseInstance = new CourseInstance {
courseID,
classroomID: @classroom.id
ownerID: @classroom.get('ownerID')
aceConfig: {}
}
@courseInstances.add(courseInstance)
courseInstance.save {}, {
success: ->
courseInstance.addMembers members
}
null
onClickSelectAll: (e) ->
e.preventDefault()
checkboxes = @$('.student-checkbox input')
if _.all(checkboxes, 'checked')
@$('.select-all input').prop('checked', false)
checkboxes.prop('checked', false)
else
@$('.select-all input').prop('checked', true)
checkboxes.prop('checked', true)
null
onClickStudentCheckbox: (e) ->
e.preventDefault()
# $(e.target).$()
checkbox = $(e.currentTarget).find('input')
checkbox.prop('checked', not checkbox.prop('checked'))
# checkboxes.prop('checked', false)
checkboxes = @$('.student-checkbox input')
@$('.select-all input').prop('checked', _.all(checkboxes, 'checked'))
calculateClassStats: ->
return {} unless @classroom.sessions?.loaded and @students.loaded
stats = {}
playtime = 0
total = 0
for session in @classroom.sessions.models
pt = session.get('playtime') or 0
playtime += pt
total += 1
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
# TODO: Humanize differently ('1 hour' instead of 'an hour')
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.totalLevelsComplete = _.size(completeSessions)
enrolledUsers = @students.filter (user) -> user.isEnrolled()
stats.enrolledUsers = _.size(enrolledUsers)
return stats
studentStatusString: (student) ->
status = student.prepaidStatus()
expires = student.get('coursePrepaid')?.endDate
string = switch status
when 'not-enrolled' then $.i18n.t('teacher.status_not_enrolled')
when 'enrolled' then (if expires then $.i18n.t('teacher.status_enrolled') else '-')
when 'expired' then $.i18n.t('teacher.status_expired')
return string.replace('{{date}}', moment(expires).utc().format('l'))