diff --git a/README.md b/README.md index 0e451ff48..bde2f934e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/assets/markdown/getting-started.md b/app/assets/markdown/getting-started.md new file mode 100644 index 000000000..246018fd1 --- /dev/null +++ b/app/assets/markdown/getting-started.md @@ -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. + +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.” + +create-new-class-modal + + + +### 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. + +add-students +_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. + +create-student-account +_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. + +create-student-account +_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. + +class-code-example +_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. + +course-guides +_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. + +resource-hub +_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 + +student-overview +_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. + +student-progress +_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 + +bulk-assign + +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! diff --git a/app/core/application.coffee b/app/core/application.coffee index 79efcb178..7fcbf5b81 100644 --- a/app/core/application.coffee +++ b/app/core/application.coffee @@ -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 diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 1991b69fc..ed66d92a4 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -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" diff --git a/app/models/CocoModel.coffee b/app/models/CocoModel.coffee index d6492218a..8a8e2f341 100644 --- a/app/models/CocoModel.coffee +++ b/app/models/CocoModel.coffee @@ -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 diff --git a/app/models/User.coffee b/app/models/User.coffee index e9705f3db..35f62b3ae 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -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 diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 555234b98..3931d641b 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -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' diff --git a/app/styles/teachers/markdown-resource-view.sass b/app/styles/teachers/markdown-resource-view.sass index e81bc8e14..214e32a11 100644 --- a/app/styles/teachers/markdown-resource-view.sass +++ b/app/styles/teachers/markdown-resource-view.sass @@ -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 diff --git a/app/templates/base-flat.jade b/app/templates/base-flat.jade index 737c5b376..1b77bb319 100644 --- a/app/templates/base-flat.jade +++ b/app/templates/base-flat.jade @@ -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") diff --git a/app/templates/new-home-view.jade b/app/templates/new-home-view.jade index 927915c5b..d81ae6bc2 100644 --- a/app/templates/new-home-view.jade +++ b/app/templates/new-home-view.jade @@ -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 diff --git a/app/templates/play/campaign-view.jade b/app/templates/play/campaign-view.jade index 816b767f3..a0d3cb7d2 100644 --- a/app/templates/play/campaign-view.jade +++ b/app/templates/play/campaign-view.jade @@ -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 | / diff --git a/app/templates/teachers/resource-hub-view.jade b/app/templates/teachers/resource-hub-view.jade index c4f7c9464..5adcd7dc8 100644 --- a/app/templates/teachers/resource-hub-view.jade +++ b/app/templates/teachers/resource-hub-view.jade @@ -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") diff --git a/app/views/ladder/LadderView.coffee b/app/views/ladder/LadderView.coffee index d192a892a..cc55b34fb 100644 --- a/app/views/ladder/LadderView.coffee +++ b/app/views/ladder/LadderView.coffee @@ -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] diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee index 98223d550..a897bb7e2 100644 --- a/app/views/play/CampaignView.coffee +++ b/app/views/play/CampaignView.coffee @@ -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 diff --git a/scripts/followupCloseIoLeads.js b/scripts/followupCloseIoLeads.js index 1334c605a..988741dd9 100644 --- a/scripts/followupCloseIoLeads.js +++ b/scripts/followupCloseIoLeads.js @@ -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) => { diff --git a/scripts/sendExportedLeads.js b/scripts/sendExportedLeads.js index 272c510cc..c66a4b14a 100644 --- a/scripts/sendExportedLeads.js +++ b/scripts/sendExportedLeads.js @@ -128,23 +128,6 @@ function activity2Html(activity) { } html += "

"; } - else if (activity._type === 'Email') { - html += `

${activity._type}

`; - if (activity.subject) html += `

${activity.subject}

`; - if (activity.date_updated) html += `
Updated: ${activity.date_updated}
`; - if (activity.opens_summary) html += `
${activity.opens_summary}
`; - if (activity.status) html += `
Status: ${activity.status}
`; - for (let email of activity.to) { - html += `
To: ${email}
`; - } - if (activity.sender) html += `
From: ${activity.sender}
`; - const lines = (activity.body_text || '').split('\n'); - html += "

"; - for (const line of lines) { - html += `

${line}
`; - } - html += "

"; - } return html; } diff --git a/scripts/updateCloseIoLeads.js b/scripts/updateCloseIoLeads.js index a67f53bb2..6e6ed2569 100644 --- a/scripts/updateCloseIoLeads.js +++ b/scripts/updateCloseIoLeads.js @@ -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]; } } diff --git a/server/handlers/earned_achievement_handler.coffee b/server/handlers/earned_achievement_handler.coffee index 4a609d5a9..04ae2ffaf 100644 --- a/server/handlers/earned_achievement_handler.coffee +++ b/server/handlers/earned_achievement_handler.coffee @@ -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 diff --git a/server/middleware/earned-achievements.coffee b/server/middleware/earned-achievements.coffee new file mode 100644 index 000000000..69ddd5350 --- /dev/null +++ b/server/middleware/earned-achievements.coffee @@ -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})) diff --git a/server/middleware/index.coffee b/server/middleware/index.coffee index b057f60d3..65b7f89b6 100644 --- a/server/middleware/index.coffee +++ b/server/middleware/index.coffee @@ -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' diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index f18a457a6..8ce363e9b 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -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())) diff --git a/server/models/Achievement.coffee b/server/models/Achievement.coffee index 6d4322139..f302810b9 100644 --- a/server/models/Achievement.coffee +++ b/server/models/Achievement.coffee @@ -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' diff --git a/server/models/EarnedAchievement.coffee b/server/models/EarnedAchievement.coffee index 71d7278bb..14d07577e 100644 --- a/server/models/EarnedAchievement.coffee +++ b/server/models/EarnedAchievement.coffee @@ -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) diff --git a/server/plugins/achievements.coffee b/server/plugins/achievements.coffee index 16ad24f77..b130aaaee 100644 --- a/server/plugins/achievements.coffee +++ b/server/plugins/achievements.coffee @@ -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 diff --git a/server/routes/index.coffee b/server/routes/index.coffee index b2ab4ba2e..cfca0edb5 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -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) diff --git a/spec/server/functional/achievement.spec.coffee b/spec/server/functional/achievement.spec.coffee index a0a97e68e..af8b2c94c 100644 --- a/spec/server/functional/achievement.spec.coffee +++ b/spec/server/functional/achievement.spec.coffee @@ -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) diff --git a/spec/server/functional/user.spec.coffee b/spec/server/functional/user.spec.coffee index cac865d89..7d088b003 100644 --- a/spec/server/functional/user.spec.coffee +++ b/spec/server/functional/user.spec.coffee @@ -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() diff --git a/test/app/models/CocoModel.spec.coffee b/test/app/models/CocoModel.spec.coffee index e373b777d..2d35029ce 100644 --- a/test/app/models/CocoModel.spec.coffee +++ b/test/app/models/CocoModel.spec.coffee @@ -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) + +