Merge remote-tracking branch 'upstream/master'

This commit is contained in:
duybkict 2016-05-28 12:44:51 +07:00
commit 27f270856a
39 changed files with 289 additions and 60 deletions

9
Vagrantfile vendored
View file

@ -17,7 +17,14 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.network "forwarded_port", guest: 3000, host: 13000
config.vm.network "forwarded_port", guest: 9485, host: 19485
config.vm.provision "shell", path: "scripts/vagrant/core/provision.sh", privileged: false
config.vm.define "default" do |default|
default.vm.provision "shell", path: "scripts/vagrant/core/provision.sh", privileged: false
end
config.vm.define "brunchv2", autostart: false do |brunchv2|
brunchv2.vm.provision "shell", path: "scripts/vagrant/core/provision.sh", privileged: false
brunchv2.vm.provision "shell", path: "scripts/vagrant/core/update-brunchv2.sh", privileged: false
end
config.vm.provider "virtualbox" do |v|
v.memory = 2048

View file

@ -69,15 +69,15 @@ module.exports = class CocoRouter extends Backbone.Router
'contribute/diplomat': go('contribute/DiplomatView')
'contribute/scribe': go('contribute/ScribeView')
'courses': go('courses/CoursesView') # , { studentsOnly: true }) # TODO: Enforce after session-less play for teachers
'Courses': go('courses/CoursesView') # , { studentsOnly: true })
'courses': go('courses/CoursesView', { studentsOnly: true })
'Courses': go('courses/CoursesView', { studentsOnly: true })
'courses/students': redirect('/courses')
'courses/teachers': redirect('/teachers/classes')
'courses/purchase': redirect('/teachers/licenses')
'courses/enroll(/:courseID)': redirect('/teachers/licenses')
'courses/update-account': go('courses/CoursesUpdateAccountView')
'courses/:classroomID': go('courses/ClassroomView') #, { studentsOnly: true })
'courses/:courseID/:courseInstanceID': go('courses/CourseDetailsView')
'courses/:classroomID': go('courses/ClassroomView', { studentsOnly: true })
'courses/:courseID/:courseInstanceID': go('courses/CourseDetailsView', { studentsOnly: true })
'db/*path': 'routeToServer'
'demo(/*subpath)': go('DemoView')
@ -142,14 +142,14 @@ module.exports = class CocoRouter extends Backbone.Router
'SEEN': go('NewHomeView')
'teachers': redirect('/teachers/classes')
'teachers/classes': go('courses/TeacherClassesView') #, { teachersOnly: true })
'teachers/classes/:classroomID': go('courses/TeacherClassView') #, { teachersOnly: true })
'teachers/classes': go('courses/TeacherClassesView', { teachersOnly: true })
'teachers/classes/:classroomID': go('courses/TeacherClassView', { teachersOnly: true })
'teachers/courses': go('courses/TeacherCoursesView')
'teachers/demo': go('teachers/RequestQuoteView')
'teachers/enrollments': redirect('/teachers/licenses')
'teachers/licenses': go('courses/EnrollmentsView') #, { teachersOnly: true })
'teachers/licenses': go('courses/EnrollmentsView', { teachersOnly: true })
'teachers/freetrial': go('teachers/RequestQuoteView')
'teachers/quote': go('teachers/RequestQuoteView')
'teachers/quote': redirect('/teachers/demo')
'teachers/signup': ->
return @routeDirectly('teachers/CreateTeacherAccountView', []) if me.isAnonymous()
@navigate('/teachers/update-account', {trigger: true, replace: true})
@ -174,7 +174,7 @@ module.exports = class CocoRouter extends Backbone.Router
routeDirectly: (path, args=[], options={}) ->
if options.teachersOnly and not me.isTeacher()
return @routeDirectly('teachers/RestrictedToTeachersView')
if options.studentsOnly and me.isTeacher()
if options.studentsOnly and not me.isStudent()
return @routeDirectly('courses/RestrictedToStudentsView')
leavingMessage = _.result(window.currentView, 'onLeaveMessage')
if leavingMessage

View file

@ -48,6 +48,9 @@ class Rand
arr[j] = arr[i]
arr[i] = t
arr
choice: (arr) =>
return arr[@rand arr.length]
module.exports = Rand

View file

@ -1279,7 +1279,8 @@
student_age_range_to: "to"
create_class: "Create Class"
class_name: "Class Name"
teacher_account_restricted: "Your account is a teacher account, and so cannot access student content."
teacher_account_restricted: "Your account is a teacher account and cannot access student content." # {change}
account_restricted: "A student account is required to access this page."
update_account_login_title: "Log in to update your account"
update_account_title: "Your account needs attention!"
update_account_blurb: "Before you can access your classes, choose how you want to use this account."
@ -1767,7 +1768,7 @@
coppa_deny:
text1: "Cant wait to learn programming?"
text2: "Ask your parents to create an account for you!"
text2: "Your parents will need to create an account for you to use! Email team@codecombat.com if you have any questions." # {change}
close: "Close Window"
loading_error:

View file

@ -25,7 +25,7 @@ module.exports = class User extends CocoModel
return name if name
[emailName, emailDomain] = @get('email')?.split('@') or []
return emailName if emailName
return 'Anoner'
return 'Anonymous'
getPhotoURL: (size=80, useJobProfilePhoto=false, useEmployerPageAvatar=false) ->
photoURL = if useJobProfilePhoto then @get('jobProfile')?.photoURL else null

View file

@ -276,9 +276,16 @@ c.extendNamedProperties LevelSchema # let's have the name be the first property
_.extend LevelSchema.properties,
description: {title: 'Description', description: 'A short explanation of what this level is about.', type: 'string', maxLength: 65536, format: 'markdown'}
loadingTip: { type: 'string', title: 'Loading Tip', description: 'What to show for this level while it\'s loading.' }
documentation: c.object {title: 'Documentation', description: 'Documentation articles relating to this level.', required: ['specificArticles', 'generalArticles'], 'default': {specificArticles: [], generalArticles: []}},
documentation: c.object {title: 'Documentation', description: 'Documentation articles relating to this level.', 'default': {specificArticles: [], generalArticles: []}},
specificArticles: c.array {title: 'Specific Articles', description: 'Specific documentation articles that live only in this level.', uniqueItems: true }, SpecificArticleSchema
generalArticles: c.array {title: 'General Articles', description: 'General documentation articles that can be linked from multiple levels.', uniqueItems: true}, GeneralArticleSchema
hints: c.array {title: 'Hints', description: 'Hints that will be gradually revealed to the player.', uniqueItems: true }, {
type: 'object'
properties: {
body: {type: 'string', title: 'Content', description: 'The body content of the article, in Markdown.', format: 'markdown'}
i18n: {type: 'object', format: 'i18n', props: ['body'], description: 'Help translate this hint'}
}
}
background: c.objectId({format: 'hidden'})
nextLevel: {
type: 'object',

View file

@ -6,7 +6,7 @@ UserSchema = c.object
default:
visa: 'Authorized to work in the US'
music: true
name: 'Anoner'
name: 'Anonymous'
autocastDelay: 5000
emails: {}
permissions: []

View file

@ -123,7 +123,7 @@ block content
if memberLanguageMap && memberLanguageMap[member.id]
span.code-language-cell(style="background-image: url(/images/common/code_languages/#{memberLanguageMap[member.id]}_small.png)", title=memberLanguageMap[member.id])
div
a(href="/user/#{member.id}")= member.get('name') || 'Anoner'
a(href="/user/#{member.id}")= member.get('name') || 'Anonymous'
div Level #{member.level()}
if isOwner && member.id !== clan.get('ownerID')
button.btn.btn-xs.btn-warning.remove-member-btn(data-id="#{member.id}", data-i18n="clans.rem_hero") Remove Hero
@ -220,7 +220,7 @@ block content
if memberLanguageMap && memberLanguageMap[member.id]
span.code-language-cell(style="background-image: url(/images/common/code_languages/#{memberLanguageMap[member.id]}_small.png)", title=memberLanguageMap[member.id])
td.name-cell
a(href="/user/#{member.id}")= member.get('name') || 'Anoner'
a(href="/user/#{member.id}")= member.get('name') || 'Anonymous'
td.level-cell= member.level()
td.achievements-cell
if memberAchievementsMap && memberAchievementsMap[member.id]

View file

@ -45,7 +45,7 @@ block content
if view.idNameMap && view.idNameMap[clan.get('ownerID')]
a(href="/user/#{clan.get('ownerID')}")= view.idNameMap[clan.get('ownerID')]
else
a(href="/user/#{clan.get('ownerID')}") Anoner
a(href="/user/#{clan.get('ownerID')}") Anonymous
td
if view.myClanIDs.indexOf(clan.id) < 0
button.btn.btn-success.join-clan-btn(data-id="#{clan.id}", data-i18n="clans.join_clan") Join Clan
@ -75,7 +75,7 @@ block content
if view.idNameMap && view.idNameMap[clan.get('ownerID')]
a(href="/user/#{clan.get('ownerID')}")= view.idNameMap[clan.get('ownerID')]
else
a(href="/user/#{clan.get('ownerID')}") Anoner
a(href="/user/#{clan.get('ownerID')}") Anonymous
td= clan.get('type')
td
if clan.get('ownerID') !== me.id

View file

@ -3,10 +3,19 @@ extends /templates/base-flat
block content
.access-restricted.container.text-center.m-y-3
h5(data-i18n='teacher.access_restricted')
p(data-i18n='courses.teacher_account_restricted')
a.btn.btn-lg.btn-primary(href="/teachers/classes" data-i18n="new_home.goto_classes")
button#logout-button.btn.btn-lg.btn-primary-alt(data-i18n="login.log_out")
if me.isTeacher()
p(data-i18n='courses.teacher_account_restricted')
a.btn.btn-lg.btn-primary(href="/teachers/classes" data-i18n="new_home.goto_classes")
button#logout-button.btn.btn-lg.btn-primary-alt(data-i18n="login.log_out")
else
p(data-i18n='courses.account_restricted')
if me.isAnonymous()
.login-button.btn.btn-lg.btn-primary(data-i18n='login.log_in')
else
a.btn.btn-lg.btn-primary(href="/courses/update-account" data-i18n="courses.update_account_update_student")
button#logout-button.btn.btn-lg.btn-primary-alt(data-i18n="login.log_out")
.teacher-account-blurb.text-center.col-xs-6.col-xs-offset-3.m-y-3
h5(data-i18n='teacher.what_is_a_teacher_account')
p(data-i18n='teacher.teacher_account_explanation')
if me.isTeacher()
.teacher-account-blurb.text-center.col-xs-6.col-xs-offset-3.m-y-3
h5(data-i18n='teacher.what_is_a_teacher_account')
p(data-i18n='teacher.teacher_account_explanation')

View file

@ -11,7 +11,7 @@ ul.user-feedback-list.list-group
em= moment(new Date(feedback.created)).fromNow()
span.spl.spr -
a(href="/user/#{feedback.creator}")
strong= feedback.creatorName || 'Anoner'
strong= feedback.creatorName || 'Anonymous'
if feedback.review
span.spr :
span= feedback.review

View file

@ -12,7 +12,7 @@ for player in view.players
.player-gold
.gold-icon
.gold-value
.player-name= player.name || 'Anoner'
.player-name= player.name || 'Anonymous'
.player-health
.health-icon
.health-bar-container

View file

@ -81,7 +81,7 @@ block append content
if idNameMap && idNameMap[clan.get('ownerID')]
a(href="/user/#{clan.get('ownerID')}")= idNameMap[clan.get('ownerID')]
else
a(href="/user/#{clan.get('ownerID')}") Anoner
a(href="/user/#{clan.get('ownerID')}") Anonymous
td= clan.get('members').length
else
.panel-body

View file

@ -23,6 +23,8 @@ module.exports = class AboutView extends RootView
'left': 'onLeftPressed'
'esc': 'onEscapePressed'
getTitle: -> return $.i18n.t('nav.about')
afterRender: ->
super(arguments...)
@$('#fixed-nav').affix({

View file

@ -19,7 +19,9 @@ module.exports = class MainAdminView extends RootView
'click #user-search-result': 'onClickUserSearchResult'
'click #create-free-sub-btn': 'onClickFreeSubLink'
'click #terminal-create': 'onClickTerminalSubLink'
getTitle: -> return $.i18n.t('account_settings.admin')
initialize: ->
if window.amActually
@amActually = new User({_id: window.amActually})
@ -65,7 +67,7 @@ module.exports = class MainAdminView extends RootView
forms.enableSubmit(@$('#user-search-button'))
result = ''
if users.length
result = ("<tr data-user-id='#{user._id}'><td><code>#{user._id}</code></td><td>#{_.escape(user.name or 'Anoner')}</td><td>#{_.escape(user.email)}</td></tr>" for user in users)
result = ("<tr data-user-id='#{user._id}'><td><code>#{user._id}</code></td><td>#{_.escape(user.name or 'Anonymous')}</td><td>#{_.escape(user.email)}</td></tr>" for user in users)
result = "<table class=\"table\">#{result.join('\n')}</table>"
@$el.find('#user-search-result').html(result)

View file

@ -138,7 +138,7 @@ module.exports = class ClanDetailsView extends RootView
return unless @members? and @memberSort?
switch @memberSort
when "nameDesc"
@members.comparator = (a, b) -> return (b.get('name') or 'Anoner').localeCompare(a.get('name') or 'Anoner')
@members.comparator = (a, b) -> return (b.get('name') or 'Anonymous').localeCompare(a.get('name') or 'Anonymous')
when "progressAsc"
@members.comparator = (a, b) ->
aComplete = (concept for concept, state of userConceptsMap[a.id] when state is 'complete')
@ -151,7 +151,7 @@ module.exports = class ClanDetailsView extends RootView
else if aStarted > bStarted then return 1
if highestUserLevelCountMap[a.id] < highestUserLevelCountMap[b.id] then return -1
else if highestUserLevelCountMap[a.id] > highestUserLevelCountMap[b.id] then return 1
(a.get('name') or 'Anoner').localeCompare(b.get('name') or 'Anoner')
(a.get('name') or 'Anonymous').localeCompare(b.get('name') or 'Anonymous')
when "progressDesc"
@members.comparator = (a, b) ->
aComplete = (concept for concept, state of userConceptsMap[a.id] when state is 'complete')
@ -164,9 +164,9 @@ module.exports = class ClanDetailsView extends RootView
else if aStarted < bStarted then return 1
if highestUserLevelCountMap[a.id] > highestUserLevelCountMap[b.id] then return -1
else if highestUserLevelCountMap[a.id] < highestUserLevelCountMap[b.id] then return 1
(b.get('name') or 'Anoner').localeCompare(a.get('name') or 'Anoner')
(b.get('name') or 'Anonymous').localeCompare(a.get('name') or 'Anonymous')
else
@members.comparator = (a, b) -> return (a.get('name') or 'Anoner').localeCompare(b.get('name') or 'Anoner')
@members.comparator = (a, b) -> return (a.get('name') or 'Anonymous').localeCompare(b.get('name') or 'Anonymous')
@members.sort()
updateHeroIcons: ->

View file

@ -110,10 +110,8 @@ module.exports = class RootView extends CocoView
@buildLanguages()
$('body').removeClass('is-playing')
if application.isProduction()
title = 'CodeCombat - ' + (@getTitle() or 'Learn how to code by playing a game')
else
title = @getTitle() or @constructor.name
if title = @getTitle() then title += ' | CodeCombat'
else title = 'CodeCombat - Learn how to code by playing a game'
$('title').text(title)

View file

@ -28,6 +28,8 @@ module.exports = class CoursesView extends RootView
'submit #join-class-form': 'onSubmitJoinClassForm'
'click #change-language-link': 'onClickChangeLanguageLink'
getTitle: -> return $.i18n.t('teacher.students')
initialize: ->
@courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance})
@courseInstances.comparator = (ci) -> return ci.get('classroomID') + ci.get('courseID')

View file

@ -19,6 +19,8 @@ module.exports = class EnrollmentsView extends RootView
'click #how-to-enroll-link': 'onClickHowToEnrollLink'
'click #contact-us-btn': 'onClickContactUsButton'
getTitle: -> return $.i18n.t('teacher.enrollments')
initialize: ->
@state = new State({
totalEnrolled: 0

View file

@ -41,6 +41,7 @@ module.exports = class TeacherClassView extends RootView
'click .select-all': 'onClickSelectAll'
'click .student-checkbox': 'onClickStudentCheckbox'
'keyup #student-search': 'onKeyPressStudentSearch'
'change .course-select, .bulk-course-select': 'onChangeCourseSelect'
getInitialState: ->
{
@ -62,6 +63,8 @@ module.exports = class TeacherClassView extends RootView
enrolledUsers: ""
}
getTitle: -> return @classroom?.get('name')
initialize: (options, classroomID) ->
super(options)
@singleStudentCourseProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-course'
@ -115,7 +118,7 @@ module.exports = class TeacherClassView extends RootView
@supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}})
@attachMediatorEvents()
attachMediatorEvents: () ->
@listenTo @state, 'sync change', ->
if _.isEmpty(_.omit(@state.changed, 'searchTerm'))
@ -149,6 +152,8 @@ module.exports = class TeacherClassView extends RootView
@listenTo @students, 'sort', ->
@state.set students: @students
@render()
@listenTo @, 'course-select:change', ({ selectedCourse }) ->
@state.set selectedCourse: selectedCourse
setCourseMembers: =>
for course in @courses.models
@ -273,6 +278,9 @@ module.exports = class TeacherClassView extends RootView
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')

View file

@ -25,6 +25,8 @@ module.exports = class TeacherClassesView extends RootView
'click .add-students-btn': 'onClickAddStudentsButton'
'click .create-classroom-btn': 'onClickCreateClassroomButton'
getTitle: -> return $.i18n.t('teacher.my_classes')
initialize: (options) ->
super(options)
@classrooms = new Classrooms()

View file

@ -39,6 +39,8 @@ module.exports = class TeacherCoursesView extends RootView
"569ed916efa72b0ced971447": null
}
getTitle: -> return $.i18n.t('teacher.courses')
constructor: (options) ->
super(options)
@ownedClassrooms = new Classrooms()

View file

@ -29,6 +29,12 @@ module.exports = class I18NEditLevelView extends I18NEditModelView
@wrapRow 'Guide article name', ['name'], doc.name, i18n[lang]?.name, ['documentation', 'specificArticles', index]
@wrapRow "'#{doc.name}' body", ['body'], doc.body, i18n[lang]?.body, ['documentation', 'specificArticles', index], 'markdown'
# hints
for hint, index in @model.get('documentation')?.hints ? []
if i18n = hint.i18n
name = "Hint #{index+1}"
@wrapRow "'#{name}' body", ['body'], hint.body, i18n[lang]?.body, ['documentation', 'hints', index], 'markdown'
# sprite dialogues
for script, scriptIndex in @model.get('scripts') ? []
for noteGroup, noteGroupIndex in script.noteChain ? []

View file

@ -61,7 +61,7 @@ module.exports = class LadderPlayModal extends ModalView
success = (@nameMap) =>
for challenger in _.values(@challengers)
challenger.opponentName = @nameMap[challenger.opponentID]?.name or 'Anoner'
challenger.opponentName = @nameMap[challenger.opponentID]?.name or 'Anonymous'
challenger.opponentWizard = @nameMap[challenger.opponentID]?.wizard or {}
@checkWizardLoaded()

View file

@ -207,7 +207,7 @@ module.exports = class SpectateLevelView extends RootView
findPlayerNames: ->
playerNames = {}
for session in [@session, @otherSession] when session?.get('team')
playerNames[session.get('team')] = session.get('creatorName') or 'Anoner'
playerNames[session.get('team')] = session.get('creatorName') or 'Anonymous'
playerNames
initGoalManager: ->

View file

@ -353,7 +353,7 @@ module.exports = class PlayLevelView extends RootView
return {} unless @level.get('type') in ['ladder', 'hero-ladder', 'course-ladder']
playerNames = {}
for session in [@session, @otherSession] when session?.get('team')
playerNames[session.get('team')] = session.get('creatorName') or 'Anoner'
playerNames[session.get('team')] = session.get('creatorName') or 'Anonymous'
playerNames
# Once Surface is Loaded ####################################################

View file

@ -87,7 +87,7 @@ emailUserInitialRecruiting = (user, callback) ->
return callback null, false if DEBUGGING and (totalEmailsSent > 1 or Math.random() > 0.05)
++totalEmailsSent
name = if user.firstName and user.lastName then "#{user.firstName}" else user.name
name = 'Wizard' if not name or name is 'Anoner'
name = 'Wizard' if not name or name in ['Anoner', 'Anonymous']
team = user.session.levelInfo.team
team = team.substr(0, team.length - 1)
context =

View file

@ -28,11 +28,11 @@ var deteacher = function deteacher(email) {
else {
print('Unset role', db.users.update({_id: user._id}, {$unset: {role: ''}}));
}
}
};
db.system.js.save(
{
_id: 'deteacher',
value: deteacher
}
)
);

View file

@ -0,0 +1,57 @@
// Script for changing prepaid start/end dates and propagating them to users.
/*
* Usage
* ---------------
* In mongo shell
*
* > db.loadServerScripts();
* > var prepaids = getPrepaidsFor('some@email.com'); // prints basic stats for prepaids found
* > prepaids.models // Raw prepaid data
* > prepaids.setStart(2001,1,1) // Set start date
* > prepaids.setEnd(2100,1,1) // Set end date
*/
function getPrepaidsFor(email) {
var user = db.users.findOne({emailLower: email.toLowerCase()});
if (!user) {
print('User not found');
return;
}
var result = {};
result.models = db.prepaids.find({creator: user._id}).toArray();
result.setStart = function(year, month, day) {
var startDate = new Date(Date.UTC(year, month-1, day)).toISOString();
print('setting to', startDate);
for (var i in this.models) {
var prepaid = this.models[i];
print('Prepaid update', db.prepaids.update({_id: prepaid._id}, {$set: {startDate: startDate}}));
print('User update', db.users.update({'coursePrepaid._id': prepaid._id}, {$set: {'coursePrepaid.startDate': startDate}}, {multi: true}));
}
};
result.setEnd = function(year, month, day) {
var endDate = new Date(Date.UTC(year, month-1, day)).toISOString();
print('setting to', endDate);
for (var i in this.models) {
var prepaid = this.models[i];
print('Prepaid update', db.prepaids.update({_id: prepaid._id}, {$set: {endDate: endDate}}));
print('User update', db.users.update({'coursePrepaid._id': prepaid._id}, {$set: {'coursePrepaid.endDate': endDate}}, {multi: true}));
}
};
for (var i in result.models) {
var prepaid = result.models[i];
print('Prepaid:', prepaid.startDate, 'to', prepaid.endDate, 'with', prepaid.redeemers.length, '/', prepaid.maxRedeemers, 'uses');
}
return result;
}
db.system.js.save(
{
_id: 'getPrepaidsFor',
value: getPrepaidsFor
}
);

View file

@ -0,0 +1,13 @@
#!/bin/bash -e
# Original content copyright (c) 2014 dpen2000 licensed under the MIT license
echo "updating brunch to v2..."
cd /vagrant
npm install \
brunch@">=2.0.0" \
auto-reload-brunch@">=2.0.0" \
coffee-script-brunch@">=2.0.0" \
coffeelint-brunch@">=2.0.0" \
css-brunch@">=2.0.0" \
javascript-brunch@">=2.0.0" \
sass-brunch@">=2.0.0" --no-bin-links

View file

@ -5,6 +5,7 @@ Classroom = require './../models/Classroom'
User = require '../models/User'
sendwithus = require '../sendwithus'
utils = require '../lib/utils'
log = require 'winston'
UserHandler = require './user_handler'
ClassroomHandler = class ClassroomHandler extends Handler
@ -68,13 +69,16 @@ ClassroomHandler = class ClassroomHandler extends Handler
return _.omit(doc.toObject(), 'code', 'codeCamel')
inviteStudents: (req, res, classroomID) ->
return @sendUnauthorizedError(res) if not req.user?
if not req.body.emails
return @sendBadInputError(res, 'Emails not included')
Classroom.findById classroomID, (err, classroom) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless classroom
return @sendForbiddenError(res) unless classroom.get('ownerID').equals(req.user.get('_id'))
unless classroom.get('ownerID').equals(req.user.get('_id'))
log.debug "classroom_handler.inviteStudents: Can't invite to classroom (#{classroom.id}) you (#{req.user.get('_id')}) don't own"
return @sendForbiddenError(res)
for email in req.body.emails
joinCode = (classroom.get('codeCamel') or classroom.get('code'))
@ -83,6 +87,7 @@ ClassroomHandler = class ClassroomHandler extends Handler
recipient:
address: email
email_data:
teacher_name: req.user.broadName()
class_name: classroom.get('name')
join_link: "https://codecombat.com/courses?_cc=" + joinCode
join_code: joinCode
@ -91,13 +96,17 @@ ClassroomHandler = class ClassroomHandler extends Handler
get: (req, res) ->
if ownerID = req.query.ownerID
return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or ownerID is req.user.id)
unless req.user and (req.user.isAdmin() or ownerID is req.user.id)
log.debug "classroom_handler.get: ownerID (#{ownerID}) must be yourself (#{req.user?.id})"
return @sendForbiddenError(res)
return @sendBadInputError(res, 'Bad ownerID') unless utils.isID ownerID
Classroom.find {ownerID: mongoose.Types.ObjectId(ownerID)}, (err, classrooms) =>
return @sendDatabaseError(res, err) if err
return @sendSuccess(res, (@formatEntity(req, classroom) for classroom in classrooms))
else if memberID = req.query.memberID
return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or memberID is req.user.id)
unless req.user and (req.user.isAdmin() or memberID is req.user.id)
log.debug "classroom_handler.get: memberID (#{memberID}) must be yourself (#{req.user?.id})"
return @sendForbiddenError(res)
return @sendBadInputError(res, 'Bad memberID') unless utils.isID memberID
Classroom.find {members: mongoose.Types.ObjectId(memberID)}, (err, classrooms) =>
return @sendDatabaseError(res, err) if err

View file

@ -192,6 +192,7 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
address: email
subject: course.get('name')
email_data:
teacher_name: req.user.broadName()
class_name: course.get('name')
join_link: "https://codecombat.com/courses/students?_ppc=" + prepaid.get('code')
sendwithus.api.send context, _.noop

View file

@ -68,4 +68,5 @@ module.exports =
return done(null, activity.sender) if /@codecombat\.com/ig.test(activity.sender)
return done(null, config.mail.supportSchools)
catch error
log.error("closeIO.getSalesContactEmail Error for #{email}: #{JSON.stringify(error)}")
return done(error, config.mail.supportSchools)

View file

@ -3,6 +3,7 @@ utils = require '../lib/utils'
errors = require '../commons/errors'
schemas = require '../../app/schemas/schemas'
wrap = require 'co-express'
log = require 'winston'
Promise = require 'bluebird'
database = require '../commons/database'
mongoose = require 'mongoose'
@ -21,6 +22,7 @@ module.exports =
return next() unless code
classroom = yield Classroom.findOne({ code: code.toLowerCase() }).select('name ownerID aceConfig')
if not classroom
log.debug("classrooms.fetchByCode: Couldn't find Classroom with code: #{code}")
throw new errors.NotFound('Classroom not found.')
classroom = classroom.toObject()
# Tack on the teacher's name for display to the user
@ -33,7 +35,9 @@ module.exports =
return next() unless ownerID
throw new errors.UnprocessableEntity('Bad ownerID') unless utils.isID ownerID
throw new errors.Unauthorized() unless req.user
throw new errors.Forbidden('"ownerID" must be yourself') unless req.user.isAdmin() or ownerID is req.user.id
unless req.user.isAdmin() or ownerID is req.user.id
log.debug("classrooms.getByOwner: Can't fetch classroom you don't own. User: #{req.user.id} Owner: #{ownerID}")
throw new errors.Forbidden('"ownerID" must be yourself')
sanitizedOptions = {}
unless _.isUndefined(options.archived)
# Handles when .archived is true, vs false-or-null
@ -114,6 +118,7 @@ module.exports =
isOwner = classroom.get('ownerID').equals(req.user._id)
isMember = req.user.id in (m.toString() for m in classroom.get('members'))
unless req.user.isAdmin() or isOwner or isMember
log.debug "classrooms.fetchMembers: Can't fetch members for class (#{classroom.id}) you (#{req.user.id}) don't own and aren't a member of."
throw new errors.Forbidden('You do not own this classroom.')
memberIDs = classroom.get('members') or []
memberIDs = memberIDs.slice(memberSkip, memberSkip + memberLimit)
@ -126,7 +131,9 @@ module.exports =
post: wrap (req, res) ->
throw new errors.Unauthorized() unless req.user and not req.user.isAnonymous()
throw new errors.Forbidden() unless req.user?.isTeacher()
unless req.user?.isTeacher()
log.debug "classrooms.post: Can't create classroom if you (#{req.user?.id}) aren't a teacher."
throw new errors.Forbidden()
classroom = database.initDoc(req, Classroom)
classroom.set 'ownerID', req.user._id
classroom.set 'members', []
@ -159,11 +166,13 @@ module.exports =
unless req.body?.code
throw new errors.UnprocessableEntity('Need a code')
if req.user.isTeacher()
log.debug("classrooms.join: Cannot join a classroom as a teacher: #{req.user.id}")
throw new errors.Forbidden('Cannot join a classroom as a teacher')
code = req.body.code.toLowerCase()
classroom = yield Classroom.findOne({code: code})
if not classroom
throw new errors.NotFound('Classroom not found.')
log.debug("classrooms.join: Classroom not found with code #{code}")
throw new errors.NotFound("Classroom not found with code #{code}")
members = _.clone(classroom.get('members'))
if _.any(members, (memberID) -> memberID.equals(req.user._id))
return res.send(classroom.toObject({req: req}))
@ -196,10 +205,12 @@ module.exports =
ownedStudentIDs = _.flatten ownedClassrooms.map (c) ->
c.get('members').map (id) ->
id.toString()
return next() unless memberID in ownedStudentIDs
unless memberID in ownedStudentIDs
throw new errors.Forbidden("Can't reset the password of a student that's not in one of your classrooms.")
student = yield User.findById(memberID)
if student.get('emailVerified')
return next new errors.Forbidden("Can't reset password for a student that has verified their email address.")
log.debug "classrooms.setStudentPassword: Can't reset password for a student (#{memberID}) that has verified their email address."
throw new errors.Forbidden("Can't reset password for a student that has verified their email address.")
{ valid, error } = tv4.validateResult(newPassword, schemas.passwordString)
unless valid
throw new errors.UnprocessableEntity(error.message)

View file

@ -49,7 +49,7 @@ UserSchema.methods.broadName = ->
return name if name
[emailName, emailDomain] = @get('email').split('@')
return emailName if emailName
return 'Anoner'
return 'Anonymous'
UserSchema.methods.isInGodMode = ->
p = @get('permissions')

View file

@ -55,6 +55,7 @@ createMailContext = (req, done) ->
email_data:
subject: "[CodeCombat] #{subject ? ('Feedback - ' + fromAddress)}"
content: content
contentHTML: content.replace /\n/g, '\n<br>'
if recipientID is 'schools@codecombat.com' or teacher
req.user.update({$set: { enrollmentRequestSent: true }}).exec(_.noop) if recipientID is 'schools@codecombat.com'
closeIO.getSalesContactEmail fromAddress, (err, salesContactEmail) ->
@ -78,7 +79,7 @@ createMailContext = (req, done) ->
], (err, results) ->
console.error "Error getting contact message context for #{sender}: #{err}" if err
if req.body.screenshotURL
context.email_data.content += "\n<img src='#{req.body.screenshotURL}' />"
context.email_data.contentHTML += "\n<br><img src='#{req.body.screenshotURL}' />"
done context
fetchRecentSessions = (user, context, sentFromLevel, callback) ->
@ -98,5 +99,5 @@ fetchRecentSessions = (user, context, sentFromLevel, callback) ->
if sentFromLevel?.levelID is s.levelID and sentFromLevel?.courseID
url += "&course=#{sentFromLevel.courseID}&course-instance=#{sentFromLevel.courseInstanceID}"
urlName += ' (course)'
context.email_data.content += "\n<a href='#{url}'>#{urlName}</a>#{sessionStatus}"
context.email_data.contentHTML += "\n<br><a href='#{url}'>#{urlName}</a>#{sessionStatus}"
callback null

View file

@ -578,7 +578,7 @@ sendLadderUpdateEmail = (session, now, daysAgo) ->
#log.info "Not sending email to #{user.get('email')} #{user.get('name')} because the session had levelName #{session.levelName} or team #{session.team} in it."
return
name = if user.get('firstName') and user.get('lastName') then "#{user.get('firstName')}" else user.get('name')
name = 'Wizard' if not name or name is 'Anoner'
name = 'Wizard' if not name or name is 'Anonymous'
# Fetch the most recent defeat and victory, if there are any.
# (We could look at strongest/weakest, but we'd have to fetch everyone, or denormalize more.)
@ -622,13 +622,13 @@ sendLadderUpdateEmail = (session, now, daysAgo) ->
if err
log.error "Couldn't find defeateded opponent: #{err}"
defeatedOpponent = null
victoryContext = {opponent_name: defeatedOpponent?.name ? 'Anoner', url: urlForMatch(victory)} if victory
victoryContext = {opponent_name: defeatedOpponent?.name ? 'Anonymous', url: urlForMatch(victory)} if victory
onFetchedVictoriousOpponent = (err, victoriousOpponent) ->
if err
log.error "Couldn't find victorious opponent: #{err}"
victoriousOpponent = null
defeatContext = {opponent_name: victoriousOpponent?.name ? 'Anoner', url: urlForMatch(defeat)} if defeat
defeatContext = {opponent_name: victoriousOpponent?.name ? 'Anonymous', url: urlForMatch(defeat)} if defeat
Level.find({original: session.level.original, created: {$gt: session.submitDate}}).select('created commitMessage version').sort('-created').lean().exec (err, levelVersions) ->
sendEmail defeatContext, victoryContext, (if levelVersions.length then levelVersions else null)
@ -706,7 +706,7 @@ sendNextStepsEmail = (user, now, daysAgo) ->
do (err, nextLevel) ->
return log.error "Couldn't find next level for #{user.get('email')}: #{err}" if err
name = if user.get('firstName') and user.get('lastName') then "#{user.get('firstName')}" else user.get('name')
name = 'hero' if not name or name is 'Anoner'
name = 'Hero' if not name or name in ['Anoner', 'Anonymous']
#secretLevel = switch user.get('testGroupNumber') % 8
# when 0, 1, 2, 3 then name: 'Forgetful Gemsmith', slug: 'forgetful-gemsmith'
# when 4, 5, 6, 7 then name: 'Signs and Portents', slug: 'signs-and-portents'

View file

@ -447,3 +447,73 @@ describe 'GET /db/classroom/:handle/members', ->
expect(user.email).toBeDefined()
expect(user.passwordHash).toBeUndefined()
done()
describe 'POST /db/classroom/:classroomID/members/:memberID/reset-password', ->
it 'changes the password', utils.wrap (done) ->
yield utils.clearModels([User, Classroom])
teacher = yield utils.initUser()
yield utils.loginUser(teacher)
student = yield utils.initUser({ name: "Firstname Lastname" })
newPassword = "this is a new password"
classroom = yield new Classroom({name: 'Classroom', ownerID: teacher._id, members: [student._id] }).save()
expect(student.get('passwordHash')).not.toEqual(User.hashPassword(newPassword))
[res, body] = yield request.postAsync({
uri: getURL("/db/classroom/#{classroom.id}/members/#{student.id}/reset-password")
json: { password: newPassword }
})
expect(res.statusCode).toBe(200)
changedStudent = yield User.findById(student.id)
expect(changedStudent.get('passwordHash')).toEqual(User.hashPassword(newPassword))
done()
it "doesn't change the password if you're not their teacher", utils.wrap (done) ->
yield utils.clearModels([User, Classroom])
teacher = yield utils.initUser()
yield utils.loginUser(teacher)
student = yield utils.initUser({ name: "Firstname Lastname" })
student2 = yield utils.initUser({ name: "Firstname Lastname 2" })
newPassword = "this is a new password"
classroom = yield new Classroom({name: 'Classroom', ownerID: teacher._id, members: [student2._id] }).save()
expect(student.get('passwordHash')).not.toEqual(User.hashPassword(newPassword))
[res, body] = yield request.postAsync({
uri: getURL("/db/classroom/#{classroom.id}/members/#{student.id}/reset-password")
json: { password: newPassword }
})
expect(res.statusCode).toBe(403)
changedStudent = yield User.findById(student.id)
expect(changedStudent.get('passwordHash')).toEqual(student.get('passwordHash'))
done()
it "doesn't change the password if their email is verified", utils.wrap (done) ->
yield utils.clearModels([User, Classroom])
teacher = yield utils.initUser()
yield utils.loginUser(teacher)
student = yield utils.initUser({ name: "Firstname Lastname", emailVerified: true })
newPassword = "this is a new password"
classroom = yield new Classroom({name: 'Classroom', ownerID: teacher._id, members: [student._id] }).save()
expect(student.get('passwordHash')).not.toEqual(User.hashPassword(newPassword))
[res, body] = yield request.postAsync({
uri: getURL("/db/classroom/#{classroom.id}/members/#{student.id}/reset-password")
json: { password: newPassword }
})
expect(res.statusCode).toBe(403)
changedStudent = yield User.findById(student.id)
expect(changedStudent.get('passwordHash')).toEqual(student.get('passwordHash'))
done()
it "doesn't let you set a 1-character password", utils.wrap (done) ->
yield utils.clearModels([User, Classroom])
teacher = yield utils.initUser()
yield utils.loginUser(teacher)
student = yield utils.initUser({ name: "Firstname Lastname" })
newPassword = "e"
classroom = yield new Classroom({name: 'Classroom', ownerID: teacher._id, members: [student._id] }).save()
expect(student.get('passwordHash')).not.toEqual(User.hashPassword(newPassword))
[res, body] = yield request.postAsync({
uri: getURL("/db/classroom/#{classroom.id}/members/#{student.id}/reset-password")
json: { password: newPassword }
})
expect(res.statusCode).toBe(422)
changedStudent = yield User.findById(student.id)
expect(changedStudent.get('passwordHash')).toEqual(student.get('passwordHash'))
done()

View file

@ -39,6 +39,21 @@ describe 'Level', ->
body = JSON.parse(body)
expect(body.type).toBeDefined()
done()
describe 'POST /db/level/:handle', ->
it 'creates a new version', utils.wrap (done) ->
yield utils.clearModels([Campaign, Course, CourseInstance, Level, User])
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
@level = yield utils.makeLevel()
levelJSON = @level.toObject()
levelJSON.name = 'New name'
url = getURL("/db/level/#{@level.id}")
[res, body] = yield request.postAsync({url: url, json: levelJSON})
expect(res.statusCode).toBe(200)
done()
describe 'GET /db/level/:handle/session', ->