Merge branch 'master' into production

This commit is contained in:
Matt Lott 2016-08-31 09:38:37 -07:00
commit b52008f19b
28 changed files with 600 additions and 253 deletions

View file

@ -9,8 +9,7 @@
CodeCombat is a multiplayer programming game for learning how to code. CodeCombat is a multiplayer programming game for learning how to code.
**See the [Archmage (coder) developer wiki](../../wiki/Archmage-Home) for a dev **See the [Archmage (coder) developer wiki](../../wiki/Archmage-Home) for a dev
setup guide, extensive documentation, and much more. Every new person that wants setup guide, extensive documentation, and much more to get started hacking!**
to start contributing the project coding should start there.**
It's both a startup and a community project, completely open source under the It's both a startup and a community project, completely open source under the
[MIT and Creative Commons licenses](http://codecombat.com/legal). It's the [MIT and Creative Commons licenses](http://codecombat.com/legal). It's the

View file

@ -0,0 +1,151 @@
##### Getting Started
# Start Using CodeCombat in 10 Minutes!
#### Get your class up and running with these steps.
##### STEP 1
## Create a Teacher Account
After youve created your Teacher account, you will be able to create classes, invite students, monitor students progress, enroll students, and assign course content once students have been enrolled.
Select the **Sign up as a Teacher** option during account creation in order to sign up as a teacher.
<img src="http://files.codecombat.com/docs/getting-started/teacher-account.png" title="Create a Teacher Account" alt="create-teacher-account-modal"/>
Once your teacher account is setup, youll be able to access your [Teacher Dashboard](/teachers/classes).
### What if I already have an account?
If you already have a CodeCombat account as a Student or Individual but want to convert it to a Teacher account, visit the [Update to Teacher Account](/teachers/update-account) page. Once youve converted, your student account will be removed from any classrooms you may have previously joined.
### What are the technical requirements for CodeCombat?
CodeCombat runs best on computers with at least 4GB of RAM, on a modern browser such as Chrome, Safari, Firefox, or Edge. Chromebooks with less RAM may have minor graphics issues in later courses.
*We do not currently support iPads or Android Tablets at this time.*
##### STEP 2
## Create a New Class
Once logged in, or if you click the Teacher link in the navigation bar, youll see your new [Teacher Dashboard](/teachers/classes). From here, youll be able to create classes and monitor your students progress.
Click the blue “Create a New Class” button, then choose a class name that will help you and your students identify the class, such as “Mr. Smith 3rd period.”
<img src="http://files.codecombat.com/docs/getting-started/create-new-class-modal.png" title="Create a New Class" alt="create-new-class-modal" width="400px" />
### Should I choose Python or JavaScript?
We recommend Python, because its both beginner-friendly and currently used by major corporations (such as Google). If you have younger or first-time learners, we strongly recommend Python.
JavaScript will work great too. Its the language of the web; used across every website, and still beginner-friendly. If you are planning to also study web development, you may prefer to choose JavaScript to avoid the confusion some students may have switching from one languages syntax to another. However, JavaScripts syntax is a little more difficult for beginners than Python.
##### STEP 3
## Add Students
Once youve created your class, youll see it under the list of Current Classes. Navigate the class where you want to add students, then choose one of three ways to add students.
<img src="http://files.codecombat.com/docs/getting-started/add-students.png" title="Add Students" alt="add-students" width="400px"/>
_Use your unique class code, class URL or invite students via email._
### Option 1: Invite Students via Email
*Easiest option if your students have email addresses*
Click the blue "Invite Students by Email" button, then enter your students email addresses (you can copy and paste this from your class list or student information system) and click "Invite Students". Students will receive an email instructing them to follow a link, which will allow them to create an account and join your class.
Make sure they are creating a **Student Account** and that the correct class name is displayed when they create their account.
<img src="http://files.codecombat.com/docs/getting-started/test-class.png" title="Create a Student Account" alt="create-student-account" width="400px"/>
_Students should see your class name in place of "Test Class 1"._
Students will need to enter the following to create a Student Account:
- First name
- Last initial
- Username (help them choose an appropriate unique username)
- Password
Email addresses are _not required_ for students creating an account when they have a valid Class Code from you. That said, using an email address is recommended if they have one, for easier password recovery.
*If your school uses Google Apps for Education*, students can connect using the red "Google" button at the top of the screen instead of manually entering their information. They can then sign in to CodeCombat using the G+ Connect button in the future.
After students have created their account, they are shown their username and instructed to write down this information.
Thats it! Students can now use their login credentials to start playing CodeCombat!
### Option 2: Students Join via a Class Code
*Easiest option if you students dont have email addresses*
Direct your students to [CodeCombat](https://codecombat.com) and have them click “Create Account” on the top navigation bar. Students should select the green "Sign up as a Student" button.
<img src="http://files.codecombat.com/docs/getting-started/student-account.png" title="Create a Student Account" alt="create-student-account" width="400px"/>
_Make sure your students click “Sign up as a Student” when prompted._
The website will request the “Class Code” for your class, which can be found if you click “View Class” on your Teacher Dashboard. Your classroom will have its own unique three-word code.
<img src="http://files.codecombat.com/docs/getting-started/class-code-example.png" title="Class Code Example" alt="class-code-example" width="400px"/>
_How to find your Class Code as a Teacher: Click on one of your classes, and look for the three-word Class Code next under “Adding Students”._
Once students enter your Class Code, they should see the correct class name and instructor on the screen.
Students will need to enter the following to create a Student Account:
- First name
- Last initial
- Username (help them choose an appropriate unique username)
- Password
Email addresses are _not required_ for students creating an account when they have a valid Class Code from you. That said, using an email address is recommended if they have one, for easier password recovery.
*If your school uses Google Apps for Education*, students can connect using the red "Google" button at the top of the screen instead of manually entering their information. They can then sign in to CodeCombat using the G+ Connect button in the future.
After students have created their account, they are shown their username and instructed to write down this information.
Thats it! Students can now use their login credentials to start playing CodeCombat!
##### STEP 4
## Introduction to Computer Science
All students are automatically granted access to the first course in CodeCombat, Introduction to Computer Science. This is a course that introduces students to concepts such as basic syntax, variables, and while loops. Generally this course takes about 1-3 hours for a middle school class.
<img src="http://files.codecombat.com/docs/getting-started/course-guides.png" title="Course Guides" alt="course-guides" width="400px"/>
_Course Guides allow you to preview course levels and view solutions._
As a teacher, you can access solutions for each course by going to [Course Guides](/teachers/courses/) (located in the blue teacher navigation bar). You can also preview every level using the dropdown selectors.
<img src="http://files.codecombat.com/docs/getting-started/resource-hub.png" title="Resource Hub" alt="resource-hub" width="400px"/>
_Course Guides allow you to preview course levels and view solutions._
If you're new to teaching computer science, we also recommend taking a look at the [Resource Hub](/teachers/resources), where you can find lesson plans, worksheets and supplemental guides to help you kickstart your classroom.
##### STEP 5
## Tracking Progress
<img src="http://files.codecombat.com/docs/getting-started/student-overview.png" title="Student Overview" alt="student-overview" width="400px"/>
_A high-level view of student progress in each course is displayed in the main class view._
After students join the class, youll see their progress appear in the individual classroom pages. Any assigned courses and each students progress in each course (starting with CS1, Introduction to Computer Science and onwards) is represented by a colored circle. A grey circle means a student has not begun any levels in that course, yellow circle means they have started working on the courses levels, and a green circle means that theyve completed all of the levels in the course.
<img src="http://files.codecombat.com/docs/getting-started/student-progress.png" title="Student Progress" alt="student-progress" width="400px"/>
_Navigate to the "Course Progress" tab to view more detailed information of student progress within each course._
If you want to see how your students are doing within a course, click on the “Course Progress” tab. Youll be able to view how much progress a student has made in a specific course. A gray circle means a level has not been started, a yellow circle indicates a level has been started but not completed, and a green circle means a level has been completed. By moving your mouse pointer over the circle for a level, you can see information about when they completed the level, as well as a rough estimate of how long the level took to complete.
##### STEP 6
## Licensing Students
Students are required to have a license to access any content after the first course. When you assign a new course, a license will automatically be applied to the student. By default, all licenses expire one year from when they are granted. A single license allows a single student access to all of the courses available.
To manually assign a license to a student, click on the *License Status* tab while viewing a class and use the "Assign License" button.
### How do I get Licenses for my students?
If you would like to purchase more licenses, click on *Student Licenses* in the Teacher Dashboard navigation bar, and follow the instructions under "Get More Licenses". One of CodeCombats specialists will be in contact with you shortly to discuss your needs.
##### STEP 7
## Assigning Courses
<img src="http://files.codecombat.com/docs/getting-started/bulk-assign.png" title="Bulk Assign Courses" alt="bulk-assign" width="400px"/>
Once a student is enrolled, youll be able to assign additional courses to them. We recommend not assigning students to more than one course ahead of where they currently are. You can bulk-assign a course to multiple students at a time by selecting students using checkboxes on the left-hand side (or the “Select All” checkbox), then choosing the appropriate course from the dropdown menu, and then clicking “Assign to Selected Students.”
##### STEP 8
## Start Teaching!
There are great supplemental materials for teachers available on our [Course Guides](/teachers/courses/) and [Resource Hub](/teachers/resources). If you're new to teaching computer science, we highly recommend checking these out -- we've built these with first-time teachers in mind. You can also browse our [Teacher Forums](https://discourse.codecombat.com/c/teachers), where you can discuss curriculum planning with other educators, share ideas, or ask questions.
You can also email us at [schools@codecombat.com](mailto:schools@codecombat.com) with any support questions or concerns!

View file

@ -44,42 +44,53 @@ window.console ?=
debug: -> debug: ->
console.debug ?= console.log # Needed for IE10 and earlier console.debug ?= console.log # Needed for IE10 and earlier
Application = initialize: -> Application = {
Router = require('core/Router') initialize: ->
@isProduction = -> document.location.href.search('https?://localhost') is -1 Router = require('core/Router')
@isIPadApp = webkit?.messageHandlers? and navigator.userAgent?.indexOf('CodeCombat-iPad') isnt -1 @isProduction = -> document.location.href.search('https?://localhost') is -1
$('body').addClass 'ipad' if @isIPadApp @isIPadApp = webkit?.messageHandlers? and navigator.userAgent?.indexOf('CodeCombat-iPad') isnt -1
$('body').addClass 'picoctf' if window.serverConfig.picoCTF $('body').addClass 'ipad' if @isIPadApp
if $.browser.msie and parseInt($.browser.version) is 10 $('body').addClass 'picoctf' if window.serverConfig.picoCTF
$("html").addClass("ie10") if $.browser.msie and parseInt($.browser.version) is 10
@tracker = new Tracker() $("html").addClass("ie10")
@facebookHandler = new FacebookHandler() @tracker = new Tracker()
@gplusHandler = new GPlusHandler() @facebookHandler = new FacebookHandler()
@githubHandler = new GitHubHandler() @gplusHandler = new GPlusHandler()
@moduleLoader = new ModuleLoader() @githubHandler = new GitHubHandler()
@moduleLoader.loadLanguage(me.get('preferredLanguage', true)) @moduleLoader = new ModuleLoader()
$(document).bind 'keydown', preventBackspace @moduleLoader.loadLanguage(me.get('preferredLanguage', true))
preload(COMMON_FILES) $(document).bind 'keydown', preventBackspace
CocoModel.pollAchievements() preload(COMMON_FILES)
$.i18n.init { CocoModel.pollAchievements()
lng: me.get('preferredLanguage', true) # @checkForNewAchievement() # TODO: Enable once thoroughly tested
fallbackLng: 'en' $.i18n.init {
resStore: locale lng: me.get('preferredLanguage', true)
useDataAttrOptions: true fallbackLng: 'en'
#debug: true resStore: locale
#sendMissing: true useDataAttrOptions: true
#sendMissingTo: 'current' #debug: true
#resPostPath: '/languages/add/__lng__/__ns__' #sendMissing: true
}, (t) => #sendMissingTo: 'current'
@router = new Router() #resPostPath: '/languages/add/__lng__/__ns__'
onIdleChanged = (to) => => Backbone.Mediator.publish 'application:idle-changed', idle: @userIsIdle = to }, (t) =>
@idleTracker = new Idle @router = new Router()
onAway: onIdleChanged true onIdleChanged = (to) => => Backbone.Mediator.publish 'application:idle-changed', idle: @userIsIdle = to
onAwayBack: onIdleChanged false @idleTracker = new Idle
onHidden: onIdleChanged true onAway: onIdleChanged true
onVisible: onIdleChanged false onAwayBack: onIdleChanged false
awayTimeout: 5 * 60 * 1000 onHidden: onIdleChanged true
@idleTracker.start() onVisible: onIdleChanged false
awayTimeout: 5 * 60 * 1000
@idleTracker.start()
checkForNewAchievement: ->
id = me.get('lastAchievementChecked') or me.id
lastAchievementChecked = new Date(parseInt(id.substring(0, 8), 16) * 1000)
daysSince = moment.duration(new Date() - lastAchievementChecked).asDays()
if daysSince > 1
me.checkForNewAchievement()
setTimeout(_.bind(@checkForNewAchievement, @), moment.duration(1, 'minute').asMilliseconds())
}
module.exports = Application module.exports = Application
window.application = Application window.application = Application

View file

@ -72,7 +72,7 @@
ffa: "Free for all students" ffa: "Free for all students"
lesson_time: "Lesson time:" lesson_time: "Lesson time:"
coming_soon: "More coming soon!" coming_soon: "More coming soon!"
courses_available_in: "Courses are available in JavaScript, Python, and Java (coming soon!)" courses_available_in: "Courses are available in JavaScript and Python. Web Development courses utilize HTML, CSS, jQuery, and Bootstrap." #{change}
boast: "Boasts riddles that are complex enough to fascinate gamers and coders alike." boast: "Boasts riddles that are complex enough to fascinate gamers and coders alike."
winning: "A winning combination of RPG gameplay and programming homework that pulls off making kid-friendly education legitimately enjoyable." winning: "A winning combination of RPG gameplay and programming homework that pulls off making kid-friendly education legitimately enjoyable."
run_class:"Everything you need to run a computer science class in your school today, no CS background required." run_class:"Everything you need to run a computer science class in your school today, no CS background required."
@ -129,6 +129,7 @@
faqs: "FAQs" faqs: "FAQs"
help_pref: "Need help? Email" help_pref: "Need help? Email"
help_suff: "and we'll get in touch!" help_suff: "and we'll get in touch!"
resource_hub: "Resource Hub"
modal: modal:
cancel: "Cancel" cancel: "Cancel"

View file

@ -416,11 +416,7 @@ class CocoModel extends Backbone.Model
# use it to determine what properties actually need to be translated # use it to determine what properties actually need to be translated
props = workingSchema.props or [] props = workingSchema.props or []
props = (prop for prop in props when parentData[prop]) props = (prop for prop in props when parentData[prop])
#unless props.length return unless props.length
# console.log 'props is', props, 'path is', path, 'data is', data, 'parentData is', parentData, 'workingSchema is', workingSchema
# langCodeArrays.push _.without _.keys(locale), 'update' # Every language has covered a path with no properties to be translated.
# return
return if 'additionalProperties' of i18n # Workaround for #2630: Programmable is weird return if 'additionalProperties' of i18n # Workaround for #2630: Programmable is weird
# get a list of lang codes where its object has keys for every prop to be translated # get a list of lang codes where its object has keys for every prop to be translated

View file

@ -368,6 +368,11 @@ module.exports = class User extends CocoModel
options.url = _.result(@, 'url') + '/deteacher' options.url = _.result(@, 'url') + '/deteacher'
options.type = 'POST' options.type = 'POST'
@fetch(options) @fetch(options)
checkForNewAchievement: (options={}) ->
options.url = _.result(@, 'url') + '/check-for-new-achievement'
options.type = 'POST'
@fetch(options)
tiersByLevel = [-1, 0, 0.05, 0.14, 0.18, 0.32, 0.41, 0.5, 0.64, 0.82, 0.91, 1.04, 1.22, 1.35, 1.48, 1.65, 1.78, 1.96, 2.1, 2.24, 2.38, 2.55, 2.69, 2.86, 3.03, 3.16, 3.29, 3.42, 3.58, 3.74, 3.89, 4.04, 4.19, 4.32, 4.47, 4.64, 4.79, 4.96, tiersByLevel = [-1, 0, 0.05, 0.14, 0.18, 0.32, 0.41, 0.5, 0.64, 0.82, 0.91, 1.04, 1.22, 1.35, 1.48, 1.65, 1.78, 1.96, 2.1, 2.24, 2.38, 2.55, 2.69, 2.86, 3.03, 3.16, 3.29, 3.42, 3.58, 3.74, 3.89, 4.04, 4.19, 4.32, 4.47, 4.64, 4.79, 4.96,
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15

View file

@ -344,6 +344,7 @@ _.extend UserSchema.properties,
schoolName: {type: 'string'} schoolName: {type: 'string'}
role: {type: 'string', enum: ["God", "advisor", "parent", "principal", "student", "superintendent", "teacher", "technology coordinator"]} role: {type: 'string', enum: ["God", "advisor", "parent", "principal", "student", "superintendent", "teacher", "technology coordinator"]}
birthday: c.stringDate({title: "Birthday"}) birthday: c.stringDate({title: "Birthday"})
lastAchievementChecked: c.objectId({ name: 'Last Achievement Checked' })
c.extendBasicProperties UserSchema, 'user' c.extendBasicProperties UserSchema, 'user'

View file

@ -6,7 +6,6 @@
.print, .print p .print, .print p
text-align: center text-align: center
font-size: .75em font-size: .75em
// text-transform: uppercase
@media print @media print
display: none display: none
@ -17,7 +16,7 @@
.back-to-top .back-to-top
text-transform: none text-transform: none
@media print @media print
display: none display: none
.lesson-plans .lesson-plans
max-width: 900px max-width: 900px
@ -44,6 +43,8 @@
padding: 0 padding: 0
font-family: Open Sans font-family: Open Sans
font-weight: 200 font-weight: 200
@media print
font-size: 2em
// module header // // module header //
h2 h2
@ -70,21 +71,25 @@
margin: 0 margin: 0
// make it so the page breaks before each module // make it so the page breaks before each module
h5[id^="module"] h5[id^="module"], h5[id^="step"]
border-top: 1px solid #666 border-top: 1px solid #666
margin: 30px 0 0 0 margin: 30px 0 0 0
padding: 20px 0 0 0 padding: 20px 0 0 0
@media print @media print
page-break-before: always page-break-before: always
h5[id="step-1"]
border-top: none !important
@media print
page-break-before: avoid
.back-to-top
display: none
h6 h6
font-style: italic font-style: italic
font-weight: 200 font-weight: 200
text-align: right text-align: right
img
width: 100%
table, tr, td, th table, tr, td, th
border: 2px solid #f8f8f8 border: 2px solid #f8f8f8
border-radius: 5px border-radius: 5px
@ -107,3 +112,16 @@
margin: 0 0 0 0px margin: 0 0 0 0px
padding: 0 60px 0 50px padding: 0 60px 0 50px
border-left: 5px solid #eee border-left: 5px solid #eee
// Styles for Getting Started page.
#getting-started
img
max-width: 400px
border: 1px solid #666
box-shadow: 2px 2px #ddd
margin: 15px auto 15px auto
display: block
@media print
max-width: 400px !important

View file

@ -105,7 +105,7 @@ mixin accountLinks
li li
a(href="/teachers/classes", data-i18n="nav.teachers") a(href="/teachers/classes", data-i18n="nav.teachers")
li li
a(href="https://sites.google.com/a/codecombat.com/teacher-guides/", data-i18n="nav.educator_wiki") a(href="/teachers/resources", data-i18n="nav.resource_hub")
li li
a(href="/teachers/demo", data-i18n="teachers_quote.title") a(href="/teachers/demo", data-i18n="teachers_quote.title")

View file

@ -269,7 +269,7 @@ block content
.clearfix .clearfix
.text-center .text-center
h4 h4
img(src="/images/pages/home/course_languages.png") img(src="/images/pages/about/new_languages.png")
div(data-i18n="new_home.courses_available_in") div(data-i18n="new_home.courses_available_in")
.testimonials-rows .testimonials-rows

View file

@ -200,10 +200,7 @@ if view.showAds()
h1#campaign-status.picoctf-hide h1#campaign-status.picoctf-hide
.campaign-status-background .campaign-status-background
.campaign-name .campaign-name
- var fullName = i18n(campaign.attributes, 'fullName') span= i18n(campaign.attributes, 'fullName')
if (me.get('preferredLanguage', true) || 'en-US').split('-')[0] == 'en' || fullName != campaign.get('fullName')
// We have a translation.
span= fullName
.levels-completed .levels-completed
span= levelsCompleted span= levelsCompleted
| / | /

View file

@ -14,9 +14,8 @@ block content
h4(data-i18n="teacher.getting_started") h4(data-i18n="teacher.getting_started")
ul ul
li li
a(href="http://files.codecombat.com/docs/resources/TeacherGettingStartedGuide.pdf" target="blank") a(href="/teachers/resources/getting-started")
span(data-i18n="teacher.teacher_getting_started") span(data-i18n="teacher.teacher_getting_started")
span.spl [PDF]
p(data-i18n="teacher.teacher_getting_started_desc") p(data-i18n="teacher.teacher_getting_started_desc")
li li
a(href="http://files.codecombat.com/docs/resources/StudentQuickStartGuide.pdf" target="blank") a(href="http://files.codecombat.com/docs/resources/StudentQuickStartGuide.pdf" target="blank")

View file

@ -41,10 +41,12 @@ module.exports = class LadderView extends RootView
initialize: (options, @levelID, @leagueType, @leagueID) -> initialize: (options, @levelID, @leagueType, @leagueID) ->
@level = @supermodel.loadModel(new Level(_id: @levelID)).model @level = @supermodel.loadModel(new Level(_id: @levelID)).model
@level.once 'sync', => onLoaded = =>
return if @destroyed return if @destroyed
@levelDescription = marked(@level.get('description')) if @level.get('description') @levelDescription = marked(@level.get('description')) if @level.get('description')
@teams = teamDataFromLevel @level @teams = teamDataFromLevel @level
if @level.loaded then onLoaded() else @level.once('sync', onLoaded)
@sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model @sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model
@winners = require('./tournament_results')[@levelID] @winners = require('./tournament_results')[@levelID]

View file

@ -371,8 +371,8 @@ module.exports = class CampaignView extends RootView
for level in orderedLevels for level in orderedLevels
# Iterate through all levels in order and look to find the first unlocked one that meets all our criteria for being pointed out as the next level. # Iterate through all levels in order and look to find the first unlocked one that meets all our criteria for being pointed out as the next level.
level.nextLevels = (reward.level for reward in level.rewards ? [] when reward.level) level.nextLevels = (reward.level for reward in level.rewards ? [] when reward.level)
break if foundNext = findNextLevel(level.nextLevels, true) # Check practice levels first foundNext = findNextLevel(level.nextLevels, true) unless foundNext # Check practice levels first
break if foundNext = findNextLevel(level.nextLevels, false) foundNext = findNextLevel(level.nextLevels, false) unless foundNext
if not foundNext and orderedLevels[0] and not orderedLevels[0].locked and @levelStatusMap[orderedLevels[0].slug] isnt 'complete' if not foundNext and orderedLevels[0] and not orderedLevels[0].locked and @levelStatusMap[orderedLevels[0].slug] isnt 'complete'
orderedLevels[0].next = true orderedLevels[0].next = true

View file

@ -257,7 +257,7 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
for (const activity of results.data) { for (const activity of results.data) {
if (activity.id === firstMailActivity.id) continue; if (activity.id === firstMailActivity.id) continue;
if (new Date(firstMailActivity.date_created) > new Date(activity.date_created)) continue; if (new Date(firstMailActivity.date_created) > new Date(activity.date_created)) continue;
if (activity._type === 'Email' && activity.to[0] !== email) continue; if (activity._type === 'Email' && activity.to[0].toLowerCase() !== email) continue;
recentActivity = activity; recentActivity = activity;
break; break;
} }
@ -269,7 +269,6 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
return done(); return done();
} }
// console.log(`TODO: ${firstMailActivity.to[0]} ${lead.id} ${firstMailActivity.contact_id} ${template} ${userApiKeyMap[firstMailActivity.user_id]}`); // console.log(`TODO: ${firstMailActivity.to[0]} ${lead.id} ${firstMailActivity.contact_id} ${template} ${userApiKeyMap[firstMailActivity.user_id]}`);
// console.log(`TODO: ${firstMailActivity.to[0]} ${lead.id}`);
sendMail(firstMailActivity.to[0], lead.id, firstMailActivity.contact_id, template, userApiKeyMap[firstMailActivity.user_id], 0, (err) => { sendMail(firstMailActivity.to[0], lead.id, firstMailActivity.contact_id, template, userApiKeyMap[firstMailActivity.user_id], 0, (err) => {
if (err) return done(err); if (err) return done(err);
@ -302,7 +301,7 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
} }
else { else {
// console.log(`Found recent activity after auto1 mail for ${lead.id}`); // console.log(`Found recent activity after auto1 mail for ${lead.id}`);
// console.log(firstMailActivity.template_id, recentActivity.template_id); // console.log(firstMailActivity.template_id, recentActivity);
return done(); return done();
} }
} }
@ -387,7 +386,6 @@ function sendSecondFollowupMails(done) {
function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) { function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) {
// Check for activity since second auto mail and status update // Check for activity since second auto mail and status update
// Add call task // Add call task
// TODO: Very similar function to createSendFollowupMailFn
const auto1Statuses = ["Auto Attempt 1", "New US Schools Auto Attempt 1", "Inbound Canada Auto Attempt 1", "Inbound AU Auto Attempt 1", "Inbound NZ Auto Attempt 1", "Inbound UK Auto Attempt 1"]; const auto1Statuses = ["Auto Attempt 1", "New US Schools Auto Attempt 1", "Inbound Canada Auto Attempt 1", "Inbound AU Auto Attempt 1", "Inbound NZ Auto Attempt 1", "Inbound UK Auto Attempt 1"];
const auto2Statuses = ["Auto Attempt 2", "New US Schools Auto Attempt 2", "Inbound Canada Auto Attempt 2", "Inbound AU Auto Attempt 2", "Inbound NZ Auto Attempt 2", "Inbound UK Auto Attempt 2"]; const auto2Statuses = ["Auto Attempt 2", "New US Schools Auto Attempt 2", "Inbound Canada Auto Attempt 2", "Inbound AU Auto Attempt 2", "Inbound NZ Auto Attempt 2", "Inbound UK Auto Attempt 2"];
return (done) => { return (done) => {

View file

@ -128,23 +128,6 @@ function activity2Html(activity) {
} }
html += "</p>"; html += "</p>";
} }
else if (activity._type === 'Email') {
html += `<h3>${activity._type}</h3>`;
if (activity.subject) html += `<h4>${activity.subject}</h4>`;
if (activity.date_updated) html += `<div>Updated: ${activity.date_updated}</div>`;
if (activity.opens_summary) html += `<div>${activity.opens_summary}</div>`;
if (activity.status) html += `<div>Status: ${activity.status}</div>`;
for (let email of activity.to) {
html += `<div>To: ${email}</div>`;
}
if (activity.sender) html += `<div>From: ${activity.sender}</div>`;
const lines = (activity.body_text || '').split('\n');
html += "<p>";
for (const line of lines) {
html += `<div>${line}</div>`;
}
html += "</p>";
}
return html; return html;
} }

View file

@ -506,20 +506,11 @@ class CocoLead {
if (!currentCustom['Lead Origin']) { if (!currentCustom['Lead Origin']) {
putData['custom.Lead Origin'] = this.getLeadOrigin(); putData['custom.Lead Origin'] = this.getLeadOrigin();
} }
for (const email in this.contacts) { for (const email in this.contacts) {
const props = this.contacts[email].trial.properties; const props = this.contacts[email].trial.properties;
if (props) { if (props) {
let haveNcesData = false;
for (const prop in props) { for (const prop in props) {
if (/nces_/ig.test(prop)) { if (!currentCustom[`demo_${prop}`] && (commonTrialProperties.indexOf(prop) >= 0 || /nces_/ig.test(prop))) {
haveNcesData = true;
putData[`custom.demo_${prop}`] = props[prop];
}
}
for (const prop in props) {
// Always overwrite common props if we have NCES data, because other fields more likely to be accurate
if (commonTrialProperties.indexOf(prop) >= 0 && (haveNcesData || !currentCustom[`demo_${prop}`] || currentCustom[`demo_${prop}`] !== props[prop] && currentCustom[`demo_${prop}`].indexOf(props[prop]) < 0)) {
putData[`custom.demo_${prop}`] = props[prop]; putData[`custom.demo_${prop}`] = props[prop];
} }
} }

View file

@ -15,10 +15,9 @@ class EarnedAchievementHandler extends Handler
editableProperties: ['notified'] editableProperties: ['notified']
# Don't allow POSTs or anything yet
hasAccess: (req) -> hasAccess: (req) ->
return false unless req.user return false unless req.user
req.method in ['GET', 'POST', 'PUT'] # or req.user.isAdmin() req.method in ['GET', 'PUT'] # or req.user.isAdmin()
get: (req, res) -> get: (req, res) ->
return @getByAchievementIDs(req, res) if req.query.view is 'get-by-achievement-ids' return @getByAchievementIDs(req, res) if req.query.view is 'get-by-achievement-ids'
@ -45,73 +44,6 @@ class EarnedAchievementHandler extends Handler
documents = (@formatEntity(req, doc) for doc in documents) documents = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents) @sendSuccess(res, documents)
post: (req, res) ->
achievementID = req.body.achievement
triggeredBy = req.body.triggeredBy
collection = req.body.collection
if collection isnt 'level.sessions'
return @sendBadInputError(res, 'Only doing level session achievements for now.')
model = mongoose.modelNameByCollection(collection)
async.parallel({
achievement: (callback) ->
Achievement.findById achievementID, (err, achievement) -> callback(err, achievement)
trigger: (callback) ->
model.findById triggeredBy, (err, trigger) -> callback(err, trigger)
earned: (callback) ->
q = { achievement: achievementID, user: req.user._id+'' }
EarnedAchievement.findOne q, (err, earned) -> callback(err, earned)
}, (err, { achievement, trigger, earned } ) =>
return @sendDatabaseError(res, err) if err
if not achievement
return @sendNotFoundError(res, 'Could not find achievement.')
else if not trigger
return @sendNotFoundError(res, 'Could not find trigger.')
else if achievement.get('proportionalTo') and earned
EarnedAchievement.createForAchievement(achievement, trigger, null, earned, (earnedAchievementDoc) =>
@sendCreated(res, (earnedAchievementDoc or earned)?.toObject())
)
else if earned
achievementEarned = achievement.get('rewards')
actuallyEarned = earned.get('earnedRewards')
if not _.isEqual(achievementEarned, actuallyEarned)
earned.set('earnedRewards', achievementEarned)
earned.save((err) =>
return @sendDatabaseError(res, err) if err
@upsertNonNumericRewards(req.user, achievement, (err) =>
return @sendDatabaseError(res, err) if err
return @sendSuccess(res, earned.toObject())
)
)
else
@upsertNonNumericRewards(req.user, achievement, (err) =>
return @sendDatabaseError(res, err) if err
return @sendSuccess(res, earned.toObject())
)
else
EarnedAchievement.createForAchievement(achievement, trigger, null, null, (earnedAchievementDoc) =>
if earnedAchievementDoc
@sendCreated(res, earnedAchievementDoc.toObject())
else
console.error "Couldn't create achievement", achievement, trigger
@sendNotFoundError res, "Couldn't create achievement"
)
)
upsertNonNumericRewards: (user, achievement, done) ->
update = {}
for rewardType, rewards of achievement.get('rewards') ? {}
continue if rewardType is 'gems'
if rewards.length
update.$addToSet ?= {}
update.$addToSet["earned.#{rewardType}"] = $each: rewards
User.update {_id: user._id}, update, {}, (err, result) ->
log.error err if err?
done?(err)
getByAchievementIDs: (req, res) -> getByAchievementIDs: (req, res) ->
query = { user: req.user._id+''} query = { user: req.user._id+''}
ids = req.query.achievementIDs ids = req.query.achievementIDs

View file

@ -0,0 +1,30 @@
log = require 'winston'
mongoose = require 'mongoose'
Achievement = require './../models/Achievement'
EarnedAchievement = require './../models/EarnedAchievement'
errors = require '../commons/errors'
wrap = require 'co-express'
exports.post = wrap (req, res) ->
achievementID = req.body.achievement
triggeredBy = req.body.triggeredBy
collection = req.body.collection
if collection isnt 'level.sessions' and not testing # TODO: remove this restriction
throw new errors.UnprocessableEntity('Only doing level session achievements for now.')
model = mongoose.modelNameByCollection(collection)
[achievement, trigger, earned] = yield [
Achievement.findById(achievementID),
model.findById(triggeredBy)
EarnedAchievement.findOne({ achievement: achievementID, user: req.user.id })
]
if not achievement
throw new errors.NotFound('Could not find achievement.')
if not trigger
throw new errors.NotFound('Could not find trigger.')
finalEarned = yield EarnedAchievement.upsertFor(achievement, trigger, earned, req.user)
res.status(201).send(finalEarned.toObject({req}))

View file

@ -7,6 +7,7 @@ module.exports =
contact: require './contact' contact: require './contact'
courseInstances: require './course-instances' courseInstances: require './course-instances'
courses: require './courses' courses: require './courses'
earnedAchievements: require './earned-achievements'
files: require './files' files: require './files'
healthcheck: require './healthcheck' healthcheck: require './healthcheck'
levels: require './levels' levels: require './levels'

View file

@ -16,7 +16,10 @@ CourseInstance = require '../models/CourseInstance'
facebook = require '../lib/facebook' facebook = require '../lib/facebook'
gplus = require '../lib/gplus' gplus = require '../lib/gplus'
TrialRequest = require '../models/TrialRequest' TrialRequest = require '../models/TrialRequest'
Achievement = require '../models/Achievement'
EarnedAchievement = require '../models/EarnedAchievement'
log = require 'winston' log = require 'winston'
LocalMongo = require '../../app/lib/LocalMongo'
module.exports = module.exports =
fetchByGPlusID: wrap (req, res, next) -> fetchByGPlusID: wrap (req, res, next) ->
@ -254,3 +257,45 @@ module.exports =
yield user.update({ $unset: {role: ''}}) yield user.update({ $unset: {role: ''}})
user.set('role', undefined) user.set('role', undefined)
return res.status(200).send(user.toObject({req: req})) return res.status(200).send(user.toObject({req: req}))
checkForNewAchievement: wrap (req, res) ->
user = req.user
lastAchievementChecked = user.get('lastAchievementChecked') or user._id
achievement = yield Achievement.findOne({ _id: { $gt: lastAchievementChecked }}).sort({_id:1})
if not achievement
userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() }
user.update({$set: userUpdate}).exec()
return res.send(userUpdate)
userUpdate = { 'lastAchievementChecked': achievement._id }
query = achievement.get('query')
collection = achievement.get('collection')
if collection is 'users'
triggers = [user]
else if collection is 'level.sessions' and query['level.original']
triggers = yield LevelSessions.find({
'level.original': query['level.original']
creator: user._id
})
else
userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() }
user.update({$set: userUpdate}).exec()
return res.send(userUpdate)
trigger = _.find(triggers, (trigger) -> LocalMongo.matchesQuery(trigger.toObject(), query))
if not trigger
user.update({$set: userUpdate}).exec()
return res.send(userUpdate)
earned = yield EarnedAchievement.findOne({ achievement: achievement.id, user: req.user })
yield [
EarnedAchievement.upsertFor(achievement, trigger, earned, req.user)
user.update({$set: userUpdate})
]
user = yield User.findById(user.id).select({points: 1, earned: 1})
return res.send(_.assign({}, userUpdate, user.toObject()))

View file

@ -6,6 +6,7 @@ plugins = require('../plugins/plugins')
AchievablePlugin = require '../plugins/achievements' AchievablePlugin = require '../plugins/achievements'
TreemaUtils = require '../../bower_components/treema/treema-utils.js' TreemaUtils = require '../../bower_components/treema/treema-utils.js'
config = require '../../server_config' config = require '../../server_config'
co = require 'co'
# `pre` and `post` are not called for update operations executed directly on the database, # `pre` and `post` are not called for update operations executed directly on the database,
# including `Model.update`,`.findByIdAndUpdate`,`.findOneAndUpdate`, `.findOneAndRemove`,and `.findByIdAndRemove`.order # including `Model.update`,`.findByIdAndUpdate`,`.findOneAndUpdate`, `.findOneAndRemove`,and `.findByIdAndRemove`.order
@ -53,27 +54,29 @@ AchievementSchema.statics.achievementCollections = {}
# Reloads all achievements into memory. # Reloads all achievements into memory.
# TODO might want to tweak this to only load new achievements # TODO might want to tweak this to only load new achievements
AchievementSchema.statics.loadAchievements = (done) -> AchievementSchema.statics.loadAchievements = co.wrap ->
AchievementSchema.statics.resetAchievements() AchievementSchema.statics.resetAchievements()
t0 = new Date()
Achievement = require('./Achievement') Achievement = require('./Achievement')
query = Achievement.find({collection: {$ne: 'level.sessions'}}) achievements = yield Achievement.find({collection: {$ne: 'level.sessions'}})
query.exec (err, docs) -> return if t0 < @lastReset # if a test has run resetAchievements during the fetch, abort
_.each docs, (achievement) -> for achievement in achievements
collection = achievement.get 'collection' collection = achievement.get 'collection'
AchievementSchema.statics.achievementCollections[collection] ?= [] AchievementSchema.statics.achievementCollections[collection] ?= []
if _.find AchievementSchema.statics.achievementCollections[collection], ((a) -> a.get('_id').toHexString() is achievement.get('_id').toHexString()) if _.find AchievementSchema.statics.achievementCollections[collection], ((a) -> a.get('_id').toHexString() is achievement.get('_id').toHexString())
log.warn "Uh oh, we tried to add another copy of the same achievement #{achievement.get('_id')} #{achievement.get('name')} to the #{collection} achievement list..." log.warn "Uh oh, we tried to add another copy of the same achievement #{achievement.get('_id')} #{achievement.get('name')} to the #{collection} achievement list..."
else else
AchievementSchema.statics.achievementCollections[collection].push achievement AchievementSchema.statics.achievementCollections[collection].push achievement
unless achievement.get('query') unless achievement.get('query')
log.error "Uh oh, there is an achievement with an empty query: #{achievement}" log.error "Uh oh, there is an achievement with an empty query: #{achievement}"
done?(AchievementSchema.statics.achievementCollections) # TODO: Return with err as first parameter return AchievementSchema.statics.achievementCollections
AchievementSchema.statics.getLoadedAchievements = -> AchievementSchema.statics.getLoadedAchievements = ->
AchievementSchema.statics.achievementCollections AchievementSchema.statics.achievementCollections
AchievementSchema.statics.resetAchievements = -> AchievementSchema.statics.resetAchievements = ->
delete AchievementSchema.statics.achievementCollections[collection] for collection of AchievementSchema.statics.achievementCollections delete AchievementSchema.statics.achievementCollections[collection] for collection of AchievementSchema.statics.achievementCollections
@lastReset = new Date()
AchievementSchema.statics.editableProperties = [ AchievementSchema.statics.editableProperties = [
'name' 'name'

View file

@ -2,6 +2,7 @@ mongoose = require 'mongoose'
jsonschema = require '../../app/schemas/models/earned_achievement' jsonschema = require '../../app/schemas/models/earned_achievement'
util = require '../../app/core/utils' util = require '../../app/core/utils'
log = require 'winston' log = require 'winston'
co = require 'co'
EarnedAchievementSchema = new mongoose.Schema({ EarnedAchievementSchema = new mongoose.Schema({
notified: notified:
@ -16,81 +17,107 @@ EarnedAchievementSchema.pre 'save', (next) ->
EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'}) EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'})
EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '}) EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '})
EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, originalDocObj=null, previouslyEarnedAchievement=null, done) ->
User = require './User'
userObjectID = doc.get(achievement.get('userField'))
userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use strings, not ObjectId's
earned = EarnedAchievementSchema.statics.upsertFor = (achievement, trigger, earned, user) ->
if achievement.get('proportionalTo') and earned
earnedAchievementDoc = yield @createForAchievement(achievement, trigger, {previouslyEarnedAchievement: earned})
return earnedAchievementDoc or earned
else if earned
achievementEarned = achievement.get('rewards')
actuallyEarned = earned.get('earnedRewards')
if not _.isEqual(achievementEarned, actuallyEarned)
earned.set('earnedRewards', achievementEarned)
yield earned.save()
# make sure user has all the levels and items they should have
update = {}
for rewardType, rewards of achievement.get('rewards') ? {}
if rewardType is 'gems'
update.$inc = { 'earned.gems': rewards - (actuallyEarned.gems ? 0) }
else if rewards.length
update.$addToSet ?= {}
update.$addToSet["earned.#{rewardType}"] = { $each: rewards }
yield user.update(update)
return earned
else
earned = yield @createForAchievement(achievement, trigger)
if not earned
console.error "Couldn't create achievement", achievement, trigger
throw new errors.NotFound("Couldn't create achievement")
return earned
EarnedAchievementSchema.statics.createForAchievement = co.wrap (achievement, doc, options={}) ->
{ previouslyEarnedAchievement, originalDocObj } = options
User = require('./User')
userObjectID = doc.get(achievement.get('userField'))
userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # TODO: Migrate to ObjectIds
earnedAttrs = {
user: userID user: userID
achievement: achievement._id.toHexString() achievement: achievement._id.toHexString()
achievementName: achievement.get 'name' achievementName: achievement.get 'name'
earnedRewards: achievement.get 'rewards' earnedRewards: achievement.get 'rewards'
}
pointWorth = achievement.get('worth') ? 10 pointWorth = achievement.get('worth') ? 10
gemWorth = achievement.get('rewards')?.gems ? 0 gemWorth = achievement.get('rewards')?.gems ? 0
earnedPoints = 0 earnedPoints = 0
earnedGems = 0 earnedGems = 0
earnedDoc = null
wrapUp = (earnedAchievementDoc) -> isRepeatable = achievement.get('proportionalTo')?
# Update user's experience points
if isRepeatable
proportionalTo = achievement.get('proportionalTo')
docObj = doc.toObject()
newAmount = util.getByPath(docObj, proportionalTo) or 0
if proportionalTo is 'simulatedBy' and newAmount > 0 and not previouslyEarnedAchievement and Math.random() < 0.1
# Because things like simulatedBy get updated with $inc and not the post-save plugin hook,
# we (infrequently) fetch the previously earned achievement so we can really update.
previouslyEarnedAchievement = yield EarnedAchievement.findOne({user: earnedAttrs.user, achievement: earnedAttrs.achievement})
if previouslyEarnedAchievement
originalAmount = previouslyEarnedAchievement.get('achievedAmount') or 0
else if originalDocObj # This branch could get buggy if unchangedCopy tracking isn't working.
originalAmount = util.getByPath(originalDocObj, proportionalTo) or 0
else
originalAmount = 0
if originalAmount isnt newAmount
expFunction = achievement.getExpFunction()
earnedAttrs.notified = false
earnedAttrs.achievedAmount = newAmount
earnedPoints = earnedAttrs.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth
earnedGems = earnedAttrs.earnedGems = (expFunction(newAmount) - expFunction(originalAmount)) * gemWorth ? 0
earnedAttrs.previouslyAchievedAmount = originalAmount
yield EarnedAchievement.update({achievement: earnedAttrs.achievement, user: earnedAttrs.user}, earnedAttrs, {upsert: true})
earnedDoc = new EarnedAchievement(earnedAttrs)
else # not alreadyAchieved
earnedAttrs.earnedPoints = pointWorth
earnedAttrs.earnedGems = gemWorth
earnedDoc = new EarnedAchievement(earnedAttrs)
yield earnedDoc.save()
earnedPoints = pointWorth
earnedGems = gemWorth
User.saveActiveUser(userID, "achievement")
if earnedDoc
update = {$inc: {points: earnedPoints, 'earned.gems': earnedGems}} update = {$inc: {points: earnedPoints, 'earned.gems': earnedGems}}
for rewardType, rewards of achievement.get('rewards') ? {} for rewardType, rewards of achievement.get('rewards') ? {}
continue if rewardType is 'gems' continue if rewardType is 'gems'
if rewards.length if rewards.length
update.$addToSet ?= {} update.$addToSet ?= {}
update.$addToSet["earned.#{rewardType}"] = $each: rewards update.$addToSet["earned.#{rewardType}"] = $each: rewards
User.update {_id: mongoose.Types.ObjectId(userID)}, update, {}, (err, result) -> yield User.update({_id: mongoose.Types.ObjectId(userID)}, update, {})
log.error err if err?
done?(earnedAchievementDoc)
isRepeatable = achievement.get('proportionalTo')? return earnedDoc
if isRepeatable
#log.debug 'Upserting repeatable achievement called \'' + (achievement.get 'name') + '\' for ' + userID
proportionalTo = achievement.get 'proportionalTo'
docObj = doc.toObject()
newAmount = util.getByPath(docObj, proportionalTo) or 0
updateEarnedAchievement = (originalAmount) ->
#console.log 'original amount is', originalAmount, 'and new amount is', newAmount, 'for', proportionalTo, 'with doc', docObj, 'and previously earned achievement amount', previouslyEarnedAchievement?.get('achievedAmount'), 'because we had originalDocObj', originalDocObj
if originalAmount isnt newAmount
expFunction = achievement.getExpFunction()
earned.notified = false
earned.achievedAmount = newAmount
#console.log 'earnedPoints is', (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth, 'was', earned.earnedPoints, earned.previouslyAchievedAmount, 'got exp function for new amount', newAmount, expFunction(newAmount), 'for original amount', originalAmount, expFunction(originalAmount), 'with point worth', pointWorth
earnedPoints = earned.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth
earnedGems = earned.earnedGems = (expFunction(newAmount) - expFunction(originalAmount)) * gemWorth ? 0
earned.previouslyAchievedAmount = originalAmount
EarnedAchievement.update {achievement: earned.achievement, user: earned.user}, earned, {upsert: true}, (err) ->
return log.error err if err?
wrapUp(new EarnedAchievement(earned))
else
done?()
if proportionalTo is 'simulatedBy' and newAmount > 0 and not previouslyEarnedAchievement and Math.random() < 0.1
# Because things like simulatedBy get updated with $inc and not the post-save plugin hook,
# we (infrequently) fetch the previously earned achievement so we can really update.
EarnedAchievement.findOne {user: earned.user, achievement: earned.achievement}, (err, previouslyEarnedAchievement) ->
log.error err if err?
updateEarnedAchievement previouslyEarnedAchievement?.get('achievedAmount') or 0
else if previouslyEarnedAchievement
updateEarnedAchievement previouslyEarnedAchievement.get('achievedAmount') or 0
else if originalDocObj # This branch could get buggy if unchangedCopy tracking isn't working.
updateEarnedAchievement util.getByPath(originalDocObj, proportionalTo) or 0
else
updateEarnedAchievement 0
else # not alreadyAchieved
#log.debug 'Creating a new earned achievement called \'' + (achievement.get 'name') + '\' for ' + userID
earned.earnedPoints = pointWorth
earned.earnedGems = gemWorth
(new EarnedAchievement(earned)).save (err, doc) ->
return log.error err if err?
earnedPoints = pointWorth
earnedGems = gemWorth
wrapUp(doc)
User.saveActiveUser userID, "achievement"
module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema) module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema)

View file

@ -42,7 +42,6 @@ AchievablePlugin = (schema, options) ->
alreadyAchieved = if isNew then false else LocalMongo.matchesQuery unchangedCopy, query alreadyAchieved = if isNew then false else LocalMongo.matchesQuery unchangedCopy, query
newlyAchieved = LocalMongo.matchesQuery(docObj, query) newlyAchieved = LocalMongo.matchesQuery(docObj, query)
return unless newlyAchieved and (not alreadyAchieved or isRepeatable) return unless newlyAchieved and (not alreadyAchieved or isRepeatable)
#log.info "Making an achievement: #{achievement.get('name')} #{achievement.get('_id')} for doc: #{doc.get('name')} #{doc.get('_id')}" EarnedAchievement.createForAchievement(achievement, doc, {originalDocObj: unchangedCopy})
EarnedAchievement.createForAchievement(achievement, doc, unchangedCopy)
module.exports = AchievablePlugin module.exports = AchievablePlugin

View file

@ -96,7 +96,10 @@ module.exports.setup = (app) ->
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)
app.get('/db/course_instance/:handle/course', mw.auth.checkLoggedIn(), mw.courseInstances.fetchCourse) app.get('/db/course_instance/:handle/course', mw.auth.checkLoggedIn(), mw.courseInstances.fetchCourse)
EarnedAchievement = require '../models/EarnedAchievement'
app.post('/db/earned_achievement', mw.auth.checkLoggedIn(), mw.earnedAchievements.post)
Level = require '../models/Level' Level = require '../models/Level'
app.post('/db/level/:handle', mw.auth.checkLoggedIn(), mw.versions.postNewVersion(Level, { hasPermissionsOrTranslations: 'artisan' })) # TODO: add /new-version to route like Article has app.post('/db/level/:handle', mw.auth.checkLoggedIn(), mw.versions.postNewVersion(Level, { hasPermissionsOrTranslations: 'artisan' })) # TODO: add /new-version to route like Article has
app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession) app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession)
@ -115,7 +118,8 @@ module.exports.setup = (app) ->
app.post('/db/user/:handle/signup-with-password', mw.users.signupWithPassword) app.post('/db/user/:handle/signup-with-password', mw.users.signupWithPassword)
app.post('/db/user/:handle/destudent', mw.auth.checkHasPermission(['admin']), mw.users.destudent) app.post('/db/user/:handle/destudent', mw.auth.checkHasPermission(['admin']), mw.users.destudent)
app.post('/db/user/:handle/deteacher', mw.auth.checkHasPermission(['admin']), mw.users.deteacher) app.post('/db/user/:handle/deteacher', mw.auth.checkHasPermission(['admin']), mw.users.deteacher)
app.post('/db/user/:handle/check-for-new-achievement', mw.auth.checkLoggedIn(), mw.users.checkForNewAchievement)
app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator) app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator)
app.get('/db/prepaid/-/active-schools', mw.auth.checkHasPermission(['admin']), mw.prepaids.fetchActiveSchools) app.get('/db/prepaid/-/active-schools', mw.auth.checkHasPermission(['admin']), mw.prepaids.fetchActiveSchools)
app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post) app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post)

View file

@ -7,12 +7,15 @@ LevelSession = require '../../../server/models/LevelSession'
User = require '../../../server/models/User' User = require '../../../server/models/User'
request = require '../request' request = require '../request'
EarnedAchievementHandler = require '../../../server/handlers/earned_achievement_handler' EarnedAchievementHandler = require '../../../server/handlers/earned_achievement_handler'
mongoose = require 'mongoose'
url = getURL('/db/achievement') url = getURL('/db/achievement')
# Fixtures # Fixtures
lockedLevelID = new mongoose.Types.ObjectId().toString()
unlockable = unlockable =
name: 'Dungeon Arena Started' name: 'Dungeon Arena Started'
description: 'Started playing Dungeon Arena.' description: 'Started playing Dungeon Arena.'
@ -22,6 +25,9 @@ unlockable =
userField: 'creator' userField: 'creator'
recalculable: true recalculable: true
related: 'a' related: 'a'
rewards: {
levels: [lockedLevelID]
}
unlockable2 = _.clone unlockable unlockable2 = _.clone unlockable
unlockable2.name = 'This one is obsolete' unlockable2.name = 'This one is obsolete'
@ -163,8 +169,9 @@ describe 'DELETE /db/achievement/:handle', ->
describe 'POST /db/earned_achievement', -> describe 'POST /db/earned_achievement', ->
beforeEach addAllAchievements beforeEach addAllAchievements
eaURL = getURL('/db/earned_achievement')
it 'can be used to manually create them for level achievements, which do not happen automatically', utils.wrap (done) -> it 'manually creates earned achievements for level achievements, which do not happen automatically', utils.wrap (done) ->
session = new LevelSession({ session = new LevelSession({
permissions: simplePermissions permissions: simplePermissions
creator: @admin._id creator: @admin._id
@ -174,7 +181,7 @@ describe 'POST /db/earned_achievement', ->
earnedAchievements = yield EarnedAchievement.find() earnedAchievements = yield EarnedAchievement.find()
expect(earnedAchievements.length).toBe(0) expect(earnedAchievements.length).toBe(0)
json = {achievement: @unlockable.id, triggeredBy: session._id, collection: 'level.sessions'} json = {achievement: @unlockable.id, triggeredBy: session._id, collection: 'level.sessions'}
[res, body] = yield request.postAsync {uri: getURL('/db/earned_achievement'), json: json} [res, body] = yield request.postAsync { url: eaURL, json }
expect(res.statusCode).toBe(201) expect(res.statusCode).toBe(201)
expect(body.achievement).toBe @unlockable.id expect(body.achievement).toBe @unlockable.id
expect(body.user).toBe @admin.id expect(body.user).toBe @admin.id
@ -185,7 +192,72 @@ describe 'POST /db/earned_achievement', ->
earnedAchievements = yield EarnedAchievement.find() earnedAchievements = yield EarnedAchievement.find()
expect(earnedAchievements.length).toBe(1) expect(earnedAchievements.length).toBe(1)
done() done()
it 'works for proportional achievements', utils.wrap (done) ->
user = yield utils.initUser()
yield utils.loginUser(user)
yield user.update({simulatedBy: 10})
json = {achievement: @repeatable.id, triggeredBy: user.id, collection: 'users'}
[res, body] = yield request.postAsync { url: eaURL, json }
expect(res.statusCode).toBe(201)
expect(body.earnedPoints).toBe(10)
yield user.update({simulatedBy: 30})
[res, body] = yield request.postAsync { url: eaURL, json }
expect(res.statusCode).toBe(201)
expect(body.earnedPoints).toBe(20) # this is kinda weird, TODO: just return total amounts
done()
it 'ensures the user has the rewards they earned', utils.wrap (done) ->
user = yield utils.initUser()
yield utils.loginUser(user)
# get the User the unlockable achievement, check they got their reward
session = new LevelSession({
permissions: simplePermissions
creator: user._id
level: original: 'dungeon-arena'
})
yield session.save()
json = {achievement: @unlockable.id, triggeredBy: session._id, collection: 'level.sessions'}
[res, body] = yield request.postAsync { url: eaURL, json }
user = yield User.findById(user.id)
expect(user.get('earned').levels[0]).toBe(lockedLevelID)
# mess with the user's earned levels, make sure they don't have it anymore
yield user.update({$unset: {earned:1}})
user = yield User.findById(user.id)
expect(user.get('earned')).toBeUndefined()
# hit the endpoint again, make sure the level was restored
[res, body] = yield request.postAsync { url: eaURL, json }
user = yield User.findById(user.id)
expect(user.get('earned').levels[0]).toBe(lockedLevelID)
done()
it 'updates the user\'s gems if the achievement gems changed', utils.wrap (done) ->
user = yield utils.initUser()
yield utils.loginUser(user)
# get the User the unlockable achievement, check they got their reward
session = new LevelSession({
permissions: simplePermissions
creator: user._id
level: original: 'dungeon-arena'
})
yield session.save()
json = {achievement: @unlockable.id, triggeredBy: session._id, collection: 'level.sessions'}
[res, body] = yield request.postAsync { url: eaURL, json }
user = yield User.findById(user.id)
expect(user.get('earned').levels[0]).toBe(lockedLevelID)
# change the achievement
yield @unlockable.update({ $set: { 'rewards.gems': 100 } })
# hit the endpoint again, make sure gems were updated
[res, body] = yield request.postAsync { url: eaURL, json }
user = yield User.findById(user.id)
expect(user.get('earned').gems).toBe(100)
done()
describe 'automatically achieving achievements', -> describe 'automatically achieving achievements', ->
beforeEach addAllAchievements beforeEach addAllAchievements
@ -193,7 +265,7 @@ describe 'automatically achieving achievements', ->
it 'happens when an object\'s properties meet achievement goals', utils.wrap (done) -> it 'happens when an object\'s properties meet achievement goals', utils.wrap (done) ->
# load achievements on server # load achievements on server
@achievements = yield Achievement.loadAchievements() @achievements = yield Achievement.loadAchievements()
expect(@achievements.length).toBe(2) expect(@achievements.users.length).toBe(2)
loadedAchievements = Achievement.getLoadedAchievements() loadedAchievements = Achievement.getLoadedAchievements()
expect(Object.keys(loadedAchievements).length).toBe(1) expect(Object.keys(loadedAchievements).length).toBe(1)

View file

@ -13,6 +13,10 @@ facebook = require '../../../server/lib/facebook'
gplus = require '../../../server/lib/gplus' gplus = require '../../../server/lib/gplus'
sendwithus = require '../../../server/sendwithus' sendwithus = require '../../../server/sendwithus'
Promise = require 'bluebird' Promise = require 'bluebird'
Achievement = require '../../../server/models/Achievement'
EarnedAchievement = require '../../../server/models/EarnedAchievement'
LevelSession = require '../../../server/models/LevelSession'
mongoose = require 'mongoose'
describe 'POST /db/user', -> describe 'POST /db/user', ->
@ -832,7 +836,7 @@ describe 'POST /db/user/:handle/signup-with-facebook', ->
beforeEach utils.wrap (done) -> beforeEach utils.wrap (done) ->
yield utils.clearModels([User]) yield utils.clearModels([User])
yield new Promise((resolve) -> setTimeout(resolve, 10)) yield new Promise((resolve) -> setTimeout(resolve, 50))
done() done()
it 'signs up the user with the facebookID and sends welcome emails', utils.wrap (done) -> it 'signs up the user with the facebookID and sends welcome emails', utils.wrap (done) ->
@ -921,7 +925,7 @@ describe 'POST /db/user/:handle/signup-with-gplus', ->
beforeEach utils.wrap (done) -> beforeEach utils.wrap (done) ->
yield utils.clearModels([User]) yield utils.clearModels([User])
yield new Promise((resolve) -> setTimeout(resolve, 10)) yield new Promise((resolve) -> setTimeout(resolve, 50))
done() done()
it 'signs up the user with the gplusID and sends welcome emails', utils.wrap (done) -> it 'signs up the user with the gplusID and sends welcome emails', utils.wrap (done) ->
@ -1032,3 +1036,55 @@ describe 'POST /db/user/:handle/deteacher', ->
teacher = yield User.findById(teacher.id) teacher = yield User.findById(teacher.id)
expect(teacher.get('role')).toBeUndefined() expect(teacher.get('role')).toBeUndefined()
done() done()
describe 'POST /db/user/:handle/check-for-new-achievements', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels [Achievement, EarnedAchievement, LevelSession, User]
Achievement.resetAchievements()
done()
it 'finds new achievements and awards them to the user', utils.wrap (done) ->
user = yield utils.initUser({points: 100})
yield utils.loginUser(user)
url = utils.getURL("/db/user/#{user.id}/check-for-new-achievement")
json = true
[res, body] = yield request.postAsync({ url, json })
earned = yield EarnedAchievement.count()
expect(earned).toBe(0)
achievementURL = getURL('/db/achievement')
achievementJSON = {
collection: 'users'
query: {'points': {$gt: 50}}
userField: '_id'
recalculable: true
worth: 75
rewards: {
gems: 50
levels: [new mongoose.Types.ObjectId().toString()]
}
name: 'Dungeon Arena Started'
description: 'Started playing Dungeon Arena.'
related: 'a'
}
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
[res, body] = yield request.postAsync { uri: achievementURL, json: achievementJSON }
achievementID = body._id
expect(res.statusCode).toBe(201)
user = yield User.findById(user.id)
expect(user.get('rewards')).toBeUndefined()
yield utils.loginUser(user)
[res, body] = yield request.postAsync({ url, json })
expect(body.points).toBe(175)
earned = yield EarnedAchievement.count()
expect(earned).toBe(1)
expect(body.lastAchievementChecked).toBe(achievementID)
done()

View file

@ -151,23 +151,49 @@ describe 'CocoModel', ->
describe 'updateI18NCoverage', -> describe 'updateI18NCoverage', ->
class FlexibleClass extends CocoModel class FlexibleClass extends CocoModel
@className: 'Flexible' @className: 'Flexible'
@schema: {} @schema: {
type: 'object'
properties: {
name: { type: 'string' }
description: { type: 'string' }
innerObject: {
type: 'object'
properties: {
name: { type: 'string' }
i18n: { type: 'object', format: 'i18n', props: ['name']}
}
}
i18n: { type: 'object', format: 'i18n', props: ['description', 'name', 'prop1']}
}
}
it 'only includes languages for which all objects include a translation', -> it 'only includes languages for which all objects include a translation', ->
m = new FlexibleClass({ m = new FlexibleClass({
i18n: { es: {}, fr: {} } i18n: { es: { name: '+', description: '+' }, fr: { name: '+', description: '+' } }
prop1: 1 name: 'Name'
prop2: 'string' description: 'Description'
prop3: true
innerObject: { innerObject: {
i18n: { es: {}, de: {}, fr: {} } i18n: { es: { name: '+' }, de: { name: '+' }, fr: {} }
prop4: [ name: 'Name'
{
i18n: { es: {} }
}
]
} }
}) })
m.updateI18NCoverage() m.updateI18NCoverage()
expect(JSON.stringify(m.get('i18nCoverage'))).toBe('["es"]') expect(_.isEqual(m.get('i18nCoverage'), ['es'])).toBe(true)
it 'ignores objects for which there is nothing to translate', ->
m = new FlexibleClass()
m.set({
name: 'Name'
i18n: {
'-': {'-':'-'}
'es': {name: 'Name in Spanish'}
}
innerObject: {
i18n: { '-': {'-':'-'} }
}
})
m.updateI18NCoverage()
expect(_.isEqual(m.get('i18nCoverage'), ['es'])).toBe(true)