Simplify applying licenses

In TeacherClassView, when a teacher assigns a paid course to any unenrolled
student, the view automatically enrolls those students, rather than requiring
the teacher to enroll those students manually first. Update copy throughout.

Also add back (smaller) padding to progress dots in TeacherClassView.
This commit is contained in:
Scott Erickson 2016-08-23 10:43:31 -07:00
parent c2ae4d3748
commit ef0547f72a
18 changed files with 293 additions and 110 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -33,4 +33,6 @@ module.exports = class CocoCollection extends Backbone.Collection
setProjection: (@project) ->
stringify: -> return JSON.stringify(@toJSON())
stringify: -> return JSON.stringify(@toJSON())
wait: (event) -> new Promise((resolve) => @once(event, resolve))

View file

@ -84,3 +84,5 @@ module.exports = class CocoClass
playSound: (trigger, volume=1) ->
Backbone.Mediator.publish 'audio-player:play-sound', trigger: trigger, volume: volume
wait: (event) -> new Promise((resolve) => @once(event, resolve))

View file

@ -1434,12 +1434,13 @@
earliest_incomplete: "Earliest incomplete level"
latest_complete: "Latest completed level"
enroll_student: "Enroll student"
apply_license: "Apply License"
course_progress: "Course Progress"
not_applicable: "N/A"
edit: "edit"
edit_2: "Edit"
remove: "remove"
latest_completed: "Latest completed"
latest_completed: "Latest completed:" # {change}
sort_by: "Sort by"
progress: "Progress"
completed: "Completed"
@ -1447,6 +1448,7 @@
click_to_view_progress: "click to view progress"
no_progress: "No progress"
select_course: "Select course to view"
students_not_assigned: "Students who have not been assigned {{courseName}}"
course_overview: "Course Overview"
copy_class_code: "Copy Class Code"
class_code_blurb: "Students can join your class using this Class Code. No email address is required when creating a Student account with this Class Code."
@ -1454,16 +1456,24 @@
class_join_url_blurb: "You can also post this unique class URL to a shared webpage."
add_students_manually: "Invite Students by Email"
bulk_assign: "Bulk-assign"
assigned_msg_1: "{{numberAssigned}} students were assigned {{courseName}}."
assigned_msg_2: "{{numberEnrolled}} licenses were applied."
assigned_msg_3: "You now have {{remainingSpots}} available licenses remaining."
assign_course: "Assign Course"
not_assigned_modal_title: "Courses were not assigned"
not_assigned_modal_body_1: "You do not have enough licenses available to assign additional Courses to all {{selected}} selected students."
not_assigned_modal_body_2: "You only have {{totalSpotsAvailable}} licenses available ({{unenrolledStudents}} students did not have an active license)."
not_assigned_modal_body_3: "Please select fewer students, or reach out to {{email}} for assistance."
assign_to_selected_students: "Assign to Selected Students"
assigned: "Assigned"
enroll_selected_students: "Enroll Selected Students"
cant_assign_to_unenrolled: "Course cannot be assigned to students who are not enrolled."
no_students_selected: "No students were selected."
guides_coming_soon: "Guides coming soon!" # Courses
show_students_from: "Show students from" # Enroll students modal
enroll_the_following_students: "Enroll the following students"
apply_licenses_to_the_following_students: "Apply Licenses to the Following Students"
students_have_licenses: "The following students already have licenses applied:"
all_students: "All Students"
enroll_students: "Enroll Students"
apply_licenses: "Apply Licenses"
not_enough_enrollments: "Not enough licenses available."
enrollments_blurb_1: "Students taking Computer Science"
enrollments_blurb_2: "require a license to access the courses."
@ -1476,9 +1486,7 @@
purchased: "Purchased!"
purchase_now: "Purchase Now"
how_to_enroll: "How to Enroll Students"
how_to_enroll_blurb_1: "If a student is not enrolled yet, there will be an \"Enroll\" button next to their course progress in your class."
how_to_enroll_blurb_2: "To bulk-enroll multiple students, select them using the checkboxes on the left side of the classroom page and click the \"Enroll Selected Students\" button."
how_to_enroll_blurb_3: "Once a student is enrolled, they will have access to all of the course content."
how_to_apply_licenses: "How to Apply Licenses"
bulk_pricing_blurb: "Purchasing for more than 25 students? Contact us to discuss next steps."
total_unenrolled: "Total unenrolled"
export_student_progress: "Export Student Progress (CSV)"
@ -1495,11 +1503,12 @@
end_date: "end date:"
num_enrollments_needed: "Number of licenses needed:"
get_enrollments_blurb: " We'll help you build a solution that meets the needs of your class, school or district."
enroll_request_sent_blurb1: "Thanks! Your request has been sent."
enroll_request_sent_blurb2: "Our classroom success team will be in touch shortly to help you find the best solution for your students' needs!"
enroll_request_sent_blurb3: "Please reach out to <a href='mailto:schools@codecombat.com'>schools@codecombat.com</a> if you have additional questions at this time."
how_to_apply_licenses_blurb_1: "When a teacher assigns a course to a student for the first time, well automatically apply a license. Use the bulk-assign dropdown in your classroom to assign a course to selected students:"
how_to_apply_licenses_blurb_2: "Can I still apply a license without assigning a course?"
how_to_apply_licenses_blurb_3: "Yes — go to the License Status tab in your classroom and click \"Apply License\" to any student who does not have an active license."
request_sent: "Request Sent!"
enrollment_status: "Enrollment Status"
license_status: "License Status"
status_expired: "Expired on {{date}}"
status_not_enrolled: "Not Enrolled"
status_enrolled: "Expires on {{date}}"

View file

@ -460,4 +460,6 @@ class CocoModel extends Backbone.Model
stringify: -> return JSON.stringify(@toJSON())
wait: (event) -> new Promise((resolve) => @once(event, resolve))
module.exports = CocoModel

View file

@ -36,11 +36,12 @@ module.exports = class CourseInstance extends CocoModel
@trigger 'add-members', { userIDs }
}
_.extend options, opts
@fetch(options)
jqxhr = @fetch(options)
if me.id in userIDs
unless me.get('courseInstances')
me.set('courseInstances', [])
me.get('courseInstances').push(@id)
return jqxhr
removeMember: (userID, opts) ->
options = {

View file

@ -19,6 +19,7 @@ module.exports = class Prepaid extends CocoModel
maxRedeemers = @get('maxRedeemers')
if _.isString(maxRedeemers)
@set 'maxRedeemers', parseInt(maxRedeemers)
super(arguments...)
status: ->
endDate = @get('endDate')

View file

@ -260,6 +260,7 @@
min-width: 34px
height: 34px
border-radius: 16px
padding: 0 5px
// margin-top: 23px
// margin-bottom: 23px
background: $gray-light
@ -337,7 +338,7 @@
top: 8px
right: 5px
#enrollment-status-table
#license-status-table
// These column widths are just to keep the cells from resizing on search
.checkbox-col
width: 75px

View file

@ -0,0 +1,4 @@
#how-to-enroll-modal
img
width: 500px
margin: 0 auto

View file

@ -3,7 +3,7 @@ extends /templates/core/modal-base-flat
block modal-header-content
.clearfix
.text-center
h1(data-i18n="teacher.enroll_students")
h1(data-i18n="teacher.apply_licenses")
h2(data-i18n="courses.grants_lifetime_access")
block modal-body-content
@ -26,7 +26,7 @@ block modal-body-content
option(selected=(!view.classroom), value='' data-i18n='teacher.all_students')
form.form.m-t-3
span(data-i18n="teacher.enroll_the_following_students")
span(data-i18n="teacher.apply_licenses_to_the_following_students")
span :
.well.form-group
- var enrolledUsers = view.users.filter(function(user){ return user.isEnrolled() })
@ -39,8 +39,7 @@ block modal-body-content
span.spr= user.broadName()
if enrolledUsers.length > 0
.small-details.m-t-3
span(data-i18n='TODO')
| The following students are already enrolled:
span(data-i18n='teacher.students_have_licenses')
for user in enrolledUsers
- var selected = Boolean(paid || state.get('selectedUsers').get(user.id))
.checkbox
@ -62,12 +61,11 @@ block modal-body-content
p
button#activate-licenses-btn.btn.btn-lg.btn-primary(type="submit" class=(tooManySelected || noneSelected ? 'disabled' : ''))
span.spr(data-i18n="courses.enroll")
| (
span(data-i18n="teacher.apply_licenses")
| (
span#total-selected-span
= numToEnroll
| )
span.spl(data-i18n="courses.students1")
p
a#get-more-licenses-btn.btn.btn-lg.btn-primary-alt(href="/teachers/licenses", data-i18n="courses.get_enrollments")

View file

@ -0,0 +1,13 @@
extends /templates/core/modal-base-flat
block modal-header-content
h1(data-i18n="teacher.not_assigned_modal_title")
block modal-body-content
p= translate("teacher.not_assigned_modal_body_1").replace('{{selected}}', view.selected)
p= translate("teacher.not_assigned_modal_body_2").replace('{{totalSpotsAvailable}}', view.totalSpotsAvailable).replace('{{unenrolledStudents}}', view.unenrolledStudents)
p!= translate("teacher.not_assigned_modal_body_3").replace('{{email}}', "<a href='mailto:support@codecombat.com'>support@codecombat.com</a>")
block modal-footer-content
button.btn.btn-primary(data-dismiss="modal", data-i18n="modal.okay")

View file

@ -36,7 +36,7 @@ block content
.pull-right
span.glyphicon.glyphicon-question-sign
=' '
a#how-to-enroll-link(data-i18n="teacher.how_to_enroll")
a#how-to-enroll-link(data-i18n="teacher.how_to_apply_licenses")
h3(data-i18n='teacher.enrollments')
h4#enrollments-blurb
span(data-i18n='teacher.enrollments_blurb_1')
@ -131,4 +131,4 @@ mixin enrollmentStats
.text-center
button#enroll-students-btn.btn.btn-lg.btn-navy
span(data-i18n='teacher.enroll_students')
span(data-i18n='teacher.apply_licenses')

View file

@ -119,9 +119,9 @@ block content
a.course-progress-tab-btn(href='#course-progress-tab')
.small-details.text-center(data-i18n='teacher.course_progress')
.tab-spacer
li(class=(activeTab === "#enrollment-status-tab" ? 'active' : ''))
a.course-progress-tab-btn(href='#enrollment-status-tab')
.small-details.text-center(data-i18n='teacher.enrollment_status')
li(class=(activeTab === "#license-status-tab" ? 'active' : ''))
a.course-progress-tab-btn(href='#license-status-tab')
.small-details.text-center(data-i18n='teacher.license_status')
.tab-filler
.tab-content
@ -129,7 +129,7 @@ block content
+studentsTab
else if activeTab === '#course-progress-tab'
+courseProgressTab
else if activeTab === '#enrollment-status-tab'
else if activeTab === '#license-status-tab'
+enrollmentStatusTab
else
@ -215,7 +215,6 @@ mixin studentRow(student)
div
i
span(data-i18n='teacher.latest_completed')
span :
div
+longLevelName(student.latestCompleteLevel)
td
@ -231,8 +230,6 @@ mixin studentRow(student)
//- - var level = ???
- var label = courseLabelsArray[index];
+studentCourseProgressDot(progress, levelsTotal, level, label)
unless student.isEnrolled()
+enrollStudentButton(student)
//- td
//- span.view-class-arrow.glyphicon.glyphicon-chevron-right
td
@ -244,10 +241,6 @@ mixin studentRow(student)
div.glyphicon.glyphicon-remove
div(data-i18n='teacher.remove')
mixin enrollStudentButton(student)
a.enroll-student-button.btn.btn-lg.btn-primary(data-classroom-id=view.classroom.id data-user-id=student.id data-event-action="Teachers Class Students Enroll Student")
span(data-i18n='teacher.enroll_student')
mixin courseProgressTab
#course-progress-tab.m-t-3
if view.courses
@ -267,14 +260,11 @@ mixin courseProgressTab
each student in state.get('students').models
if _.contains(state.get('selectedCourse').members, student.id)
+studentLevelsRow(student)
//- TODO: If any students aren't assigned the course
.unassigned-students.render-on-course-sync
if state.get('selectedCourse') && state.get('selectedCourse').members.length < state.get('students').length
h2
span(data-i18n='TODO')
| Students who have not been assigned
|
span= state.get('selectedCourse').get('name')
- var courseName = i18n(state.get('selectedCourse').attributes, 'name');
span= translate('teacher.students_not_assigned').replace('{{courseName}}', courseName)
for student in state.get('students').models
unless _.contains(state.get('selectedCourse').members, student.id)
.row.unassigned-student-row.alternating-background
@ -285,19 +275,11 @@ mixin courseProgressTab
.col-sm-4
.latest-completed.truncate.small
i.m-r-1
span(data-i18n='TODO')
| Latest completed
| :
span(data-i18n='teacher.latest_completed')
+longLevelName(student.latestCompleteLevel)
.col-sm-2
if student.isEnrolled()
.assign-student-button.btn.btn-md.btn-navy.pull-right(data-user-id=student.id data-course-id=state.get('selectedCourse').id)
span(data-i18n='TODO')
| Assign Course
else
.enroll-student-button.btn.btn-md.btn-navy.pull-right(data-user-id=student.id data-event-action="Teachers Class Course Enroll Student")
span(data-i18n='TODO')
| Enroll Student
.assign-student-button.btn.btn-md.btn-navy.pull-right(data-user-id=student.id data-course-id=state.get('selectedCourse').id)
span(data-i18n='teacher.assign_course')
mixin courseOverview
- var course = state.get('selectedCourse')
@ -417,8 +399,6 @@ mixin bulkAssignControls
.bulk-assign-controls.form-inline
.no-students-selected.small-details(class=state.get('errors').assigningToNobody ? 'visible' : '')
span(data-i18n='teacher.no_students_selected')
.cant-assign-to-unenrolled.small-details(class=state.get('errors').assigningToUnenrolled ? 'visible' : '')
span(data-i18n='teacher.cant_assign_to_unenrolled')
span.small
span(data-i18n='teacher.bulk_assign')
span :
@ -429,8 +409,6 @@ mixin bulkAssignControls
= i18n(course.attributes, 'name')
button.btn.btn-primary-alt.assign-to-selected-students
span(data-i18n='teacher.assign_to_selected_students')
button.btn.btn-primary-alt.enroll-selected-students
span(data-i18n='teacher.enroll_selected_students')
mixin enrollmentStatusTab
// TODO: Have search input in all tabs
@ -441,7 +419,7 @@ mixin enrollmentStatusTab
// input#student-search.form-control.m-l-1(type="search")
// span.glyphicon.glyphicon-search.form-control-feedback
table.table#enrollment-status-table.table-condensed.m-t-3
table.table#license-status-table.table-condensed.m-t-3
thead
// Checkbox code works, but don't need it yet.
//th.checkbox-col.select-all
@ -473,4 +451,4 @@ mixin enrollmentStatusTab
strong(class= status === 'expired' ? 'text-danger' : '')= view.studentStatusString(student)
td.enroll-col
if status !== 'enrolled'
button.enroll-student-button.btn.btn-navy(data-i18n="teacher.enroll_student", data-user-id=student.id, data-event-action="Teachers Class Enrollment Enroll Student")
button.enroll-student-button.btn.btn-navy(data-i18n="teacher.apply_license", data-user-id=student.id, data-event-action="Teachers Class Enrollment Enroll Student")

View file

@ -2,11 +2,11 @@ extends /templates/core/modal-base-flat
block modal-header-content
.text-center
h3(data-i18n='teacher.how_to_enroll')
h3(data-i18n='teacher.how_to_apply_licenses')
block modal-body
block modal-body-content
ol
li.m-t-1(data-i18n='teacher.how_to_enroll_blurb_1')
li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_2')
li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_3')
p(data-i18n='teacher.how_to_apply_licenses_blurb_1')
img.m-y-3(src="/images/pages/courses/how_to_apply_licenses.png")
h5(data-i18n='teacher.how_to_apply_licenses_blurb_2')
p(data-i18n='teacher.how_to_apply_licenses_blurb_3')

View file

@ -504,6 +504,8 @@ module.exports = class CocoView extends Backbone.View
message = 'Oops, unable to copy'
noty text: message, layout: 'topCenter', type: 'error', killer: false
wait: (event) -> new Promise((resolve) => @once(event, resolve))
mobileRELong = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i
mobileREShort = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i

View file

@ -0,0 +1,9 @@
ModalView = require 'views/core/ModalView'
template = require 'templates/courses/courses-not-assigned-modal'
module.exports = class CoursesNotAssignedModal extends ModalView
id: 'courses-not-assigned-modal'
template: template
initialize: (options) ->
_.assign(@, _.pick(options, 'selected', 'totalSpotsAvailable', 'unenrolledStudents'))

View file

@ -7,6 +7,7 @@ InviteToClassroomModal = require 'views/courses/InviteToClassroomModal'
ActivateLicensesModal = require 'views/courses/ActivateLicensesModal'
EditStudentModal = require 'views/teachers/EditStudentModal'
RemoveStudentModal = require 'views/courses/RemoveStudentModal'
CoursesNotAssignedModal = require './CoursesNotAssignedModal'
Campaigns = require 'collections/Campaigns'
Classroom = require 'models/Classroom'
@ -19,6 +20,7 @@ Course = require 'models/Course'
Courses = require 'collections/Courses'
CourseInstance = require 'models/CourseInstance'
CourseInstances = require 'collections/CourseInstances'
Prepaids = require 'collections/Prepaids'
module.exports = class TeacherClassView extends RootView
id: 'teacher-class-view'
@ -38,7 +40,6 @@ module.exports = class TeacherClassView extends RootView
'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'
@ -55,7 +56,6 @@ module.exports = class TeacherClassView extends RootView
joinURL: ""
errors:
assigningToNobody: false
assigningToUnenrolled: false
selectedCourse: undefined
checkboxStates: {}
classStats:
@ -85,6 +85,10 @@ module.exports = class TeacherClassView extends RootView
@onKeyPressStudentSearch = _.debounce(@onKeyPressStudentSearch, 200)
@sortedCourses = []
@prepaids = new Prepaids()
@prepaids.comparator = 'endDate' # use prepaids in order of expiration
@supermodel.trackRequest @prepaids.fetchByCreator(me.id)
@students = new Users()
@listenTo @classroom, 'sync', ->
jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true)
@ -140,8 +144,6 @@ module.exports = class TeacherClassView extends RootView
@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?
@ -173,7 +175,7 @@ module.exports = class TeacherClassView extends RootView
@listenTo @courseInstances, 'sync change update', @debouncedRender
@listenTo @state, 'sync change', ->
if _.isEmpty(_.omit(@state.changed, 'searchTerm'))
@renderSelectors('#enrollment-status-table')
@renderSelectors('#license-status-table')
else
@debouncedRender()
@listenTo @students, 'sort', @debouncedRender
@ -304,12 +306,6 @@ module.exports = class TeacherClassView extends RootView
@enrollStudents(selectedUsers)
window.tracker?.trackEvent $(e.currentTarget).data('event-action'), category: 'Teachers', classroomID: @classroom.id, userID: userID, ['Mixpanel']
onClickBulkEnroll: ->
userIDs = @getSelectedStudentIDs()
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)
@ -390,35 +386,95 @@ module.exports = class TeacherClassView extends RootView
onClickBulkAssign: ->
courseID = @$('.bulk-course-select').val()
selectedIDs = @getSelectedStudentIDs()
members = selectedIDs.filter (userID) =>
user = @students.get(userID)
user.isEnrolled()
assigningToUnenrolled = _.any selectedIDs, (userID) =>
not @students.get(userID).isEnrolled()
assigningToNobody = selectedIDs.length is 0
@state.set errors: { assigningToNobody, assigningToUnenrolled }
@state.set errors: { assigningToNobody }
return if assigningToNobody
@assignCourse courseID, members
@assignCourse courseID, selectedIDs
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
courseInstance = null
numberEnrolled = 0
remainingSpots = 0
return Promise.resolve()
.then =>
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
if not courseInstance
courseInstance = new CourseInstance {
courseID,
classroomID: @classroom.id
ownerID: @classroom.get('ownerID')
aceConfig: {}
}
courseInstance.notyErrors = false # handling manually
@courseInstances.add(courseInstance)
return courseInstance.save()
.then =>
availablePrepaids = @prepaids.filter((prepaid) -> prepaid.status() is 'available')
unenrolledStudents = _(members)
.map((userID) => @students.get(userID))
.filter((user) => user.prepaidStatus() isnt 'enrolled')
.value()
totalSpotsAvailable = _.reduce(prepaid.openSpots() for prepaid in availablePrepaids, (val, total) -> val + total) or 0
if totalSpotsAvailable < _.size(unenrolledStudents)
modal = new CoursesNotAssignedModal({
selected: members.length
totalSpotsAvailable
unenrolledStudents: _.size(unenrolledStudents)
})
@openModalView(modal)
error = new Error('Not enough licenses available')
error.handled = true
throw error
numberEnrolled = _.size(unenrolledStudents)
remainingSpots = totalSpotsAvailable - numberEnrolled
requests = []
for prepaid in availablePrepaids
for i in _.range(prepaid.openSpots())
break unless _.size(unenrolledStudents) > 0
user = unenrolledStudents.shift()
requests.push(prepaid.redeem(user))
@trigger 'begin-redeem-for-assign-course'
return $.when(requests...)
.then =>
# refresh prepaids, since the racing multiple parallel redeem requests in the previous `then` probably did not
# end up returning the final result of all those requests together.
@prepaids.fetchByCreator(me.id)
@trigger 'begin-assign-course'
if members.length
return courseInstance.addMembers(members)
.then =>
course = @courses.get(courseID)
lines = [
$.i18n.t('teacher.assigned_msg_1')
.replace('{{numberAssigned}}', members.length)
.replace('{{courseName}}', course.get('name'))
]
if numberEnrolled > 0
lines.push(
$.i18n.t('teacher.assigned_msg_2')
.replace('{{numberEnrolled}}', numberEnrolled)
)
lines.push(
$.i18n.t('teacher.assigned_msg_3')
.replace('{{remainingSpots}}', remainingSpots)
)
noty text: lines.join('<br />'), layout: 'center', type: 'information', killer: true, timeout: 5000
.catch (e) =>
# TODO: Use this handling for errors site-wide?
return if e.handled
throw e if e instanceof Error and application.testing
text = if e instanceof Error then 'Runtime error' else e.responseJSON?.message or e.message or $.i18n.t('loading_error.unknown')
noty { text, layout: 'center', type: 'error', killer: true, timeout: 5000 }
onClickSelectAll: (e) ->
e.preventDefault()

View file

@ -7,6 +7,7 @@ Courses = require 'collections/Courses'
Levels = require 'collections/Levels'
LevelSessions = require 'collections/LevelSessions'
CourseInstances = require 'collections/CourseInstances'
Prepaids = require 'collections/Prepaids'
describe '/teachers/classes/:handle', ->
@ -30,13 +31,15 @@ describe 'TeacherClassView', ->
factories.makeCourse({name: 'Beta Course', releasePhase: 'beta'}),
])
@releasedCourses = new Courses(@courses.where({ releasePhase: 'released' }))
available = factories.makePrepaid()
@available1 = factories.makePrepaid({maxRedeemers: 1})
@available2 = factories.makePrepaid({maxRedeemers: 1})
expired = factories.makePrepaid({endDate: moment().subtract(1, 'day').toISOString()})
@prepaids = new Prepaids([@available1, @available2, expired])
@students = new Users([
factories.makeUser({name: 'Abner'})
factories.makeUser({name: 'Abigail'})
factories.makeUser({name: 'Abby'}, {prepaid: available})
factories.makeUser({name: 'Ben'}, {prepaid: available})
factories.makeUser({name: 'Abby'}, {prepaid: @available1})
factories.makeUser({name: 'Ben'}, {prepaid: @available1})
factories.makeUser({name: 'Ned'}, {prepaid: expired})
factories.makeUser({name: 'Ebner'}, {prepaid: expired})
])
@ -74,6 +77,7 @@ describe 'TeacherClassView', ->
@view.students.fakeRequests[0].respondWith({ status: 200, responseText: @students.stringify() })
@view.classroom.sessions.fakeRequests[0].respondWith({ status: 200, responseText: @levelSessions.stringify() })
@view.levels.fakeRequests[0].respondWith({ status: 200, responseText: @levels.stringify() })
@view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: @prepaids.stringify() })
jasmine.demoEl(@view.$el)
_.defer done
@ -94,14 +98,6 @@ describe 'TeacherClassView', ->
# it 'sorts correctly by Progress'
describe 'bulk-assign controls', ->
it 'shows alert when assigning course 2 to unenrolled students', (done) ->
expect(@view.$('.cant-assign-to-unenrolled').hasClass('visible')).toBe(false)
@view.$('.student-row .checkbox-flat').click()
@view.$('.assign-to-selected-students').click()
_.defer =>
expect(@view.$('.cant-assign-to-unenrolled').hasClass('visible')).toBe(true)
done()
it 'shows alert when assigning but no students are selected', (done) ->
expect(@view.$('.no-students-selected').hasClass('visible')).toBe(false)
@view.$('.assign-to-selected-students').click()
@ -116,9 +112,10 @@ describe 'TeacherClassView', ->
# it 'still shows the correct Course Overview progress'
#
describe 'the Enrollment Status tab', ->
beforeEach ->
@view.state.set('activeTab', '#enrollment-status-tab')
describe 'the License Status tab', ->
beforeEach (done) ->
@view.state.set('activeTab', '#license-status-tab')
_.defer(done)
describe 'Enroll button', ->
it 'calls enrollStudents with that user when clicked', ->
@ -182,6 +179,7 @@ describe 'TeacherClassView', ->
@view.students.fakeRequests[0].respondWith({ status: 200, responseText: @students.stringify() })
@view.classroom.sessions.fakeRequests[0].respondWith({ status: 200, responseText: @levelSessions.stringify() })
@view.levels.fakeRequests[0].respondWith({ status: 200, responseText: @levels.stringify() })
@view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: @prepaids.stringify() })
jasmine.demoEl(@view.$el)
_.defer done
@ -207,3 +205,110 @@ describe 'TeacherClassView', ->
return true
@view.$('.export-student-progress-btn').click()
expect(window.open).toHaveBeenCalled()
describe '.assignCourse(courseID, members)', ->
beforeEach (done) ->
@classroom = factories.makeClassroom({ aceConfig: { language: 'javascript' }}, { courses: @releasedCourses, members: @students, levels: [@levels, new Levels()]})
@courseInstances = new CourseInstances([
factories.makeCourseInstance({}, { course: @releasedCourses.first(), @classroom, members: @students })
factories.makeCourseInstance({}, { course: @releasedCourses.last(), @classroom, members: @students })
])
sessions = []
@finishedStudent = @students.first()
@unfinishedStudent = @students.last()
classLanguage = @classroom.get('aceConfig')?.language
for level in @levels.models
continue if classLanguage and classLanguage is level.get('primerLanguage')
sessions.push(factories.makeLevelSession(
{state: {complete: true}, playtime: 60},
{level, creator: @finishedStudent})
)
sessions.push(factories.makeLevelSession(
{state: {complete: true}, playtime: 60},
{level: @levels.first(), creator: @unfinishedStudent})
)
@levelSessions = new LevelSessions(sessions)
@view = new TeacherClassView({}, @courseInstances.first().id)
@view.classroom.fakeRequests[0].respondWith({ status: 200, responseText: @classroom.stringify() })
@view.courses.fakeRequests[0].respondWith({ status: 200, responseText: @courses.stringify() })
@view.courseInstances.fakeRequests[0].respondWith({ status: 200, responseText: @courseInstances.stringify() })
@view.students.fakeRequests[0].respondWith({ status: 200, responseText: @students.stringify() })
@view.classroom.sessions.fakeRequests[0].respondWith({ status: 200, responseText: @levelSessions.stringify() })
@view.levels.fakeRequests[0].respondWith({ status: 200, responseText: @levels.stringify() })
@view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: @prepaids.stringify() })
jasmine.demoEl(@view.$el)
_.defer done
describe 'when no course instance exists for the given course', ->
beforeEach (done) ->
@view.courseInstances.reset()
@view.assignCourse(@courses.first().id, @students.pluck('_id').slice(0, 1))
@view.courseInstances.wait('add').then(done)
it 'creates the missing course instance', ->
request = jasmine.Ajax.requests.mostRecent()
expect(request.method).toBe('POST')
expect(request.url).toBe('/db/course_instance')
it 'shows a noty if the course instance request fails', (done) ->
spyOn(window, 'noty').and.callFake(done)
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({
status: 500,
responseText: JSON.stringify({ message: "Internal Server Error" })
})
describe 'when the course is not free and some students are not enrolled', ->
beforeEach (done) ->
# first two students are unenrolled
@view.assignCourse(@courses.first().id, @students.pluck('_id').slice(0, 2))
@view.wait('begin-redeem-for-assign-course').then(done)
it 'enrolls all unenrolled students', (done) ->
numberOfRequests = _(@view.prepaids.models)
.map((prepaid) -> prepaid.fakeRequests.length)
.reduce((num, value) -> num + value)
expect(numberOfRequests).toBe(2)
done()
it 'shows a noty if a redeem request fails', (done) ->
spyOn(window, 'noty').and.callFake(done)
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({
status: 500,
responseText: JSON.stringify({ message: "Internal Server Error" })
})
describe 'when there are not enough licenses available', ->
beforeEach (done) ->
# first four students are unenrolled, but only two licenses are available
@view.assignCourse(@courses.first().id, @students.pluck('_id'))
spyOn(@view, 'openModalView').and.callFake(done)
it 'shows CoursesNotAssignedModal', ->
expect(@view.openModalView).toHaveBeenCalled()
describe 'when there is nothing else to do first', ->
beforeEach (done) ->
@courseInstance = @view.courseInstances.first()
@courseInstance.set('members', [])
@view.assignCourse(@courseInstance.get('courseID'), @students.pluck('_id').slice(2, 4))
@view.wait('begin-assign-course').then(done)
it 'adds students to the course instances', ->
request = jasmine.Ajax.requests.mostRecent()
expect(request.url).toBe("/db/course_instance/#{@courseInstance.id}/members")
expect(request.method).toBe('POST')
it 'shows a noty if POSTing students fails', (done) ->
spyOn(window, 'noty').and.callFake(done)
request = jasmine.Ajax.requests.mostRecent()
request.respondWith({
status: 500,
responseText: JSON.stringify({ message: "Internal Server Error" })
})