mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-11 16:21:08 -05:00
Merge branch 'master' into production
This commit is contained in:
commit
b52008f19b
28 changed files with 600 additions and 253 deletions
|
@ -9,8 +9,7 @@
|
|||
|
||||
CodeCombat is a multiplayer programming game for learning how to code.
|
||||
**See the [Archmage (coder) developer wiki](../../wiki/Archmage-Home) for a dev
|
||||
setup guide, extensive documentation, and much more. Every new person that wants
|
||||
to start contributing the project coding should start there.**
|
||||
setup guide, extensive documentation, and much more to get started hacking!**
|
||||
|
||||
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
|
||||
|
|
151
app/assets/markdown/getting-started.md
Normal file
151
app/assets/markdown/getting-started.md
Normal 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 you’ve 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, you’ll 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 you’ve 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, you’ll see your new [Teacher Dashboard](/teachers/classes). From here, you’ll be able to create classes and monitor your student’s 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 it’s 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. It’s 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 language’s syntax to another. However, JavaScript’s syntax is a little more difficult for beginners than Python.
|
||||
|
||||
##### STEP 3
|
||||
## Add Students
|
||||
Once you’ve created your class, you’ll 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.
|
||||
|
||||
That’s 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 don’t 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.
|
||||
|
||||
That’s 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, you’ll see their progress appear in the individual classroom pages. Any assigned courses and each student’s 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 course’s levels, and a green circle means that they’ve 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. You’ll 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 CodeCombat’s 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, you’ll 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!
|
|
@ -44,42 +44,53 @@ window.console ?=
|
|||
debug: ->
|
||||
console.debug ?= console.log # Needed for IE10 and earlier
|
||||
|
||||
Application = initialize: ->
|
||||
Router = require('core/Router')
|
||||
@isProduction = -> document.location.href.search('https?://localhost') is -1
|
||||
@isIPadApp = webkit?.messageHandlers? and navigator.userAgent?.indexOf('CodeCombat-iPad') isnt -1
|
||||
$('body').addClass 'ipad' if @isIPadApp
|
||||
$('body').addClass 'picoctf' if window.serverConfig.picoCTF
|
||||
if $.browser.msie and parseInt($.browser.version) is 10
|
||||
$("html").addClass("ie10")
|
||||
@tracker = new Tracker()
|
||||
@facebookHandler = new FacebookHandler()
|
||||
@gplusHandler = new GPlusHandler()
|
||||
@githubHandler = new GitHubHandler()
|
||||
@moduleLoader = new ModuleLoader()
|
||||
@moduleLoader.loadLanguage(me.get('preferredLanguage', true))
|
||||
$(document).bind 'keydown', preventBackspace
|
||||
preload(COMMON_FILES)
|
||||
CocoModel.pollAchievements()
|
||||
$.i18n.init {
|
||||
lng: me.get('preferredLanguage', true)
|
||||
fallbackLng: 'en'
|
||||
resStore: locale
|
||||
useDataAttrOptions: true
|
||||
#debug: true
|
||||
#sendMissing: true
|
||||
#sendMissingTo: 'current'
|
||||
#resPostPath: '/languages/add/__lng__/__ns__'
|
||||
}, (t) =>
|
||||
@router = new Router()
|
||||
onIdleChanged = (to) => => Backbone.Mediator.publish 'application:idle-changed', idle: @userIsIdle = to
|
||||
@idleTracker = new Idle
|
||||
onAway: onIdleChanged true
|
||||
onAwayBack: onIdleChanged false
|
||||
onHidden: onIdleChanged true
|
||||
onVisible: onIdleChanged false
|
||||
awayTimeout: 5 * 60 * 1000
|
||||
@idleTracker.start()
|
||||
Application = {
|
||||
initialize: ->
|
||||
Router = require('core/Router')
|
||||
@isProduction = -> document.location.href.search('https?://localhost') is -1
|
||||
@isIPadApp = webkit?.messageHandlers? and navigator.userAgent?.indexOf('CodeCombat-iPad') isnt -1
|
||||
$('body').addClass 'ipad' if @isIPadApp
|
||||
$('body').addClass 'picoctf' if window.serverConfig.picoCTF
|
||||
if $.browser.msie and parseInt($.browser.version) is 10
|
||||
$("html").addClass("ie10")
|
||||
@tracker = new Tracker()
|
||||
@facebookHandler = new FacebookHandler()
|
||||
@gplusHandler = new GPlusHandler()
|
||||
@githubHandler = new GitHubHandler()
|
||||
@moduleLoader = new ModuleLoader()
|
||||
@moduleLoader.loadLanguage(me.get('preferredLanguage', true))
|
||||
$(document).bind 'keydown', preventBackspace
|
||||
preload(COMMON_FILES)
|
||||
CocoModel.pollAchievements()
|
||||
# @checkForNewAchievement() # TODO: Enable once thoroughly tested
|
||||
$.i18n.init {
|
||||
lng: me.get('preferredLanguage', true)
|
||||
fallbackLng: 'en'
|
||||
resStore: locale
|
||||
useDataAttrOptions: true
|
||||
#debug: true
|
||||
#sendMissing: true
|
||||
#sendMissingTo: 'current'
|
||||
#resPostPath: '/languages/add/__lng__/__ns__'
|
||||
}, (t) =>
|
||||
@router = new Router()
|
||||
onIdleChanged = (to) => => Backbone.Mediator.publish 'application:idle-changed', idle: @userIsIdle = to
|
||||
@idleTracker = new Idle
|
||||
onAway: onIdleChanged true
|
||||
onAwayBack: onIdleChanged false
|
||||
onHidden: onIdleChanged true
|
||||
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
|
||||
window.application = Application
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
ffa: "Free for all students"
|
||||
lesson_time: "Lesson time:"
|
||||
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."
|
||||
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."
|
||||
|
@ -129,6 +129,7 @@
|
|||
faqs: "FAQs"
|
||||
help_pref: "Need help? Email"
|
||||
help_suff: "and we'll get in touch!"
|
||||
resource_hub: "Resource Hub"
|
||||
|
||||
modal:
|
||||
cancel: "Cancel"
|
||||
|
|
|
@ -416,11 +416,7 @@ class CocoModel extends Backbone.Model
|
|||
# use it to determine what properties actually need to be translated
|
||||
props = workingSchema.props or []
|
||||
props = (prop for prop in props when parentData[prop])
|
||||
#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 unless props.length
|
||||
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
|
||||
|
|
|
@ -368,6 +368,11 @@ module.exports = class User extends CocoModel
|
|||
options.url = _.result(@, 'url') + '/deteacher'
|
||||
options.type = 'POST'
|
||||
@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,
|
||||
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
|
||||
|
|
|
@ -344,6 +344,7 @@ _.extend UserSchema.properties,
|
|||
schoolName: {type: 'string'}
|
||||
role: {type: 'string', enum: ["God", "advisor", "parent", "principal", "student", "superintendent", "teacher", "technology coordinator"]}
|
||||
birthday: c.stringDate({title: "Birthday"})
|
||||
lastAchievementChecked: c.objectId({ name: 'Last Achievement Checked' })
|
||||
|
||||
c.extendBasicProperties UserSchema, 'user'
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
.print, .print p
|
||||
text-align: center
|
||||
font-size: .75em
|
||||
// text-transform: uppercase
|
||||
@media print
|
||||
display: none
|
||||
|
||||
|
@ -17,7 +16,7 @@
|
|||
.back-to-top
|
||||
text-transform: none
|
||||
@media print
|
||||
display: none
|
||||
display: none
|
||||
|
||||
.lesson-plans
|
||||
max-width: 900px
|
||||
|
@ -44,6 +43,8 @@
|
|||
padding: 0
|
||||
font-family: Open Sans
|
||||
font-weight: 200
|
||||
@media print
|
||||
font-size: 2em
|
||||
|
||||
// module header //
|
||||
h2
|
||||
|
@ -70,21 +71,25 @@
|
|||
margin: 0
|
||||
|
||||
// make it so the page breaks before each module
|
||||
h5[id^="module"]
|
||||
h5[id^="module"], h5[id^="step"]
|
||||
border-top: 1px solid #666
|
||||
margin: 30px 0 0 0
|
||||
padding: 20px 0 0 0
|
||||
@media print
|
||||
page-break-before: always
|
||||
|
||||
h5[id="step-1"]
|
||||
border-top: none !important
|
||||
@media print
|
||||
page-break-before: avoid
|
||||
.back-to-top
|
||||
display: none
|
||||
|
||||
h6
|
||||
font-style: italic
|
||||
font-weight: 200
|
||||
text-align: right
|
||||
|
||||
img
|
||||
width: 100%
|
||||
|
||||
table, tr, td, th
|
||||
border: 2px solid #f8f8f8
|
||||
border-radius: 5px
|
||||
|
@ -107,3 +112,16 @@
|
|||
margin: 0 0 0 0px
|
||||
padding: 0 60px 0 50px
|
||||
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
|
||||
|
|
|
@ -105,7 +105,7 @@ mixin accountLinks
|
|||
li
|
||||
a(href="/teachers/classes", data-i18n="nav.teachers")
|
||||
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
|
||||
a(href="/teachers/demo", data-i18n="teachers_quote.title")
|
||||
|
||||
|
|
|
@ -269,7 +269,7 @@ block content
|
|||
.clearfix
|
||||
.text-center
|
||||
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")
|
||||
|
||||
.testimonials-rows
|
||||
|
|
|
@ -200,10 +200,7 @@ if view.showAds()
|
|||
h1#campaign-status.picoctf-hide
|
||||
.campaign-status-background
|
||||
.campaign-name
|
||||
- var fullName = i18n(campaign.attributes, 'fullName')
|
||||
if (me.get('preferredLanguage', true) || 'en-US').split('-')[0] == 'en' || fullName != campaign.get('fullName')
|
||||
// We have a translation.
|
||||
span= fullName
|
||||
span= i18n(campaign.attributes, 'fullName')
|
||||
.levels-completed
|
||||
span= levelsCompleted
|
||||
| /
|
||||
|
|
|
@ -14,9 +14,8 @@ block content
|
|||
h4(data-i18n="teacher.getting_started")
|
||||
ul
|
||||
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.spl [PDF]
|
||||
p(data-i18n="teacher.teacher_getting_started_desc")
|
||||
li
|
||||
a(href="http://files.codecombat.com/docs/resources/StudentQuickStartGuide.pdf" target="blank")
|
||||
|
|
|
@ -41,10 +41,12 @@ module.exports = class LadderView extends RootView
|
|||
|
||||
initialize: (options, @levelID, @leagueType, @leagueID) ->
|
||||
@level = @supermodel.loadModel(new Level(_id: @levelID)).model
|
||||
@level.once 'sync', =>
|
||||
onLoaded = =>
|
||||
return if @destroyed
|
||||
@levelDescription = marked(@level.get('description')) if @level.get('description')
|
||||
@teams = teamDataFromLevel @level
|
||||
|
||||
if @level.loaded then onLoaded() else @level.once('sync', onLoaded)
|
||||
@sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model
|
||||
@winners = require('./tournament_results')[@levelID]
|
||||
|
||||
|
|
|
@ -371,8 +371,8 @@ module.exports = class CampaignView extends RootView
|
|||
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.
|
||||
level.nextLevels = (reward.level for reward in level.rewards ? [] when reward.level)
|
||||
break if foundNext = findNextLevel(level.nextLevels, true) # Check practice levels first
|
||||
break if foundNext = findNextLevel(level.nextLevels, false)
|
||||
foundNext = findNextLevel(level.nextLevels, true) unless foundNext # Check practice levels first
|
||||
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'
|
||||
orderedLevels[0].next = true
|
||||
|
|
|
@ -257,7 +257,7 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
|
|||
for (const activity of results.data) {
|
||||
if (activity.id === firstMailActivity.id) 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;
|
||||
break;
|
||||
}
|
||||
|
@ -269,7 +269,6 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
|
|||
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}`);
|
||||
sendMail(firstMailActivity.to[0], lead.id, firstMailActivity.contact_id, template, userApiKeyMap[firstMailActivity.user_id], 0, (err) => {
|
||||
if (err) return done(err);
|
||||
|
||||
|
@ -302,7 +301,7 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
|
|||
}
|
||||
else {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
@ -387,7 +386,6 @@ function sendSecondFollowupMails(done) {
|
|||
function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) {
|
||||
// Check for activity since second auto mail and status update
|
||||
// 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 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) => {
|
||||
|
|
|
@ -128,23 +128,6 @@ function activity2Html(activity) {
|
|||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -506,20 +506,11 @@ class CocoLead {
|
|||
if (!currentCustom['Lead Origin']) {
|
||||
putData['custom.Lead Origin'] = this.getLeadOrigin();
|
||||
}
|
||||
|
||||
for (const email in this.contacts) {
|
||||
const props = this.contacts[email].trial.properties;
|
||||
if (props) {
|
||||
let haveNcesData = false;
|
||||
for (const prop in props) {
|
||||
if (/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)) {
|
||||
if (!currentCustom[`demo_${prop}`] && (commonTrialProperties.indexOf(prop) >= 0 || /nces_/ig.test(prop))) {
|
||||
putData[`custom.demo_${prop}`] = props[prop];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,10 +15,9 @@ class EarnedAchievementHandler extends Handler
|
|||
|
||||
editableProperties: ['notified']
|
||||
|
||||
# Don't allow POSTs or anything yet
|
||||
hasAccess: (req) ->
|
||||
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) ->
|
||||
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)
|
||||
@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) ->
|
||||
query = { user: req.user._id+''}
|
||||
ids = req.query.achievementIDs
|
||||
|
|
30
server/middleware/earned-achievements.coffee
Normal file
30
server/middleware/earned-achievements.coffee
Normal 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}))
|
|
@ -7,6 +7,7 @@ module.exports =
|
|||
contact: require './contact'
|
||||
courseInstances: require './course-instances'
|
||||
courses: require './courses'
|
||||
earnedAchievements: require './earned-achievements'
|
||||
files: require './files'
|
||||
healthcheck: require './healthcheck'
|
||||
levels: require './levels'
|
||||
|
|
|
@ -16,7 +16,10 @@ CourseInstance = require '../models/CourseInstance'
|
|||
facebook = require '../lib/facebook'
|
||||
gplus = require '../lib/gplus'
|
||||
TrialRequest = require '../models/TrialRequest'
|
||||
Achievement = require '../models/Achievement'
|
||||
EarnedAchievement = require '../models/EarnedAchievement'
|
||||
log = require 'winston'
|
||||
LocalMongo = require '../../app/lib/LocalMongo'
|
||||
|
||||
module.exports =
|
||||
fetchByGPlusID: wrap (req, res, next) ->
|
||||
|
@ -254,3 +257,45 @@ module.exports =
|
|||
yield user.update({ $unset: {role: ''}})
|
||||
user.set('role', undefined)
|
||||
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()))
|
||||
|
|
|
@ -6,6 +6,7 @@ plugins = require('../plugins/plugins')
|
|||
AchievablePlugin = require '../plugins/achievements'
|
||||
TreemaUtils = require '../../bower_components/treema/treema-utils.js'
|
||||
config = require '../../server_config'
|
||||
co = require 'co'
|
||||
|
||||
# `pre` and `post` are not called for update operations executed directly on the database,
|
||||
# including `Model.update`,`.findByIdAndUpdate`,`.findOneAndUpdate`, `.findOneAndRemove`,and `.findByIdAndRemove`.order
|
||||
|
@ -53,27 +54,29 @@ AchievementSchema.statics.achievementCollections = {}
|
|||
|
||||
# Reloads all achievements into memory.
|
||||
# TODO might want to tweak this to only load new achievements
|
||||
AchievementSchema.statics.loadAchievements = (done) ->
|
||||
AchievementSchema.statics.loadAchievements = co.wrap ->
|
||||
AchievementSchema.statics.resetAchievements()
|
||||
t0 = new Date()
|
||||
Achievement = require('./Achievement')
|
||||
query = Achievement.find({collection: {$ne: 'level.sessions'}})
|
||||
query.exec (err, docs) ->
|
||||
_.each docs, (achievement) ->
|
||||
collection = achievement.get 'collection'
|
||||
AchievementSchema.statics.achievementCollections[collection] ?= []
|
||||
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..."
|
||||
else
|
||||
AchievementSchema.statics.achievementCollections[collection].push achievement
|
||||
unless achievement.get('query')
|
||||
log.error "Uh oh, there is an achievement with an empty query: #{achievement}"
|
||||
done?(AchievementSchema.statics.achievementCollections) # TODO: Return with err as first parameter
|
||||
achievements = yield Achievement.find({collection: {$ne: 'level.sessions'}})
|
||||
return if t0 < @lastReset # if a test has run resetAchievements during the fetch, abort
|
||||
for achievement in achievements
|
||||
collection = achievement.get 'collection'
|
||||
AchievementSchema.statics.achievementCollections[collection] ?= []
|
||||
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..."
|
||||
else
|
||||
AchievementSchema.statics.achievementCollections[collection].push achievement
|
||||
unless achievement.get('query')
|
||||
log.error "Uh oh, there is an achievement with an empty query: #{achievement}"
|
||||
return AchievementSchema.statics.achievementCollections
|
||||
|
||||
AchievementSchema.statics.getLoadedAchievements = ->
|
||||
AchievementSchema.statics.achievementCollections
|
||||
|
||||
AchievementSchema.statics.resetAchievements = ->
|
||||
delete AchievementSchema.statics.achievementCollections[collection] for collection of AchievementSchema.statics.achievementCollections
|
||||
@lastReset = new Date()
|
||||
|
||||
AchievementSchema.statics.editableProperties = [
|
||||
'name'
|
||||
|
|
|
@ -2,6 +2,7 @@ mongoose = require 'mongoose'
|
|||
jsonschema = require '../../app/schemas/models/earned_achievement'
|
||||
util = require '../../app/core/utils'
|
||||
log = require 'winston'
|
||||
co = require 'co'
|
||||
|
||||
EarnedAchievementSchema = new mongoose.Schema({
|
||||
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, 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
|
||||
achievement: achievement._id.toHexString()
|
||||
achievementName: achievement.get 'name'
|
||||
earnedRewards: achievement.get 'rewards'
|
||||
}
|
||||
|
||||
pointWorth = achievement.get('worth') ? 10
|
||||
gemWorth = achievement.get('rewards')?.gems ? 0
|
||||
earnedPoints = 0
|
||||
earnedGems = 0
|
||||
earnedDoc = null
|
||||
|
||||
wrapUp = (earnedAchievementDoc) ->
|
||||
# Update user's experience points
|
||||
isRepeatable = achievement.get('proportionalTo')?
|
||||
|
||||
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}}
|
||||
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: mongoose.Types.ObjectId(userID)}, update, {}, (err, result) ->
|
||||
log.error err if err?
|
||||
done?(earnedAchievementDoc)
|
||||
yield User.update({_id: mongoose.Types.ObjectId(userID)}, update, {})
|
||||
|
||||
isRepeatable = achievement.get('proportionalTo')?
|
||||
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"
|
||||
return earnedDoc
|
||||
|
||||
module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema)
|
||||
|
|
|
@ -42,7 +42,6 @@ AchievablePlugin = (schema, options) ->
|
|||
alreadyAchieved = if isNew then false else LocalMongo.matchesQuery unchangedCopy, query
|
||||
newlyAchieved = LocalMongo.matchesQuery(docObj, query)
|
||||
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, unchangedCopy)
|
||||
EarnedAchievement.createForAchievement(achievement, doc, {originalDocObj: unchangedCopy})
|
||||
|
||||
module.exports = AchievablePlugin
|
||||
|
|
|
@ -96,7 +96,10 @@ module.exports.setup = (app) ->
|
|||
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/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'
|
||||
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)
|
||||
|
@ -115,7 +118,8 @@ module.exports.setup = (app) ->
|
|||
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/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/-/active-schools', mw.auth.checkHasPermission(['admin']), mw.prepaids.fetchActiveSchools)
|
||||
app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post)
|
||||
|
|
|
@ -7,12 +7,15 @@ LevelSession = require '../../../server/models/LevelSession'
|
|||
User = require '../../../server/models/User'
|
||||
request = require '../request'
|
||||
EarnedAchievementHandler = require '../../../server/handlers/earned_achievement_handler'
|
||||
mongoose = require 'mongoose'
|
||||
|
||||
url = getURL('/db/achievement')
|
||||
|
||||
|
||||
# Fixtures
|
||||
|
||||
lockedLevelID = new mongoose.Types.ObjectId().toString()
|
||||
|
||||
unlockable =
|
||||
name: 'Dungeon Arena Started'
|
||||
description: 'Started playing Dungeon Arena.'
|
||||
|
@ -22,6 +25,9 @@ unlockable =
|
|||
userField: 'creator'
|
||||
recalculable: true
|
||||
related: 'a'
|
||||
rewards: {
|
||||
levels: [lockedLevelID]
|
||||
}
|
||||
|
||||
unlockable2 = _.clone unlockable
|
||||
unlockable2.name = 'This one is obsolete'
|
||||
|
@ -163,8 +169,9 @@ describe 'DELETE /db/achievement/:handle', ->
|
|||
|
||||
describe 'POST /db/earned_achievement', ->
|
||||
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({
|
||||
permissions: simplePermissions
|
||||
creator: @admin._id
|
||||
|
@ -174,7 +181,7 @@ describe 'POST /db/earned_achievement', ->
|
|||
earnedAchievements = yield EarnedAchievement.find()
|
||||
expect(earnedAchievements.length).toBe(0)
|
||||
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(body.achievement).toBe @unlockable.id
|
||||
expect(body.user).toBe @admin.id
|
||||
|
@ -185,7 +192,72 @@ describe 'POST /db/earned_achievement', ->
|
|||
earnedAchievements = yield EarnedAchievement.find()
|
||||
expect(earnedAchievements.length).toBe(1)
|
||||
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', ->
|
||||
beforeEach addAllAchievements
|
||||
|
@ -193,7 +265,7 @@ describe 'automatically achieving achievements', ->
|
|||
it 'happens when an object\'s properties meet achievement goals', utils.wrap (done) ->
|
||||
# load achievements on server
|
||||
@achievements = yield Achievement.loadAchievements()
|
||||
expect(@achievements.length).toBe(2)
|
||||
expect(@achievements.users.length).toBe(2)
|
||||
loadedAchievements = Achievement.getLoadedAchievements()
|
||||
expect(Object.keys(loadedAchievements).length).toBe(1)
|
||||
|
||||
|
|
|
@ -13,6 +13,10 @@ facebook = require '../../../server/lib/facebook'
|
|||
gplus = require '../../../server/lib/gplus'
|
||||
sendwithus = require '../../../server/sendwithus'
|
||||
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', ->
|
||||
|
||||
|
@ -832,7 +836,7 @@ describe 'POST /db/user/:handle/signup-with-facebook', ->
|
|||
|
||||
beforeEach utils.wrap (done) ->
|
||||
yield utils.clearModels([User])
|
||||
yield new Promise((resolve) -> setTimeout(resolve, 10))
|
||||
yield new Promise((resolve) -> setTimeout(resolve, 50))
|
||||
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) ->
|
||||
yield utils.clearModels([User])
|
||||
yield new Promise((resolve) -> setTimeout(resolve, 10))
|
||||
yield new Promise((resolve) -> setTimeout(resolve, 50))
|
||||
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)
|
||||
expect(teacher.get('role')).toBeUndefined()
|
||||
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()
|
||||
|
|
|
@ -151,23 +151,49 @@ describe 'CocoModel', ->
|
|||
describe 'updateI18NCoverage', ->
|
||||
class FlexibleClass extends CocoModel
|
||||
@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', ->
|
||||
m = new FlexibleClass({
|
||||
i18n: { es: {}, fr: {} }
|
||||
prop1: 1
|
||||
prop2: 'string'
|
||||
prop3: true
|
||||
i18n: { es: { name: '+', description: '+' }, fr: { name: '+', description: '+' } }
|
||||
name: 'Name'
|
||||
description: 'Description'
|
||||
innerObject: {
|
||||
i18n: { es: {}, de: {}, fr: {} }
|
||||
prop4: [
|
||||
{
|
||||
i18n: { es: {} }
|
||||
}
|
||||
]
|
||||
i18n: { es: { name: '+' }, de: { name: '+' }, fr: {} }
|
||||
name: 'Name'
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue