diff --git a/app/collections/StripeCoupons.coffee b/app/collections/StripeCoupons.coffee new file mode 100644 index 000000000..4638b6f3f --- /dev/null +++ b/app/collections/StripeCoupons.coffee @@ -0,0 +1,7 @@ +StripeCoupon = require 'models/StripeCoupon' +CocoCollection = require 'collections/CocoCollection' + +module.exports = class StripeCoupons extends CocoCollection + model: StripeCoupon + url: '/stripe/coupons' + diff --git a/app/collections/Users.coffee b/app/collections/Users.coffee index 4fb75d666..e66dfc936 100644 --- a/app/collections/Users.coffee +++ b/app/collections/Users.coffee @@ -29,3 +29,9 @@ module.exports = class Users extends CocoCollection @remove @filter (user) -> user.get('deleted') true + + search: (term) -> + return @slice() unless term + term = term.toLowerCase() + return @filter (user) -> + user.broadName().toLowerCase().indexOf(term) > -1 or (user.get('email') ? '').indexOf(term) > -1 diff --git a/app/core/Router.coffee b/app/core/Router.coffee index 44bc54c6a..a6c42752b 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -73,8 +73,8 @@ module.exports = class CocoRouter extends Backbone.Router 'Courses': go('courses/CoursesView') # , { studentsOnly: true }) 'courses/students': redirect('/courses') 'courses/teachers': redirect('/teachers/classes') - 'courses/purchase': redirect('/teachers/enrollments') - 'courses/enroll(/:courseID)': redirect('/teachers/enrollments') + 'courses/purchase': redirect('/teachers/licenses') + 'courses/enroll(/:courseID)': redirect('/teachers/licenses') 'courses/update-account': go('courses/CoursesUpdateAccountView') 'courses/:classroomID': go('courses/ClassroomView') #, { studentsOnly: true }) 'courses/:courseID/:courseInstanceID': go('courses/CourseDetailsView') @@ -146,7 +146,8 @@ module.exports = class CocoRouter extends Backbone.Router 'teachers/classes/:classroomID': go('courses/TeacherClassView') #, { teachersOnly: true }) 'teachers/courses': go('courses/TeacherCoursesView') 'teachers/demo': go('teachers/RequestQuoteView') - 'teachers/enrollments': go('courses/EnrollmentsView') #, { teachersOnly: true }) + 'teachers/enrollments': redirect('/teachers/licenses') + 'teachers/licenses': go('courses/EnrollmentsView') #, { teachersOnly: true }) 'teachers/freetrial': go('teachers/RequestQuoteView') 'teachers/quote': go('teachers/RequestQuoteView') 'teachers/signup': -> @@ -159,6 +160,7 @@ module.exports = class CocoRouter extends Backbone.Router 'test(/*subpath)': go('TestView') 'user/:slugOrID': go('user/MainUserView') + 'user/:userID/verify/:verificationCode': go('user/EmailVerifiedView') '*name/': 'removeTrailingSlash' '*name': go('NotFoundView') diff --git a/app/core/auth.coffee b/app/core/auth.coffee index 43092d5d6..79c9f8ade 100644 --- a/app/core/auth.coffee +++ b/app/core/auth.coffee @@ -60,6 +60,12 @@ module.exports.logoutUser = -> res = $.post('/auth/logout', {}, callback) res.fail(genericFailure) +module.exports.sendRecoveryEmail = (email, options={}) -> + options = _.merge(options, + {method: 'POST', url: '/auth/reset', data: { email }} + ) + $.ajax(options) + onSetVolume = (e) -> return if e.volume is me.get('volume') me.set('volume', e.volume) diff --git a/app/core/contact.coffee b/app/core/contact.coffee index 2db72bc2e..b8677bbe3 100644 --- a/app/core/contact.coffee +++ b/app/core/contact.coffee @@ -1,10 +1,19 @@ -module.exports.sendContactMessage = (contactMessageObject, modal) -> - modal?.find('.sending-indicator').show() - jqxhr = $.post '/contact', contactMessageObject, (response) -> - return unless modal - modal.find('.sending-indicator').hide() - modal.find('#contact-message').val('Thanks!') - _.delay -> - modal.find('#contact-message').val('') - modal.modal 'hide' - , 1000 +module.exports = { + sendContactMessage: (contactMessageObject, modal) -> + # deprecated + modal?.find('.sending-indicator').show() + return $.post '/contact', contactMessageObject, (response) -> + return unless modal + modal.find('.sending-indicator').hide() + modal.find('#contact-message').val('Thanks!') + _.delay -> + modal.find('#contact-message').val('') + modal.modal 'hide' + , 1000 + + send: (options={}) -> + options.type = 'POST' + options.url = '/contact' + $.ajax(options) + +} diff --git a/app/core/treema-ext.coffee b/app/core/treema-ext.coffee index 7f1204181..f64f6f3cb 100644 --- a/app/core/treema-ext.coffee +++ b/app/core/treema-ext.coffee @@ -238,10 +238,8 @@ codeLanguages = javascript: 'ace/mode/javascript' coffeescript: 'ace/mode/coffee' python: 'ace/mode/python' - clojure: 'ace/mode/clojure' lua: 'ace/mode/lua' java: 'ace/mode/java' - io: 'ace/mode/text' class CodeLanguagesObjectTreema extends TreemaNode.nodeMap.object childPropertiesAvailable: -> diff --git a/app/core/utils.coffee b/app/core/utils.coffee index 6b7814f2e..72b007db3 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -267,9 +267,7 @@ module.exports.aceEditModes = aceEditModes = 'coffeescript': 'ace/mode/coffee' 'python': 'ace/mode/python' 'java': 'ace/mode/java' - 'clojure': 'ace/mode/clojure' 'lua': 'ace/mode/lua' - 'io': 'ace/mode/text' 'java': 'ace/mode/java' module.exports.initializeACE = (el, codeLanguage) -> @@ -294,13 +292,9 @@ module.exports.initializeACE = (el, codeLanguage) -> session.setNewLineMode 'unix' return editor -module.exports.capitalLanguages = capitalLanguages = +module.exports.capitalLanguages = capitalLanguages = 'javascript': 'JavaScript' 'coffeescript': 'CoffeeScript' 'python': 'Python' 'java': 'Java' - 'clojure': 'Clojure' 'lua': 'Lua' - 'io': 'Io' - - \ No newline at end of file diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index 8a95569a5..29a201235 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -126,8 +126,7 @@ module.exports = class LevelLoader extends CocoClass @sessionResource = @supermodel.loadModel(session, 'level_session', {cache: false}) @session = @sessionResource.model if @opponentSessionID - opponentURL = "/db/level.session/#{@opponentSessionID}" - opponentURL += "?interpret=true" if @spectateMode or utils.getQueryVariable 'esper' + opponentURL = "/db/level.session/#{@opponentSessionID}?interpret=true" opponentSession = new LevelSession().setURL opponentURL opponentSession.project = session.project if @headless @opponentSessionResource = @supermodel.loadModel(opponentSession, 'opponent_session', {cache: false}) @@ -158,6 +157,8 @@ module.exports = class LevelLoader extends CocoClass code[if session.get('team') is 'humans' then 'hero-placeholder' else 'hero-placeholder-1'].plan = uncompressed session.set 'code', code session.unset 'interpret' + if session.get('codeLanguage') in ['io', 'clojure'] + session.set 'codeLanguage', 'python' if session is @session @addSessionBrowserInfo session # hero-ladder games require the correct session team in level:loaded diff --git a/app/lib/aether_utils.coffee b/app/lib/aether_utils.coffee index 870c9330f..aeffc27d5 100644 --- a/app/lib/aether_utils.coffee +++ b/app/lib/aether_utils.coffee @@ -7,14 +7,6 @@ module.exports.createAetherOptions = (options) -> throw new Error 'Specify a function name to create an Aether instance' unless options.functionName throw new Error 'Specify a code language to create an Aether instance' unless options.codeLanguage - useInterpreter = options.useInterpreter - defaultToEsper = true #switch options.codeLanguage -# when 'python' then me.level() < 15 # Esper currently works well until using range() -# when 'javascript' then me.level() < 22 # Esper currently works well until using hero.myFn = function() pattern -# when 'lua' then me.level() < 10 # Functions don't work in Esper yet, can't play forest function levels -# when 'coffeescript' then false # CoffeeScript has a toNative error if it ever finishes plan(), and also @fn = -> pattern doesn't work -# when 'clojure' then false # No Clojure support - useInterpreter ?= !!utils.getQueryVariable 'esper', defaultToEsper aetherOptions = functionName: options.functionName protectAPI: not options.skipProtectAPI @@ -37,7 +29,7 @@ module.exports.createAetherOptions = (options) -> #functionParameters: # TODOOOOO executionLimit: 3 * 1000 * 1000 language: options.codeLanguage - useInterpreter: useInterpreter + useInterpreter: true parameters = functionParameters[options.functionName] unless parameters console.warn "Unknown method #{options.functionName}: please add function parameters to lib/aether_utils.coffee." diff --git a/app/lib/coursesHelper.coffee b/app/lib/coursesHelper.coffee index fd9a5b0e3..0866d95e7 100644 --- a/app/lib/coursesHelper.coffee +++ b/app/lib/coursesHelper.coffee @@ -11,9 +11,14 @@ module.exports = instance = courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id }) continue if not instance instance.numCompleted = 0 - instance.numStarted = 0 + instance.started = false levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true}) for userID in instance.get('members') + instance.started ||= _.any levels.models, (level) -> + return false if level.isLadder() + session = _.find classroom.sessions.models, (session) -> + session.get('creator') is userID and session.get('level').original is level.get('original') + session? levelCompletes = _.map levels.models, (level) -> return true if level.isLadder() #TODO: Hella slow! Do the mapping first! @@ -23,8 +28,6 @@ module.exports = session?.completed() if _.every levelCompletes instance.numCompleted += 1 - if _.any levelCompletes - instance.numStarted += 1 calculateEarliestIncomplete: (classroom, courses, courseInstances, students) -> # Loop through all the combinations of things, return the first one that somebody hasn't finished diff --git a/app/lib/simulator/Simulator.coffee b/app/lib/simulator/Simulator.coffee index e4918133f..473ea06af 100644 --- a/app/lib/simulator/Simulator.coffee +++ b/app/lib/simulator/Simulator.coffee @@ -443,7 +443,7 @@ module.exports = class Simulator extends CocoClass aether.transpile '' createAether: (methodName, method, useProtectAPI, codeLanguage) -> - aetherOptions = createAetherOptions functionName: methodName, codeLanguage: codeLanguage, skipProtectAPI: not useProtectAPI, useInterpreter: true + aetherOptions = createAetherOptions functionName: methodName, codeLanguage: codeLanguage, skipProtectAPI: not useProtectAPI return new Aether aetherOptions class SimulationTask diff --git a/app/lib/world/names.coffee b/app/lib/world/names.coffee index f2d398af7..ee446841d 100644 --- a/app/lib/world/names.coffee +++ b/app/lib/world/names.coffee @@ -145,21 +145,15 @@ module.exports.thangNames = thangNames = 'Ofgar' 'Randall' ] - 'Raven': [ + 'Raven Pet': [ # Animal - 'Baltimore' - 'Columbia' - 'Dawnstar' 'Nevermore' ] - 'Cougar': [ + 'Cougar Pet': [ # Animal - 'Guenhwyvar' 'Kitty' - 'Shasta' - 'Simbia' ] - 'Frog': [ + 'Frog Pet': [ # Animal 'Bighead' 'Bufo' diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 3515767d3..504663152 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -1,4 +1,4 @@ -module.exports = nativeDescription: "English", englishDescription: "English", translation: +module.exports = nativeDescription: "English", englishDescription: "English", translation: home: slogan: "Learn to Code by Playing a Game" no_ie: "CodeCombat does not run in Internet Explorer 8 or older. Sorry!" # Warning that only shows up in IE8 and older @@ -132,6 +132,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr help_suff: "and we'll get in touch!" modal: + cancel: "Cancel" close: "Close" okay: "Okay" @@ -645,9 +646,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr python_blurb: "Simple yet powerful, great for beginners and experts." javascript_blurb: "The language of the web. (Not the same as Java.)" coffeescript_blurb: "Nicer JavaScript syntax." - clojure_blurb: "A modern Lisp." lua_blurb: "Game scripting language." - io_blurb: "Simple but obscure." java_blurb: "(Subscriber Only) Android and enterprise." status: "Status" hero_type: "Type" @@ -861,7 +860,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr signup_with: "Sign up with:" connect_with: "Connect with:" conversion_warning: "WARNING: Your current account is a <em>Student Account</em>. Once you submit this form, your account will be updated to a Teacher Account." - learn_more_modal: "Teacher accounts on CodeCombat have the ability to monitor student progress, assign enrollments and manage classrooms. Teacher accounts cannot be a part of a classroom - if you are currently enrolled in a class using this account, you will no longer be able to access it once you update to a Teacher Account." + learn_more_modal: "Teacher accounts on CodeCombat have the ability to monitor student progress, assign licenses and manage classrooms. Teacher accounts cannot be a part of a classroom - if you are currently enrolled in a class using this account, you will no longer be able to access it once you update to a Teacher Account." # {change} create_account: "Create a Teacher Account" create_account_subtitle: "Get access to teacher-only tools for using CodeCombat in the classroom. <strong>Set up a class</strong>, add your students, and <strong>monitor their progress</strong>!" convert_account_title: "Update to Teacher Account" @@ -1115,13 +1114,13 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr get_free: "Get FREE course" enroll_paid: "Enroll Students in Paid Courses" you_have1: "You have" - you_have2: "unused paid enrollments" - use_one: "Use 1 paid enrollment for" - use_multiple: "Use paid enrollments for the following students:" + you_have2: "unused student licenses" # {change} + use_one: "Use 1 student license for" # {change} + use_multiple: "Use licenses for the following students:" # {change} already_enrolled: "already enrolled" licenses_remaining: "licenses remaining:" - insufficient_enrollments: "insufficient paid enrollments" - get_enrollments: "Get More Enrollments" + insufficient_enrollments: "insufficient student licenses" # {change} + get_enrollments: "Get More Licenses" # {change} change_language: "Change Course Language" keep_using: "Keep Using" switch_to: "Switch To" @@ -1130,7 +1129,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr back_courses: "Back to my courses" edit_details: "Edit class details" enrolled_courses: "enrolled in paid courses:" - purchase_enrollments: "Purchase Enrollments" + purchase_enrollments: "Purchase Student Licenses" # {change} remove_student: "remove student" assign: "Assign" to_assign: "to assign paid courses." @@ -1175,6 +1174,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr view_class: "view class" view_levels: "view levels" join_class: "Join A Class" + join_class_2: "Join class" ask_teacher_for_code: "Ask your teacher if you have a CodeCombat class code! If so, enter it below:" enter_c_code: "<Enter Class Code>" join: "Join" @@ -1201,10 +1201,10 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr return_to_course_man: "Return to course management." students_not_enrolled: "students not enrolled" total_all_classes: "Total Across All Classes" - how_many_enrollments: "How many additional paid enrollments do you need?" + how_many_enrollments: "How many additional student licenses do you need?" # {change} each_student_access: "Each student in a class will get access to Courses 2-4 once they are enrolled in paid courses. You may assign each course to each student individually." purchase_now: "Purchase Now" - enrollments: "enrollments" + enrollments: "licenses" # {change} remove_student1: "Remove Student" are_you_sure: "Are you sure you want to remove this student from this class?" remove_description1: "Student will lose access to this classroom and assigned classes. Progress and gameplay is NOT lost, and the student can be added back to the classroom at any time." @@ -1232,20 +1232,6 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr getting_started_1: "Create a new class by clicking the green 'Create New Class' button below." getting_started_2: "Once you've created a class, click the blue 'Add Students' button." getting_started_3: "You'll see student's progress below as they sign up and join your class." - additional_resources: "Additional Resources" - additional_resources_1_pref: "Download/print our" - additional_resources_1_mid: "Course 1" - additional_resources_1_mid2: "and" - additional_resources_1_mid3: "Course 2" - additional_resources_1_suff: "teacher's guides with solutions for each level." - additional_resources_2_pref: "Complete our" - additional_resources_2_suff: "to get two free enrollments for the rest of our paid courses." - additional_resources_3_pref: "Visit our" - additional_resources_3_mid: "Teacher Forums" - additional_resources_3_suff: "to connect to fellow educators who are using CodeCombat." - additional_resources_4_pref: "Check out our" - additional_resources_4_mid: "Schools Page" - additional_resources_4_suff: "to learn more about CodeCombat's classroom offerings." educator_wiki_pref: "Or check out our new" educator_wiki_mid: "educator wiki" educator_wiki_suff: "to browse the guide online." @@ -1253,8 +1239,8 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr no_classes: "No classes yet!" create_new_class1: "create new class" available_courses: "Available Courses" - unused_enrollments: "Unused enrollments available:" - students_access: "All students get access to Introduction to Computer Science for free. One enrollment per student is required to assign them to paid CodeCombat courses. A single student does not need multiple enrollments to access all paid courses." + unused_enrollments: "Unused licenses available:" # {change} + students_access: "All students get access to Introduction to Computer Science for free. One license per student is required to assign them to paid CodeCombat courses. A single student does not need multiple licenses to access all paid courses." # {change} active_courses: "active courses" no_students: "No students yet!" add_students1: "add students" @@ -1273,7 +1259,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr view_guide_online: "View Guide Online (PDF)" last_updated: "Last updated:" grants_lifetime_access: "Grants access to all Courses." - enrollment_credits_available: "Enrollment Credits Available:" + enrollment_credits_available: "Licenses Available:" # {change} description: "Description" # ClassroomSettingsModal language_select: "Select a language" language_cannot_change: "Language cannot be changed once students join a class." @@ -1321,12 +1307,21 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr update_account_update_student: "Update to Student" update_account_not_sure: "Not sure which one to choose? Email" update_account_confirm_update_student: "Are you sure you want to update your account to a Student experience?\n\nYou will not be able to manage any classes that you have previously created or create new classes. Your previously created classes will be removed from CodeCombat and cannot be restored." + instructor: "Instructor: " + youve_been_invited_1: "You've been invited to join " + youve_been_invited_2: ", where you'll learn " + youve_been_invited_3: " with your classmates in CodeCombat." + by_joining_1: "By joining " + by_joining_2: "will be able to help reset your password if you forget or lose it. You can also verify your email address so that you can reset the password yourself!" + sent_verification: "We've sent a verification email to:" + you_can_edit: "You can edit your email address in " + account_settings: "Account Settings" teacher: teacher_dashboard: "Teacher Dashboard" # Navbar my_classes: "My Classes" - courses: "Courses" - enrollments: "Enrollments" + courses: "Course Guides" # {change} + enrollments: "Student Licenses" # {change} resources: "Resources" help: "Help" students: "Students" # Shared @@ -1337,7 +1332,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr teacher_account_required: "A teacher account is required to access this content." create_teacher_account: "Create Teacher Account" what_is_a_teacher_account: "What's a Teacher Account?" - teacher_account_explanation: "A CodeCombat Teacher account allows you to set up classrooms, monitor students’ progress as they work through courses, manage enrollments and access resources to aid in your curriculum-building." + teacher_account_explanation: "A CodeCombat Teacher account allows you to set up classrooms, monitor students’ progress as they work through courses, manage licenses and access resources to aid in your curriculum-building." # {change} current_classes: "Current Classes" archived_classes: "Archived Classes" archived_classes_blurb: "Classes can be archived for future reference. Unarchive a class to view it in the Current Classes list again." @@ -1362,6 +1357,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr course_progress: "Course Progress" not_applicable: "N/A" edit: "edit" + edit_2: "Edit" remove: "remove" latest_completed: "Latest Completed" sort_by: "Sort by" @@ -1388,14 +1384,14 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr enroll_the_following_students: "Enroll the following students" all_students: "All Students" enroll_students: "Enroll Students" - not_enough_enrollments: "Not enough Enrollments available." - enrollments_blurb_1: "Students taking Computer Science" # Enrollments page - enrollments_blurb_2: "require enrollments to access the courses." - credits_available: "Credits Available" - total_unique_students: "Total Unique Students" - total_enrolled_students: "Total Enrolled Students" + not_enough_enrollments: "Not enough licenses available." # {change} + enrollments_blurb_1: "Students taking Computer Science" # {change} + enrollments_blurb_2: "require a license to access the courses." # {change} + credits_available: "Licenses Available" # {change} + total_unique_students: "Total Students" # {change} + total_enrolled_students: "Enrolled Students" # {change} unenrolled_students: "Unenrolled Students" - add_enrollment_credits: "Add Enrollment Credits" + add_enrollment_credits: "Add Licenses" # {change} purchasing: "Purchasing..." purchased: "Purchased!" purchase_now: "Purchase Now" @@ -1406,6 +1402,27 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr bulk_pricing_blurb: "Purchasing for more than 25 students? Contact us to discuss next steps." total_unenrolled: "Total unenrolled" export_student_progress: "Export Student Progress (CSV)" + send_email_to: "Send Recover Password Email to:" + email_sent: "Email sent" + send_recovery_email: "Send recovery email" + enter_new_password_below: "Enter new password below:" + change_password: "Change Password" + changed: "Changed" + available_credits: "Available Licenses" # {change} + pending_credits: "Pending Licenses" # {change} + credits: "licenses" # {change} + start_date: "start date:" + end_date: "end date:" + num_enrollments_needed: "Number of licenses needed:" # {change} + get_enrollments_blurb: " We'll help you build a solution that meets the needs of your class, school or district." + enroll_request_sent_blurb1: "Thanks! Your request has been sent." + enroll_request_sent_blurb2: "Our classroom success team will be in touch shortly to help you find the best solution for your students' needs!" + enroll_request_sent_blurb3: "Please reach out to <a href='mailto:schools@codecombat.com'>schools@codecombat.com</a> if you have additional questions at this time." + request_sent: "Request Sent!" + enrollment_status: "Enrollment Status" + status_expired: "Expired on {{date}}" + status_not_enrolled: "Not Enrolled" + status_enrolled: "Expires on {{date}}" classes: archmage_title: "Archmage" @@ -1563,7 +1580,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr scribe_introduction_url_mozilla: "Mozilla Developer Network" scribe_introduction_suf: " has built. If your idea of fun is articulating the concepts of programming in Markdown form, then this class might be for you." scribe_attribute_1: "Skill in words is pretty much all you need. Not only grammar and spelling, but able to convey complicated ideas to others." - contact_us_url: "Contact us" + contact_us_url: "Contact Us" # {change} scribe_join_description: "tell us a little about yourself, your experience with programming and what sort of things you'd like to write about. We'll go from there!" scribe_subscribe_desc: "Get emails about article writing announcements." diplomat_introduction_pref: "So, if there's one thing we learned from the " @@ -1706,6 +1723,15 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr card: "Card" status_unsubscribed_active: "You're not subscribed and won't be billed, but your account is still active for now." status_unsubscribed: "Get access to new levels, heroes, items, and bonus gems with a CodeCombat subscription!" + not_yet_verified: "Not yet verified." + resend_email: "Resend email" + email_sent: "Email sent! Check your inbox." + verifying_email: "Verifying your email address..." + successfully_verified: "You've successfully verified your email address!" + back_to_student_page: "Go back to student things" + back_to_teacher_page: "Go to My Classes" + back_to_game: "Go play some more levels!" + verify_error: "Something went wrong when verifying your email :(" account_invoices: amount: "Amount in US dollars" diff --git a/app/locale/sr.coffee b/app/locale/sr.coffee index 529522b78..1dd626186 100644 --- a/app/locale/sr.coffee +++ b/app/locale/sr.coffee @@ -102,7 +102,7 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian blog: "Блог" forum: "Форум" account: "Налог" -# my_account: "My Account" + my_account: "Мој налог" profile: "Профил" stats: "Статистика" code: "Код" @@ -629,7 +629,7 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian currently_free: "Тренутно имате бесплатну претплату" currently_free_until: "Тренутно имате претплату до" was_free_until: "Имали сте бесплатну претплату до" - # managed_subs: "Managed Subscriptions" + managed_subs: "Успешне претплате" subscribing: "Претплата је у току..." current_recipients: "Тренутни примаоци" unsubscribing: "Одјава претплате" @@ -648,7 +648,7 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian clojure_blurb: "Модерни Lisp." lua_blurb: "Скриптни језик за игре." io_blurb: "Једноставан, али непознат." - # java_blurb: "(Subscriber Only) Android and enterprise." + java_blurb: "(Само за претплатнике) Андроид и предузетништво." status: "Статус" hero_type: "Врста" weapons: "Оружја" @@ -808,75 +808,75 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian next: "Следеће" location_title: "Налазимо се у центру Сан Франциска:" -# teachers: -# who_for_title: "Who is CodeCombat for?" -# who_for_1: "We recommend CodeCombat for students aged 9 and up. No prior programming experience is needed. We've designed CodeCombat to appeal to both boys and girls." -# who_for_2: "Our Courses system allows teachers to set up classrooms, track progress and assign additional content to students through a dedicated interface." -# more_info_title: "Where can I find more information?" -# more_info_1: "Our" -# more_info_2: "teachers forum" -# more_info_3: "is a good place to connect with fellow educators who are using CodeCombat." + teachers: + who_for_title: "За кога је CodeCombat?" + who_for_1: "Ми препоручујемо CodeCombat ученицима старости 9 година и више. Претходно искуство у програмирању није потребно. Дизајнирали смо CodeCombat да се допадне и дечацима и девојчицама." + who_for_2: "Наш систем Курсеви омогућава учитељима да подесе разреде, прате напредак и доделе додатни материјал ученицима кроз наменски интерфејс." + more_info_title: "Где могу да нађем више информација?" + more_info_1: "Наш" + more_info_2: "форум за учитеље" + more_info_3: "је добро место да се повежеш са колегама едукаторима који користе CodeCombat." -# teachers_quote: -# name: "Demo Form" -# title: "Request a Demo" -# subtitle: "Get your students started in less than an hour. You'll be able to <strong>create a class, add students, and monitor their progress</strong> as they learn computer science." -# email_exists: "User exists with this email." -# phone_number: "Phone number" -# phone_number_help: "Where can we reach you during the workday?" -# primary_role_label: "Your Primary Role" -# role_default: "Select Role" -# primary_role_default: "Select Primary Role" -# purchaser_role_default: "Select Purchaser Role" -# tech_coordinator: "Technology coordinator" -# advisor: "Advisor" -# principal: "Principal" -# superintendent: "Superintendent" -# parent: "Parent" -# purchaser_role_label: "Your Purchaser Role" -# influence_advocate: "Influence/Advocate" -# evaluate_recommend: "Evaluate/Recommend" -# approve_funds: "Approve Funds" -# no_purchaser_role: "No role in purchase decisions" -# organization_label: "Name of School/District" -# city: "City" -# state: "State" -# country: "Country" -# num_students_help: "How many do you anticipate enrolling in CodeCombat?" -# num_students_default: "Select Range" -# education_level_label: "Education Level of Students" -# education_level_help: "Choose as many as apply." -# elementary_school: "Elementary School" -# high_school: "High School" -# please_explain: "(please explain)" -# middle_school: "Middle School" -# college_plus: "College or higher" -# anything_else: "Anything else we should know?" -# thanks_header: "Request Received!" -# thanks_sub_header: "Thanks for expressing interest in CodeCombat for your school." -# thanks_p: "We'll be in touch soon! If you need to get in contact, you can reach us at:" -# back_to_classes: "Back to Classes" -# finish_signup: "Finish creating your teacher account:" -# finish_signup_p: "Create an account to set up a class, add your students, and monitor their progress as they learn computer science." -# signup_with: "Sign up with:" -# connect_with: "Connect with:" + teachers_quote: + name: "Демо формулар" + title: "Затражи демо верзију" + subtitle: "Нека твоји ученици почну за мање од једног сата. Моћи ћеш да <strong>направиш разред, додаш ученике и пратиш њихов напредак</strong> док уче компјутерске науке." + email_exists: "Корисник постоји са овим мејлом." + phone_number: "Број телефона" + phone_number_help: "Где можемо да те добијемо током радног дана?" + primary_role_label: "Твоја примарна улога" + role_default: "Изабери улогу" + primary_role_default: "Изабери примарну улогу" + purchaser_role_default: "Изабери куповну улогу" + tech_coordinator: "Технички координатор" + advisor: "Саветннк" + principal: "Директор" + superintendent: "Управник" + parent: "Родитељ" + purchaser_role_label: "Твоја куповна улога" + influence_advocate: "Утицај/заступник" + evaluate_recommend: "Евалуација/препорука" + approve_funds: "Одобрење средстава" + no_purchaser_role: "Без улоге у куповним одлукама" + organization_label: "Име школе/округа" + city: "Град" + state: "Савезна држава" + country: "Држава" + num_students_help: "Колико ученика очекујеш да се упишу на CodeCombat?" + num_students_default: "Изабери опсег" + education_level_label: "Образовни ниво ученика" + education_level_help: "Изабери колико год важи." + elementary_school: "Основна школа" + high_school: "Средња школа" + please_explain: "(објасните)" + middle_school: "Основна школа (виши разреди)" + college_plus: "Факултет или више" + anything_else: "Још нешто што треба да знамо?" + thanks_header: "Захтев је примљен!" + thanks_sub_header: "Хвала на интересовању за CodeCombat за твоју школу." + thanks_p: "Бићемо у контакту ускоро! Ако желиш да ступиш у контакт, можеш нас добити на:" + back_to_classes: "Назад на разреде" + finish_signup: "Заврши креирање свој учитељског налога:" + finish_signup_p: "Направи налог да оснујеш разред, додаш своје ученике и пратиш њихов напредак док уче компјутерске науке." + signup_with: "Пријави се са:" + connect_with: "Повежи се са:" # conversion_warning: "WARNING: Your current account is a <em>Student Account</em>. Once you submit this form, your account will be updated to a Teacher Account." # learn_more_modal: "Teacher accounts on CodeCombat have the ability to monitor student progress, assign enrollments and manage classrooms. Teacher accounts cannot be a part of a classroom - if you are currently enrolled in a class using this account, you will no longer be able to access it once you update to a Teacher Account." -# create_account: "Create a Teacher Account" + create_account: "Направи учитељски налог" # create_account_subtitle: "Get access to teacher-only tools for using CodeCombat in the classroom. <strong>Set up a class</strong>, add your students, and <strong>monitor their progress</strong>!" # convert_account_title: "Update to Teacher Account" # not: "Not" -# setup_a_class: "Set Up a Class" + setup_a_class: "Подеси разред" -# versions: -# save_version_title: "Save New Version" -# new_major_version: "New Major Version" -# submitting_patch: "Submitting Patch..." -# cla_prefix: "To save changes, first you must agree to our" -# cla_url: "CLA" -# cla_suffix: "." -# cla_agree: "I AGREE" -# owner_approve: "An owner will need to approve it before your changes will become visible." + versions: + save_version_title: "Сачувај нову верзију" + new_major_version: "Нова главна верзија" + submitting_patch: "Подношење измене..." + cla_prefix: "Да би сачувао измене, прво мораш да се сложиш са нашимr" + cla_url: "CLA" + cla_suffix: "." + cla_agree: "СЛАЖЕМ СЕ" + owner_approve: "Власник ће морати да одобри твоје измене пре него што постану видљиве." contact: contact_us: "Контактирај CodeCombat" @@ -900,18 +900,18 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian autosave: "Измене се чувају аутоматски" me_tab: "Ја" picture_tab: "Фотографија" -# delete_account_tab: "Delete Your Account" -# wrong_email: "Wrong Email" -# wrong_password: "Wrong Password" -# upload_picture: "Upload a picture" -# delete_this_account: "Delete this account permanently" -# reset_progress_tab: "Reset All Progress" -# reset_your_progress: "Clear all your progress and start over" + delete_account_tab: "Избриши свој налог" + wrong_email: "Погрешан мејл" + wrong_password: "Погрешна шифра" + upload_picture: "Постави слику" + delete_this_account: "Избриши овај налог заувек" + reset_progress_tab: "Ресетуј цео напредак" + reset_your_progress: "Избриши цео свој напредак и почни поново" # god_mode: "God Mode" password_tab: "Шифра" emails_tab: "Мејлови" # admin: "Admin" -# manage_subscription: "Click here to manage your subscription." + manage_subscription: "Кликни овде да би управљао својом претплатом." new_password: "Нова Шифра" new_password_verify: "Потврди" # type_in_email: "Type in your email to confirm account deletion." @@ -925,8 +925,8 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian # email_notifications_summary: "Controls for personalized, automatic email notifications related to your CodeCombat activity." # email_any_notes: "Any Notifications" # email_any_notes_description: "Disable to stop all activity notification emails." -# email_news: "News" -# email_recruit_notes: "Job Opportunities" + email_news: "Вести" + email_recruit_notes: "Пословне могућности" # email_recruit_notes_description: "If you play really well, we may contact you about getting you a (better) job." contributor_emails: "Мејлови реда сарадника" contribute_prefix: "Тражимо људе који би нам се придружили! Погледај " @@ -936,7 +936,7 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian error_saving: "Чување грешке..." saved: "Измене су сачуване" password_mismatch: "Шифре се не слажу." -# password_repeat: "Please repeat your password." + password_repeat: "Понови своју шифру." # keyboard_shortcuts: # keyboard_shortcuts: "Keyboard Shortcuts" diff --git a/app/locale/vi.coffee b/app/locale/vi.coffee index 3653b92b7..fb407b1b3 100644 --- a/app/locale/vi.coffee +++ b/app/locale/vi.coffee @@ -136,14 +136,14 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn okay: "OK" not_found: - page_not_found: "không tìm thấy trang" + page_not_found: "Không tìm thấy trang" diplomat_suggestion: title: "Hãy giúp chúng tôi phiên dịch CodeCombat!" # This shows up when a player switches to a non-English language using the language selector. sub_heading: "Chúng tôi cần kỹ năng ngoại ngữ của bạn." pitch_body: "Chúng tôi xây dựng Codecombat bằng Tiếng Anh, tuy nhiên có rất nhiều bạn trẻ trên toàn thế giới đều muốn tham gia. Các bạn trẻ Việt Nam cũng muốn chơi với nội dung Tiếng Việt, nếu như bạn có thể đọc và viết thành thạo cả 2 ngôn ngữ xin hãy đăng kí làm dịch thuật cho chúng tôi." missing_translations: "Bạn sẽ tiếp tục thấy Tiếng Anh cho đến khi chúng tôi dịch tất cả nội dung qua Tiếng Việt." - learn_more: "Tìm hiểu thêm để tham gia làm Phiên Dịch Viên" + learn_more: "Tìm hiểu thêm để tham gia trở thành Phiên Dịch Viên" subscribe_as_diplomat: "Trở thành Phiên Dịch Viên" play: @@ -152,22 +152,22 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn spectate: "Quan sát" # Ladder page players: "người chơi" # Hover over a level on /play hours_played: "Thời gian chơi" # Hover over a level on /play - items: "Trang bị" # Tooltip on item shop button from /play + items: "Trang Bị" # Tooltip on item shop button from /play unlock: "Mua" # For purchasing items and heroes confirm: "Xác nhận" owned: "Đã có" # For items you own locked: "Bị khóa" purchasable: "Có thể mua" # For a hero you unlocked but haven't purchased available: "Khả dụng" - skills_granted: "Đã nhận được Kĩ Năng" # Property documentation details - heroes: "Các Tướng" # Tooltip on hero shop button from /play - achievements: "Thành tích" # Tooltip on achievement list button from /play + skills_granted: "Kỹ năng nhận được" # Property documentation details + heroes: "Tướng" # Tooltip on hero shop button from /play + achievements: "Thành Tích" # Tooltip on achievement list button from /play account: "Tài khoản" # Tooltip on account button from /play settings: "Tùy Chỉnh" # Tooltip on settings button from /play poll: "Bỏ phiếu" # Tooltip on poll button from /play next: "Tiếp" # Go from choose hero to choose inventory before playing a level change_hero: "Đổi Tướng" # Go back from choose inventory to choose hero - buy_gems: "Mua ngọc" + buy_gems: "Mua Ngọc" subscription_required: "Cần đăng kí" anonymous: "Người chơi ẩn danh" level_difficulty: "Độ khó: " @@ -251,7 +251,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn finishing: "Sắp hoàn tất" sign_in_with_facebook: "Đăng nhập với Facebook" sign_in_with_gplus: "Đăng nhập với G+" - signup_switch: "Bạn có muốn tạo tài khoản mới?" + signup_switch: "Bạn muốn tạo tài khoản mới?" signup: email_announcements: "Nhận thông báo bằng email" @@ -289,9 +289,9 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn common: back: "Trở lại" # When used as an action verb, like "Navigate backward" continue: "Tiếp tục" # When used as an action verb, like "Continue forward" - loading: "Đang tải..." - saving: "Đang lưu..." - sending: "Đang gửi..." + loading: "Đang Tải..." + saving: "Đang Lưu..." + sending: "Đang Gửi..." send: "Gửi" cancel: "Hủy" save: "Lưu" @@ -345,7 +345,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn message: "Tin nhắn" code: "Code" ladder: "Thang điểm" - when: "Khi nào" + when: "Thời gian" opponent: "Đối thủ" rank: "Hạng" score: "Điểm" @@ -358,7 +358,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn player: "Người chơi" player_level: "Cấp độ" # Like player level 5, not like level: Dungeons of Kithgard warrior: "Chiến binh" - ranger: "Cung thủ" + ranger: "Xạ thủ" wizard: "Phù thủy" first_name: "Tên" last_name: "Họ" @@ -420,8 +420,8 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn victory_review_placeholder: "Màn chơi vừa rồi như thế nào?" victory_hour_of_code_done: "Bạn xong chưa?" victory_hour_of_code_done_yes: "Đúng vậy, tôi đã hoàn tất thời gian lập trình!" - victory_experience_gained: "Đã tăng XP" - victory_gems_gained: "Nhận được Ngọc" + victory_experience_gained: "XP nhận được" + victory_gems_gained: "Ngọc nhận được" victory_new_item: "Vật phẩm mới" victory_viking_code_school: "Thật tuyệt vời, bạn vừa vượt qua một màn chơi khó khủng khiếp! Không lâu nữa bạn sẽ trở thành một lập trình viên thôi. Bạn vừa được nhận thẳng vào trường Viking Code School, nơi bạn có thể nâng tầm kĩ năng của mình và trở thành lập trình viên web chuyên nghiệp trong 14 tuần." victory_become_a_viking: "Trở thành Viking" @@ -442,7 +442,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn skip_tutorial: "Bỏ qua (esc)" keyboard_shortcuts: "Các phím tắt" loading_ready: "Sẵn sàng!" - loading_start: "Bắt đầu màn này" + loading_start: "Bắt đầu màn chơi" problem_alert_title: "Hãy sửa lại Code của bạn" time_current: "Bây giờ:" time_total: "Tối đa:" @@ -542,12 +542,12 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn view_other_solutions: "Xem xếp hạng" # {change} scores: "Điểm" top_players: "Người chơi dẫn đầu xếp theo" - day: "Hôm nay" - week: "Tuần này" - all: "Tất cả" - time: "Thời gian" + day: "Hôm Nay" + week: "Tuần Này" + all: "Tất Cả" + time: "Thời Gian" damage_taken: "Sát thương nhận vào" - damage_dealt: "Sát thương gây ra" + damage_dealt: "Mức Sát Thương" difficulty: "Độ khó" gold_collected: "Vàng đã thu thập" @@ -560,7 +560,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn equipped: "(đã trang bị)" locked: "(khóa)" restricted: "(bị giới hạn ở màn này)" - equip: "Mặc trang bị" + equip: "Mặc" unequip: "Cởi ra" buy_gems: @@ -577,7 +577,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn price: "x{{gems}} / tháng" subscribe: - comparison_blurb: "Tăng cường kĩ năng bằng việc đăng kí theo dõi CodeCombat!" + comparison_blurb: "Tăng cường kĩ năng bằng cách mua gói dịch vụ nâng cao của CodeCombat!" feature1: "__levelsCount__+ màn chơi cơ bản trên __worldsCount__ bản đồ thế giới" feature2: "__heroesCount__ <strong>tướng mới</strong> mạnh mẽ với những kĩ năng đặc biệt!" feature3: "__bonusLevelsCount__+ màn chơi thêm" @@ -589,7 +589,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn free: "Miễn phí" month: "tháng" must_be_logged: "Trước tiên bạn phải đăng nhập. Hãy tạo một tài khoản mới hoặc đăng nhập ở menu phía trên." - subscribe_title: "Đăng kí theo dõi" + subscribe_title: "Mua gói nâng cao" unsubscribe: "Ngừng theo dõi" confirm_unsubscribe: "Xác nhận ngừng theo dõi" never_mind: "Đừng bận tâm, tôi vẫn yêu bạn" @@ -598,8 +598,8 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn thank_you: "Cảm ơn bạn đã ủng hộ CodeCombat." sorry_to_see_you_go: "Thật đáng tiếc khi phải chia tay bạn! Hãy góp ý để chúng tôi có thể cái thiện tốt hơn." unsubscribe_feedback_placeholder: "Ồ, chúng tôi đã làm gì sai ư?" - parent_button: "Hãy hỏi phụ huynh bạn" - parent_email_description: "Chúng tôi sẽ email cho họ để họ có thể mua cho bạn một gói dịch vụ của CodeCombat." + parent_button: "Hỏi phụ huynh bạn" + parent_email_description: "Chúng tôi sẽ email cho họ để họ có thể mua cho bạn một gói dịch vụ nâng cao của CodeCombat." parent_email_input_invalid: "Địa chỉ email không hợp lệ." parent_email_input_label: "Địa chỉ email của phụ huynh" parent_email_input_placeholder: "Hãy nhập địa chi email của phụ huynh bạn" @@ -608,13 +608,13 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn parent_email_title: "Địa chỉ email của phụ huynh bạn là gì?" parents: "Dành cho Phụ huynh" parents_title: "Xin chào: Con của bạn muốn học lập trình. Bạn đồng ý chứ?" # {change} - parents_blurb1: "Con của bạn đã hoàn thiện __nLevels__ cấp độ lập trình đầu tiên với CodeCombat. Hãy giúp con bạn theo đuổi giấc mơ lập trình bằng cách đăng kí thêm khóa học." - parents_blurb1a: "Lập trình là một kỹ năng cần thiết mà con bạn chắc chắn sẽ cần khi trưởng thành. Tới 2020, kỹ năng phần mếm cơ bản sẽ được dùng trong 77% các ngành nghề, và khắp nơi trên thế giới hiện nay đều đang có nhu cầu cao tìm kiếm những kĩ sư phần mềm. Bạn có biết Công Nghệ Thông Tin đang là bằng cấp đại học đem lại mức lương cao nhất?" + parents_blurb1: "Con của bạn đã hoàn thiện __nLevels__ cấp độ lập trình cơ bản với CodeCombat. Hãy giúp con bạn theo đuổi giấc mơ lập trình bằng cách đăng kí thêm khóa học." + parents_blurb1a: "Lập trình là một kỹ năng cần thiết mà con bạn chắc chắn sẽ cần khi trưởng thành. Tính đến 2020, kỹ năng phần mếm cơ bản sẽ được dùng trong 77% các ngành nghề, và khắp mọi nơi trên thế giới hiện nay đều đang có nhu cầu cao tìm kiếm kĩ sư phần mềm. Bạn có biết Công Nghệ Thông Tin đang là bằng cấp đại học đem lại mức lương cao nhất?" parents_blurb2: "Chỉ với ${{price}} USD/tháng, con bạn sẽ nhận được những thử thách mới mỗi tháng và sẽ nhận được sự hỗ trợ đặc biệt từ các lập trình viên chuyên nghiệp." # {change} - parents_blurb3: "Không hề có rủi ro: Nếu bạn không hài lòng bạn có thể nhận lại 100% số tiền mình bỏ ra chỉ với 1 cú nhấp chuốt." + parents_blurb3: "Không hề có rủi ro: Nếu bạn không hài lòng bạn có thể nhận lại 100% số tiền mình bỏ ra chỉ với 1 cú click chuốt." payment_methods: "Phương thức thanh toán" payment_methods_title: "Những phương thức thanh toán được chấp nhận." - payment_methods_blurb1: "Hiện tại chúng tôi chấp nhận thẻ tín dụng và Alipay. Bạn cũng có thể sử dụng PayPal để chuyển {{three_month_price}} USD tới nick@codecombat.com và ghi rõ email tài khoản để mua dịch vụ trong 3 tháng, hoặc ${{year_price}} để mua dịch vụ trong 1 năm." + payment_methods_blurb1: "Hiện tại chúng tôi chấp nhận thẻ tín dụng và Alipay. Bạn cũng có thể sử dụng PayPal để chuyển ${{three_month_price}} tới nick@codecombat.com và ghi rõ email tài khoản để mua dịch vụ trong 3 tháng, hoặc ${{year_price}} để mua dịch vụ trong 1 năm." payment_methods_blurb2: "Nếu bạn cần một phương thức thanh toán khác, hãy liên hệ" sale_button: "Ưu đãi!" sale_button_title: "Tiết kiệm $21 khi mua gói dịch vụ 1 năm" @@ -644,7 +644,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn experimental: "Thử" python_blurb: "Đơn giản nhưng mạnh mẽ, tốt cho cả những người mới bắt đầu và chuyên gia." javascript_blurb: "Ngôn ngữ của thế giới web. (Không giống với Java đâu nhé.)" - coffeescript_blurb: "Là JavaScript viết bằng cú pháp tốt hơn." + coffeescript_blurb: "JavaScript với cú pháp tốt hơn." clojure_blurb: "Lisp thời đại mới." lua_blurb: "Ngôn ngữ được ưa chuông để làm game." io_blurb: "Đơn giản nhưng ít người biết đến." @@ -659,7 +659,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn health: "Sinh lực" speed: "Tốc độ" regeneration: "Hồi sinh lực" - range: "Tầm đánh/Tầm nhìn" # As in "attack or visual range" + range: "Tầm xa" # As in "attack or visual range" blocks: "Đỡ" # As in "this shield blocks this much damage" # backstab: "Backstab" # As in "this dagger does this much backstab damage" skills: "Kĩ năng" @@ -707,14 +707,14 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn granularity_change_history: "Lịch sử" options: - general_options: "Tùy chỉnh chung" # Check out the Options tab in the Game Menu while playing a level - volume_label: "Âm lượng" - music_label: "Âm nhạc" + general_options: "Tùy Chỉnh Chung" # Check out the Options tab in the Game Menu while playing a level + volume_label: "Âm Lượng" + music_label: "Âm Nhạc" music_description: "Bật/tắt nhạc nền." - editor_config_title: "Cấu hình Editor" -# editor_config_keybindings_label: "Key Bindings" + editor_config_title: "Cấu Hình Editor" + editor_config_keybindings_label: "Các phím tắt" editor_config_keybindings_default: "Mặc định (Ace)" - editor_config_keybindings_description: "Thêm shortcuts chung cho các công cụ editor." + editor_config_keybindings_description: "Hệ thống phím tắt chung cho các công cụ editor." editor_config_livecompletion_label: "Gợi ý tự động" editor_config_livecompletion_description: "Hiển thị gợi ý tự động trong khi gõ phím." editor_config_invisibles_label: "Hiện kí tự ẩn" @@ -899,14 +899,14 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn not_logged_in: "Đăng nhập hoặc tạo tài khoản để thay đổi cài đặt." autosave: "Tự động lưu thay đổi" me_tab: "Tôi" - picture_tab: "Ảnh đại diện" - delete_account_tab: "Xóa tài khoản" + picture_tab: "Ảnh Đại Diện" + delete_account_tab: "Xóa Tài Khoản" wrong_email: "Email không đúng" wrong_password: "Mật khẩu không đúng" upload_picture: "Tải ảnh lên" delete_this_account: "Xóa tài khoản này vĩnh viễn" -# reset_progress_tab: "Reset All Progress" -# reset_your_progress: "Clear all your progress and start over" + reset_progress_tab: "Xóa Mọi Tiến Trình" + reset_your_progress: "Xóa mọi tiến trình của bạn và bắt đầu lại từ đầu" # god_mode: "God Mode" password_tab: "Mật khẩu" emails_tab: "Email" @@ -914,9 +914,9 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn manage_subscription: "Nhấn vào đây để chỉnh sửa gói đăng ký." new_password: "Mật khẩu mới" new_password_verify: "Xác nhận" -# type_in_email: "Type in your email to confirm account deletion." -# type_in_email_progress: "Type in your email to confirm deleting your progress." -# type_in_password: "Also, type in your password." + type_in_email: "Nhập email của bạn để xác nhận xóa tài khoản." + type_in_email_progress: "Nhập email của bạn để xác nhận xóa tiến trình." + type_in_password: "Nhập lại mật khẩu của bạn." email_subscriptions: "Thuê bao Email" # email_subscriptions_none: "No Email Subscriptions." email_announcements: "Thông báo" @@ -928,8 +928,8 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn # email_news: "News" email_recruit_notes: "Cơ hội việc làm" email_recruit_notes_description: "Nếu bạn chơi trò này rất giỏi, chúng tôi có thể sẽ liên lạc với bạn về cơ hội nghề nghiệp." -# contributor_emails: "Contributor Class Emails" - contribute_prefix: "Chúng tôi đang tìm thêm người vào nhóm của chúng tôi! Hãy kiểm " + contributor_emails: "Email tham gia đóng góp" + contribute_prefix: "Chúng tôi đang tìm thêm người để cùng tham gia với chúng tôi! Hãy vào " contribute_page: "trang đóng góp" contribute_suffix: " để tìm hiểu thêm." # email_toggle: "Toggle All" @@ -980,78 +980,78 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn clans: clan: "Clan" -# clans: "Clans" - new_name: "Tên mới cho bè đảng" - new_description: "Miêu tả cho bè đảng mới này" - make_private: "Tạo bè đảng kín" -# subs_only: "subscribers only" + clans: "Các Clan" + new_name: "Tên Clan mới" + new_description: "Mô tả Clan mới" + make_private: "Make clan private" + subs_only: "chỉ dành cho các subscriber" create_clan: "Tạo một clan mới" -# private_preview: "Preview" -# private_clans: "Private Clans" -# public_clans: "Public Clans" -# my_clans: "My Clans" -# clan_name: "Clan Name" + private_preview: "Xem trước" + private_clans: "Các Clan kín" + public_clans: "Các Clan mở" + my_clans: "Các Clan của tôi" + clan_name: "Tên Clan" name: "Tên" -# chieftain: "Chieftain" -# type: "Type" -# edit_clan_name: "Edit Clan Name" -# edit_clan_description: "Edit Clan Description" -# edit_name: "edit name" -# edit_description: "edit description" -# private: "(private)" -# summary: "Summary" -# average_level: "Average Level" + chieftain: "Thủ lĩnh" + type: "Loại" + edit_clan_name: "Sửa tên Clan" + edit_clan_description: "Sửa mô tả Clan" + edit_name: "sửa tên" + edit_description: "sửa mô tả" + private: "(kín)" + summary: "Tóm tắt" + average_level: "Cấp độ trng bình" # average_achievements: "Average Achievements" -# delete_clan: "Delete Clan" -# leave_clan: "Leave Clan" -# join_clan: "Join Clan" -# invite_1: "Invite:" -# invite_2: "*Invite players to this Clan by sending them this link." - members: "Những thành viên" -# progress: "Progress" -# not_started_1: "not started" -# started_1: "started" -# complete_1: "complete" + delete_clan: "Xóa Clan" + leave_clan: "Rời Clan" + join_clan: "Tham gia Clan" + invite_1: "Mời:" + invite_2: "*Mời người chơi khác tham dự Clan này bằng cách gửi họ đường link này." + members: "Thành viên" + progress: "Tiến trình" + not_started_1: "chưa băt đầu" + started_1: "đã bắt đầu" + complete_1: "hoàn thành" # exp_levels: "Expand levels" # rem_hero: "Remove Hero" # status: "Status" -# complete_2: "Complete" -# started_2: "Started" -# not_started_2: "Not Started" -# view_solution: "Click to view solution." + complete_2: "Hoàn thành" + started_2: "Đã bắt đầu" + not_started_2: "Chưa bắt đầu" + view_solution: "Click để xem lời giải." # view_attempt: "Click to view attempt." # latest_achievement: "Latest Achievement" -# playtime: "Playtime" -# last_played: "Last played" + playtime: "Thời gian chơi" + last_played: "Lần chơi cuối" # leagues_explanation: "Play in a league against other clan members in these multiplayer arena instances." # track_concepts1: "Track concepts" # track_concepts2a: "learned by each student" # track_concepts2b: "learned by each member" # track_concepts3a: "Track levels completed for each student" # track_concepts3b: "Track levels completed for each member" -# track_concepts4a: "See your students'" -# track_concepts4b: "See your members'" -# track_concepts5: "solutions" -# track_concepts6a: "Sort students by name or progress" -# track_concepts6b: "Sort members by name or progress" + track_concepts4a: "Xem các học viên của bạn'" + track_concepts4b: "Xem các thành viên của bạn'" + track_concepts5: "lời giải" + track_concepts6a: "Sắp xếp học viên theo tên hoặc tiến trình" + track_concepts6b: "Sắp xếp thành viên theo tên hoặc tiến trình" # track_concepts7: "Requires invitation" # track_concepts8: "to join" -# private_require_sub: "Private clans require a subscription to create or join." + private_require_sub: "Các Clan kín cần mua subscription để tạo hoặc tham gia." courses: course: "Khóa học" courses: "Những khóa học" - create_new_class: "Tạo khóa học mới" + create_new_class: "Tạo Lớp Học Mới" not_enrolled: "Bạn không đăng kí vào khóa học này" -# visit_pref: "Please visit the" -# visit_suf: "page to enroll." -# select_class: "Select one of your classes" -# unnamed: "*unnamed*" + visit_pref: "Hãy ghé thăm trang" + visit_suf: "để tham gia." + select_class: "Chọn một trong các lớp học của bạn" + unnamed: "*unnamed*" # select: "Select" -# unnamed_class: "Unnamed Class" -# edit_settings: "edit class settings" -# edit_settings1: "Edit Class Settings" - progress: "Tiến trình của khóa học" + unnamed_class: "Lớp học chưa đặt tên" + edit_settings: "thay đổi tùy chỉnh lớp học" + edit_settings1: "Thay đổi tùy chỉnh lớp học" + progress: "Tiến trình của lớp học" add_students: "Thêm học sinh" stats: "Thống kê" total_students: "Tổng số học sinh:" @@ -1073,9 +1073,9 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn # invite_link_p_2: "Or have us email them directly:" # capacity_used: "Course slots used:" # enter_emails: "Enter student emails to invite, one per line" -# send_invites: "Send Invites" -# creating_class: "Creating class..." -# purchasing_course: "Purchasing course..." + send_invites: "Gửi lời mời" + creating_class: "Đang tạo lớp..." + purchasing_course: "Đang mua khóa học..." buy_course: "Mua khóa học" buy_course1: "Mua khóa học này" select_all_courses: "Lựa chọn 'Tất cả khóa học' để nhận giảm giá 50%!" @@ -1083,7 +1083,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn number_programming_students: "Số Lượng Học Viên" number_total_students: "Tổng Số Học Viên tại Trường/Quận" enter_number_students: "Hãy nhập vào số lượng học sinh bạn cần cho lớp học này." -# name_class: "Name your class" + name_class: "Đặt tên lớp của bạn" # displayed_course_page: "This will be displayed on the course page for you and your students. It can be changed later." buy: "Mua" # purchasing_for: "You are purchasing a license for" @@ -1273,25 +1273,25 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn # last_updated: "Last updated:" # grants_lifetime_access: "Grants access to all Courses." # enrollment_credits_available: "Enrollment Credits Available:" - description: "Miêu tả" # ClassroomSettingsModal + description: "Mô tả" # ClassroomSettingsModal language_select: "Lựa chọn một ngôn ngữ" language_cannot_change: "Không thể đổi ngôn ngữ sau khi học viên đã gia nhập lớp học." learn_p: "Học Python" learn_j: "Học JavaScript" -# avg_student_exp_label: "Average Student Programming Experience" -# avg_student_exp_desc: "This will help us understand how to pace courses better." -# avg_student_exp_select: "Select the best option" -# avg_student_exp_none: "No Experience - little to no experience" -# avg_student_exp_beginner: "Beginner - some exposure or block-based" -# avg_student_exp_intermediate: "Intermediate - some experience with typed code" -# avg_student_exp_advanced: "Advanced - extensive experience with typed code" -# avg_student_exp_varied: "Varied Levels of Experience" -# student_age_range_label: "Student Age Range" -# student_age_range_younger: "Younger than 6" -# student_age_range_older: "Older than 18" -# student_age_range_to: "to" -# create_class: "Create Class" -# class_name: "Class Name" + avg_student_exp_label: "Kinh nghiệm lập trình trung bình của học viên" + avg_student_exp_desc: "Điều này sẽ giúp chúng tôi điều chỉnh tốc độ các khóa học tốt hơn." + avg_student_exp_select: "Chọn lựa chọn đúng nhất" + avg_student_exp_none: "Không Kinh Nghiệm - ít hoặc không có kinh nghiệm" + avg_student_exp_beginner: "Mới Bắt Đầu - đã tiếp cận hoặc đã được giới thiệu" + avg_student_exp_intermediate: "Khá - có chút kinh nghiệm viết code" + avg_student_exp_advanced: "Nâng Cao - có nhiều kình nghiệm viết code" + avg_student_exp_varied: "Nhiều Trình Độ" + student_age_range_label: "Lứa tuổi học viên" + student_age_range_younger: "Nhỏ hơn 6 tuổi" + student_age_range_older: "Lớn hơn 18 tuổi" + student_age_range_to: "tới" + create_class: "Tạo Lớp" + class_name: "Tên lớp" # teacher_account_restricted: "Your account is a teacher account, and so cannot access student content." teacher: @@ -1318,7 +1318,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn # unarchive_class: "unarchive class" # no_students_yet: "This class has no students yet." add_students: "Thêm Học Viên" -# create_new_class: "Create a New Class" + create_new_class: "Tạo Lớp học mới" # class_overview: "Class Overview" # View Class page # avg_playtime: "Average level playtime" # total_playtime: "Total play time" @@ -1530,13 +1530,13 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn adventurer_forum_url: "diễn đàn" adventurer_join_suf: "vì vậy nếu bạn muốn liên lạc thông qua những kênh trên, hãy đăng ký ở đó!" adventurer_subscribe_desc: "Nhận email khi có màn chơi mới cần kiểm thử." -# scribe_introduction_pref: "CodeCombat isn't just going to be a bunch of levels. It will also include a resource for knowledge, a wiki of programming concepts that levels can hook into. That way rather than each Artisan having to describe in detail what a comparison operator is, they can simply link their level to the Article describing them that is already written for the player's edification. Something along the lines of what the " -# scribe_introduction_url_mozilla: "Mozilla Developer Network" -# scribe_introduction_suf: " has built. If your idea of fun is articulating the concepts of programming in Markdown form, then this class might be for you." -# scribe_attribute_1: "Skill in words is pretty much all you need. Not only grammar and spelling, but able to convey complicated ideas to others." + scribe_introduction_pref: "CodeCombat không chỉ là một đống các màn chơi. Nó cũng sẽ là một nguồn tài nguyên tri thức, một cuốn thư viện về các khái niệm lập trình, chính những thứ mà được sử dụng để xây dựng các màn chơi. Như vậy các Thợ Thủ Công không phải bỏ công giải thích đi giải thích lại những khái niệm cơ bản như toán tử là gì, họ có thể một cách đơn giản dẫn link từ các màn chơi của họ sang các bài viết dành cho người chơi. Việc này tương tự như những thứ mà " + scribe_introduction_url_mozilla: "Cộng Đồng Lập Trình Mozilla" + scribe_introduction_suf: " đã xây dựng. Nếu bạn có thể truyền tải được các tư tưởng lập trình dưới dạng Markdown, thì lớp nhân vật này có thể phù hợp với bạn." + scribe_attribute_1: "Khả năng hành văn gần như là thứ duy nhất bạn cần. Không chỉ cần ngữ pháp và đánh vần thành thạo, mà bạn còn cần khả năng truyền tải những thông điệp phức tạp tới người khác." contact_us_url: "Liên hệ với chúng tôi" -# scribe_join_description: "tell us a little about yourself, your experience with programming and what sort of things you'd like to write about. We'll go from there!" -# scribe_subscribe_desc: "Get emails about article writing announcements." + scribe_join_description: "kể cho chung tôi một chút về bạn, kinh nghiệm lập trình của bạn và bạn hứng thú viết về điều gì. Chúng ta sẽ cùng bắt đầu từ đó!" + scribe_subscribe_desc: "Nhận email về những thông tin viết bài." diplomat_introduction_pref: "Nếu như bạn hỏi chúng tôi đã nhận được gì kể từ khi " diplomat_launch_url: "bắt đầu vào tháng Mười" diplomat_introduction_suf: "thì đó chính là niềm quan tâm rất lớn với CodeCombat đến từ nhiều quốc gia trên thế giới! Chúng tôi đang xây dựng một đội ngũ phiên dịch viên đầy nhiệt huyết để đưa CodeCombat đến với mọi nơi trên thế giới. Nếu bạn muốn cập nhật những nội dung mới nhất đồng thời muốn truyền tải chúng tới quốc gia của bạn, thì lớp nhân vật này có thể sẽ phù hợp với bạn." @@ -1548,7 +1548,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn diplomat_github_url: "ở trên GitHub" diplomat_join_suf_github: ", chỉnh sửa online, và submit một pull request. Đồng thời, đánh dấu vào ô phía dưới để theo dõi những thông tin cập nhật về việc phát triển đa ngôn ngữ!" diplomat_subscribe_desc: "Nhận email về việc phát triển đa ngôn ngữ và màn chơi mới cần dịch thuật." -# ambassador_introduction: "This is a community we're building, and you are the connections. We've got forums, emails, and social networks with lots of people to talk with and help get acquainted with the game and learn from. If you want to help people get involved and have fun, and get a good feel of the pulse of CodeCombat and where we're going, then this class might be for you." + ambassador_introduction: "Đây là cộng đồng mà chúng tôi đang gây dựng, và bạn là những người kết nối. Chúng tôi có các diễn đàn, email, và các mạng xã hội với rất nhiêu người để nói chuyện và giúp đỡ làm quen và học từ game. Nếu bạn muốn giúp đỡ người khác tham gia chơi, và cùng tham gia CodeCombat trên con đường chúng tôi đang hướng đến, thì lớp nhân vật này có thể phù hợp với bạn." ambassador_attribute_1: "Kỹ năng giao tiếp. Có thể nhận định được vấn đề của người chơi đang gặp phải và giúp họ giải quyết. Đồng thời, thông báo cho chúng tôi biết ý kiến của người chơi, những gì họ thích và không thích và những điều họ mong muốn!" ambassador_join_desc: "kể cho chúng tôi một chút về bạn, bạn đã làm gì và bạn hứng thú làm gì. Chúng ta sẽ cùng bắt đầu từ đó!" ambassador_join_note_strong: "Chú thích" @@ -1564,7 +1564,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn helpful_ambassadors: "Những Sứ Giả đầy hữu ích của chúng tôi:" ladder: -# please_login: "Please log in first before playing a ladder game." + please_login: "Hãy đăng nhập để tham gia thi đấu." my_matches: "Những trận đấu của tôi" # simulate: "Simulate" # simulation_explanation: "By simulating games you can get your game ranked faster!" @@ -1574,7 +1574,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn # games_simulated_for: "Games simulated for you:" # games_in_queue: "Games currently in the queue:" # games_simulated: "Games simulated" -# games_played: "Games played" + games_played: "Các game đã chơi" ratio: "Tỷ lệ" leaderboard: "Bạng xếp hạng" # battle_as: "Battle as " @@ -1593,40 +1593,40 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn # code_being_simulated: "Your new code is being simulated by other players for ranking. This will refresh as new matches come in." # no_ranked_matches_pre: "No ranked matches for the " # no_ranked_matches_post: " team! Play against some competitors and then come back here to get your game ranked." -# choose_opponent: "Choose an Opponent" -# select_your_language: "Select your language!" -# tutorial_play: "Play Tutorial" + choose_opponent: "Chọn một đối thủ" + select_your_language: "Lựa chọn ngôn ngữ của bạn!" + tutorial_play: "Chạy hướng dẫn" # tutorial_recommended: "Recommended if you've never played before" # tutorial_skip: "Skip Tutorial" # tutorial_not_sure: "Not sure what's going on?" # tutorial_play_first: "Play the Tutorial first." # simple_ai: "Simple CPU" -# warmup: "Warmup" + warmup: "Khởi động" # friends_playing: "Friends Playing" -# log_in_for_friends: "Log in to play with your friends!" -# social_connect_blurb: "Connect and play against your friends!" -# invite_friends_to_battle: "Invite your friends to join you in battle!" -# fight: "Fight!" + log_in_for_friends: "Đăng nhập để chơi với bàn bè!" + social_connect_blurb: "Kết nối và chơi với bạn bè!" + invite_friends_to_battle: "Mời bạn bè tham gia thi đấu tranh tài!" + fight: "Chiến!" # watch_victory: "Watch your victory" # defeat_the: "Defeat the" -# watch_battle: "Watch the battle" + watch_battle: "Xem trận đấu" # tournament_started: ", started" -# tournament_ends: "Tournament ends" -# tournament_ended: "Tournament ended" -# tournament_rules: "Tournament Rules" + tournament_ends: "Giải đấu kết thúc" + tournament_ended: "Giải đấu đã kết thúc" + tournament_rules: "Luật lệ giải đấu" # tournament_blurb: "Write code, collect gold, build armies, crush foes, win prizes, and upgrade your career in our $40,000 Greed tournament! Check out the details" # tournament_blurb_criss_cross: "Win bids, construct paths, outwit opponents, grab gems, and upgrade your career in our Criss-Cross tournament! Check out the details" # tournament_blurb_zero_sum: "Unleash your coding creativity in both gold gathering and battle tactics in this alpine mirror match between red sorcerer and blue sorcerer. The tournament began on Friday, March 27 and will run until Monday, April 6 at 5PM PDT. Compete for fun and glory! Check out the details" # tournament_blurb_ace_of_coders: "Battle it out in the frozen glacier in this domination-style mirror match! The tournament began on Wednesday, September 16 and will run until Wednesday, October 14 at 5PM PDT. Check out the details" # tournament_blurb_blog: "on our blog" rules: "Những điều lệ" -# winners: "Winners" + winners: "Những người thắng cuộc" # league: "League" -# red_ai: "Red CPU" # "Red AI Wins", at end of multiplayer match playback -# blue_ai: "Blue CPU" -# wins: "Wins" # At end of multiplayer match playback -# humans: "Red" # Ladder page display team name -# ogres: "Blue" + red_ai: "CPU Đỏ" # "Red AI Wins", at end of multiplayer match playback + blue_ai: "CPU Xanh" + wins: "Những chiến thắng" # At end of multiplayer match playback + humans: "Đỏ" # Ladder page display team name + ogres: "Xanh" user: stats: "Chỉ số" @@ -1660,7 +1660,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn payments: "Thanh Toán" prepaid_codes: "Mã Trả Trước" purchased: "Đã Thanh Toán" - subscription: "Subscription" + subscription: "Dịch vụ nâng cao" invoices: "Hóa Đơn" service_apple: "Apple" service_web: "Web" @@ -1722,16 +1722,16 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn login_required: "Yêu cầu đăng nhập" login_required_desc: "Bạn cần đăng nhập để truy cập trang này." unauthorized: "Bạn cần đăng nhập. Bạn có vô hiệu hóa cookie không?" -# forbidden: "Forbidden" + forbidden: "Cấm" forbidden_desc: "Ôi không, không có gì ở đây cả! Hãy đảm bảo rằng bạn đăng nhập đúng tài khoản, hoặc truy cập trong số những đường link phía dưới để quay lại tiếp tục lập trình!" not_found: "Không tìm thấy" not_found_desc: "Hm, chả có gì ở đây cả. Truy cập một trong số những đường link phía dưới để quay lại tiếp tục lập trình!" -# not_allowed: "Method not allowed." -# timeout: "Server Timeout" + not_allowed: "Phương thức không được phép." + timeout: "Máy chủ timeout" conflict: "Xung đột tài nguyên." bad_input: "Lỗi đầu vào." server_error: "Lỗi server." -# unknown: "Unknown Error" + unknown: "Lỗi không xác định" error: "LỖI" general_desc: "Có lỗi xảy ra, và có thể là lỗi do chúng tôi. Hãy cố đợi một lát và tải lại trang, hoặc truy cập một trong số những đường link phía dưới để quay lại tiếp tục lập trình!" @@ -1753,7 +1753,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn arithmetic: "Toán tử" arrays: "Mảng" basic_syntax: "Cú pháp cơ bản" -# boolean_logic: "Boolean Logic" + boolean_logic: "Luận lý Boolean" break_statements: "Câu lệnh Break" classes: "Lớp" continue_statements: "Câu lệnh Continue" @@ -1766,7 +1766,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn # object_literals: "Object Literals" parameters: "Tham số" strings: "Chuỗi" - variables: "Biến" + variables: "Biến số" vectors: "Các Vector" while_loops: "Vòng lặp While" recursion: "Đệ quy" diff --git a/app/locale/zh-HANT.coffee b/app/locale/zh-HANT.coffee index 66b67eb85..62d408037 100644 --- a/app/locale/zh-HANT.coffee +++ b/app/locale/zh-HANT.coffee @@ -25,7 +25,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese im_a_student: "我是學生"#"I'm a Student" learn_more: "了解更多"#"Learn more" classroom_in_a_box: "在一個盒子中的電腦科學教室。"#"A classroom in-a-box for teaching computer science." - codecombat_is: "CodeCombat是一個給<strong>學生</strong>透過進行遊戲來學習電腦科學的平台。" #"CodeCombat is a platform <strong>for students</strong> to learn computer science while playing through a real game." + codecombat_is: "CodeCombat是一個讓<strong>學生</strong>透過進行遊戲來學習電腦科學的平台。" #"CodeCombat is a platform <strong>for students</strong> to learn computer science while playing through a real game." our_courses: "我們的課程經過特別的遊戲測試來<strong>超越教室教學</strong>,甚至是超越一些先前沒有編程經驗的老師。" #"Our courses have been specifically playtested to <strong>excel in the classroom</strong>, even by teachers with little to no prior programming experience." top_screenshots_hint: "學生們寫程式碼並且隨時觀看他們的更新。" #"Students write code and see their changes update in real-time" designed_with: "在心中與老師們一起設計"#"Designed with teachers in mind" @@ -37,31 +37,31 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese teaching_computer_science: "教電腦科學不需要一個昂貴的學位,因為我們提供了工具來支援各種背景的教學者。" #"Teaching computer science does not require a costly degree, because we provide tools to support educators of all backgrounds." accessible_to: "提供給" everyone: "每一個人" - democratizing: "使學習寫程式碼的過程民主化是我們的核心理念,每個人都應該有機會學習寫程式。" #"Democratizing the process of learning coding is at the core of our philosophy. Everyone should be able to learn to code." + democratizing: "使學習寫程式碼的過程普及化是我們的核心理念,每個人都應該有機會學習寫程式。" #"Democratizing the process of learning coding is at the core of our philosophy. Everyone should be able to learn to code." forgot_learning: "我認為他們甚至忘記了他們正在學東西。" #"I think they actually forgot that they were actually learning something." wanted_to_do: "寫程式一直是我想要做的事情,而我從不認為我會在學校裡學到它們。" #" Coding is something I've always wanted to do, and I never thought I would be able to learn it in school." why_games: "為何從遊戲中學習是很重要的呢?" #"Why is learning through games important?" games_reward: "遊戲鼓勵我們良性競爭。"#"Games reward the productive struggle." - encourage: "遊戲是一個促進互動、發現及嘗試錯誤的媒介,一款好的遊戲可以試煉玩家並隨著時間精進技巧,這個過程與學生在學習中的經歷是同等重要的。" #"Gaming is a medium that encourages interaction, discovery, and trial-and-error. A good game challenges the player to master skills over time, which is the same critical process students go through as they learn." + encourage: "遊戲是一個促進互動、發現及嘗試錯誤的媒介,一款好的遊戲可以試煉玩家並隨著時間精進技巧,這個過程與學生在學校學習中所經歷到的一樣重要。" #"Gaming is a medium that encourages interaction, discovery, and trial-and-error. A good game challenges the player to master skills over time, which is the same critical process students go through as they learn." excel: "遊戲在獎勵方面勝出" #"Games excel at rewarding" struggle: "良性競爭"#"productive struggle" - kind_of_struggle: "那種競爭使學習是引人入勝的,並且"#"the kind of struggle that results in learning that’s engaging and" + kind_of_struggle: "那種競爭使學習引人入勝,並且"#"the kind of struggle that results in learning that’s engaging and" motivating: "有動力"#"motivating" - not_tedious: "不乏味"#"not tedious." + not_tedious: "不乏味。"#"not tedious." gaming_is_good: "研究指出玩遊戲對小孩的大腦是有幫助的。(這是真的!)"#"Studies suggest gaming is good for children’s brains. (it’s true!)" game_based: "在基於遊戲的學習系統中這是"#"When game-based learning systems are" - compared: "相符的" - conventional: "相較於傳統評定方式的差異很明顯,「遊戲在保存知識和保持專注上是有幫助的」。" #"against conventional assessment methods, the difference is clear: games are better at helping students retain knowledge, concentrate and" - perform_at_higher_level: "表現更高的成就水平"#"perform at a higher level of achievement" + compared: "相符的。" + conventional: "與傳統評定方式比較有很明顯的不同,「遊戲在保存知識和保持專注上是有幫助的」。" #"against conventional assessment methods, the difference is clear: games are better at helping students retain knowledge, concentrate and" + perform_at_higher_level: "展現更高的成就水平"#"perform at a higher level of achievement" feedback: "遊戲中也提供了及時回饋,讓學生去調整他們的解決途徑,並且更完整的了解學習內容,而不是被解答的正確或是不正確限制住。" #"Games also provide real-time feedback that allows students to adjust their solution path and understand concepts more holistically, instead of being limited to just “correct” or “incorrect” answers." real_game: "這是一個真正的遊戲,利用真正寫入程式碼來遊玩。"#"A real game, played with real coding." - great_game: "一個很棒的遊戲不只有徽章和成就--它更是玩家的旅程、設計優良的關卡,以及藉由幫助和信心完成挑戰的能力。" # "A great game is more than just badges and achievements - it’s about a player’s journey, well-designed puzzles, and the ability to tackle challenges with agency and confidence." + great_game: "一個很棒的遊戲不只有徽章和成就--它更是玩家的旅程、設計優良的關卡,以及藉由信心和幫助完成挑戰的能力。" # "A great game is more than just badges and achievements - it’s about a player’s journey, well-designed puzzles, and the ability to tackle challenges with agency and confidence." agency: "CodeCombat是一個能給予玩家協助以及信心的遊戲,藉由我們強大的程式碼鍵入引擎,可以幫助新手或是進階的學生寫出正確的、有效的程式碼。" #"CodeCombat is a game that gives players that agency and confidence with our robust typed code engine, which helps beginner and advanced students alike write proper, valid code." request_demo_title: "讓您的學生從今天開始遊玩吧!"#"Get your students started today!" - request_demo_subtitle: "要求一個demo示範版本,並且讓您的學生在一個小時之內就能上手。"#"Request a demo and get your students started in less than an hour." + request_demo_subtitle: "申請一個demo示範版本,並且讓您的學生在一個小時之內就能上手。"#"Request a demo and get your students started in less than an hour." get_started_title: "現在就設立您的班級。"#"Set up your class today" get_started_subtitle: "設立一個班級、加入您的學生,並在他們學習電腦科學的過程中掌握他們的進度。"#"Set up a class, add your students, and monitor their progress as they learn computer science." - request_demo: "要求一個demo示範版本"#"Request a Demo" + request_demo: "申請一個demo示範版本"#"Request a Demo" setup_a_class: "設立一個班級"#"Set Up a Class" have_an_account: "您是否擁有一個帳號?"#"Have an account?" logged_in_as: "您現在登入的身分為"#"You are currently logged in as" @@ -69,7 +69,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese computer_science: "全年齡向的電腦科學課程"#"Computer science courses for all ages" show_me_lesson_time: "顯示課程預估時間:"#"Show me lesson time estimates for:" curriculum: "課程總共時數:"#"Total curriculum hours:" - ffa: "對所有學生都是免費的"#"Free for all students" + ffa: "學生免費"#"Free for all students" lesson_time: "課程時間:"#"Lesson time:" coming_soon: "敬請期待!"#"Coming soon!" courses_available_in: "包含JavaScript, Python,和Java的課程(敬請期待!)"#"Courses are available in JavaScript, Python, and Java (coming soon!)" @@ -107,7 +107,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese stats: "記錄" code: "程式碼" home: "首頁" - contribute: "貢獻" + contribute: "幫助我們" legal: "版權聲明" about: "關於" contact: "聯繫我們" @@ -124,9 +124,9 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese jobs: "工作" schools: "學校" educator_wiki: "教育者 Wiki" - get_involved: "參與其中" + get_involved: "親身參與" open_source: "開源 (GitHub)" - support: "支援" + support: "取得幫助" faqs: "FAQs常見問題" help_pref: "需要協助嗎? 寫封Email給我們" help_suff: "然後我們會與您接觸!" @@ -139,7 +139,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese page_not_found: "找不到網頁" diplomat_suggestion: - title: "幫我們翻譯CodeCombat" # This shows up when a player switches to a non-English language using the language selector. + title: "幫助我們翻譯CodeCombat" # This shows up when a player switches to a non-English language using the language selector. sub_heading: "我們需要您的語言技能" pitch_body: "我們開發了CodeCombat的英文版,但是現在我們的玩家遍佈全球。很多人想玩中文版的,卻不會說英文,所以如果您中英文都會,請考慮一下參加我們的翻譯工作,幫忙把 CodeCombat 網站還有所有的關卡翻譯成中文(繁體)。" missing_translations: "直至所有正體中文的翻譯完畢,當無法提供正體中文時還會以英文顯示。" @@ -168,7 +168,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese next: "下一步" # Go from choose hero to choose inventory before playing a level change_hero: "更換英雄" # Go back from choose inventory to choose hero buy_gems: "購買寶石" - subscription_required: "需要訂購" + subscription_required: "訂閱限定" anonymous: "匿名玩家" level_difficulty: "難度" play_classroom_version: "遊玩教室版本" # Choose a level in campaign version that you also can play in one of your courses @@ -179,7 +179,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese adjust_volume: "調整音量" campaign_multiplayer: "多人競技場" campaign_multiplayer_description: "…在這裡您可以和其他玩家進行對戰。" - campaign_old_multiplayer: "(過時的)舊多人競技場" + campaign_old_multiplayer: "(過時的)舊的多人競技場" campaign_old_multiplayer_description: "多個文明時代的遺跡。已沒有模擬運行這些陳舊、英雄蕪絕的多人競技場。" code: @@ -387,7 +387,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese done: "完成" next_level: "下一個關卡:" next_game: "下一個遊戲" - show_menu: "顯示遊戲菜單" + show_menu: "顯示遊戲選單" home: "首頁" # Not used any more, will be removed soon. level: "關卡" # Like "Level: Dungeons of Kithgard" skip: "跳過" @@ -425,11 +425,11 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese victory_new_item: "新的物品" victory_viking_code_school: "太厲害了,您剛完成了非常困難的關卡!如果您想成為一個軟體開發人員,您就應該去試一下Viking Code School。在這裡您可以把您的知識增長到另一個台階。只需要14個星期您就能成為一個專業的網頁開發人員。" victory_become_a_viking: "成為一個維京人。" - victory_no_progress_for_teachers: "老師不能保存進度,但是您可以將自己的帳號加入班級作為學生來保存進度。" #"Progress is not saved for teachers. But, you can add a student account to your classroom for yourself." + victory_no_progress_for_teachers: "老師們不能保存進度,但是您可以將自己的帳號加入班級作為學生來保存進度。" #"Progress is not saved for teachers. But, you can add a student account to your classroom for yourself." guide_title: "指南" - tome_cast_button_run: "運作" - tome_cast_button_running: "運作中" - tome_cast_button_ran: "已運作" + tome_cast_button_run: "運行" + tome_cast_button_running: "運行中" + tome_cast_button_ran: "已運行" tome_submit_button: "送出" tome_reload_method: "重新載入該方法的原程式碼" # Title text for individual method reload button. tome_select_method: "選擇一個方法" @@ -522,7 +522,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese tip_mulan: "相信你可以做到,然後你就會做到。 - Mulan"#"Believe you can, then you will. - Mulan" game_menu: - inventory_tab: "倉庫" + inventory_tab: "道具欄" save_load_tab: "保存/載入" options_tab: "選項" guide_tab: "導引" @@ -546,16 +546,16 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese week: "這週" all: "長期以來" time: "時間" - damage_taken: "遭受的攻擊" - damage_dealt: "造成的攻擊" + damage_taken: "遭受的傷害" + damage_dealt: "造成的傷害" difficulty: "困難度" gold_collected: "收集的黃金" inventory: equipped_item: "已裝備" - required_purchase_title: "需要" - available_item: "可使用" - restricted_title: "被限制" + required_purchase_title: "必要的" + available_item: "可使用的" + restricted_title: "被限制的" should_equip: "連點物品兩下可裝備" equipped: "(已裝備)" locked: "(需解鎖)" @@ -578,12 +578,12 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese subscribe: comparison_blurb: "訂閱 CodeCombat 來磨練您的技巧!" - feature1: "__levelsCount__ 個以上的基本關卡分散在__worldsCount__張地圖中" # {change} + feature1: "__levelsCount__ 個以上的基本關卡分佈在__worldsCount__張地圖中" # {change} feature2: "__heroesCount__ 個強力的<strong>新英雄</strong>並且每位都有不同技能!" # {change} feature3: "__bonusLevelsCount__ 個以上的額外關卡" # {change} feature4: "每個月<strong>{{gems}}顆額外寶石</strong>!" feature5: "影片教學" - feature6: "頂級信箱支援" + feature6: "高級郵件幫助" feature7: "私密<strong>部落</strong>" feature8: "<strong>沒有廣告!</strong>" free: "免費" @@ -744,7 +744,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese scott_title: "共同創辦人" # {change} scott_blurb: "理性至上" # maka_title: "Customer Advocate" -# maka_blurb: "Storyteller" + maka_blurb: "說書人" rob_title: "編譯工程師" # {change} rob_blurb: "編寫一些的程式碼" josh_c_title: "遊戲設計師" @@ -1184,9 +1184,9 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese # last_level: "Last Level" # welcome_to_hoc: "Adventurers, welcome to our Hour of Code!" # logged_in_as: "Logged in as:" -# not_you: "Not you?" + not_you: "不是您嗎?"#"Not you?" # welcome_back: "Hi adventurer, welcome back!" -# continue_playing: "Continue Playing" + continue_playing: "繼續進行遊戲"#"Continue Playing" # more_options: "More options:" # option1_header: "Option 1: Invite students via email" # option1_body: "Students will automatically be sent an invitation to join this class, and will need to create an account with a username and password." @@ -1223,7 +1223,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese # class_code: "Class Code" # optional_ask: "optional - ask your teacher to give you one!" # optional_school: "optional - what school do you go to?" -# start_playing: "Start Playing" + start_playing: "開始遊戲"#"Start Playing" # skip_this: "Skip this, I'll create an account later!" # welcome: "Welcome" # getting_started: "Getting Started with Courses" @@ -1398,7 +1398,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese ambassador_title: "使節" ambassador_title_description: "(Support)" ambassador_summary: "安撫我們論壇的用戶並且提供發問者適當的方向。我們的使節代表CodeCombat面對全世界。" -# teacher_title: "Teacher" + teacher_title: "教師"#"Teacher" editor: main_title: "CodeCombat編輯器" @@ -1407,7 +1407,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese level_title: "關卡編輯器" achievement_title: "目標編輯器" poll_title: "投票編輯器" - back: "後退" + back: "返回" revert: "還原" revert_models: "還原模式" pick_a_terrain: "選擇地形" @@ -1511,7 +1511,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese join_desc_3: ",或者找到我們在" join_desc_4: "讓我們從這開始!" join_url_email: "發信給我們" -# join_url_slack: "public Slack channel" + join_url_slack: "公共休閒頻道"#"public Slack channel" archmage_subscribe_desc: "取得郵件關於新的編程機會和公告。" artisan_introduction_pref: "我們必須建造更多的關卡!大家為了更多的內容在高聲吶喊,但只靠我們只能建造這麼多。現在您的工作場所就是一關;我們的關卡編輯器是勉強可用的,所以請小心。只要您有新的靈感,不論從簡單的 for-loops 到" artisan_introduction_suf: ",那個這職業會適合您。" @@ -1555,7 +1555,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese ambassador_join_note_strong: "注意" ambassador_join_note_desc: "其中一件我們優先要做的事情是建立多人連線,玩家將面臨難以獨自解決的關卡而且可以招喚更高等級的法師來幫助。這將對於使節是一個很棒的方式來完成自己的責任。我們會及時地向大家公佈!" ambassador_subscribe_desc: "取得更新和多人連線開發的郵件。" -# teacher_subscribe_desc: "Get emails on updates and announcements for teachers." + teacher_subscribe_desc: "取得給教師的更新以及消息。"#"Get emails on updates and announcements for teachers." changes_auto_save: "當您勾選後,改變將自動儲存。" diligent_scribes: "我們勤奮的文書:" powerful_archmages: "我們強勁的大法師:" @@ -1565,11 +1565,11 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese helpful_ambassadors: "我們善於幫助的使節:" ladder: - please_login: "在參與對弈前請先登入。" + please_login: "在參與對戰前請先登入。" my_matches: "我的對手" simulate: "模擬" simulation_explanation: "通過模擬遊戲,您可以使您的遊戲更快得到評分!" - simulation_explanation_leagues: "你會主要給在你的部落或者課程的同伴幫忙模擬遊戲。" + simulation_explanation_leagues: "你主要會為你的部落或者課程的同伴幫忙模擬遊戲。" simulate_games: "模擬遊戲!" games_simulated_by: "您模擬過的次數:" games_simulated_for: "替您模擬的次數:" @@ -1590,14 +1590,14 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese rank_failed: "評分失敗" rank_being_ranked: "已評分" rank_last_submitted: "已上傳 " - help_simulate: "模擬遊戲需要幫助?" + help_simulate: "幫我們模擬遊戲?" code_being_simulated: "您的新程式碼正在被其他人模擬評分中。分數將隨每次新的配對而更新。" no_ranked_matches_pre: "對這個隊伍尚未有評分過的配對!" - no_ranked_matches_post: " 在別人的戰場上扮演競爭者並且回到這使您的程式碼接受評分。" + no_ranked_matches_post: " 在別人的戰場上扮演對手並且回到這使您的程式碼接受評分。" choose_opponent: "選擇對手" select_your_language: "選擇您的語言!" tutorial_play: "教學" - tutorial_recommended: "如果您尚未玩過,推薦先嘗試教學" + tutorial_recommended: "如果您尚未玩過,建議先嘗試教學" tutorial_skip: "略過教學" tutorial_not_sure: "不確定發生啥事?" tutorial_play_first: "先嘗試教學" @@ -1650,11 +1650,11 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese amount_achieved: "數量" achievement: "成就" current_xp_prefix: "當前總共" - current_xp_postfix: "經驗" + current_xp_postfix: "經驗值" new_xp_prefix: "獲得" - new_xp_postfix: "經驗" + new_xp_postfix: "經驗值" left_xp_prefix: "還需要" - left_xp_infix: "經驗" + left_xp_infix: "經驗值" left_xp_postfix: "到下一個等級" account: @@ -1694,13 +1694,13 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese # purchase_code1: "Subscription Codes can be redeemed to add premium subscription time to one or more CodeCombat accounts." # purchase_code2: "Each CodeCombat account can only redeem a particular Subscription Code once." # purchase_code3: "Subscription Code months will be added to the end of any existing subscription on the account." -# users: "Users" -# months: "Months" + users: "使用者"#"Users" + months: "月數"#"Months" purchase_total: "總共" purchase_button: "提交購買" your_codes: "你的訂閱碼:" # {change} redeem_codes: "兌換訂閱碼" -# prepaid_code: "Prepaid Code" + prepaid_code: "預付代碼"#"Prepaid Code" # lookup_code: "Lookup prepaid code" # apply_account: "Apply to your account" # copy_link: "You can copy the code's link and send it to someone." @@ -1847,7 +1847,7 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese nutshell_description: "我們在關卡編輯器裡公開的任何資源,您都可以在製作關卡時隨意使用,但我們保留在 codecombat.com 之上創建的關卡本身傳播的權利,因為我們往後可能決定以它們收費。" canonical: "我們宣告這篇說明的英文版本是權威版本。如果各個翻譯版本之間有任何衝突,以英文版為準。" third_party_title: "第三方服務" -# third_party_description: "CodeCombat uses the following third party services (among others):" + third_party_description: "CodeCombat使用下列的第三方服務"#"CodeCombat uses the following third party services (among others):" ladder_prizes: title: "錦標賽獎項" # This section was for an old tournament and doesn't need new translations now. @@ -1869,19 +1869,19 @@ module.exports = nativeDescription: "繁體中文", englishDescription: "Chinese license: "許可證" oreilly: "您選擇的電子書" -# calendar: -# year: "Year" -# day: "Day" -# month: "Month" -# january: "January" -# february: "February" -# march: "March" -# april: "April" -# may: "May" -# june: "June" -# july: "July" -# august: "August" -# september: "September" -# october: "October" -# november: "November" -# december: "December" + calendar: + year: "年"#"Year" + day: "日"#"Day" + month: "月"#"Month" + january: "一月"#"January" + february: "二月"#"February" + march: "三月"#"March" + april: "四月"#"April" + may: "五月"#"May" + june: "六月"#"June" + july: "七月"#"July" + august: "八月"#"August" + september: "九月"#"September" + october: "十月"#"October" + november: "十一月"#"November" + december: "十二月"#"December" diff --git a/app/models/Classroom.coffee b/app/models/Classroom.coffee index d75d0ab47..bc1c987a9 100644 --- a/app/models/Classroom.coffee +++ b/app/models/Classroom.coffee @@ -1,6 +1,7 @@ CocoModel = require './CocoModel' schema = require 'schemas/models/classroom.schema' utils = require 'core/utils' +User = require 'models/User' module.exports = class Classroom extends CocoModel @className: 'Classroom' @@ -10,6 +11,15 @@ module.exports = class Classroom extends CocoModel initialize: () -> @listenTo @, 'change:aceConfig', @capitalizeLanguageName super(arguments...) + + parse: (obj) -> + if obj._id + # It's just the classroom object + return obj + else + # It's a compound response with other stuff too + @owner = new User(obj.owner) + return obj.data capitalizeLanguageName: -> language = @get('aceConfig')?.language @@ -17,9 +27,19 @@ module.exports = class Classroom extends CocoModel joinWithCode: (code, opts) -> options = { - url: _.result(@, 'url') + '/~/members' + url: @urlRoot + '/~/members' type: 'POST' data: { code: code } + success: => @trigger 'join:success' + error: => @trigger 'join:error' + } + _.extend options, opts + @fetch(options) + + fetchByCode: (code, opts) -> + options = { + url: _.result(@, 'url') + data: { code: code, "with-owner": true } } _.extend options, opts @fetch(options) @@ -32,6 +52,16 @@ module.exports = class Classroom extends CocoModel } _.extend options, opts @fetch(options) + + setStudentPassword: (student, password, options) -> + classroomID = @.id + $.ajax { + url: "/db/classroom/#{classroomID}/members/#{student.id}/reset-password" + method: 'POST' + data: { password } + success: => @trigger 'save-password:success' + error: (response) => @trigger 'save-password:error', response.responseJSON + } getLevels: (options={}) -> # options: courseID, withoutLadderLevels diff --git a/app/models/Prepaid.coffee b/app/models/Prepaid.coffee index 55c6bd936..360a21f50 100644 --- a/app/models/Prepaid.coffee +++ b/app/models/Prepaid.coffee @@ -19,3 +19,24 @@ module.exports = class Prepaid extends CocoModel maxRedeemers = @get('maxRedeemers') if _.isString(maxRedeemers) @set 'maxRedeemers', parseInt(maxRedeemers) + + status: -> + endDate = @get('endDate') + if endDate and new Date(endDate) < new Date() + return 'expired' + + startDate = @get('startDate') + if startDate and new Date(startDate) > new Date() + return 'pending' + + if @openSpots() <= 0 + return 'empty' + + return 'available' + + redeem: (user, options={}) -> + options.url = _.result(@, 'url')+'/redeemers' + options.type = 'POST' + options.data ?= {} + options.data.userID = user.id or user + @fetch(options) diff --git a/app/models/StripeCoupon.coffee b/app/models/StripeCoupon.coffee new file mode 100644 index 000000000..f3ee4f195 --- /dev/null +++ b/app/models/StripeCoupon.coffee @@ -0,0 +1,19 @@ +CocoModel = require './CocoModel' + +module.exports = class StripeCoupon extends CocoModel + @className: 'StripeCoupon' + @schema: {} + urlRoot: '/stripe/coupons' + idAttribute: 'id' + + formatString: -> + bits = [@id] + if @get('percent_off') + bits.push "(#{@get('percent_off')}% off)" + else if @get('amount_off') + bits.push "($#{@get('amount_off')} off)" + if @get('duration') + bits.push "(duration: #{@get('duration')})" + if @redeem_by + bits.push "(redeem by: #{moment(@get('redeem_by')).format('lll')}" + return bits.join(' ') diff --git a/app/models/User.coffee b/app/models/User.coffee index 8f9933796..3f8720f16 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -23,8 +23,8 @@ module.exports = class User extends CocoModel return name if name name = _.filter([@get('firstName'), @get('lastName')]).join(' ') return name if name - email = @get('email') - return email if email + [emailName, emailDomain] = @get('email')?.split('@') or [] + return emailName if emailName return 'Anoner' getPhotoURL: (size=80, useJobProfilePhoto=false, useEmployerPageAvatar=false) -> @@ -35,6 +35,9 @@ module.exports = class User extends CocoModel return "#{photoURL}#{prefix}s=#{size}" if photoURL.search('http') isnt -1 # legacy return "/file/#{photoURL}#{prefix}s=#{size}" return "/db/user/#{@id}/avatar?s=#{size}&employerPageAvatar=#{useEmployerPageAvatar}" + + getRequestVerificationEmailURL: -> + @url() + "/request-verify-email" getSlugOrID: -> @get('slug') or @get('_id') @@ -65,6 +68,11 @@ module.exports = class User extends CocoModel isTeacher: -> return @get('role') in ['teacher', 'technology coordinator', 'advisor', 'principal', 'superintendent', 'parent'] + + justPlaysCourses: -> + # This heuristic could be better, but currently we don't add to me.get('courseInstances') for single-player anonymous intro courses, so they have to beat a level without choosing a hero. + return true if me.get('role') is 'student' + return me.get('stats')?.gamesCompleted and not me.get('heroConfig') isSessionless: -> # TODO: Fix old users who got mis-tagged as teachers @@ -205,12 +213,27 @@ module.exports = class User extends CocoModel return true if me.hasSubscription() return false - isEnrolled: -> - Boolean(@get('coursePrepaidID')) - isOnPremiumServer: -> me.get('country') in ['china', 'brazil'] - + + sendVerificationCode: (code) -> + $.ajax({ + method: 'POST' + url: "/db/user/#{@id}/verify/#{code}" + success: (attributes) => + this.set attributes + @trigger 'email-verify-success' + error: => + @trigger 'email-verify-error' + }) + + isEnrolled: -> @prepaidStatus() is 'enrolled' + + prepaidStatus: -> # 'not-enrolled', 'enrolled', 'expired' + coursePrepaid = @get('coursePrepaid') + return 'not-enrolled' unless coursePrepaid + return 'enrolled' unless coursePrepaid.endDate + return if coursePrepaid.endDate > new Date().toISOString() then 'enrolled' else 'expired' # Function meant for "me" @@ -266,6 +289,12 @@ module.exports = class User extends CocoModel options.data.facebookID = facebookID options.data.facebookAccessToken = application.facebookHandler.token() @fetch(options) + + makeCoursePrepaid: -> + coursePrepaid = @get('coursePrepaid') + return null unless coursePrepaid + Prepaid = require 'models/Prepaid' + return new Prepaid(coursePrepaid) becomeStudent: (options={}) -> options.url = '/db/user/-/become-student' diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee index 6df2c44c1..0d7148026 100644 --- a/app/schemas/models/level.coffee +++ b/app/schemas/models/level.coffee @@ -20,7 +20,7 @@ defaultTasks = [ 'Choose music file in Introduction script.' 'Choose autoplay in Introduction script.' - 'Add Clojure/Lua/CoffeeScript.' + 'Add Lua/CoffeeScript/Java.' 'Write the description.' 'Write the guide.' diff --git a/app/schemas/models/prepaid.schema.coffee b/app/schemas/models/prepaid.schema.coffee index 51be47d1d..08ea80845 100644 --- a/app/schemas/models/prepaid.schema.coffee +++ b/app/schemas/models/prepaid.schema.coffee @@ -6,11 +6,13 @@ PrepaidSchema = c.object({title: 'Prepaid', required: ['creator', 'type']}, { c.object {required: ['date', 'userID']}, date: c.date {title: 'Redeemed date'} userID: c.objectId(links: [ {rel: 'extra', href: '/db/user/{($)}'} ]) - maxRedeemers: { type: 'integer'} + maxRedeemers: { type: 'integer' } code: c.shortString(title: "Unique code to redeem") type: { type: 'string' } - properties: {type: 'object'} + properties: {type: 'object' } exhausted: { type: 'boolean' } + startDate: c.stringDate() + endDate: c.stringDate() }) c.extendBasicProperties(PrepaidSchema, 'prepaid') diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 40b76ef01..0f7936205 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -50,13 +50,14 @@ visa = c.shortString _.extend UserSchema.properties, email: c.shortString({title: 'Email', format: 'email'}) + emailVerified: { type: 'boolean' } iosIdentifierForVendor: c.shortString({format: 'hidden'}) firstName: c.shortString({title: 'First Name'}) lastName: c.shortString({title: 'Last Name'}) gender: {type: 'string'} # , 'enum': ['male', 'female', 'secret', 'trans', 'other'] # NOTE: ageRange enum changed on 4/27/16 from ['0-13', '14-17', '18-24', '25-34', '35-44', '45-100'] ageRange: {type: 'string'} # 'enum': ['13-15', '16-17', '18-24', '25-34', '35-44', '45-100'] - password: {type: 'string', maxLength: 256, minLength: 2, title: 'Password'} + password: c.passwordString passwordReset: {type: 'string'} photoURL: {type: 'string', format: 'image-file', title: 'Profile Picture', description: 'Upload a 256x256px or larger image to serve as your profile picture.'} @@ -327,6 +328,16 @@ _.extend UserSchema.properties, coursePrepaidID: c.objectId({ description: 'Prepaid which has paid for this user\'s course access' }) + coursePrepaid: { + type: 'object' + properties: { + _id: c.objectId() + startDate: c.stringDate() + endDate: c.stringDate() + } + } + enrollmentRequestSent: { type: 'boolean' } + schoolName: {type: 'string'} role: {type: 'string', enum: ["God", "advisor", "parent", "principal", "student", "superintendent", "teacher", "technology coordinator"]} birthday: c.stringDate({title: "Birthday"}) diff --git a/app/schemas/schemas.coffee b/app/schemas/schemas.coffee index c99f83b28..f9be247c2 100644 --- a/app/schemas/schemas.coffee +++ b/app/schemas/schemas.coffee @@ -15,6 +15,7 @@ me.object = (ext, props) -> combine({type: 'object', additionalProperties: false me.array = (ext, items) -> combine({type: 'array', items: items or {}}, ext) me.shortString = (ext) -> combine({type: 'string', maxLength: 100}, ext) me.pct = (ext) -> combine({type: 'number', maximum: 1.0, minimum: 0.0}, ext) +me.passwordString = {type: 'string', maxLength: 256, minLength: 2, title: 'Password'} # Dates should usually be strings, ObjectIds should be strings: https://github.com/codecombat/codecombat/issues/1384 me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext) # old diff --git a/app/styles/admin/administer-user-modal.sass b/app/styles/admin/administer-user-modal.sass new file mode 100644 index 000000000..9404ecd90 --- /dev/null +++ b/app/styles/admin/administer-user-modal.sass @@ -0,0 +1,3 @@ +#administer-user-modal + .modal-dialog + width: 90% diff --git a/app/styles/courses/enrollments-view.sass b/app/styles/courses/enrollments-view.sass index 51390e5ea..e3179a5d2 100644 --- a/app/styles/courses/enrollments-view.sass +++ b/app/styles/courses/enrollments-view.sass @@ -1,4 +1,28 @@ +@import "app/styles/bootstrap/variables" + #enrollments-view + + @media (min-width: $screen-md-min) + #prepaids-col + padding-right: 40px + + #actions-col + border-left: 1px solid gray // Will tend to be the longer one + padding-left: 40px + + .prepaid-card + border-radius: 10px + + p + color: white + + h1 + font-size: 68px + + &.pending-prepaid-card + background: #6e939f + + .how-to-enroll padding: 10px ol @@ -8,11 +32,20 @@ border-radius: 5px #students-input - width: 100px - height: 50px - line-height: 30px - font-size: 30px + width: 220px + height: 80px + font-size: 50px &::-webkit-inner-spin-button, &::-webkit-outer-spin-button -webkit-appearance: none margin: 0 + + #enrollment-stats-table + td, th + border: none + + .classroom-name-td + padding-left: 20px + + th + padding-bottom: 10px diff --git a/app/styles/courses/purchase-courses-view.sass b/app/styles/courses/purchase-courses-view.sass deleted file mode 100644 index 1eda341af..000000000 --- a/app/styles/courses/purchase-courses-view.sass +++ /dev/null @@ -1,11 +0,0 @@ -#purchase-courses-view - - .enrollment-count - font-size: 30px - width: 120px - - .not-enrolled - line-height: 16px - - .uppercase - text-transform: uppercase diff --git a/app/styles/courses/teacher-class-view.sass b/app/styles/courses/teacher-class-view.sass index 4d83ab4c0..70ba4d654 100644 --- a/app/styles/courses/teacher-class-view.sass +++ b/app/styles/courses/teacher-class-view.sass @@ -157,8 +157,14 @@ .glyphicon color: $gray-light + .edit-student-link + color: black + .remove-student-link color: $burgandy + + .edit-student-link, .remove-student-link + display: inline-block font-weight: bold text-decoration: underline line-height: 16px @@ -279,3 +285,28 @@ .export-student-progress-btn margin-top: 10px + + // Enrollment Status Tab + + #search-form-group + position: relative + input + width: auto + .glyphicon + color: $gray + position: absolute + top: 8px + right: 5px + + #enrollment-status-table + // These column widths are just to keep the cells from resizing on search + .checkbox-col + width: 75px + .student-info-col + width: 240px + .status-col + width: 300px + .enroll-col + width: 140px + td + vertical-align: middle diff --git a/app/styles/style-flat.sass b/app/styles/style-flat.sass index 376010aee..939de0aa4 100644 --- a/app/styles/style-flat.sass +++ b/app/styles/style-flat.sass @@ -248,6 +248,11 @@ body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang=' background-color: $burgandy color: white + .btn-burgandy-alt + background-color: white + border: 1px solid $burgandy + color: $burgandy + .btn-lg font-size: 18px diff --git a/app/styles/teachers/edit-student-modal.sass b/app/styles/teachers/edit-student-modal.sass new file mode 100644 index 000000000..3e734b945 --- /dev/null +++ b/app/styles/teachers/edit-student-modal.sass @@ -0,0 +1,4 @@ +#edit-student-modal + .new-password-input + width: 300px + text-align: center diff --git a/app/styles/teachers/teachers-contact-modal.sass b/app/styles/teachers/teachers-contact-modal.sass new file mode 100644 index 000000000..54ac06755 --- /dev/null +++ b/app/styles/teachers/teachers-contact-modal.sass @@ -0,0 +1,3 @@ +#teachers-contact-modal + textarea + height: 200px diff --git a/app/styles/test.sass b/app/styles/test.sass index bde200b27..f32c27af2 100644 --- a/app/styles/test.sass +++ b/app/styles/test.sass @@ -1,6 +1,5 @@ #test-view background-color: #eee - margin: 0 20px padding: 0 #test-h2 diff --git a/app/styles/user/email-verified-view.sass b/app/styles/user/email-verified-view.sass new file mode 100644 index 000000000..1c8eabd8a --- /dev/null +++ b/app/styles/user/email-verified-view.sass @@ -0,0 +1,11 @@ +#email-verified-view + .alert + display: flex + align-items: center + justify-content: center + + .glyphicon + font-size: 20pt + + .btn-lg + min-width: 246px diff --git a/app/templates/account/account-settings-view.jade b/app/templates/account/account-settings-view.jade index adb7f45bc..315a5169b 100644 --- a/app/templates/account/account-settings-view.jade +++ b/app/templates/account/account-settings-view.jade @@ -17,7 +17,15 @@ else label.control-label(for="name", data-i18n="general.name") Name input#name-input.form-control(name="name", type="text", value="#{name}") .form-group - label.control-label(for="email", data-i18n="general.email") Email + label.control-label(for="email") + span(data-i18n="general.email") Email + unless me.get('emailVerified') + span.spl ( + span.spr(data-i18n="account.not_yet_verified") + a.resend-verification-email + span.resend-text(data-i18n="account.resend_email") + span.sent-text.hide(data-i18n="account.email_sent") + span ) input#email.form-control(name="email", type="text", value="#{email}") if !isProduction .form-group.checkbox diff --git a/app/templates/account/unsubscribe-view.jade b/app/templates/account/unsubscribe-view.jade index 5eeee57cc..e7fcdfe67 100644 --- a/app/templates/account/unsubscribe-view.jade +++ b/app/templates/account/unsubscribe-view.jade @@ -5,7 +5,7 @@ block content p span(data-i18n="account.unsubscribe") Unsubscribing for span - strong= email + strong= view.email button.btn.btn-warning#unsubscribe-button(data-i18n="account.unsubscribe_button") Do it diff --git a/app/templates/admin/administer-user-modal.jade b/app/templates/admin/administer-user-modal.jade index 001fc413e..00b55b972 100644 --- a/app/templates/admin/administer-user-modal.jade +++ b/app/templates/admin/administer-user-modal.jade @@ -1,54 +1,80 @@ extends /templates/core/modal-base-flat +// DNT + block modal-header-content h3 Administer User - h4 #{user.get('name') || 'Unnamed'} / #{user.get('email')} - span= user.id - + h4 #{view.user.get('name') || 'Unnamed'} / #{view.user.get('email')} + span= view.user.id block modal-body-content - + + h3.m-t-3 Grant Student Licenses + #prepaid-form.form + if view.state === 'creating-prepaid' + .progress.progress-striped.active + .progress-bar(style="width: 100%") + + else if view.state === 'made-prepaid' + .alert.alert-success Licenses created! + + else + .form-group + label Number of Licenses + input#seats-input.form-control(type="number", name="maxRedeemers") + .form-group + label Start Date + input.form-control(type="date" name="startDate" value=moment().format('YYYY-MM-DD')) + .form-group + label End Date + input.form-control(type="date" name="endDate" value=moment().add(1, 'year').format('YYYY-MM-DD'))) + .form-group + button#add-seats-btn.btn.btn-primary Add Licenses + + if view.prepaids.size() + h3.m-t-3 Existing Prepaids + table.table.table-condensed + tr + th ID + th Type + th Start + th End + th Used + for prepaid in view.prepaids.models + tr + td= prepaid.id + td= prepaid.get('type') + td + if prepaid.get('startDate') + = moment(prepaid.get('startDate')).utc().format('lll') + td + if prepaid.get('endDate') + = moment(prepaid.get('endDate')).utc().format('lll') + td #{(prepaid.get('redeemers') || []).length} / #{prepaid.get('maxRedeemers') || 0} + h3 Stripe Benefit .form .form-group .radio label - input(type="radio" name="stripe-benefit" value="" checked=none) + input(type="radio" name="stripe-benefit" value="" checked=view.none) | None .radio label - input(type="radio" name="stripe-benefit" value="free" checked=free) + input(type="radio" name="stripe-benefit" value="free" checked=view.free) | Free .radio label - input(type="radio" name="stripe-benefit" value="free-until" checked=FreeUntil) + input(type="radio" name="stripe-benefit" value="free-until" checked=view.freeUntil) | Free Until - input.form-control.spl(type="date" name="stripe-free-until" value=freeUntilDate)#free-until-date + input.form-control.spl(type="date" name="stripe-free-until" value=view.freeUntilDate)#free-until-date .radio label - input(type="radio" name="stripe-benefit" value="coupon" checked=coupon) + input(type="radio" name="stripe-benefit" value="coupon" checked=view.coupon) | Coupon select.form-control#coupon-select - for couponOption in coupons - option(value=couponOption.id selected=coupon===couponOption.id)= couponOption.format + for coupon in view.coupons.models + option(value=coupon.id selected=coupon.id===view.currentCouponID)= coupon.formatString() button#save-changes.btn.btn-primary Save Changes - - h3 Grant Prepaid for Courses - #prepaid-form.form - if view.state === 'creating-prepaid' - .progress.progress-striped.active - .progress-bar(style="width: 100%") - - else if view.state === 'made-prepaid' - .alert.alert-success Prepaid created! - - else - .form-group - label Seats - input#seats-input.form-control(type="number") - .form-group - button#add-seats-btn.btn.btn-primary Add Seats - -block modal-footer-content - +block modal-footer diff --git a/app/templates/admin/analytics.jade b/app/templates/admin/analytics.jade index 123f467eb..51dc0c100 100644 --- a/app/templates/admin/analytics.jade +++ b/app/templates/admin/analytics.jade @@ -9,25 +9,25 @@ block content .row .col-md-5.big-stat.active-classes div.description Monthly Active Classes - if activeClasses.length > 0 - div.count= activeClasses[0].groups[activeClasses[0].groups.length - 1] + if view.activeClasses.length > 0 + div.count= view.activeClasses[0].groups[view.activeClasses[0].groups.length - 1] .col-md-5.big-stat.recurring-revenue div.description Monthly Recurring Revenue - if revenue.length > 0 - div.count $#{Math.round((revenue[0].groups[revenue[0].groups.length - 1]) / 100)} + if view.revenue.length > 0 + div.count $#{Math.round((view.revenue[0].groups[view.revenue[0].groups.length - 1]) / 100)} .col-md-5.big-stat.classroom-active-users div.description Classroom Monthly Active Users - if activeUsers.length > 0 + if view.activeUsers.length > 0 - var classroomBigMAU = 0; - each count, event in activeUsers[0].events + each count, event in view.activeUsers[0].events if event.indexOf('MAU classroom') >= 0 - classroomBigMAU += count; div.count= classroomBigMAU .col-md-5.big-stat.campaign-active-users div.description Campaign Monthly Active Users - if activeUsers.length > 0 + if view.activeUsers.length > 0 - var campaignBigMAU = 0; - each count, event in activeUsers[0].events + each count, event in view.activeUsers[0].events if event.indexOf('MAU campaign') >= 0 - campaignBigMAU += count; div.count= campaignBigMAU @@ -57,8 +57,8 @@ block content .tab-pane#tab_active_classes h3 Active Classes 90 days .small Active class: 12+ students in a classroom, with 6+ who played in last 30 days. Played == 'Started Level' analytics event. - .small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set - .small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set + .small Paid student: user.coursePrepaid set and prepaid.properties.trialRequestID NOT set + .small Trial student: user.coursePrepaid set and prepaid.properties.trialRequestID set .small Paid class: at least one paid student in the classroom .small Trial class: not paid, at least one trial student in classroom .small Free class: not paid, not trial @@ -71,9 +71,9 @@ block content table.table.table-striped.table-condensed tr th Day - for group in activeClassGroups + for group in view.activeClassGroups th= group.replace('Active classes', '') - each activeClass in activeClasses + each activeClass in view.activeClasses tr td= activeClass.day each val in activeClass.groups @@ -125,9 +125,9 @@ block content table.table.table-striped.table-condensed tr th(style='min-width:85px;') Day - for group in revenueGroups + for group in view.revenueGroups th= group.replace('DRR ', 'Daily ').replace('MRR ', 'Monthly ') - each entry in revenue + each entry in view.revenue tr td= entry.day each val in entry.groups @@ -135,8 +135,8 @@ block content .tab-pane#tab_classroom h3 Classroom Daily Active Users 90 days - .small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set - .small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set + .small Paid student: user.coursePrepaid set and prepaid.properties.trialRequestID NOT set + .small Trial student: user.coursePrepaid set and prepaid.properties.trialRequestID set .small Free student: not paid, not trial .classroom-daily-active-users-chart-90.line-chart-container @@ -149,7 +149,7 @@ block content h3 Classroom Monthly Active Users 365 days .classroom-monthly-active-users-chart-365.line-chart-container - h3 Enrollments Issued and Redeemed 90 days + h3 Licenses Issued and Redeemed 90 days .paid-courses-chart.line-chart-container #furthest-course @@ -159,8 +159,8 @@ block content .small Student: member of a course instance (assigned to course) .small For course instances != Single Player, hourOfCode != true .small Counts are not summed. I.e. a student or teacher only contributes to the count of one course - .small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set - .small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set + .small Paid student: user.coursePrepaid set and prepaid.properties.trialRequestID NOT set + .small Trial student: user.coursePrepaid set and prepaid.properties.trialRequestID set .small Free student: not paid, not trial .small Paid teacher: at least one paid student in course instance .small Trial teacher: at least one trial student in course instance, and no paid students @@ -265,9 +265,9 @@ block content div Loading ... h1 Active Users - if activeUsers.length > 0 + if view.activeUsers.length > 0 - var eventNames = []; - each count, event in activeUsers[0].events + each count, event in view.activeUsers[0].events if event.indexOf('classroom') >= 0 - eventNames.push(event) - eventNames.sort(function (a, b) {return a.localeCompare(b);}); @@ -276,28 +276,28 @@ block content th(style='min-width:85px;') Day each eventName in eventNames th= eventName - each activeUser in activeUsers + each activeUser in view.activeUsers tr td= activeUser.day each eventName in eventNames td= activeUser.events[eventName] || 0 - h1#enrollments-table Enrollments + h1#enrollments-table Licenses table.table.table-striped.table-condensed tr th Day - th Paid Enrollments Issued - th Paid Enrollments Redeemed - th Trial Enrollments Issued - th Trial Enrollments Redeemed - each day in enrollmentDays + th Paid Licenses Issued + th Paid Licenses Redeemed + th Trial Licenses Issued + th Trial Licenses Redeemed + each day in view.enrollmentDays tr td= day - if dayEnrollmentsMap[day] - td= dayEnrollmentsMap[day].paidIssued || 0 - td= dayEnrollmentsMap[day].paidRedeemed || 0 - td= dayEnrollmentsMap[day].trialIssued || 0 - td= dayEnrollmentsMap[day].trialRedeemed || 0 + if view.dayEnrollmentsMap[day] + td= view.dayEnrollmentsMap[day].paidIssued || 0 + td= view.dayEnrollmentsMap[day].paidRedeemed || 0 + td= view.dayEnrollmentsMap[day].trialIssued || 0 + td= view.dayEnrollmentsMap[day].trialRedeemed || 0 else td 0 td 0 @@ -320,9 +320,9 @@ block content .campaign-monthly-active-users-chart-365.line-chart-container h1 Active Users - if activeUsers.length > 0 + if view.activeUsers.length > 0 - var eventNames = []; - each count, event in activeUsers[0].events + each count, event in view.activeUsers[0].events if event.indexOf('campaign') >= 0 - eventNames.push(event) - eventNames.sort(function (a, b) {return a.localeCompare(b);}); @@ -331,7 +331,7 @@ block content th(style='min-width:85px;') Day each eventName in eventNames th= eventName - each activeUser in activeUsers + each activeUser in view.activeUsers tr td= activeUser.day each eventName in eventNames @@ -345,9 +345,9 @@ block content .campaign-vs-classroom-monthly-active-users-chart.line-chart-container h1 Active Users - if activeUsers.length > 0 + if view.activeUsers.length > 0 - var eventNames = []; - each count, event in activeUsers[0].events + each count, event in view.activeUsers[0].events - eventNames.push(event) - eventNames.sort(function (a, b) { - if (a.indexOf('campaign') == b.indexOf('campaign') || a.indexOf('classroom') == b.indexOf('classroom')) { @@ -365,7 +365,7 @@ block content th(style='min-width:85px;') Day each eventName in eventNames th= eventName - each activeUser in activeUsers + each activeUser in view.activeUsers tr td= activeUser.day each eventName in eventNames diff --git a/app/templates/clans/clans.jade b/app/templates/clans/clans.jade index e5af78513..6cccf104d 100644 --- a/app/templates/clans/clans.jade +++ b/app/templates/clans/clans.jade @@ -32,8 +32,8 @@ block content th(data-i18n="clans.chieftain") Chieftain th tbody - if publicClans.length - each clan in publicClans + if view.publicClansArray.length + each clan in view.publicClansArray tr td if clan.get('ownerID') === me.id @@ -42,12 +42,12 @@ block content a(href="/clans/#{clan.id}")= clan.get('name') td= clan.get('memberCount') td - if idNameMap && idNameMap[clan.get('ownerID')] - a(href="/user/#{clan.get('ownerID')}")= idNameMap[clan.get('ownerID')] + if view.idNameMap && view.idNameMap[clan.get('ownerID')] + a(href="/user/#{clan.get('ownerID')}")= view.idNameMap[clan.get('ownerID')] else a(href="/user/#{clan.get('ownerID')}") Anoner td - if myClanIDs.indexOf(clan.id) < 0 + if view.myClanIDs.indexOf(clan.id) < 0 button.btn.btn-success.join-clan-btn(data-id="#{clan.id}", data-i18n="clans.join_clan") Join Clan else if clan.get('ownerID') !== me.id button.btn.btn-xs.btn-warning.leave-clan-btn(data-id="#{clan.id}", data-i18n="clans.leave_clan") Leave Clan @@ -62,8 +62,8 @@ block content th(data-i18n="clans.type") Type th tbody - if myClans.length - each clan in myClans + if view.myClansArray.length + each clan in view.myClansArray tr td if clan.get('ownerID') === me.id @@ -72,8 +72,8 @@ block content a(href="/clans/#{clan.id}")= clan.get('name') td= clan.get('memberCount') td - if idNameMap && idNameMap[clan.get('ownerID')] - a(href="/user/#{clan.get('ownerID')}")= idNameMap[clan.get('ownerID')] + if view.idNameMap && view.idNameMap[clan.get('ownerID')] + a(href="/user/#{clan.get('ownerID')}")= view.idNameMap[clan.get('ownerID')] else a(href="/user/#{clan.get('ownerID')}") Anoner td= clan.get('type') diff --git a/app/templates/courses/activate-licenses-modal.jade b/app/templates/courses/activate-licenses-modal.jade index ed1ca6d12..2509cbf20 100644 --- a/app/templates/courses/activate-licenses-modal.jade +++ b/app/templates/courses/activate-licenses-modal.jade @@ -21,16 +21,16 @@ block modal-body-content span.spr : select.classroom-select each classroom in view.classrooms.models - option(selected=(classroom.id === view.classroom.id), value=classroom.id) + option(selected=(view.classroom ? classroom.id === view.classroom.id : false), value=classroom.id) = classroom.get('name') - option(selected=(view.classroom.id === 'all-students'), value='all-students' data-i18n='teacher.all_students') + option(selected=(!view.classroom), value='' data-i18n='teacher.all_students') form.form.m-t-3 span(data-i18n="teacher.enroll_the_following_students") span : .well.form-group - - var enrolledUsers = view.users.filter(function(user){ return Boolean(user.get('coursePrepaidID')) }) - - var unenrolledUsers = view.users.filter(function(user){ return !Boolean(user.get('coursePrepaidID')) }) + - var enrolledUsers = view.users.filter(function(user){ return user.isEnrolled() }) + - var unenrolledUsers = view.users.filter(function(user){ return !user.isEnrolled() }) for user in unenrolledUsers - var selected = Boolean(paid || state.get('selectedUsers').get(user.id)) .checkbox @@ -70,6 +70,6 @@ block modal-body-content span.spl(data-i18n="courses.students1") p - a#get-more-licenses-btn.btn.btn-lg.btn-primary-alt(href="/teachers/enrollments", data-i18n="courses.get_enrollments") + a#get-more-licenses-btn.btn.btn-lg.btn-primary-alt(href="/teachers/licenses", data-i18n="courses.get_enrollments") block modal-footer-content diff --git a/app/templates/courses/classroom-view.jade b/app/templates/courses/classroom-view.jade index 288d45ba4..c57237de5 100644 --- a/app/templates/courses/classroom-view.jade +++ b/app/templates/courses/classroom-view.jade @@ -83,7 +83,7 @@ block content span.spr : span= playtime - - var paidFor = user.get('coursePrepaidID'); + - var paidFor = user.isEnrolled(); for courseInstance in view.courseInstances.models - var inCourse = _.contains(courseInstance.get('members'), user.id); if !(inCourse || view.teacherMode) diff --git a/app/templates/courses/enrollments-view.jade b/app/templates/courses/enrollments-view.jade index 47779b36d..15e77b330 100644 --- a/app/templates/courses/enrollments-view.jade +++ b/app/templates/courses/enrollments-view.jade @@ -31,79 +31,105 @@ block content a.btn.btn-primary.btn-lg(href="/teachers/update-account") Upgrade to teacher account .container.m-t-5 + .pull-right + span.glyphicon.glyphicon-question-sign + =' ' + a#how-to-enroll-link(data-i18n="teacher.how_to_enroll") h3(data-i18n='teacher.enrollments') - h4 + h4#enrollments-blurb span(data-i18n='teacher.enrollments_blurb_1') - span 2–8 + span 2–#{view.state.get('totalCourses')} span(data-i18n='teacher.enrollments_blurb_2') + - var available = view.state.get('prepaidGroups').available + - var pending = view.state.get('prepaidGroups').pending + - var anyPrepaids = available || pending + .row.m-t-3 - .col-xs-4 - +enrollmentStats - .col-xs-4 - +addCredits - .col-xs-3.col-xs-offset-1 - +howToEnroll - +quoteSection + if anyPrepaids + #prepaids-col.col-md-9 + if _.size(available) > 0 + h5.m-b-1(data-i18n="teacher.available_credits") + .row + for prepaid in available + .col-sm-4.col-xs-6 + +prepaidCard(prepaid) + + if _.size(pending) > 0 + h5.m-b-1.m-t-3(data-i18n="teacher.pending_credits") + .row + for prepaid in pending + .col-sm-4.col-xs-6 + +prepaidCard(prepaid, 'pending-prepaid-card') + + #actions-col.col-md-3 + +addCredits + +enrollmentStats + + else + // no prepaids + .col-sm-6.col-lg-4.col-lg-offset-2 + +addCredits + .col-sm-6.col-lg-4 + +enrollmentStats + +mixin prepaidCard(prepaid, className) + .prepaid-card.bg-navy.text-center.m-b-2.p-a-2(class=className) + h1.m-t-2.m-b-0= prepaid.openSpots() + div(data-i18n="teacher.credits") + hr + em.small-details + .pull-left(data-i18n="teacher.start_date") + .pull-right= moment(prepaid.get('startDate')).utc().format('l') + .clearfix + + .pull-left(data-i18n="teacher.end_date") + .pull-right= moment(prepaid.get('endDate')).utc().format('l') + .clearfix + + +mixin addCredits + .text-center.m-b-3.m-t-3 + h5(data-i18n="courses.get_enrollments") + if me.get('enrollmentRequestSent') + #enrollment-request-sent-blurb.small + p(data-i18n="teacher.enroll_request_sent_blurb1") + p(data-i18n="teacher.enroll_request_sent_blurb2") + p(data-i18n="[html]teacher.enroll_request_sent_blurb3") + button#request-sent-btn.btn-lg.btn.btn-forest(disabled=true, data-i18n="teacher.request_sent") + else + p(data-i18n="teacher.num_enrollments_needed") + div.m-t-2 + input#students-input.enrollment-count.text-center(value=view.state.get('numberOfStudents') type='number') + strong(data-i18n="teacher.credits") + p.m-y-2(data-i18n="teacher.get_enrollments_blurb") + button#contact-us-btn.btn-lg.btn.btn-forest(data-i18n="contribute.contact_us_url") + mixin enrollmentStats - h5 - span(data-i18n='teacher.credits_available') - span.spr : - = view.prepaids.totalAvailable() - .small-details - span(data-i18n='teacher.total_unique_students') - span.spr : - = view.totalEnrolled + view.totalNotEnrolled - .small-details - span(data-i18n='teacher.total_enrolled_students') - span.spr : - = view.totalEnrolled - - h5.small-details.m-t-3 - span(data-i18n='teacher.unenrolled_students') - each classroom in view.classrooms.models - if classroom.get('members').length > 0 && view.classroomNotEnrolledMap && view.classroomNotEnrolledMap[classroom.id] > 0 - .small-details - span= classroom.get('name') - span.spr : - span= view.classroomNotEnrolledMap[classroom.id] + h5.text-center.m-t-3.m-b-2(data-i18n='teacher.enrollment_status') + table#enrollment-stats-table.table-condensed.table.small-details + tr + td + span(data-i18n='teacher.total_unique_students') + span.spr : + td= view.state.get('totalEnrolled') + view.state.get('totalNotEnrolled') + tr + td + span(data-i18n='teacher.total_enrolled_students') + span.spr : + td= view.state.get('totalEnrolled') + tr + th(data-i18n='teacher.unenrolled_students') + th= view.state.get('totalNotEnrolled') + each classroom in view.classrooms.models + if classroom.get('members').length > 0 && view.state.get('classroomNotEnrolledMap')[classroom.id] > 0 + tr + td.classroom-name-td + span= classroom.get('name') + span.spr : + td= view.state.get('classroomNotEnrolledMap')[classroom.id] - .small-details - span(data-i18n='teacher.total_unenrolled') - span.spr : - = view.totalNotEnrolled - - //- .enroll-students.btn.btn-lg.btn-navy - //- span(data-i18n='teacher.enroll_students') - -mixin addCredits .text-center - h5(data-i18n='teacher.add_enrollment_credits') - div.m-t-1 - input#students-input.text-center.enrollment-count(value=view.numberOfStudents type='number') - div.m-t-1 - if view.state === 'purchasing' - .purchase-now.btn.btn-lg.btn-forest.disabled - span(data-i18n='teacher.purchasing') - else if view.state === 'purchased' - .purchase-now.btn.btn-lg.btn-forest - span(data-i18n='teacher.purchased') - else - .purchase-now.btn.btn-lg.btn-forest - span(data-i18n='teacher.purchase_now') - -mixin howToEnroll - .how-to-enroll.small-details - .text-center - b(data-i18n='teacher.how_to_enroll') - ol - li.m-t-1(data-i18n='teacher.how_to_enroll_blurb_1') - li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_2') - li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_3') - -mixin quoteSection - .text-center.m-t-5 - h4(data-i18n='teacher.bulk_pricing_blurb') - a.request-quote.btn.btn-lg.btn-navy.m-t-2(href='/teachers/demo') - span(data-i18n='teachers_quote.title') + button#enroll-students-btn.btn.btn-lg.btn-navy + span(data-i18n='teacher.enroll_students') diff --git a/app/templates/courses/join-class-modal.jade b/app/templates/courses/join-class-modal.jade new file mode 100644 index 000000000..3a99d26aa --- /dev/null +++ b/app/templates/courses/join-class-modal.jade @@ -0,0 +1,44 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + if view.classroom.loaded + .text-center + h3.modal-title + span(data-i18n="courses.join") + span.spr + span= view.classroom.get('name') + b.small-details + span.spr(data-i18n="courses.instructor") + span= view.classroom.owner.get('name') + +block modal-body-content + if view.classroom.loaded + p + span.spr(data-i18n="courses.youve_been_invited_1") + span= view.classroom.get('name') + span.spr(data-i18n="courses.youve_been_invited_2") + span= view.classroom.capitalLanguage + span.spl(data-i18n="courses.youve_been_invited_3") + p + span.spr(data-i18n="courses.by_joining_1") + span= view.classroom.get('name') + span.spr , + span= view.classroom.owner.get('name') + span.spr + span(data-i18n="courses.by_joining_2") + + unless me.get('emailVerified') + div.text-center.m-t-4 + div + b.small-details + span(data-i18n="courses.sent_verification") + .small= me.get('email') + .small + span.spr(data-i18n="courses.you_can_edit") + a(href="/account/settings") + span(data-i18n="courses.account_settings") + +block modal-footer-content + .text-center + button.join-class-btn.btn.btn-lg.btn-navy(data-i18n="courses.join_class_2") + button.btn.btn-lg(data-dismiss="modal", data-i18n="common.cancel") diff --git a/app/templates/courses/purchase-courses-view.jade b/app/templates/courses/purchase-courses-view.jade deleted file mode 100644 index 826f072b0..000000000 --- a/app/templates/courses/purchase-courses-view.jade +++ /dev/null @@ -1,98 +0,0 @@ -extends /templates/base - -block content - - if view.state === 'purchasing' - p.text-center(data-i18n="buy_gems.purchasing") - .progress.progress-striped.active - .progress-bar(style="width: 100%") - - else if view.state === 'purchased' - p - span.spr(data-i18n="courses.thank_you_pref") - span= view.numberOfStudents - span.spl(data-i18n="courses.thank_you_suff") - - p.text-center - if view.fromClassroom - a(href="/courses/"+view.fromClassroom, data-i18n="courses.return_to_class") - else - a(href="/teachers/classes", data-i18n="courses.return_to_course_man") - - else - - h2.text-center(data-i18n="courses.purchase_enrollments") - br - - if view.state === 'error' - .alert.alert-danger= view.stateMessage - - - var usedEnrollments = view.prepaids.totalRedeemers(); - - var totalEnrollments = view.prepaids.totalMaxRedeemers(); - - var remainingEnrollments = totalEnrollments - usedEnrollments; - .row - .col-md-4 - .col-md-3 - strong.uppercase(data-i18n="courses.you_have2") - .col-md-1 - strong= remainingEnrollments - br - .row - .col-md-4 - .col-md-3 - strong.uppercase(data-i18n="courses.students_not_enrolled") - .row - .col-md-4 - .col-md-3 - each classroom in view.classrooms.models - if classroom.get('members').length > 0 - .not-enrolled= classroom.get('name') - .not-enrolled(data-i18n="courses.total_all_classes") - .col-md-1 - - var totalNotEnrolled = 0 - each classroom in view.classrooms.models - if classroom.get('members').length > 0 && view.classroomNotEnrolledMap - .not-enrolled - strong= view.classroomNotEnrolledMap[classroom.id] - .not-enrolled - strong= view.totalNotEnrolled - - br - br - - p.text-center - strong(data-i18n="courses.how_many_enrollments") - br - - p.text-center - input#students-input.text-center.enrollment-count( - value= view.numberOfStudents - type='number' - ) - br - - .container-fluid - .row - .col-md-offset-3.col-md-6 - span(data-i18n="courses.each_student_access") - br - - p.text-center#price-form-group - if view.numberOfStudentsIsValid() - strong - span(data-i18n="account_prepaid.purchase_total") - span.spr : #{view.numberOfStudents} - span(data-i18n="courses.enrollments") - span.spl x $#{(view.pricePerStudent/100).toFixed(2)} = #{view.getPriceString()} - else - strong Invalid number of students - - p.text-center - button#purchase-btn.btn.btn-lg.btn-success.uppercase(data-i18n="courses.purchase_now" disabled=me.isAnonymous()) - - if me.isAnonymous() - // DNT. Temporary redirect until teacher-dashboard is finished - .alert.alert-danger.text-center - h2 You must be signed up to purchase enrollments. - p - a.btn.btn-primary.btn-lg(href="/teachers/signup") Create a teacher account diff --git a/app/templates/courses/remove-student-modal.jade b/app/templates/courses/remove-student-modal.jade index f3bec562e..2b96a05e5 100644 --- a/app/templates/courses/remove-student-modal.jade +++ b/app/templates/courses/remove-student-modal.jade @@ -1,24 +1,24 @@ -extends /templates/core/modal-base +extends /templates/core/modal-base-flat block modal-header-content .text-center - h3.modal-title(data-i18n="courses.remove_student1") + h1.modal-title(data-i18n="courses.remove_student1") span.glyphicon.glyphicon-warning-sign.text-danger - h3(data-i18n="courses.are_you_sure") + h2(data-i18n="courses.are_you_sure") block modal-body-content p.text-center span(data-i18n="courses.remove_description1") - if view.user.get('coursePrepaidID') + if view.user.isEnrolled() span(data-i18n="courses.remove_description2") block modal-footer-content #remove-student-buttons.text-center p - button.btn.btn-lg.btn-success.text-uppercase(data-dismiss="modal", data-i18n="courses.keep_student") + button.btn.btn-lg.btn-forest.text-uppercase(data-dismiss="modal", data-i18n="courses.keep_student") p - OR - p - button#remove-student-btn.btn.btn-lg.btn-default.text-uppercase(data-i18n="courses.remove_student1") + button#remove-student-btn.btn.btn-lg.btn-burgandy.text-uppercase(data-i18n="courses.remove_student1") #remove-student-progress.text-center.hide .progress diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade index cfd455d5f..11c689eea 100644 --- a/app/templates/courses/teacher-class-view.jade +++ b/app/templates/courses/teacher-class-view.jade @@ -108,21 +108,28 @@ block content +copyCodes +addStudentsButton - ul#student-info-tabs.nav.nav-tabs.m-t-5(role='tablist') - li(class=(state.get('activeTab')==="#students-tab" ? 'active' : '')) + ul.nav.nav-tabs.m-t-5(role='tablist') + - var activeTab = state.get('activeTab'); + li(class=(activeTab === "#students-tab" ? 'active' : '')) a.students-tab-btn(href='#students-tab') .small-details.text-center(data-i18n='teacher.students') .tab-spacer - li(class=(state.get('activeTab')==="#course-progress-tab" ? 'active' : '')) + li(class=(activeTab === "#course-progress-tab" ? 'active' : '')) a.course-progress-tab-btn(href='#course-progress-tab') .small-details.text-center(data-i18n='teacher.course_progress') + .tab-spacer + li(class=(activeTab === "#enrollment-status-tab" ? 'active' : '')) + a.course-progress-tab-btn(href='#enrollment-status-tab') + .small-details.text-center(data-i18n='teacher.enrollment_status') .tab-filler .tab-content - if state.get('activeTab')=='#students-tab' + if activeTab === '#students-tab' +studentsTab - else + else if activeTab === '#course-progress-tab' +courseProgressTab + else + +enrollmentStatusTab mixin breadcrumbs .breadcrumbs @@ -179,8 +186,8 @@ mixin sortButtons .sort-buttons.small span(data-i18n='teacher.sort_by') span.spr : - button.sort-button.sort-by-name(data-i18n='general.name') - button.sort-button.sort-by-progress(data-i18n='teacher.progress') + button.sort-button.sort-by-name(data-i18n='general.name', value='name') + button.sort-button.sort-by-progress(data-i18n='teacher.progress', value='progress') mixin studentRow(student) tr.student-row.alternating-background @@ -220,9 +227,13 @@ mixin studentRow(student) //- td //- span.view-class-arrow.glyphicon.glyphicon-chevron-right td - a.remove-student-link.small.center-block.text-center.pull-right.m-r-2(data-student-id=student.id) - div.glyphicon.glyphicon-remove - div(data-i18n='teacher.remove') + .pull-right + a.edit-student-link.small.center-block.text-center.m-r-2(data-student-id=student.id) + div.glyphicon.glyphicon-edit + div(data-i18n='teacher.edit') + a.remove-student-link.small.center-block.text-center.m-r-2(data-student-id=student.id) + div.glyphicon.glyphicon-remove + div(data-i18n='teacher.remove') mixin enrollStudentButton(student) a.enroll-student-button.btn.btn-lg.btn-primary(data-classroom-id=view.classroom.id data-user-id=student.id) @@ -365,3 +376,46 @@ mixin bulkAssignControls span(data-i18n='teacher.assign_to_selected_students') button.btn.btn-primary-alt.enroll-selected-students span(data-i18n='teacher.enroll_selected_students') + +mixin enrollmentStatusTab + // TODO: Have search input in all tabs + + //form.form-inline.text-center.m-t-3 + // #search-form-group.form-group + // label(for="student-search") Search for student: + // input#student-search.form-control.m-l-1(type="search") + // span.glyphicon.glyphicon-search.form-control-feedback + + table.table#enrollment-status-table.table-condensed.m-t-3 + thead + // Checkbox code works, but don't need it yet. + //th.checkbox-col.select-all + .checkbox-flat + input(type='checkbox' id='checkbox-all-students') + label.checkmark(for='checkbox-all-students') + th + .sort-buttons.small + span(data-i18n='teacher.sort_by') + span.spr : + button.sort-button.sort-by-name(data-i18n='general.name', value='name') + button.sort-button.sort-by-status(data-i18n='user.status', value='status') + tbody + - var searchTerm = view.state.get('searchTerm'); + each student in state.get('students').search(searchTerm) + - var status = student.prepaidStatus() + tr.student-row.alternating-background + //td.checkbox-col.student-checkbox + .checkbox-flat + input(type='checkbox' id='checkbox-student-' + student.id, data-student-id=student.id) + label.checkmark(for='checkbox-student-' + student.id) + td.student-info-col + .student-info + div.student-name= student.broadName() + div.student-email.small-details= student.get('email') + td.status-col + span(data-i18n='user.status') + span.spr : + strong(class= status === 'expired' ? 'text-danger' : '')= view.studentStatusString(student) + td.enroll-col + if status !== 'enrolled' + button.enroll-student-button.btn.btn-navy(data-i18n="teacher.enroll_student", data-user-id=student.id) diff --git a/app/templates/courses/teacher-classes-view.jade b/app/templates/courses/teacher-classes-view.jade index 7b6bafdd2..c861328f8 100644 --- a/app/templates/courses/teacher-classes-view.jade +++ b/app/templates/courses/teacher-classes-view.jade @@ -107,7 +107,7 @@ mixin progressDot(classroom, course, index) - var started = 0; if courseInstance - complete = courseInstance.numCompleted - - started = courseInstance.numStarted + - started = courseInstance.started - dotClass = complete === total ? 'forest' : started ? 'gold' : ''; - var progressDotContext = {total: total, complete: complete}; .progress-dot(class=dotClass, data-title=view.progressDotTemplate(progressDotContext)) diff --git a/app/templates/courses/teacher-dashboard-nav.jade b/app/templates/courses/teacher-dashboard-nav.jade index 164aea924..6ee1bb32b 100644 --- a/app/templates/courses/teacher-dashboard-nav.jade +++ b/app/templates/courses/teacher-dashboard-nav.jade @@ -18,8 +18,8 @@ li(class= path.indexOf('/teachers/courses') === 0 ? 'active' : '') a(href='/teachers/courses') small.label(data-i18n='teacher.courses') - li(class= path.indexOf('/teachers/enrollments') === 0 ? 'active' : '') - a(href='/teachers/enrollments') + li(class= path.indexOf('/teachers/licenses') === 0 ? 'active' : '') + a(href='/teachers/licenses') small.label(data-i18n='teacher.enrollments') //- li(class= path.indexOf('TODO') === 0 ? 'active' : '') //- a(href='TODO') diff --git a/app/templates/new-home-view.jade b/app/templates/new-home-view.jade index 4d87c5182..866b0af65 100644 --- a/app/templates/new-home-view.jade +++ b/app/templates/new-home-view.jade @@ -85,7 +85,7 @@ mixin box h6(data-i18n="new_home.want_coco") a.btn.btn-primary.btn-lg.btn-block(href=view.demoRequestURL, data-i18n="new_home.get_started") - else if view.justPlaysCourses() + else if me.justPlaysCourses() div a.btn.btn-forest.btn-lg.btn-block(href=view.playURL, data-i18n="courses.continue_playing") div diff --git a/app/templates/teachers/edit-student-modal.jade b/app/templates/teachers/edit-student-modal.jade new file mode 100644 index 000000000..3e1bf7d67 --- /dev/null +++ b/app/templates/teachers/edit-student-modal.jade @@ -0,0 +1,41 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + h1 + span.spr(data-i18n="teacher.edit_2") + span=view.user.broadName() + +block modal-body-content + .text-center + if view.user.get('emailVerified') + p + span(data-i18n="teacher.send_email_to") + p.m-b-3 + = view.user.get('email') + if state.get('emailSent') + .send-recovery-email-btn.btn.btn-lg.btn-primary.uppercase.disabled + span(data-i18n="teacher.email_sent") + else + .send-recovery-email-btn.btn.btn-lg.btn-primary.uppercase + span(data-i18n="teacher.send_recovery_email") + else + div.m-b-1 + span(data-i18n="teacher.enter_new_password_below") + div.m-b-2.form-group(class=(state.get('errorMessage') ? 'has-error' : '')) + input.new-password-input(placeholder="type a new password here" value=state.get('newPassword')) + div.help-block.error-help-block.m-t-1.small + span=state.get('errorMessage') + if state.get('passwordChanged') + button.change-password-btn.btn.btn-lg.btn-primary.uppercase.disabled + span(data-i18n="teacher.changed") + else + button.change-password-btn.btn.btn-lg.btn-primary.uppercase + span(data-i18n="teacher.change_password") + + +block modal-footer-content + button.btn.btn-primary(type="button", data-dismiss="modal", aria-hidden="true") + if state.get('passwordChanged') || state.get('emailSent') + span(data-i18n="modal.close") + else + span(data-i18n="modal.cancel") diff --git a/app/templates/teachers/how-to-enroll-modal.jade b/app/templates/teachers/how-to-enroll-modal.jade new file mode 100644 index 000000000..819dc19e3 --- /dev/null +++ b/app/templates/teachers/how-to-enroll-modal.jade @@ -0,0 +1,12 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + .text-center + h3(data-i18n='teacher.how_to_enroll') + +block modal-body + + ol + li.m-t-1(data-i18n='teacher.how_to_enroll_blurb_1') + li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_2') + li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_3') diff --git a/app/templates/teachers/teachers-contact-modal.jade b/app/templates/teachers/teachers-contact-modal.jade new file mode 100644 index 000000000..de3826063 --- /dev/null +++ b/app/templates/teachers/teachers-contact-modal.jade @@ -0,0 +1,42 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + .text-center + h3 Contact Our Classroom Team + +block modal-body-content + p Send us a message and our classroom success team will be in touch to help find the best solution for your students' needs! + form + - var sending = view.state.get('sendingState') === 'sending' + - var sent = view.state.get('sendingState') === 'sent'; + - var values = view.state.get('formValues'); + - var errors = view.state.get('formErrors'); + + .form-group(class=errors.email ? 'has-error' : '') + label.control-label(for="email" data-i18n="general.email") + +formErrors(errors.email) + input.form-control(name="email", type="email", value=values.email || '', tabindex=1, disabled=sending || sent) + + .form-group(class=errors.message ? 'has-error' : '') + label.control-label(for="message" data-i18n="general.message") + +formErrors(errors.message) + textarea.form-control(name="message", tabindex=1 disabled=sending || sent)= values.message + + if view.state.get('sendingState') === 'error' + .alert.alert-danger Could not send message. + + if sent + .alert.alert-success Message sent! + + .text-right + button#submit-btn.btn.btn-navy.btn-lg(type='submit' disabled=sending || sent) Submit + +block modal-footer + +mixin formErrors(errors) + if _.isString(errors) + - errors = [errors] + if _.size(errors) + .help-block + for error in errors + div= error diff --git a/app/templates/test-view.jade b/app/templates/test-view.jade index 9a7e4ae28..bb520438f 100644 --- a/app/templates/test-view.jade +++ b/app/templates/test-view.jade @@ -1,3 +1,5 @@ +#demo-area + h2#test-h2 Testing Page ol.breadcrumb @@ -10,7 +12,6 @@ ol.breadcrumb .row .col-md-8 #test-wrapper.well - #demo-area #testing-area .col-md-4.hidden-sm.hidden-xs @@ -31,4 +32,4 @@ ol.breadcrumb span.spl= child.name if child.type == 'folder' strong (#{child.size}) - \ No newline at end of file + diff --git a/app/templates/user/email-verified-view.jade b/app/templates/user/email-verified-view.jade new file mode 100644 index 000000000..cdd0bb6bf --- /dev/null +++ b/app/templates/user/email-verified-view.jade @@ -0,0 +1,37 @@ +extends /templates/base-flat + +block content + .container.text-center + if state.get('verifyStatus') === "pending" + span(data-i18n="account.verifying_email") + | Verifying your email address... + else if state.get('verifyStatus') === "success" + .alert.alert-success.center-block + .glyphicon.glyphicon-ok-circle.m-r-1 + span(data-i18n="account.successfully_verified") + | You've successfully verified your email address! + if view.userID !== me.id + a.login-button.btn.btn-navy.btn-lg(data-i18n="login.log_in") + else if view.user.isTeacher() + a.btn.btn-lg.btn-forest(href="/teachers/classes") + span(data-i18n="new_home.goto_classes") + else if me.justPlaysCourses() + div.m-b-1 + a.btn.btn-forest.btn-lg(href="/courses", data-i18n="courses.continue_playing") + div + a.btn.btn-primary.btn-lg.play-btn(href="/courses", data-i18n="new_home.view_progress") + else + div.m-b-1 + a.btn.btn-forest.btn-lg.play-btn(href="/play", data-i18n="courses.continue_playing") + div + a.btn.btn-primary.btn-lg(href="/user/#{me.getSlugOrID()}", data-i18n="new_home.view_profile") + else if state.get('verifyStatus') === "error" + .alert.alert-danger.center-block + .glyphicon.glyphicon-remove-circle.m-r-1 + span(data-i18n="account.verify_error") + | Something went wrong when verifying your email :( + else + div + | This really shouldn't happen + div + = state.get('verifyStatus') diff --git a/app/views/NewHomeView.coffee b/app/views/NewHomeView.coffee index 995db446c..962c7cc05 100644 --- a/app/views/NewHomeView.coffee +++ b/app/views/NewHomeView.coffee @@ -44,12 +44,12 @@ module.exports = class NewHomeView extends RootView @supermodel.loadCollection(@trialRequests) isHourOfCodeWeek = false # Temporary: default to /hoc flow during the main event week - if isHourOfCodeWeek and (@isNewPlayer() or (@justPlaysCourses() and me.isAnonymous())) + if isHourOfCodeWeek and (@isNewPlayer() or (me.justPlaysCourses() and me.isAnonymous())) # Go/return straight to playing single-player HoC course on Play click @playURL = '/hoc?go=true' @alternatePlayURL = '/play' @alternatePlayText = 'home.play_campaign_version' - else if @justPlaysCourses() + else if me.justPlaysCourses() # Save players who might be in a classroom from getting into the campaign @playURL = '/courses' @alternatePlayURL = '/play' @@ -123,11 +123,6 @@ module.exports = class NewHomeView extends RootView $(@).find('.course-duration .unit').text($.i18n.t(if duration is '1' then 'units.hour' else 'units.hours')) @$el.find('#semester-duration').text levels[level].total - justPlaysCourses: -> - # This heuristic could be better, but currently we don't add to me.get('courseInstances') for single-player anonymous intro courses, so they have to beat a level without choosing a hero. - return true if me.get('role') is 'student' - return me.get('stats')?.gamesCompleted and not me.get('heroConfig') - isNewPlayer: -> not me.get('stats')?.gamesCompleted and not me.get('heroConfig') diff --git a/app/views/TestView.coffee b/app/views/TestView.coffee index 51b7e206c..631a3374f 100644 --- a/app/views/TestView.coffee +++ b/app/views/TestView.coffee @@ -60,14 +60,10 @@ module.exports = TestView = class TestView extends RootView @specFiles = (f for f in @specFiles when _.string.startsWith f, prefix) @runTests: (specFiles, demosOn=false) -> + application.testing = true specFiles ?= @getAllSpecFiles() if demosOn jasmine.demoEl = ($el) -> - $el.css({ - 'border': '2px solid black' - 'background': 'white' - 'padding': '20px' - }) $('#demo-area').append($el) jasmine.demoModal = _.once (modal) -> currentView.openModalView(modal) diff --git a/app/views/account/AccountSettingsRootView.coffee b/app/views/account/AccountSettingsRootView.coffee index 62ca07826..d04a05dd7 100644 --- a/app/views/account/AccountSettingsRootView.coffee +++ b/app/views/account/AccountSettingsRootView.coffee @@ -1,6 +1,7 @@ RootView = require 'views/core/RootView' template = require 'templates/account/account-settings-root-view' AccountSettingsView = require './AccountSettingsView' +CreateAccountModal = require 'views/core/CreateAccountModal' module.exports = class AccountSettingsRootView extends RootView id: "account-settings-root-view" @@ -21,6 +22,9 @@ module.exports = class AccountSettingsRootView extends RootView @listenTo @accountSettingsView, 'save-user-success', @onUserSaveSuccess @listenTo @accountSettingsView, 'save-user-error', @onUserSaveError + afterInsert: -> + @openModalView new CreateAccountModal() if me.get('anonymous') + onInputChanged: -> @$el.find('#save-button') .text($.i18n.t('common.save', defaultValue: 'Save')) @@ -45,4 +49,3 @@ module.exports = class AccountSettingsRootView extends RootView .text($.i18n.t('account_settings.error_saving', defaultValue: 'Error Saving')) .removeClass('btn-success') .addClass('btn-danger', 500) - diff --git a/app/views/account/AccountSettingsView.coffee b/app/views/account/AccountSettingsView.coffee index d6a1a6eca..cfadd285a 100644 --- a/app/views/account/AccountSettingsView.coffee +++ b/app/views/account/AccountSettingsView.coffee @@ -3,7 +3,6 @@ template = require 'templates/account/account-settings-view' {me} = require 'core/auth' forms = require 'core/forms' User = require 'models/User' -CreateAccountModal = require 'views/core/CreateAccountModal' ConfirmModal = require 'views/editor/modal/ConfirmModal' {logoutUser, me} = require('core/auth') @@ -19,16 +18,13 @@ module.exports = class AccountSettingsView extends CocoView 'click #profile-photo-panel-body': 'onClickProfilePhotoPanelBody' 'click #delete-account-btn': 'onClickDeleteAccountButton' 'click #reset-progress-btn': 'onClickResetProgressButton' + 'click .resend-verification-email': 'onClickResendVerificationEmail' constructor: (options) -> super options require('core/services/filepicker')() unless window.application.isIPadApp # Initialize if needed @uploadFilePath = "db/user/#{me.id}" - afterInsert: -> - super() - @openModalView new CreateAccountModal() if me.get('anonymous') - getEmailSubsDict: -> subs = {} return subs unless me @@ -83,6 +79,12 @@ module.exports = class AccountSettingsView extends CocoView confirmModal.on 'confirm', @resetProgress @openModalView confirmModal + onClickResendVerificationEmail: (e) -> + $.post me.getRequestVerificationEmailURL(), -> + link = $(e.currentTarget) + link.find('.resend-text').addClass('hide') + link.find('.sent-text').removeClass('hide') + validateCredentialsForDestruction: ($form, onSuccess) -> forms.clearFormAlerts($form) enteredEmail = $form.find('input[type="email"]').val() diff --git a/app/views/account/UnsubscribeView.coffee b/app/views/account/UnsubscribeView.coffee index b098a9a38..4bf25bdad 100644 --- a/app/views/account/UnsubscribeView.coffee +++ b/app/views/account/UnsubscribeView.coffee @@ -6,14 +6,12 @@ module.exports = class UnsubscribeView extends RootView id: 'unsubscribe-view' template: template + initialize: -> + @email = @getQueryVariable 'email' + events: 'click #unsubscribe-button': 'onUnsubscribeButtonClicked' - getRenderData: -> - context = super() - context.email = @getQueryVariable 'email' - context - onUnsubscribeButtonClicked: -> @$el.find('#unsubscribe-button').hide() @$el.find('.progress').show() diff --git a/app/views/admin/AdministerUserModal.coffee b/app/views/admin/AdministerUserModal.coffee index 524c867a1..c45caa27e 100644 --- a/app/views/admin/AdministerUserModal.coffee +++ b/app/views/admin/AdministerUserModal.coffee @@ -2,47 +2,37 @@ ModalView = require 'views/core/ModalView' template = require 'templates/admin/administer-user-modal' User = require 'models/User' Prepaid = require 'models/Prepaid' +StripeCoupons = require 'collections/StripeCoupons' +forms = require 'core/forms' +Prepaids = require 'collections/Prepaids' module.exports = class AdministerUserModal extends ModalView - id: "administer-user-modal" + id: 'administer-user-modal' template: template events: - 'click #save-changes': 'onSaveChanges' + 'click #save-changes': 'onClickSaveChanges' 'click #add-seats-btn': 'onClickAddSeatsButton' - constructor: (options, @userHandle) -> - super(options) - @user = @supermodel.loadModel(new User({_id:@userHandle}), {cache: false}).model - options = {cache: false, url: '/stripe/coupons'} - options.success = (@coupons) => - @couponsResource = @supermodel.addRequestResource('coupon', options) - @couponsResource.load() - - getRenderData: -> - c = super() + initialize: (options, @userHandle) -> + @user = new User({_id:@userHandle}) + @supermodel.trackRequest @user.fetch({cache: false}) + @coupons = new StripeCoupons() + @supermodel.trackRequest @coupons.fetch({cache: false}) + @prepaids = new Prepaids() + @supermodel.trackRequest @prepaids.fetchByCreator(@userHandle) + + onLoaded: -> + # TODO: Figure out a better way to expose this info, perhaps User methods? stripe = @user.get('stripe') or {} - c.free = stripe.free is true - c.freeUntil = _.isString(stripe.free) - c.freeUntilDate = if c.freeUntil then stripe.free else new Date().toISOString()[...10] - c.coupon = stripe.couponID - c.coupons = @coupons or [] - for coupon in c.coupons - bits = [coupon.id] - if coupon.percent_off - bits.push "(#{coupon.percent_off}% off)" - else if coupon.amount_off - bits.push "($#{coupon.amount_off} off)" - if coupon.duration - bits.push "(duration: #{coupon.duration})" - if coupon.redeem_by - bits.push "(redeem by: #{moment(coupon.redeem_by).format('lll')}" - coupon.format = bits.join(' ') - c.none = not (c.free or c.freeUntil or c.coupon) - c.user = @user - c - - onSaveChanges: -> + @free = stripe.free is true + @freeUntil = _.isString(stripe.free) + @freeUntilDate = if @freeUntil then stripe.free else new Date().toISOString()[...10] + @currentCouponID = stripe.couponID + @none = not (@free or @freeUntil or @coupon) + super() + + onClickSaveChanges: -> stripe = _.clone(@user.get('stripe') or {}) delete stripe.free delete stripe.couponID @@ -61,15 +51,20 @@ module.exports = class AdministerUserModal extends ModalView @user.patch(options) onClickAddSeatsButton: -> - maxRedeemers = parseInt(@$('#seats-input').val()) - return unless maxRedeemers and maxRedeemers > 0 - prepaid = new Prepaid({ - maxRedeemers: maxRedeemers + attrs = forms.formToObject(@$('#prepaid-form')) + attrs.maxRedeemers = parseInt(attrs.maxRedeemers) + return unless _.all(_.values(attrs)) + return unless attrs.maxRedeemers > 0 + return unless attrs.endDate and attrs.startDate and attrs.endDate > attrs.startDate + attrs.startDate = new Date(attrs.startDate).toISOString() + attrs.endDate = new Date(attrs.endDate).toISOString() + _.extend(attrs, { type: 'course' creator: @user.id properties: adminAdded: me.id }) + prepaid = new Prepaid(attrs) prepaid.save() @state = 'creating-prepaid' @renderSelectors('#prepaid-form') diff --git a/app/views/admin/AnalyticsView.coffee b/app/views/admin/AnalyticsView.coffee index fe5c10f25..f23c0e58a 100644 --- a/app/views/admin/AnalyticsView.coffee +++ b/app/views/admin/AnalyticsView.coffee @@ -16,21 +16,16 @@ module.exports = class AnalyticsView extends RootView lineColors: ['red', 'blue', 'green', 'purple', 'goldenrod', 'brown', 'darkcyan'] minSchoolCount: 20 - constructor: (options) -> - super options + initialize: -> + @activeClasses = [] + @activeClassGroups = {} + @activeUsers = [] + @revenue = [] + @revenueGroups = {} + @dayEnrollmentsMap = {} + @enrollmentDays = [] @loadData() - getRenderData: -> - context = super() - context.activeClasses = @activeClasses ? [] - context.activeClassGroups = @activeClassGroups ? {} - context.activeUsers = @activeUsers ? [] - context.revenue = @revenue ? [] - context.revenueGroups = @revenueGroups ? {} - context.dayEnrollmentsMap = @dayEnrollmentsMap ? {} - context.enrollmentDays = @enrollmentDays ? [] - context - afterRender: -> super() @createLineCharts() @@ -295,7 +290,7 @@ module.exports = class AnalyticsView extends RootView prepaidUserMap = {} for user in data.students continue unless studentPaidStatusMap[user._id] - if prepaidID = user.coursePrepaidID + if prepaidID = user.coursePrepaidID or user.coursePrepaid?._id studentPaidStatusMap[user._id] = 'paid' prepaidUserMap[prepaidID] ?= [] prepaidUserMap[prepaidID].push(user._id) diff --git a/app/views/clans/ClansView.coffee b/app/views/clans/ClansView.coffee index f8a84c656..d5d06d70b 100644 --- a/app/views/clans/ClansView.coffee +++ b/app/views/clans/ClansView.coffee @@ -13,6 +13,7 @@ SubscribeModal = require 'views/core/SubscribeModal' module.exports = class ClansView extends RootView id: 'clans-view' template: template + events: 'click .create-clan-btn': 'onClickCreateClan' @@ -20,28 +21,25 @@ module.exports = class ClansView extends RootView 'click .leave-clan-btn': 'onLeaveClan' 'click .private-clan-checkbox': 'onClickPrivateCheckbox' - constructor: (options) -> - super options - @initData() + initialize: -> + @publicClansArray = [] + @myClansArray = [] + @idNameMap = {} + @loadData() destroy: -> @stopListening?() - getRenderData: -> - context = super() - context.idNameMap = @idNameMap - context.publicClans = _.filter(@publicClans.models, (clan) -> clan.get('type') is 'public') - context.myClans = @myClans.models - context.myClanIDs = me.get('clans') ? [] - context - afterRender: -> super() @setupPrivateInfoPopover() - initData: -> - @idNameMap = {} + onLoaded: -> + super() + @publicClansArray = _.filter(@publicClans.models, (clan) -> clan.get('type') is 'public') + @myClansArray = @myClans.models + loadData: -> sortClanList = (a, b) -> if a.get('memberCount') isnt b.get('memberCount') if a.get('memberCount') < b.get('memberCount') then 1 else -1 @@ -52,12 +50,15 @@ module.exports = class ClansView extends RootView @refreshNames @publicClans.models @render?() @supermodel.loadCollection(@publicClans, 'public_clans', {cache: false}) + @myClans = new CocoCollection([], { url: "/db/user/#{me.id}/clans", model: Clan, comparator: sortClanList }) @listenTo @myClans, 'sync', => @refreshNames @myClans.models @render?() @supermodel.loadCollection(@myClans, 'my_clans', {cache: false}) + @listenTo me, 'sync', => @render?() + @myClanIDs = me.get('clans') ? [] refreshNames: (clans) -> clanIDs = _.filter(clans, (clan) -> clan.get('type') is 'public') diff --git a/app/views/core/CreateAccountModal.coffee b/app/views/core/CreateAccountModal.coffee index 917b6e6d1..1fc210d16 100644 --- a/app/views/core/CreateAccountModal.coffee +++ b/app/views/core/CreateAccountModal.coffee @@ -61,7 +61,7 @@ module.exports = class CreateAccountModal extends ModalView error = false birthday = new Date Date.UTC attrs.birthdayYear, attrs.birthdayMonth - 1, attrs.birthdayDay if @classCode - #PASS + attrs.role = 'student' else if isNaN(birthday.getTime()) forms.setErrorToProperty @$el, 'birthdayDay', 'Required' error = true diff --git a/app/views/courses/ActivateLicensesModal.coffee b/app/views/courses/ActivateLicensesModal.coffee index 709a1e2dc..a1bf8afa5 100644 --- a/app/views/courses/ActivateLicensesModal.coffee +++ b/app/views/courses/ActivateLicensesModal.coffee @@ -16,9 +16,11 @@ module.exports = class ActivateLicensesModal extends ModalView 'change input[type="checkbox"][name="user"]': 'updateSelectedStudents' 'change select.classroom-select': 'replaceStudentList' 'submit form': 'onSubmitForm' + 'click #get-more-licenses-btn': 'onClickGetMoreLicensesButton' getInitialState: (options) -> - selectedUserModels = _.filter(options.selectedUsers.models, (user) -> not user.isEnrolled()) + selectedUsers = options.selectedUsers or options.users + selectedUserModels = _.filter(selectedUsers.models, (user) -> not user.isEnrolled()) { selectedUsers: new Users(selectedUserModels) visibleSelectedUsers: new Users(selectedUserModels) @@ -31,11 +33,10 @@ module.exports = class ActivateLicensesModal extends ModalView @users = options.users.clone() @users.comparator = (user) -> user.broadName().toLowerCase() @prepaids = new Prepaids() - @prepaids.comparator = '_id' - @prepaids.fetchByCreator(me.id) - @supermodel.trackCollection(@prepaids) + @prepaids.comparator = 'endDate' # use prepaids in order of expiration + @supermodel.trackRequest @prepaids.fetchByCreator(me.id) @classrooms = new Classrooms() - @classrooms.fetchMine({ + @supermodel.trackRequest @classrooms.fetchMine({ data: {archived: false} success: => @classrooms.each (classroom) => @@ -43,7 +44,6 @@ module.exports = class ActivateLicensesModal extends ModalView jqxhrs = classroom.users.fetchForClassroom(classroom, { removeDeleted: true }) @supermodel.trackRequests(jqxhrs) }) - @supermodel.trackCollection(@classrooms) @listenTo @state, 'change', @render @listenTo @state.get('selectedUsers'), 'change add remove reset', -> @@ -56,6 +56,10 @@ module.exports = class ActivateLicensesModal extends ModalView @state.set { unusedEnrollments: @prepaids.totalMaxRedeemers() - @prepaids.totalRedeemers() } + + onLoaded: -> + @prepaids.reset(@prepaids.filter((prepaid) -> prepaid.status() is 'available')) + super() afterRender: -> super() @@ -73,8 +77,7 @@ module.exports = class ActivateLicensesModal extends ModalView replaceStudentList: (e) -> selectedClassroomID = $(e.currentTarget).val() @classroom = @classrooms.get(selectedClassroomID) - if selectedClassroomID is 'all-students' - @classroom = new Classroom({ _id: 'all-students', name: 'All Students' }) # TODO: This is a horrible hack so the select shows the right option! + if not @classroom users = _.uniq _.flatten @classrooms.map (classroom) -> classroom.users.models @users.reset(users) @users.sort() @@ -96,27 +99,21 @@ module.exports = class ActivateLicensesModal extends ModalView return user = usersToRedeem.first() - prepaid = @prepaids.find((prepaid) -> prepaid.get('properties')?.endDate? and prepaid.openSpots() > 0) - prepaid = @prepaids.find((prepaid) -> prepaid.openSpots() > 0) unless prepaid - $.ajax({ - method: 'POST' - url: _.result(prepaid, 'url') + '/redeemers' - data: { userID: user.id } - context: @ - success: (prepaid) -> - user.set('coursePrepaidID', prepaid._id) + prepaid = @prepaids.find((prepaid) -> prepaid.status() is 'available') + prepaid.redeem(user, { + success: (prepaid) => + user.set('coursePrepaid', prepaid.pick('_id', 'startDate', 'endDate')) usersToRedeem.remove(user) # pct = 100 * (usersToRedeem.originalSize - usersToRedeem.size() / usersToRedeem.originalSize) # @$('#progress-area .progress-bar').css('width', "#{pct.toFixed(1)}%") application.tracker?.trackEvent 'Enroll modal finished enroll student', category: 'Courses', userID: user.id @redeemUsers(usersToRedeem) - error: (jqxhr, textStatus, errorThrown) -> - if jqxhr.status is 402 - message = arguments[2] - else - message = "#{jqxhr.status}: #{jqxhr.responseText}" - @state.set { error: message } # TODO: Test this! ("should" never happen. Only on server responding with an error.) + error: (prepaid, jqxhr) => + @state.set { error: jqxhr.responseJSON.message } }) finishRedeemUsers: -> @trigger 'redeem-users', @state.get('selectedUsers') + + onClickGetMoreLicensesButton: -> + @hide?() # In case this is opened in /teachers/licenses itself, otherwise the button does nothing diff --git a/app/views/courses/ClassroomView.coffee b/app/views/courses/ClassroomView.coffee index b16e9ec6c..4ba47d3ce 100644 --- a/app/views/courses/ClassroomView.coffee +++ b/app/views/courses/ClassroomView.coffee @@ -118,8 +118,7 @@ module.exports = class ClassroomView extends RootView userID = $(e.target).closest('.btn').data('user-id') if @prepaids.totalMaxRedeemers() - @prepaids.totalRedeemers() > 0 # Have an unused enrollment, enroll student immediately instead of opening the enroll modal - prepaid = @prepaids.find((prepaid) -> prepaid.get('properties')?.endDate? and prepaid.openSpots() > 0) - prepaid = @prepaids.find((prepaid) -> prepaid.openSpots() > 0) unless prepaid + prepaid = @prepaids.find((prepaid) -> prepaid.status() is 'available') $.ajax({ method: 'POST' url: _.result(prepaid, 'url') + '/redeemers' @@ -182,7 +181,7 @@ module.exports = class ClassroomView extends RootView stats.averageLevelsComplete = if @users.size() then (_.size(completeSessions) / @users.size()).toFixed(1) else 'N/A' # ' stats.totalLevelsComplete = _.size(completeSessions) - enrolledUsers = @users.filter (user) -> user.get('coursePrepaidID') + enrolledUsers = @users.filter (user) -> user.isEnrolled() stats.enrolledUsers = _.size(enrolledUsers) return stats diff --git a/app/views/courses/CoursesView.coffee b/app/views/courses/CoursesView.coffee index a68d8b481..749318cf7 100644 --- a/app/views/courses/CoursesView.coffee +++ b/app/views/courses/CoursesView.coffee @@ -5,6 +5,7 @@ AuthModal = require 'views/core/AuthModal' CreateAccountModal = require 'views/core/CreateAccountModal' ChangeCourseLanguageModal = require 'views/courses/ChangeCourseLanguageModal' ChooseLanguageModal = require 'views/courses/ChooseLanguageModal' +JoinClassModal = require 'views/courses/JoinClassModal' CourseInstance = require 'models/CourseInstance' CocoCollection = require 'collections/CocoCollection' Course = require 'models/Course' @@ -91,10 +92,19 @@ module.exports = class CoursesView extends RootView @renderSelectors '#join-class-form' return @renderSelectors '#join-class-form' - newClassroom = new Classroom() - newClassroom.joinWithCode(@classCode) - newClassroom.on 'sync', @onJoinClassroomSuccess, @ - newClassroom.on 'error', @onJoinClassroomError, @ + if me.get('emailVerified') or me.isStudent() + newClassroom = new Classroom() + jqxhr = newClassroom.joinWithCode(@classCode) + @listenTo newClassroom, 'join:success', -> @onJoinClassroomSuccess(newClassroom) + @listenTo newClassroom, 'join:error', -> @onJoinClassroomError(newClassroom, jqxhr) + else + modal = new JoinClassModal({ @classCode }) + @openModalView modal + @listenTo modal, 'join:success', @onJoinClassroomSuccess + @listenTo modal, 'join:error', @onJoinClassroomError + @listenTo modal, 'hidden', -> + @state = null + @renderSelectors '#join-class-form' onJoinClassroomError: (classroom, jqxhr, options) -> @state = null @@ -108,6 +118,7 @@ module.exports = class CoursesView extends RootView @renderSelectors '#join-class-form' onJoinClassroomSuccess: (newClassroom, data, options) -> + @state = null application.tracker?.trackEvent 'Joined classroom', { category: 'Courses' classCode: @classCode diff --git a/app/views/courses/EnrollmentsView.coffee b/app/views/courses/EnrollmentsView.coffee index e4d91a55e..b79d51886 100644 --- a/app/views/courses/EnrollmentsView.coffee +++ b/app/views/courses/EnrollmentsView.coffee @@ -1,90 +1,83 @@ -app = require 'core/application' -CreateAccountModal = require 'views/core/CreateAccountModal' -Classroom = require 'models/Classroom' -Classrooms = require 'collections/Classrooms' -CocoCollection = require 'collections/CocoCollection' -Course = require 'models/Course' -Prepaids = require 'collections/Prepaids' RootView = require 'views/core/RootView' -stripeHandler = require 'core/services/stripe' +Classrooms = require 'collections/Classrooms' +State = require 'models/State' +Prepaids = require 'collections/Prepaids' template = require 'templates/courses/enrollments-view' -User = require 'models/User' Users = require 'collections/Users' -utils = require 'core/utils' -Products = require 'collections/Products' +Courses = require 'collections/Courses' +HowToEnrollModal = require 'views/teachers/HowToEnrollModal' +TeachersContactModal = require 'views/teachers/TeachersContactModal' +ActivateLicensesModal = require 'views/courses/ActivateLicensesModal' module.exports = class EnrollmentsView extends RootView id: 'enrollments-view' template: template - numberOfStudents: 15 - pricePerStudent: 0 - - initialize: (options) -> - @ownedClassrooms = new Classrooms() - @ownedClassrooms.fetchMine({data: {project: '_id'}}) - @supermodel.trackCollection(@ownedClassrooms) - @listenTo stripeHandler, 'received-token', @onStripeReceivedToken - @fromClassroom = utils.getQueryVariable('from-classroom') - @members = new Users() - # @listenTo @members, 'sync add remove', @calculateEnrollmentStats - @classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom }) - @classrooms.comparator = '_id' - @listenToOnce @classrooms, 'sync', @onceClassroomsSync - @supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}}) - @prepaids = new Prepaids() - @prepaids.comparator = '_id' - @prepaids.fetchByCreator(me.id) - @supermodel.loadCollection(@prepaids, 'prepaids') - @products = new Products() - @supermodel.loadCollection(@products, 'products') - super(options) events: 'input #students-input': 'onInputStudentsInput' - 'click .purchase-now': 'onClickPurchaseButton' - # 'click .enroll-students': 'onClickEnrollStudents' + 'click #enroll-students-btn': 'onClickEnrollStudentsButton' + 'click #how-to-enroll-link': 'onClickHowToEnrollLink' + 'click #contact-us-btn': 'onClickContactUsButton' - onLoaded: -> - @calculateEnrollmentStats() - @pricePerStudent = @products.findWhere({name: 'course'}).get('amount') - super() + initialize: -> + @state = new State({ + totalEnrolled: 0 + totalNotEnrolled: 0 + classroomNotEnrolledMap: {} + classroomEnrolledMap: {} + numberOfStudents: 15 + totalCourses: 0 + prepaidGroups: { + 'available': [] + 'pending': [] + } + }) - getPriceString: -> '$' + (@getPrice()/100).toFixed(2) - getPrice: -> @pricePerStudent * @numberOfStudents + @courses = new Courses() + @supermodel.trackRequest @courses.fetch({data: { project: 'free' }}) + @members = new Users() + @classrooms = new Classrooms() + @classrooms.comparator = '_id' + @listenToOnce @classrooms, 'sync', @onceClassroomsSync + @supermodel.trackRequest @classrooms.fetchMine() + @prepaids = new Prepaids() + @prepaids.comparator = '_id' + @supermodel.trackRequest @prepaids.fetchByCreator(me.id) + @debouncedRender = _.debounce @render, 0 + @listenTo @prepaids, 'sync', @updatePrepaidGroups + @listenTo(@state, 'all', @debouncedRender) + @listenTo(me, 'change:enrollmentRequestSent', @debouncedRender) onceClassroomsSync: -> for classroom in @classrooms.models @supermodel.trackRequests @members.fetchForClassroom(classroom, {remove: false, removeDeleted: true}) + onLoaded: -> + @calculateEnrollmentStats() + @state.set('totalCourses', @courses.size()) + super() + + updatePrepaidGroups: -> + @state.set('prepaidGroups', @prepaids.groupBy((p) -> p.status())) + calculateEnrollmentStats: -> @removeDeletedStudents() - @memberEnrolledMap = {} - for user in @members.models - @memberEnrolledMap[user.id] = user.get('coursePrepaidID')? - - @totalEnrolled = _.reduce @members.models, ((sum, user) -> - sum + (if user.get('coursePrepaidID') then 1 else 0) - ), 0 - @numberOfStudents = @totalNotEnrolled = _.reduce @members.models, ((sum, user) -> - sum + (if not user.get('coursePrepaidID') then 1 else 0) - ), 0 + # sort users into enrolled, not enrolled + groups = @members.groupBy (m) -> m.isEnrolled() + enrolledUsers = new Users(groups.true) + @notEnrolledUsers = new Users(groups.false) + + map = {} - @classroomEnrolledMap = _.reduce @classrooms.models, ((map, classroom) => - enrolled = _.reduce classroom.get('members'), ((sum, userID) => - sum + (if @members.get(userID).get('coursePrepaidID') then 1 else 0) - ), 0 - map[classroom.id] = enrolled - map - ), {} + for classroom in @classrooms.models + map[classroom.id] = _.countBy(classroom.get('members'), (userID) -> enrolledUsers.get(userID)?).false - @classroomNotEnrolledMap = _.reduce @classrooms.models, ((map, classroom) => - enrolled = _.reduce classroom.get('members'), ((sum, userID) => - sum + (if not @members.get(userID).get('coursePrepaidID') then 1 else 0) - ), 0 - map[classroom.id] = enrolled - map - ), {} + @state.set({ + totalEnrolled: enrolledUsers.size() + totalNotEnrolled: @notEnrolledUsers.size() + classroomNotEnrolledMap: map + }) true @@ -95,70 +88,24 @@ module.exports = class EnrollmentsView extends RootView ) true + onClickHowToEnrollLink: -> + @openModalView(new HowToEnrollModal()) + + onClickContactUsButton: -> + @openModalView(new TeachersContactModal({ enrollmentsNeeded: @state.get('numberOfStudents') })) + onInputStudentsInput: -> input = @$('#students-input').val() if input isnt "" and (parseFloat(input) isnt parseInt(input) or _.isNaN parseInt(input)) - @$('#students-input').val(@numberOfStudents) + @$('#students-input').val(@state.get('numberOfStudents')) else - @numberOfStudents = Math.max(parseInt(@$('#students-input').val()) or 0, 0) - @updatePrice() + @state.set({'numberOfStudents': Math.max(parseInt(@$('#students-input').val()) or 0, 0)}, {silent: true}) # do not re-render - updatePrice: -> - @renderSelectors '#price-form-group' + numberOfStudentsIsValid: -> 0 < @get('numberOfStudents') < 100000 - numberOfStudentsIsValid: -> 0 < @numberOfStudents < 100000 - - # onClickEnrollStudents: -> - # TODO: Needs "All students" in modal dropdown - - onClickPurchaseButton: -> - return @openModalView new CreateAccountModal() if me.isAnonymous() - unless @numberOfStudentsIsValid() - alert("Please enter the maximum number of students needed for your class.") - return - - @state = undefined - @stateMessage = undefined - @render() - - # Show Stripe handler - application.tracker?.trackEvent 'Started course prepaid purchase', { - price: @pricePerStudent, students: @numberOfStudents} - stripeHandler.open - amount: @numberOfStudents * @pricePerStudent - description: "Full course access for #{@numberOfStudents} students" - bitcoin: true - alipay: if me.get('country') is 'china' or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto' - - onStripeReceivedToken: (e) -> - @state = 'purchasing' - @render?() - - data = - maxRedeemers: @numberOfStudents - type: 'course' - stripe: - token: e.token.id - timestamp: new Date().getTime() - - $.ajax({ - url: '/db/prepaid/-/purchase', - data: data, - method: 'POST', - context: @ - success: (prepaid) -> - application.tracker?.trackEvent 'Finished course prepaid purchase', {price: @pricePerStudent, seats: @numberOfStudents} - @state = 'purchased' - @prepaids.add(prepaid) - @render?() - - error: (jqxhr, textStatus, errorThrown) -> - application.tracker?.trackEvent 'Failed course prepaid purchase', status: textStatus - if jqxhr.status is 402 - @state = 'error' - @stateMessage = arguments[2] - else - @state = 'error' - @stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}" - @render?() - }) + onClickEnrollStudentsButton: -> + modal = new ActivateLicensesModal({ selectedUsers: @notEnrolledUsers, users: @members }) + @openModalView(modal) + modal.once 'hidden', => + @prepaids.add(modal.prepaids.models, { merge: true }) + @debouncedRender() # Because one changed model does not a collection update make diff --git a/app/views/courses/JoinClassModal.coffee b/app/views/courses/JoinClassModal.coffee new file mode 100644 index 000000000..78b3ad161 --- /dev/null +++ b/app/views/courses/JoinClassModal.coffee @@ -0,0 +1,28 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/courses/join-class-modal' +Classroom = require 'models/Classroom' +User = require 'models/User' + +module.exports = class JoinClassModal extends ModalView + id: 'join-class-modal' + template: template + + events: + 'click .join-class-btn': 'onClickJoinClassButton' + + initialize: ({ @classCode }) -> + @classroom = new Classroom() + @teacher = new User() + jqxhr = @supermodel.trackRequest @classroom.fetchByCode(@classCode) + unless me.get('emailVerified') + @supermodel.trackRequest $.post("/db/user/#{me.id}/request-verify-email") + @listenTo @classroom, 'sync', -> + @render + @listenTo @classroom, 'join:success', -> + @trigger('join:success', @classroom) + @listenTo @classroom, 'join:error', -> + @trigger('join:error', @classroom, jqxhr) + # @close() + + onClickJoinClassButton: -> + @classroom.joinWithCode(@classCode) diff --git a/app/views/courses/PurchaseCoursesView.coffee b/app/views/courses/PurchaseCoursesView.coffee deleted file mode 100644 index 9afd4b2fe..000000000 --- a/app/views/courses/PurchaseCoursesView.coffee +++ /dev/null @@ -1,126 +0,0 @@ -app = require 'core/application' -Classroom = require 'models/Classroom' -CocoCollection = require 'collections/CocoCollection' -Course = require 'models/Course' -Prepaids = require 'collections/Prepaids' -RootView = require 'views/core/RootView' -stripeHandler = require 'core/services/stripe' -template = require 'templates/courses/purchase-courses-view' -User = require 'models/User' -utils = require 'core/utils' -Products = require 'collections/Products' - -module.exports = class PurchaseCoursesView extends RootView - id: 'purchase-courses-view' - template: template - numberOfStudents: 30 - pricePerStudent: 0 - - initialize: (options) -> - @listenTo stripeHandler, 'received-token', @onStripeReceivedToken - @fromClassroom = utils.getQueryVariable('from-classroom') - @members = new CocoCollection([], { model: User }) - @listenTo @members, 'sync', @membersSync - @classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom }) - @classrooms.comparator = '_id' - @listenToOnce @classrooms, 'sync', @onceClassroomsSync - @supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}}) - @prepaids = new Prepaids() - @prepaids.comparator = '_id' - @prepaids.fetchByCreator(me.id) - @supermodel.loadCollection(@prepaids, 'prepaids') - @products = new Products() - @supermodel.loadCollection(@products, 'products') - super(options) - - events: - 'input #students-input': 'onInputStudentsInput' - 'click #purchase-btn': 'onClickPurchaseButton' - - onLoaded: -> - @pricePerStudent = @products.findWhere({name: 'course'}).get('amount') - super() - - getPriceString: -> '$' + (@getPrice()/100).toFixed(2) - getPrice: -> @pricePerStudent * @numberOfStudents - - onceClassroomsSync: -> - for classroom in @classrooms.models - @members.fetch({ - remove: false - url: "/db/classroom/#{classroom.id}/members" - }) - - membersSync: -> - @memberEnrolledMap = {} - for user in @members.models - @memberEnrolledMap[user.id] = user.get('coursePrepaidID')? - @classroomNotEnrolledMap = {} - @totalNotEnrolled = 0 - for classroom in @classrooms.models - @classroomNotEnrolledMap[classroom.id] = 0 - for memberID in classroom.get('members') - @classroomNotEnrolledMap[classroom.id]++ unless @memberEnrolledMap[memberID] - @totalNotEnrolled += @classroomNotEnrolledMap[classroom.id] - @numberOfStudents = @totalNotEnrolled - @render?() - - onInputStudentsInput: -> - @numberOfStudents = Math.max(parseInt(@$('#students-input').val()) or 0, 0) - @updatePrice() - - updatePrice: -> - @renderSelectors '#price-form-group' - - numberOfStudentsIsValid: -> @numberOfStudents > 0 and @numberOfStudents < 100000 - - onClickPurchaseButton: -> - return application.router.navigate('/teachers/signup', {trigger: true}) if me.isAnonymous() - unless @numberOfStudentsIsValid() - alert("Please enter the maximum number of students needed for your class.") - return - - @state = undefined - @stateMessage = undefined - @render() - - # Show Stripe handler - application.tracker?.trackEvent 'Started course prepaid purchase', { - price: @pricePerStudent, students: @numberOfStudents} - stripeHandler.open - amount: @numberOfStudents * @pricePerStudent - description: "Full course access for #{@numberOfStudents} students" - bitcoin: true - alipay: if me.get('country') is 'china' or (me.get('preferredLanguage') or 'en-US')[...2] is 'zh' then true else 'auto' - - onStripeReceivedToken: (e) -> - @state = 'purchasing' - @render?() - - data = - maxRedeemers: @numberOfStudents - type: 'course' - stripe: - token: e.token.id - timestamp: new Date().getTime() - - $.ajax({ - url: '/db/prepaid/-/purchase', - data: data, - method: 'POST', - context: @ - success: -> - application.tracker?.trackEvent 'Finished course prepaid purchase', {price: @pricePerStudent, seats: @numberOfStudents} - @state = 'purchased' - @render?() - - error: (jqxhr, textStatus, errorThrown) -> - application.tracker?.trackEvent 'Failed course prepaid purchase', status: textStatus - if jqxhr.status is 402 - @state = 'error' - @stateMessage = arguments[2] - else - @state = 'error' - @stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}" - @render?() - }) diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index 5302d0ee2..723d50562 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -5,6 +5,7 @@ helper = require 'lib/coursesHelper' ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal' InviteToClassroomModal = require 'views/courses/InviteToClassroomModal' ActivateLicensesModal = require 'views/courses/ActivateLicensesModal' +EditStudentModal = require 'views/teachers/EditStudentModal' RemoveStudentModal = require 'views/courses/RemoveStudentModal' Classroom = require 'models/Classroom' @@ -23,39 +24,29 @@ module.exports = class TeacherClassView extends RootView template: template events: - 'click .students-tab-btn': (e) -> - e.preventDefault() - @trigger 'open-students-tab' - 'click .course-progress-tab-btn': (e) -> - e.preventDefault() - @trigger 'open-course-progress-tab' + 'click .nav-tabs a': 'onClickNavTabLink' 'click .unarchive-btn': 'onClickUnarchive' 'click .edit-classroom': 'onClickEditClassroom' 'click .add-students-btn': 'onClickAddStudents' - 'click .sort-by-name': 'sortByName' - 'click .sort-by-progress': 'sortByProgress' - 'click #copy-url-btn': 'copyURL' - 'click #copy-code-btn': 'copyCode' + 'click .edit-student-link': 'onClickEditStudentLink' + 'click .sort-button': 'onClickSortButton' + 'click #copy-url-btn': 'onClickCopyURLButton' + 'click #copy-code-btn': 'onClickCopyCodeButton' 'click .remove-student-link': 'onClickRemoveStudentLink' - 'click .assign-student-button': 'onClickAssign' - 'click .enroll-student-button': 'onClickEnroll' + 'click .assign-student-button': 'onClickAssignStudentButton' + 'click .enroll-student-button': 'onClickEnrollStudentButton' 'click .assign-to-selected-students': 'onClickBulkAssign' 'click .enroll-selected-students': 'onClickBulkEnroll' 'click .export-student-progress-btn': 'onClickExportStudentProgress' 'click .select-all': 'onClickSelectAll' 'click .student-checkbox': 'onClickStudentCheckbox' - 'change .course-select, .bulk-course-select': (e) -> - @trigger 'course-select:change', { selectedCourse: @courses.get($(e.currentTarget).val()) } + 'keyup #student-search': 'onKeyPressStudentSearch' getInitialState: -> - if Backbone.history.getHash() in ['students-tab', 'course-progress-tab'] - activeTab = '#' + Backbone.history.getHash() - else - activeTab = '#students-tab' { sortAttribute: 'name' sortDirection: 1 - activeTab + activeTab: '#' + (Backbone.history.getHash() or 'students-tab') students: new Users() classCode: "" joinURL: "" @@ -78,38 +69,59 @@ module.exports = class TeacherClassView extends RootView @allStudentsLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-all-students-single-level' @state = new State(@getInitialState()) - window.location.hash = @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab) + @updateHash @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab) @classroom = new Classroom({ _id: classroomID }) - @classroom.fetch() - @supermodel.trackModel(@classroom) + @supermodel.trackRequest @classroom.fetch() + @onKeyPressStudentSearch = _.debounce(@onKeyPressStudentSearch, 200) @students = new Users() @listenTo @classroom, 'sync', -> jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true) - if jqxhrs.length > 0 - @supermodel.trackCollection(@students) + @supermodel.trackRequests jqxhrs @classroom.sessions = new LevelSessions() requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom) @supermodel.trackRequests(requests) - @courses = new Courses() - @courses.fetch() - @supermodel.trackCollection(@courses) + @students.comparator = (student1, student2) => + dir = @state.get('sortDirection') + value = @state.get('sortValue') + if value is 'name' + return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir) + + if value is 'progress' + # TODO: I would like for this to be in the Level model, + # but it doesn't know about its own courseNumber. + level1 = student1.latestCompleteLevel + level2 = student2.latestCompleteLevel + return -dir if not level1 + return dir if not level2 + return dir * (level1.courseNumber - level2.courseNumber or level1.levelNumber - level2.levelNumber) + + if value is 'status' + statusMap = { expired: 0, 'not-enrolled': 1, enrolled: 2 } + diff = statusMap[student1.prepaidStatus()] - statusMap[student2.prepaidStatus()] + return dir * diff if diff + return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir) + @courses = new Courses() + @supermodel.trackRequest @courses.fetch() + @courseInstances = new CourseInstances() - @courseInstances.fetchForClassroom(classroomID) - @supermodel.trackCollection(@courseInstances) + @supermodel.trackRequest @courseInstances.fetchForClassroom(classroomID) @levels = new Levels() - @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}}) - @supermodel.trackCollection(@levels) + @supermodel.trackRequest @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}}) @attachMediatorEvents() attachMediatorEvents: () -> - @listenTo @state, 'sync change', @render + @listenTo @state, 'sync change', -> + if _.isEmpty(_.omit(@state.changed, 'searchTerm')) + @renderSelectors('#enrollment-status-table') + else + @render() # Model/Collection events @listenTo @classroom, 'sync change update', -> @removeDeletedStudents() @@ -126,8 +138,6 @@ module.exports = class TeacherClassView extends RootView @render() # TODO: use state @listenTo @courseInstances, 'add-members', -> noty text: $.i18n.t('teacher.assigned'), layout: 'center', type: 'information', killer: true, timeout: 5000 - @listenToOnce @students, 'sync', # TODO: This seems like it's in the wrong place? - @sortByName @listenTo @students, 'sync change update add remove reset', -> # Set state/props of things that depend on students? # Set specific parts of state based on the models, rather than just dumping the collection there? @@ -139,18 +149,6 @@ module.exports = class TeacherClassView extends RootView @listenTo @students, 'sort', -> @state.set students: @students @render() - - # DOM events - @listenTo @, 'open-students-tab', -> - if window.location.hash isnt '#students-tab' - window.location.hash = '#students-tab' - @state.set activeTab: '#students-tab' - @listenTo @, 'open-course-progress-tab', -> - if window.location.hash isnt '#course-progress-tab' - window.location.hash = '#course-progress-tab' - @state.set activeTab: '#course-progress-tab' - @listenTo @, 'course-select:change', ({ selectedCourse }) -> - @state.set selectedCourse: selectedCourse setCourseMembers: => for course in @courses.models @@ -194,12 +192,22 @@ module.exports = class TeacherClassView extends RootView progressData classStats: @calculateClassStats() } - - copyCode: -> + + onClickNavTabLink: (e) -> + e.preventDefault() + hash = $(e.target).closest('a').attr('href') + @updateHash(hash) + @state.set activeTab: hash + + updateHash: (hash) -> + return if application.testing + window.location.hash = hash + + onClickCopyCodeButton: -> @$('#join-code-input').val(@state.get('classCode')).select() @tryCopy() - copyURL: -> + onClickCopyURLButton: -> @$('#join-url-input').val(@state.get('joinURL')).select() @tryCopy() @@ -220,6 +228,11 @@ module.exports = class TeacherClassView extends RootView @openModalView(modal) @listenToOnce modal, 'hide', @render + onClickEditStudentLink: (e) -> + user = @students.get($(e.currentTarget).data('student-id')) + modal = new EditStudentModal({ user, @classroom }) + @openModalView(modal) + onClickRemoveStudentLink: (e) -> user = @students.get($(e.currentTarget).data('student-id')) modal = new RemoveStudentModal({ @@ -246,35 +259,19 @@ module.exports = class TeacherClassView extends RootView ) true - sortByName: (e) -> - if @state.get('sortValue') is 'name' + onClickSortButton: (e) -> + value = $(e.target).val() + if value is @state.get('sortValue') @state.set('sortDirection', -@state.get('sortDirection')) else - @state.set('sortValue', 'name') - @state.set('sortDirection', 1) - - dir = @state.get('sortDirection') - @students.comparator = (student1, student2) -> - return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir) + @state.set({ + sortValue: value + sortDirection: 1 + }) @students.sort() - sortByProgress: (e) -> - if @state.get('sortValue') is 'progress' - @state.set('sortDirection', -@state.get('sortDirection')) - else - @state.set('sortValue', 'progress') - @state.set('sortDirection', 1) - - dir = @state.get('sortDirection') - - @students.comparator = (student) -> - #TODO: I would like for this to be in the Level model, - # but it doesn't know about its own courseNumber - level = student.latestCompleteLevel - if not level - return -dir - return dir * ((1000 * level.courseNumber) + level.levelNumber) - @students.sort() + onKeyPressStudentSearch: (e) -> + @state.set('searchTerm', $(e.target).val()) getSelectedStudentIDs: -> @$('.student-row .checkbox-flat input:checked').map (index, checkbox) -> @@ -282,7 +279,7 @@ module.exports = class TeacherClassView extends RootView ensureInstance: (courseID) -> - onClickEnroll: (e) -> + onClickEnrollStudentButton: (e) -> userID = $(e.currentTarget).data('user-id') user = @students.get(userID) selectedUsers = new Users([user]) @@ -330,7 +327,7 @@ module.exports = class TeacherClassView extends RootView window.open(encodedUri) - onClickAssign: (e) -> + onClickAssignStudentButton: (e) -> userID = $(e.currentTarget).data('user-id') user = @students.get(userID) members = [userID] @@ -373,7 +370,7 @@ module.exports = class TeacherClassView extends RootView courseInstance.addMembers members } null - + onClickSelectAll: (e) -> e.preventDefault() checkboxes = @$('.student-checkbox input') @@ -412,7 +409,16 @@ module.exports = class TeacherClassView extends RootView stats.averageLevelsComplete = if @students.size() then (_.size(completeSessions) / @students.size()).toFixed(1) else 'N/A' # ' stats.totalLevelsComplete = _.size(completeSessions) - enrolledUsers = @students.filter (user) -> user.get('coursePrepaidID') + enrolledUsers = @students.filter (user) -> user.isEnrolled() stats.enrolledUsers = _.size(enrolledUsers) return stats + + studentStatusString: (student) -> + status = student.prepaidStatus() + expires = student.get('coursePrepaid')?.endDate + string = switch status + when 'not-enrolled' then $.i18n.t('teacher.status_not_enrolled') + when 'enrolled' then (if expires then $.i18n.t('teacher.status_enrolled') else '-') + when 'expired' then $.i18n.t('teacher.status_expired') + return string.replace('{{date}}', moment(expires).utc().format('l')) diff --git a/app/views/courses/TeacherCoursesView.coffee b/app/views/courses/TeacherCoursesView.coffee index 3ba95d3e9..482f58ec6 100644 --- a/app/views/courses/TeacherCoursesView.coffee +++ b/app/views/courses/TeacherCoursesView.coffee @@ -12,7 +12,6 @@ CourseInstance = require 'models/CourseInstance' RootView = require 'views/core/RootView' template = require 'templates/courses/teacher-courses-view' ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal' -Prepaids = require 'collections/Prepaids' module.exports = class TeacherCoursesView extends RootView id: 'teacher-courses-view' @@ -52,17 +51,11 @@ module.exports = class TeacherCoursesView extends RootView @listenToOnce @classrooms, 'sync', @onceClassroomsSync @supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}}) @campaigns = new Campaigns() - @campaigns.fetch() - @supermodel.trackCollection(@campaigns) + @supermodel.trackRequest @campaigns.fetchByType('course', { data: { project: 'levels,levelsUpdated' } }) @courseInstances = new CocoCollection([], { url: "/db/course_instance", model: CourseInstance }) @courseInstances.comparator = 'courseID' @courseInstances.sliceWithMembers = -> return @filter (courseInstance) -> _.size(courseInstance.get('members')) and courseInstance.get('classroomID') @supermodel.loadCollection(@courseInstances, 'course_instances', {data: {ownerID: me.id}}) - @prepaids = new Prepaids() - @prepaids.comparator = '_id' - if not me.isAnonymous() - @prepaids.fetchByCreator(me.id) - @supermodel.loadCollection(@prepaids, 'prepaids') # just registers @members = new CocoCollection([], { model: User }) @listenTo @members, 'sync', @render @ diff --git a/app/views/editor/level/LevelEditView.coffee b/app/views/editor/level/LevelEditView.coffee index e6da43ea4..bf86e6825 100644 --- a/app/views/editor/level/LevelEditView.coffee +++ b/app/views/editor/level/LevelEditView.coffee @@ -36,8 +36,7 @@ require 'vendor/aether-javascript' require 'vendor/aether-python' require 'vendor/aether-coffeescript' require 'vendor/aether-lua' -require 'vendor/aether-clojure' -require 'vendor/aether-io' +require 'vendor/aether-java' module.exports = class LevelEditView extends RootView id: 'editor-level-view' @@ -113,7 +112,7 @@ module.exports = class LevelEditView extends RootView @insertSubView new ComponentsDocumentationView lazy: true # Don't give it the supermodel, it'll pollute it! @insertSubView new SystemsDocumentationView lazy: true # Don't give it the supermodel, it'll pollute it! @insertSubView new LevelFeedbackView level: @level - + Backbone.Mediator.publish 'editor:level-loaded', level: @level @showReadOnly() if me.get('anonymous') diff --git a/app/views/ladder/SimulateTabView.coffee b/app/views/ladder/SimulateTabView.coffee index 8dd20b65d..9a9a2f927 100644 --- a/app/views/ladder/SimulateTabView.coffee +++ b/app/views/ladder/SimulateTabView.coffee @@ -21,8 +21,6 @@ module.exports = class SimulateTabView extends CocoView require 'vendor/aether-coffeescript' require 'vendor/aether-lua' require 'vendor/aether-java' - require 'vendor/aether-clojure' - require 'vendor/aether-io' onLoaded: -> super() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index eb22ac31d..a75ac807c 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -225,10 +225,7 @@ module.exports = class PlayLevelView extends RootView opponentSpells = opponentSpells.concat spells if (not @session.get('teamSpells')) and @otherSession?.get('teamSpells') @session.set('teamSpells', @otherSession.get('teamSpells')) - if @getQueryVariable 'esper' - opponentCode = @otherSession?.get('code') or {} - else - opponentCode = @otherSession?.get('transpiledCode') or {} + opponentCode = @otherSession?.get('code') or {} myCode = @session.get('code') or {} for spell in opponentSpells [thang, spell] = spell.split '/' @@ -363,15 +360,6 @@ module.exports = class PlayLevelView extends RootView onLevelStarted: -> return unless @surface? - - #TODO: Remove this at some point - if @session.get('codeLanguage') in ['clojure', 'io'] - problem = - aetherProblem: - message: "Sorry, support for #{@session.get('codeLanguage')} has been removed." - - Backbone.Mediator.publish 'tome:show-problem-alert', problem: problem - @loadingView.showReady() @trackLevelLoadEnd() if window.currentModal and not window.currentModal.destroyed and window.currentModal.constructor isnt VictoryModal @@ -429,16 +417,14 @@ module.exports = class PlayLevelView extends RootView perhapsStartSimulating: -> return unless @shouldSimulate() + return console.error "Should not auto-simulate until we fix how these languages are loaded" # TODO: how can we not require these as part of /play bundle? - #require "vendor/aether-#{codeLanguage}" for codeLanguage in ['javascript', 'python', 'coffeescript', 'lua', 'clojure', 'io'] - require 'vendor/aether-javascript' - require 'vendor/aether-python' - require 'vendor/aether-coffeescript' - require 'vendor/aether-lua' - require 'vendor/aether-java' - require 'vendor/aether-clojure' - require 'vendor/aether-io' - require 'vendor/aether-java' + ##require "vendor/aether-#{codeLanguage}" for codeLanguage in ['javascript', 'python', 'coffeescript', 'lua', 'java'] + #require 'vendor/aether-javascript' + #require 'vendor/aether-python' + #require 'vendor/aether-coffeescript' + #require 'vendor/aether-lua' + #require 'vendor/aether-java' @simulateNextGame() simulateNextGame: -> diff --git a/app/views/play/level/tome/DocFormatter.coffee b/app/views/play/level/tome/DocFormatter.coffee index a551a3301..93cdd1d6f 100644 --- a/app/views/play/level/tome/DocFormatter.coffee +++ b/app/views/play/level/tome/DocFormatter.coffee @@ -57,23 +57,18 @@ module.exports = class DocFormatter else (if @options.useHero then 'hero' else 'this') if @doc.type is 'function' [docName, args] = @getDocNameAndArguments() - sep = {clojure: ' '}[@options.language] ? ', ' - argNames = args.join sep + argNames = args.join ', ' argString = if argNames then '__ARGS__' else '' @doc.shortName = switch @options.language when 'coffeescript' then "#{ownerName}#{if ownerName is '@' then '' else '.'}#{docName}#{if argString then ' ' + argString else '()'}" when 'python' then "#{ownerName}.#{docName}(#{argString})" when 'lua' then "#{ownerName}:#{docName}(#{argString})" - when 'clojure' then "(.#{docName} #{ownerName}#{if argNames then ' ' + argString else ''})" - when 'io' then "#{if ownerName is 'this' then '' else ownerName + ' '}#{docName}#{if argNames then '(' + argNames + ')' else ''}" else "#{ownerName}.#{docName}(#{argString});" else @doc.shortName = switch @options.language when 'coffeescript' then "#{ownerName}#{if ownerName is '@' then '' else '.'}#{@doc.name}" when 'python' then "#{ownerName}.#{@doc.name}" when 'lua' then "#{ownerName}.#{@doc.name}" - when 'clojure' then "(.#{@doc.name} #{ownerName})" - when 'io' then "#{if ownerName is 'this' then '' else ownerName + ' '}#{@doc.name}" else "#{ownerName}.#{@doc.name};" @doc.shorterName = @doc.shortName if @doc.type is 'function' and argString @@ -89,7 +84,7 @@ module.exports = class DocFormatter translatedName = utils.i18n(@doc, 'name') if translatedName isnt @doc.name @doc.translatedShortName = @doc.shortName.replace(@doc.name, translatedName) - + # Grab the language-specific documentation for some sub-properties, if we have it. toTranslate = [{obj: @doc, prop: 'description'}, {obj: @doc, prop: 'example'}] @@ -153,16 +148,12 @@ module.exports = class DocFormatter when 'coffeescript' then "loop" when 'python' then "while True:" when 'lua' then "while true do" - when 'clojure' then "(while true)" - when 'io' then "while(true)" else "while (true)" for field in ['example', 'description'] [simpleLoop, whileLoop] = switch @options.language when 'coffeescript' then [/loop/g, "loop"] when 'python' then [/loop:/g, "while True:"] when 'lua' then [/loop/g, "while true do"] - when 'clojure' then [/\(dotimes( \[n \d+\])?/g, "(while true"] - when 'io' then [/loop\(/g, "while(true,"] else [/loop/g, "while (true)"] @doc[field] = @doc[field].replace simpleLoop, whileLoop diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee index a57e52657..0e6642d67 100644 --- a/app/views/play/level/tome/Spell.coffee +++ b/app/views/play/level/tome/Spell.coffee @@ -31,14 +31,13 @@ module.exports = class Spell @languages.javascript ?= p.source @name = p.name @permissions = read: p.permissions?.read ? [], readwrite: p.permissions?.readwrite ? [] # teams + @team = @permissions.readwrite[0] ? 'common' if @canWrite() @setLanguage options.language - else if @isEnemySpell() - @setLanguage @otherSession?.get('submittedCodeLanguage') ? @spectateOpponentCodeLanguage + else if @otherSession and @team is @otherSession.get 'team' + @setLanguage @otherSession.get('submittedCodeLanguage') or @otherSession.get('codeLanguage') else @setLanguage 'javascript' - @useTranspiledCode = @shouldUseTranspiledCode() - #console.log 'Spell', @spellKey, 'is using transpiled code (should only happen if it\'s an enemy/spectate writable method).' if @useTranspiledCode @source = @originalSource @parameters = p.parameters @@ -53,7 +52,6 @@ module.exports = class Spell @view.render() # Get it ready and code loaded in advance @tabView = new SpellListTabEntryView spell: @, supermodel: @supermodel, codeLanguage: @language, level: options.level @tabView.render() - @team = @permissions.readwrite[0] ? 'common' Backbone.Mediator.publish 'tome:spell-created', spell: @ destroy: -> @@ -88,10 +86,8 @@ module.exports = class Spell @originalSource = switch @language when 'python' then @originalSource.replace /loop:/, 'while True:' when 'javascript' then @originalSource.replace /loop {/, 'while (true) {' - when 'clojure' then @originalSource.replace /dotimes \[n 1000\]/, '(while true' when 'lua' then @originalSource.replace /loop\n/, 'while true then\n' when 'coffeescript' then @originalSource - when 'io' then @originalSource.replace /loop\n/, 'while true,\n' else @originalSource addPicoCTFProblem: -> @@ -126,15 +122,10 @@ module.exports = class Spell else source = @getSource() [pure, problems] = [null, null] - if @useTranspiledCode - transpiledCode = @session.get('code') for thangID, spellThang of @thangs unless pure - if @useTranspiledCode and transpiledSpell = transpiledCode[@spellKey.split('/')[0]]?[@name] - spellThang.aether.pure = transpiledSpell - else - pure = spellThang.aether.transpile source - problems = spellThang.aether.problems + pure = spellThang.aether.transpile source + problems = spellThang.aether.problems #console.log 'aether transpiled', source.length, 'to', spellThang.aether.pure.length, 'for', thangID, @spellKey else spellThang.aether.raw = source @@ -182,7 +173,7 @@ module.exports = class Spell skipProtectAPI: skipProtectAPI includeFlow: includeFlow problemContext: problemContext - useInterpreter: if @spectateView then true else undefined + useInterpreter: true aether = new Aether aetherOptions if @worker workerMessage = @@ -207,22 +198,6 @@ module.exports = class Spell toString: -> "<Spell: #{@spellKey}>" - isEnemySpell: -> - return false unless @permissions.readwrite.length - return false unless @otherSession or @spectateView - teamSpells = @session.get('teamSpells') - team = @session.get('team') ? 'humans' - teamSpells and not _.contains(teamSpells[team], @spellKey) - - shouldUseTranspiledCode: -> - # Determine whether this code has already been transpiled, or whether it's raw source needing transpilation. - return false if @spectateView or utils.getQueryVariable 'esper' # Don't use transpiled code with interpreter - return true if @isEnemySpell() # Use transpiled for enemy spells. - # Players without permissions can't view the raw code. - return false if @observing and @levelType in ['hero', 'course'] - return true if @session.get('creator') isnt me.id and not (me.isAdmin() or 'employer' in me.get('permissions', true)) - false - createProblemContext: (thang) -> # Create problemContext Aether can use to craft better error messages # stringReferences: values that should be referred to as a string instead of a variable (e.g. "Brak", not Brak) diff --git a/app/views/play/level/tome/SpellListEntryView.coffee b/app/views/play/level/tome/SpellListEntryView.coffee index 4bd68e71e..eb6b93cf6 100644 --- a/app/views/play/level/tome/SpellListEntryView.coffee +++ b/app/views/play/level/tome/SpellListEntryView.coffee @@ -46,10 +46,6 @@ module.exports = class SpellListEntryView extends CocoView paramString = parameters.join ', ' name = @spell.name switch @spell.language - when 'io' - "#{name} := method(#{paramString})" - when 'clojure' - "(defn #{name} [#{paramString}] ...)" when 'python' "def #{name}(#{paramString}):" when 'lua' diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index ac23440da..ad9749986 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -230,7 +230,7 @@ module.exports = class SpellView extends CocoView disableSpaces = @options.level.get('disableSpaces') or false aceConfig = me.get('aceConfig') ? {} disableSpaces = false if aceConfig.keyBindings and aceConfig.keyBindings isnt 'default' # Not in vim/emacs mode - disableSpaces = false if @spell.language in ['clojure', 'lua', 'java', 'coffeescript', 'io'] # Don't disable for more advanced/experimental languages + disableSpaces = false if @spell.language in ['lua', 'java', 'coffeescript'] # Don't disable for more advanced/experimental languages if not disableSpaces or (_.isNumber(disableSpaces) and disableSpaces < me.level()) return @ace.execCommand 'insertstring', ' ' line = @aceDoc.getLine @ace.getCursorPosition().row @@ -301,7 +301,10 @@ module.exports = class SpellView extends CocoView for row in [0..@aceSession.getLength()] foldWidgets[row] = @aceSession.getFoldWidget(row) unless foldWidgets[row]? continue unless foldWidgets? and foldWidgets[row] is "start" - docRange = @aceSession.getFoldWidgetRange(row) + try + docRange = @aceSession.getFoldWidgetRange(row) + catch error + console.warn "Couldn't find fold widget docRange for row #{row}:", error if not docRange? guess = startOfRow(row) docRange = new Range(row,guess,row,guess+4) @@ -520,10 +523,8 @@ module.exports = class SpellView extends CocoView content = switch e.language when 'python' then content.replace /loop:/, 'while True:' when 'javascript' then content.replace /loop/, 'while (true)' - when 'clojure' then content.replace /dotimes \[n 1000\]/, '(while true' when 'lua' then content.replace /loop/, 'while true then' when 'coffeescript' then content - when 'io' then content.replace /loop/, 'while true,' else content name = switch e.language when 'python' then 'while True' @@ -557,9 +558,8 @@ module.exports = class SpellView extends CocoView if doc.userShouldCaptureReturn varName = doc.userShouldCaptureReturn.variableName ? 'result' entry.captureReturn = switch e.language - when 'io' then varName + ' := ' when 'javascript' then 'var ' + varName + ' = ' - when 'clojure' then '(let [' + varName + ' ' + #when 'lua' then 'local ' + varName + ' = ' # TODO: should we do this? else varName + ' = ' # TODO: Generalize this snippet replacement @@ -583,15 +583,8 @@ module.exports = class SpellView extends CocoView translateFindNearest: -> # If they have advanced glasses but are playing a level which assumes earlier glasses, we'll adjust the sample code to use the more advanced APIs instead. oldSource = @getSource() - if @spell.language is 'clojure' - newSource = oldSource.replace /\(.findNearestEnemy this\)/g, "(.findNearest this (.findEnemies this))" - newSource = newSource.replace /\(.findNearestItem this\)/g, "(.findNearest this (.findItems this))" - else if @spell.language is 'io' - newSource = oldSource.replace /findNearestEnemy/g, "findNearest(findEnemies)" - newSource = newSource.replace /findNearestItem/g, "findNearest(findItems)" - else - newSource = oldSource.replace /(self:|self.|this.|@)findNearestEnemy\(\)/g, "$1findNearest($1findEnemies())" - newSource = newSource.replace /(self:|self.|this.|@)findNearestItem\(\)/g, "$1findNearest($1findItems())" + newSource = oldSource.replace /(self:|self.|this.|@)findNearestEnemy\(\)/g, "$1findNearest($1findEnemies())" + newSource = newSource.replace /(self:|self.|this.|@)findNearestItem\(\)/g, "$1findNearest($1findItems())" return if oldSource is newSource @spell.originalSource = newSource @updateACEText newSource @@ -640,7 +633,7 @@ module.exports = class SpellView extends CocoView return if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] # We'll turn this on later, maybe, but not yet. @debugView = new SpellDebugView ace: @ace, thang: @thang, spell:@spell @$el.append @debugView.render().$el.hide() - + createTranslationView: -> @translationView = new SpellTranslationView { @ace, @supermodel } @$el.append @translationView.render().$el.hide() @@ -731,7 +724,7 @@ module.exports = class SpellView extends CocoView # Uncomment the below line for a debug panel to display inside the level #@spade.debugPlay(spadeEvents) condensedEvents = @spade.condense(spadeEvents) - + return unless condensedEvents.length compressedEvents = LZString.compressToUTF16(JSON.stringify(condensedEvents)) @@ -746,7 +739,7 @@ module.exports = class SpellView extends CocoView }) codeLog.save() - + onShowVictory: (e) -> if @saveSpadeTimeout? window.clearTimeout @saveSpadeTimeout @@ -1067,12 +1060,8 @@ module.exports = class SpellView extends CocoView return unless @ace.isFocused() and e.x? and e.y? if @spell.language is 'python' @ace.insert "{\"x\": #{e.x}, \"y\": #{e.y}}" - else if @spell.language is 'clojure' - @ace.insert "{:x #{e.x} :y #{e.y}}" else if @spell.language is 'lua' @ace.insert "{x=#{e.x}, y=#{e.y}}" - else if @spell.language is 'io' - return else @ace.insert "{x: #{e.x}, y: #{e.y}}" @@ -1355,6 +1344,5 @@ commentStarts = javascript: '//' python: '#' coffeescript: '#' - clojure: ';' lua: '--' - io: '//' + java: '//' diff --git a/app/views/play/modal/PollModal.coffee b/app/views/play/modal/PollModal.coffee index 4a9af778b..420b3d971 100644 --- a/app/views/play/modal/PollModal.coffee +++ b/app/views/play/modal/PollModal.coffee @@ -129,7 +129,5 @@ commentStarts = javascript: '// ' python: '# ' coffeescript: '# ' - clojure: '; ' lua: '-- ' - io: '// ' java: '// ' diff --git a/app/views/teachers/EditStudentModal.coffee b/app/views/teachers/EditStudentModal.coffee new file mode 100644 index 000000000..7f7feae65 --- /dev/null +++ b/app/views/teachers/EditStudentModal.coffee @@ -0,0 +1,45 @@ +ModalView = require 'views/core/ModalView' +State = require 'models/State' +template = require 'templates/teachers/edit-student-modal' +auth = require 'core/auth' + +module.exports = class EditStudentModal extends ModalView + id: 'edit-student-modal' + template: template + + events: + 'click .send-recovery-email-btn:not(.disabled)': 'onClickSendRecoveryEmail' + 'click .change-password-btn:not(.disabled)': 'onClickChangePassword' + 'input .new-password-input': 'onChangeNewPasswordInput' + + initialize: ({ @user, @classroom }) -> + @supermodel.trackRequest @user.fetch() + @utils = require 'core/utils' + @state = new State({ + emailSent: false + passwordChanged: false + newPassword: "" + errorMessage: "" + }) + @listenTo @state, 'change', @render + @listenTo @classroom, 'save-password:success', -> + @state.set { passwordChanged: true, errorMessage: "" } + @listenTo @classroom, 'save-password:error', (error) -> + @state.set({ errorMessage: error.message }) + # TODO: Show an error. (password too short) + + onClickSendRecoveryEmail: -> + email = @user.get('email') + auth.sendRecoveryEmail(email).then => + @state.set { emailSent: true } + + onClickChangePassword: -> + @classroom.setStudentPassword(@user, @state.get('newPassword')) + + onChangeNewPasswordInput: (e) -> + @state.set { + newPassword: $(e.currentTarget).val() + emailSent: false + passwordChanged: false + }, { silent: true } + @renderSelectors('.change-password-btn') diff --git a/app/views/teachers/HowToEnrollModal.coffee b/app/views/teachers/HowToEnrollModal.coffee new file mode 100644 index 000000000..636b21d15 --- /dev/null +++ b/app/views/teachers/HowToEnrollModal.coffee @@ -0,0 +1,6 @@ +ModalView = require 'views/core/ModalView' + +module.exports = class HowToEnrollModal extends ModalView + id: 'how-to-enroll-modal' + template: require 'templates/teachers/how-to-enroll-modal' + diff --git a/app/views/teachers/TeachersContactModal.coffee b/app/views/teachers/TeachersContactModal.coffee new file mode 100644 index 000000000..57ee0bc58 --- /dev/null +++ b/app/views/teachers/TeachersContactModal.coffee @@ -0,0 +1,70 @@ +ModalView = require 'views/core/ModalView' +State = require 'models/State' +TrialRequests = require 'collections/TrialRequests' +forms = require 'core/forms' +contact = require 'core/contact' + +module.exports = class TeachersContactModal extends ModalView + id: 'teachers-contact-modal' + template: require 'templates/teachers/teachers-contact-modal' + + events: + 'submit form': 'onSubmitForm' + + initialize: (options={}) -> + @state = new State({ + formValues: { + email: '' + message: '' + } + formErrors: {} + sendingState: 'standby' # 'sending', 'sent', 'error' + }) + @enrollmentsNeeded = options.enrollmentsNeeded or '-' + @trialRequests = new TrialRequests() + @supermodel.trackRequest @trialRequests.fetchOwn() + @state.on 'change', @render, @ + + onLoaded: -> + trialRequest = @trialRequests.first() + props = trialRequest?.get('properties') or {} + message = """ + Name of School/District: #{props.organization or ''} + Your Name: #{props.name || ''} + Enrollments Needed: #{@enrollmentsNeeded} + + Message: Hi CodeCombat! I want to learn more about the Classroom experience and get licenses so that my students can access Computer Science 2 and on. + """ + email = props.email or me.get('email') or '' + @state.set('formValues', { email, message }) + super() + + onSubmitForm: (e) -> + e.preventDefault() + return if @state.get('sendingState') is 'sending' + + formValues = forms.formToObject @$el + @state.set('formValues', formValues) + + formErrors = {} + if not forms.validateEmail(formValues.email) + formErrors.email = 'Invalid email.' + if not formValues.message + formErrors.message = 'Message required.' + @state.set({ formErrors, formValues, sendingState: 'standby' }) + return unless _.isEmpty(formErrors) + + @state.set('sendingState', 'sending') + data = _.extend({ country: me.get('country'), recipientID: 'schools@codecombat.com' }, formValues) + contact.send({ + data + context: @ + success: -> + @state.set({ sendingState: 'sent' }) + me.set('enrollmentRequestSent', true) + setTimeout(=> + @hide?() + , 3000) + error: -> @state.set({ sendingState: 'error' }) + }) + diff --git a/app/views/user/EmailVerifiedView.coffee b/app/views/user/EmailVerifiedView.coffee new file mode 100644 index 000000000..5c117fa6d --- /dev/null +++ b/app/views/user/EmailVerifiedView.coffee @@ -0,0 +1,31 @@ +RootView = require 'views/core/RootView' +State = require 'models/State' +template = require 'templates/user/email-verified-view' +User = require 'models/User' + +module.exports = class EmailVerifiedView extends RootView + id: 'email-verified-view' + template: template + + events: + 'click .login-button': 'onClickLoginButton' + + initialize: (options, @userID, @verificationCode) -> + super(options) + @state = new State(@getInitialState()) + @user = new User({ _id: @userID }) + @user.sendVerificationCode(@verificationCode) + + @listenTo @state, 'change', @render + @listenTo @user, 'email-verify-success', -> + @state.set { verifyStatus: 'success' } + me.fetch() + @listenTo @user, 'email-verify-error', -> + @state.set { verifyStatus: 'error' } + + getInitialState: -> + verifyStatus: 'pending' + + onClickLoginButton: (e) -> + AuthModal = require 'views/core/AuthModal' + @openModalView(new AuthModal()) diff --git a/bower.json b/bower.json index 2a24feca4..5bba7ea9f 100644 --- a/bower.json +++ b/bower.json @@ -32,7 +32,7 @@ "firepad": "~0.1.2", "marked": "~0.3.0", "moment": "~2.5.0", - "aether": "~0.4.5", + "aether": "~0.5.0", "underscore.string": "~2.3.3", "firebase": "~1.0.2", "d3": "~3.4.4", diff --git a/config.coffee b/config.coffee index aa1d8e10f..6690d8e2e 100644 --- a/config.coffee +++ b/config.coffee @@ -110,9 +110,7 @@ exports.config = 'javascripts/lodash.js': regJoin('^bower_components/lodash/dist/lodash.js') 'javascripts/aether.js': regJoin('^bower_components/aether/build/aether.js') 'javascripts/esper.js': 'bower_components/esper.js/esper.js' - 'javascripts/app/vendor/aether-clojure.js': 'bower_components/aether/build/clojure.js' 'javascripts/app/vendor/aether-coffeescript.js': 'bower_components/aether/build/coffeescript.js' - 'javascripts/app/vendor/aether-io.js': 'bower_components/aether/build/io.js' 'javascripts/app/vendor/aether-javascript.js': 'bower_components/aether/build/javascript.js' 'javascripts/app/vendor/aether-lua.js': 'bower_components/aether/build/lua.js' 'javascripts/app/vendor/aether-java.js': 'bower_components/aether/build/java.js' diff --git a/package.json b/package.json index eff9caf2c..1475f6ff1 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dependencies": { "JQDeferred": "~2.1.0", "ace-builds": "https://github.com/ajaxorg/ace-builds/archive/3fb55e8e374ab02ce47c1ae55ffb60a1835f3055.tar.gz", - "aether": "~0.4.5", + "aether": "~0.5.0", "async": "0.2.x", "aws-sdk": "~2.0.0", "bayesian-battle": "0.0.7", @@ -101,6 +101,8 @@ "commonjs-require-definition": "0.2.0", "compressible": "~1.0.1", "css-brunch": ">=2.0.0", + "country-data": "0.0.24", + "country-list": "0.0.3", "fs-extra": "^0.26.2", "http-proxy": "^1.13.2", "jade-brunch": "1.7.5", @@ -112,7 +114,7 @@ "karma-coverage": "~0.5.1", "karma-firefox-launcher": "~0.1.3", "karma-html2js-preprocessor": "~0.1.0", - "karma-jasmine": "~0.2.0", + "karma-jasmine": "^1.0.2", "karma-phantomjs-launcher": "~0.1.1", "karma-requirejs": "~0.2.1", "karma-script-launcher": "~0.1.0", diff --git a/scripts/followupCloseIoLeads.js b/scripts/followupCloseIoLeads.js index b6abbebe8..bc8096dc0 100644 --- a/scripts/followupCloseIoLeads.js +++ b/scripts/followupCloseIoLeads.js @@ -1,8 +1,8 @@ // Follow up on Close.io leads 'use strict'; -if (process.argv.length !== 6) { - log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <mongo connection Url>"); +if (process.argv.length !== 7) { + log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <mongo connection Url>"); process.exit(); } @@ -20,8 +20,8 @@ const demoRequestEmailTemplatesAuto2 = ['tmpl_HJ5zebh1SqC1QydDto05VPUMu4F7i5M35L const scriptStartTime = new Date(); const closeIoApiKey = process.argv[2]; -const closeIoMailApiKeys = [process.argv[3], process.argv[4]]; // Automatic mails sent as API owners -const mongoConnUrl = process.argv[5]; +const closeIoMailApiKeys = [process.argv[3], process.argv[4], process.argv[5]]; // Automatic mails sent as API owners +const mongoConnUrl = process.argv[6]; const MongoClient = require('mongodb').MongoClient; const async = require('async'); const request = require('request'); @@ -44,11 +44,6 @@ async.series([ // ** Utilities -function getRandomEmailApiKey() { - if (closeIoMailApiKeys.length < 0) return; - return closeIoMailApiKeys[Math.floor(Math.random() * closeIoMailApiKeys.length)]; -} - function getRandomEmailTemplate(templates) { if (templates.length < 0) return ''; return templates[Math.floor(Math.random() * templates.length)]; diff --git a/scripts/mongodb/deteacher.js b/scripts/mongodb/deteacher.js new file mode 100644 index 000000000..bb1dac2e3 --- /dev/null +++ b/scripts/mongodb/deteacher.js @@ -0,0 +1,38 @@ + +// Unset someone with a teacher role. Remove trial requests, set role to student or nothing +// depending on if they're in any classrooms. + +// Usage +// --------------- +// In mongo shell +// +// > db.loadServerScripts(); +// > deteacher('some@email.com'); + +var deteacher = function deteacher(email) { + var user = db.users.findOne({emailLower: email.toLowerCase()}); + if (!user) { + print('User not found'); + return; + } + print('Found user', user.name, user.email, user.role, user._id); + var trialRequests = db.trial.requests.find({applicant: user._id}).toArray(); + for (var index in trialRequests) { + var trialRequest = trialRequests[index]; + print('Delete trial request', JSON.stringify(trialRequest, null, ' '), db.trial.requests.remove({_id: trialRequest._id}, true)); + } + var classroomCount = db.classrooms.count({members: user._id}); + if (classroomCount > 0) { + print('Set to student', db.users.update({_id: user._id}, {$set: {role: 'student'}})); + } + else { + print('Unset role', db.users.update({_id: user._id}, {$unset: {role: ''}})); + } +} + +db.system.js.save( + { + _id: 'deteacher', + value: deteacher + } +) diff --git a/scripts/mongodb/migrations/2016-05-19-enrollment-start-end-dates.js b/scripts/mongodb/migrations/2016-05-19-enrollment-start-end-dates.js new file mode 100644 index 000000000..fe3d8160a --- /dev/null +++ b/scripts/mongodb/migrations/2016-05-19-enrollment-start-end-dates.js @@ -0,0 +1,46 @@ +// Migrate users from coursePrepaidID to coursePrepaid + +startDate = new Date(Date.UTC(2016,4,15)).toISOString(); // NOTE: Month is 0 indexed... +endDate = new Date(Date.UTC(2017,5,1)).toISOString(); + +cutoffDate = new Date(2015,11,11); +cutoffID = ObjectId(Math.floor(cutoffDate/1000).toString(16)+'0000000000000000'); + +print('Setting start/end', startDate, endDate, cutoffID); + +var cursor = db.prepaids.find({type: 'course', _id: { $gt: cutoffID }}) + +cursor.forEach(function (prepaid) { + var properties = prepaid.properties || {}; + if (!(prepaid.endDate && prepaid.startDate)) { + if (!prepaid.endDate) { + if(properties.endDate) { + print('Updating from existing end date', properties.endDate); + prepaid.endDate = properties.endDate.toISOString(); + } + else { + prepaid.endDate = endDate; + } + } + if (!prepaid.startDate) { + prepaid.startDate = startDate; + } + print('updating prepaid', prepaid._id, 'creator', prepaid.creator, 'start/end', prepaid.startDate, prepaid.endDate); + print(' -', db.prepaids.save(prepaid)); + } + + var redeemers = prepaid.redeemers || []; + for (var index in redeemers) { + var redeemer = redeemers[index]; + var user = db.users.findOne({ _id: redeemer.userID }, { coursePrepaid: 1, coursePrepaidID: 1, email:1, name:1, permissions: 1 }); + if (user.coursePrepaidID && !user.coursePrepaid) { + var update = { + $set: { coursePrepaid: { _id: user.coursePrepaidID, startDate: prepaid.startDate, endDate: prepaid.endDate } }, + $unset: { coursePrepaidID: '' } + } + print('\t updating user', user._id, user.name, user.email, user.permissions, JSON.stringify(update)); + print('\t', db.users.update({_id: user._id}, update)); + } + } + +}); diff --git a/scripts/updateCloseIoLeads.js b/scripts/updateCloseIoLeads.js index 613e07e08..6d9cfc029 100644 --- a/scripts/updateCloseIoLeads.js +++ b/scripts/updateCloseIoLeads.js @@ -1,8 +1,8 @@ // Upsert new lead data into Close.io 'use strict'; -if (process.argv.length !== 7) { - log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Intercom 'App ID:API key'> <mongo connection Url>"); +if (process.argv.length !== 9) { + log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <Close.io EU mail API key> <Intercom 'App ID:API key'> <mongo connection Url>"); process.exit(); } @@ -13,6 +13,7 @@ if (process.argv.length !== 7) { // TODO: Use generators and promises // TODO: Reduce response data via _fields param // TODO: Assumes 1:1 contact:email relationship (Close.io supports multiple emails for a single contact) +// TODO: Cleanup country/status lookup code // Save as custom fields instead of user-specific lead notes (also saving nces_ props) const commonTrialProperties = ['organization', 'city', 'state', 'country']; @@ -30,6 +31,10 @@ const leadsToSkip = ['6 sınıflar', 'fdsafd', 'ashtasht', 'matt+20160404teacher const createTeacherEmailTemplatesAuto1 = ['tmpl_i5bQ2dOlMdZTvZil21bhTx44JYoojPbFkciJ0F560mn', 'tmpl_CEZ9PuE1y4PRvlYiKB5kRbZAQcTIucxDvSeqvtQW57G']; const demoRequestEmailTemplatesAuto1 = ['tmpl_s7BZiydyCHOMMeXAcqRZzqn0fOtk0yOFlXSZ412MSGm', 'tmpl_cGb6m4ssDvqjvYd8UaG6cacvtSXkZY3vj9b9lSmdQrf']; +const createTeacherInternationalEmailTemplateAuto1 = 'tmpl_8vsXwcr6dWefMnAEfPEcdHaxqSfUKUY8UKq6WfReGqG'; +const demoRequestInternationalEmailTemplateAuto1 = 'tmpl_nnH1p3II7G7NJYiPOIHphuj4XUaDptrZk1mGQb2d9Xa'; +const createTeacherNlEmailTemplatesAuto1 = ['tmpl_yf9tAPasz8KV7L414GhWWIclU8ewclh3Z8lCx2mCoIU', 'tmpl_OgPCV2p59uq0daVuUPF6r1rcQkxJbViyZ1ZMtW45jY8']; +const demoRequestNlEmailTemplatesAuto1 = ['tmpl_XGKyZm6gcbqZ5jirt7A54Vu8p68cLxAsKZtb9QBABUE', 'tmpl_xcfgQjUHPa6LLsbPWuPvEUElFXHmIpLa4IZEybJ0b0u']; // Prioritized Close.io lead status match list const closeIoInitialLeadStatuses = [ @@ -41,18 +46,27 @@ const closeIoInitialLeadStatuses = [ {status: 'Inbound International Auto Attempt 1', regex: /^[A-Za-z]{2}$|\.[A-Za-z]{2}$/}, {status: 'Auto Attempt 1', regex: /^[A-Za-z]*$/} ]; +const defaultLeadStatus = 'Auto Attempt 1'; +const defaultInternationalLeadStatus = 'Inbound International Auto Attempt 1'; +const defaultEuLeadStatus = 'Inbound EU Auto Attempt 1'; + +const usSchoolStatuses = ['Auto Attempt 1', 'New US Schools Auto Attempt 1', 'New US Schools Auto Attempt 1 Low']; const emailDelayMinutes = 27; const scriptStartTime = new Date(); const closeIoApiKey = process.argv[2]; -const closeIoMailApiKeys = [process.argv[3], process.argv[4]]; // Automatic mails sent as API owners -const intercomAppIdApiKey = process.argv[5]; +// Automatic mails sent as API owners, first key assumed to be primary and gets 50% of the leads +const closeIoMailApiKeys = [process.argv[3], process.argv[3], process.argv[4], process.argv[5]]; +const closeIoEuMailApiKey = process.argv[6]; +const intercomAppIdApiKey = process.argv[7]; const intercomAppId = intercomAppIdApiKey.split(':')[0]; const intercomApiKey = intercomAppIdApiKey.split(':')[1]; -const mongoConnUrl = process.argv[6]; +const mongoConnUrl = process.argv[8]; const MongoClient = require('mongodb').MongoClient; const async = require('async'); +const countryData = require('country-data'); +const countryList = require('country-list')(); const parseDomain = require('parse-domain'); const request = require('request'); @@ -90,14 +104,60 @@ function upsertLeads(done) { // ** Utilities -function getInitialLeadStatusViaCountry(country, trialRequests) { - if (/usa|america|united states/ig.test(country)) { - const status = 'New US Schools Auto Attempt 1' - return isLowValueLead(status, trialRequests) ? `${status} Low` : status; +function getCountryCode(country, emails) { + // console.log(`DEBUG: getCountryCode ${country} ${emails.length}`); + if (country) { + let countryCode = countryList.getCode(country); + if (countryCode) return countryCode; + } + for (const email of emails) { + const tld = parseDomain(email).tld; + if (tld) { + const matches = /^[A-Za-z]*\.?([A-Za-z]{2})$/ig.exec(tld); + if (matches && matches.length === 2) { + return matches[1].toUpperCase(); + } + } } } +function getInitialLeadStatusViaCountry(country, trialRequests) { + // console.log(`DEBUG: getInitialLeadStatusViaCountry ${country} ${trialRequests.length}`); + if (/^u\.s\.?(\.a)?\.?$|^us$|usa|america|united states/ig.test(country)) { + const status = 'New US Schools Auto Attempt 1' + return isLowValueUsLead(status, trialRequests) ? `${status} Low` : status; + } + const highValueLead = isHighValueLead(trialRequests); + if (/^england$|^uk$|^united kingdom$/ig.test(country) && highValueLead) { + return 'Inbound UK Auto Attempt 1'; + } + if (/^ca$|^canada$/ig.test(country)) { + return 'Inbound Canada Auto Attempt 1'; + } + if (/^au$|^australia$/ig.test(country)) { + return 'Inbound AU Auto Attempt 1'; + } + if (/^nz$|^new zealand$/ig.test(country)) { + return 'Inbound AU Auto Attempt 1'; + } + if (/bolivia|iran|korea|macedonia|taiwan|tanzania|^venezuela$/ig.test(country)) { + return defaultInternationalLeadStatus; + } + const countryCode = countryList.getCode(country); + if (countryCode) { + if (countryCode === 'NL' || countryCode === 'BE') { + return defaultEuLeadStatus; + } + if (isEuCountryCode(countryCode)) { + return highValueLead ? 'Inbound EU Auto Attempt 1 High' : defaultEuLeadStatus; + } + return defaultInternationalLeadStatus; + } + return null; +} + function getInitialLeadStatusViaEmails(emails, trialRequests) { + // console.log(`DEBUG: getInitialLeadStatusViaEmails ${emails.length} ${trialRequests.length}`); let currentStatus = null; let currentRank = closeIoInitialLeadStatuses.length; for (const email of emails) { @@ -110,16 +170,45 @@ function getInitialLeadStatusViaEmails(emails, trialRequests) { } } } - currentStatus = currentStatus ? currentStatus : closeIoInitialLeadStatuses[closeIoInitialLeadStatuses.length - 1].status; - return isLowValueLead(currentStatus, trialRequests) ? `${currentStatus} Low` : currentStatus; + if (!currentStatus || [defaultLeadStatus, defaultInternationalLeadStatus].indexOf(currentStatus) >= 0) { + // Look for a better EU match + const countryCode = getCountryCode(null, emails); + if (countryCode === 'NL' || countryCode === 'BE') { + return defaultEuLeadStatus; + } + if (isEuCountryCode(countryCode)) { + return isHighValueLead(trialRequests) ? 'Inbound EU Auto Attempt 1 High' : defaultEuLeadStatus; + } + } + currentStatus = currentStatus ? currentStatus : defaultLeadStatus; + return isLowValueUsLead(currentStatus, trialRequests) ? `${currentStatus} Low` : currentStatus; } -function isLowValueLead(status, trialRequests) { - if (['Auto Attempt 1', 'New US Schools Auto Attempt 1'].indexOf(status) >= 0) { +function isEuCountryCode(countryCode) { + if (countryData.regions.northernEurope.countries.indexOf(countryCode) >= 0) { + return true; + } + if (countryData.regions.southernEurope.countries.indexOf(countryCode) >= 0) { + return true; + } + if (countryData.regions.easternEurope.countries.indexOf(countryCode) >= 0) { + return true; + } + if (countryData.regions.westernEurope.countries.indexOf(countryCode) >= 0) { + return true; + } + return false; +} + +function isLowValueUsLead(status, trialRequests) { + if (isUSSchoolStatus(status)) { for (const trialRequest of trialRequests) { if (parseInt(trialRequest.properties.nces_district_students) < 5000) { return true; } + else if (parseInt(trialRequest.properties.nces_district_students) >= 5000) { + return false; + } } for (const trialRequest of trialRequests) { // Must match these values: https://github.com/codecombat/codecombat/blob/master/app/templates/teachers/request-quote-view.jade#L159 @@ -131,7 +220,22 @@ function isLowValueLead(status, trialRequests) { return false; } -function getRandomEmailApiKey() { +function isHighValueLead(trialRequests) { + for (const trialRequest of trialRequests) { + // Must match these values: https://github.com/codecombat/codecombat/blob/master/app/templates/teachers/request-quote-view.jade#L159 + if (['5,000-10,000', '10,000+'].indexOf(trialRequest.properties.numStudentsTotal) >= 0) { + return true; + } + } + return false; +} + +function isUSSchoolStatus(status) { + return usSchoolStatuses.indexOf(status) >= 0; +} + +function getEmailApiKey(leadStatus) { + if (leadStatus === defaultEuLeadStatus) return closeIoEuMailApiKey; if (closeIoMailApiKeys.length < 0) return; return closeIoMailApiKeys[Math.floor(Math.random() * closeIoMailApiKeys.length)]; } @@ -141,7 +245,30 @@ function getRandomEmailTemplate(templates) { return templates[Math.floor(Math.random() * templates.length)]; } +function getEmailTemplate(siteOrigin, leadStatus, countryCode) { + // console.log(`DEBUG: getEmailTemplate ${siteOrigin} ${leadStatus} ${countryCode}`); + if (isUSSchoolStatus(leadStatus)) { + if (['create teacher', 'convert teacher'].indexOf(siteOrigin) >= 0) { + return getRandomEmailTemplate(createTeacherEmailTemplatesAuto1); + } + return getRandomEmailTemplate(demoRequestEmailTemplatesAuto1); + } + if (leadStatus === defaultEuLeadStatus && (countryCode === 'NL' || countryCode === 'BE')) { + if (['create teacher', 'convert teacher'].indexOf(siteOrigin) >= 0) { + return getRandomEmailTemplate(createTeacherNlEmailTemplatesAuto1); + } + return getRandomEmailTemplate(demoRequestNlEmailTemplatesAuto1); + } + if (['create teacher', 'convert teacher'].indexOf(siteOrigin) >= 0) { + return createTeacherInternationalEmailTemplateAuto1; + } + return demoRequestInternationalEmailTemplateAuto1; +} + function isSameEmailTemplateType(template1, template2) { + if (template1 == template2) { + return true; + } if (createTeacherEmailTemplatesAuto1.indexOf(template1) >= 0 && createTeacherEmailTemplatesAuto1.indexOf(template2) >= 0) { return true; } @@ -348,8 +475,7 @@ class CocoLead { } 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}`] !== props[prop] && currentCustom[`demo_${prop}`].indexOf(props[prop]) < 0)) { + if (commonTrialProperties.indexOf(prop) >= 0 && (haveNcesData || !currentCustom[`demo_${prop}`] || currentCustom[`demo_${prop}`] !== props[prop] && currentCustom[`demo_${prop}`].indexOf(props[prop]) < 0)) { putData[`custom.demo_${prop}`] = props[prop]; } } @@ -552,12 +678,9 @@ function saveNewLead(lead, done) { const tasks = []; for (const contact of existingLead.contacts) { for (const email of contact.emails) { - if (['create teacher', 'convert teacher'].indexOf(lead.contacts[email.email].trial.properties.siteOrigin) >= 0) { - tasks.push(createSendEmailFn(email.email, existingLead.id, contact.id, getRandomEmailTemplate(createTeacherEmailTemplatesAuto1))); - } - else { - tasks.push(createSendEmailFn(email.email, existingLead.id, contact.id, getRandomEmailTemplate(demoRequestEmailTemplatesAuto1))); - } + const countryCode = getCountryCode(lead.contacts[email.email].trial.properties.country, [email.email]); + const emailTemplate = getEmailTemplate(lead.contacts[email.email].trial.properties.siteOrigin, postData.status, countryCode); + tasks.push(createSendEmailFn(email.email, existingLead.id, contact.id, emailTemplate, postData.status)); } } async.parallel(tasks, (err, results) => { @@ -620,7 +743,7 @@ function createUpdateLeadFn(lead, existingLeads) { return updateExistingLead(lead, data.data[0], done); } catch (error) { // console.log(url); - // console.log(error); + console.log(`ERROR: updateLead ${error}`); // console.log(body); return done(); } @@ -646,12 +769,9 @@ function createAddContactFn(postData, internalLead, externalLead) { // Send emails to new contact const email = postData.emails[0].email; - if (['create teacher', 'convert teacher'].indexOf(internalLead.contacts[email].trial.properties.siteOrigin) >= 0) { - return sendMail(email, externalLead.id, newContact.id, getRandomEmailTemplate(createTeacherEmailTemplatesAuto1), getRandomEmailApiKey(), emailDelayMinutes, done); - } - else { - return sendMail(email, externalLead.id, newContact.id, getRandomEmailTemplate(demoRequestEmailTemplatesAuto1), getRandomEmailApiKey(), emailDelayMinutes, done); - } + const countryCode = getCountryCode(internalLead.contacts[email].trial.properties.country, [email]); + const emailTemplate = getEmailTemplate(internalLead.contacts[email].trial.properties.siteOrigin, externalLead.status_label); + sendMail(email, externalLead.id, newContact.id, emailTemplate, getEmailApiKey(externalLead.status_label), emailDelayMinutes, done); }); }; } @@ -680,9 +800,9 @@ function createAddNoteFn(leadId, newNote) { }; } -function createSendEmailFn(email, leadId, contactId, template) { +function createSendEmailFn(email, leadId, contactId, template, leadStatus) { return (done) => { - return sendMail(email, leadId, contactId, template, getRandomEmailApiKey(), emailDelayMinutes, done); + return sendMail(email, leadId, contactId, template, getEmailApiKey(leadStatus), emailDelayMinutes, done); }; } diff --git a/server/handlers/course_instance_handler.coffee b/server/handlers/course_instance_handler.coffee index 94e3951f8..0aa959124 100644 --- a/server/handlers/course_instance_handler.coffee +++ b/server/handlers/course_instance_handler.coffee @@ -38,7 +38,6 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler return @removeMember(req, res, args[0]) if req.method is 'DELETE' and args[1] is 'members' return @getMembersAPI(req, res, args[0]) if args[1] is 'members' return @inviteStudents(req, res, args[0]) if relationship is 'invite_students' - return @getRecentAPI(req, res) if relationship is 'recent' return @redeemPrepaidCodeAPI(req, res) if args[1] is 'redeem_prepaid' return @getMyCourseLevelSessionsAPI(req, res, args[0]) if args[1] is 'my-course-level-sessions' return @findByLevel(req, res, args[2]) if args[1] is 'find_by_level' @@ -169,33 +168,6 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler cleandocs = (UserHandler.formatEntity(req, doc) for doc in users) @sendSuccess(res, cleandocs) - getRecentAPI: (req, res) -> - return @sendUnauthorizedError(res) unless req.user?.isAdmin() - query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]} - query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay? - query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay? - CourseInstance.find query, {courseID: 1, members: 1, ownerID: 1}, (err, courseInstances) => - return @sendDatabaseError(res, err) if err - userIDs = [] - for courseInstance in courseInstances - if members = courseInstance.get('members') - userIDs.push(userID) for userID in members - - User.find {_id: {$in: userIDs}}, {coursePrepaidID: 1}, (err, users) => - return @sendDatabaseError(res, err) if err - prepaidIDs = [] - for user in users - if prepaidID = user.get('coursePrepaidID') - prepaidIDs.push(prepaidID) - - Prepaid.find {_id: {$in: prepaidIDs}}, {properties: 1}, (err, prepaids) => - return @sendDatabaseError(res, err) if err - data = - courseInstances: (@formatEntity(req, courseInstance) for courseInstance in courseInstances) - students: (@formatEntity(req, user) for user in users) - prepaids: (@formatEntity(req, prepaid) for prepaid in prepaids) - @sendSuccess(res, data) - inviteStudents: (req, res, courseInstanceID) -> return @sendUnauthorizedError(res) if not req.user? if not req.body.emails diff --git a/server/handlers/level_session_handler.coffee b/server/handlers/level_session_handler.coffee index f90ad98d2..bb5e62c09 100644 --- a/server/handlers/level_session_handler.coffee +++ b/server/handlers/level_session_handler.coffee @@ -16,7 +16,7 @@ class LevelSessionHandler extends Handler formatEntity: (req, document) -> document = super(req, document) - submittedCode = document.submittedCode + submittedCode = document.submittedCode ? {} unless req.user?.isAdmin() or req.user?.id is document.creator or ('employer' in (req.user?.get('permissions') ? [])) or diff --git a/server/handlers/prepaid_handler.coffee b/server/handlers/prepaid_handler.coffee index a18e2ec09..1936f87a9 100644 --- a/server/handlers/prepaid_handler.coffee +++ b/server/handlers/prepaid_handler.coffee @@ -30,7 +30,6 @@ PrepaidHandler = class PrepaidHandler extends Handler return @getPrepaidAPI(req, res, args[2]) if relationship is 'code' return @createPrepaidAPI(req, res) if relationship is 'create' return @purchasePrepaidAPI(req, res) if relationship is 'purchase' - return @postRedeemerAPI(req, res, args[0]) if relationship is 'redeemers' super arguments... getCoursePrepaidsAPI: (req, res, code) -> @@ -78,45 +77,6 @@ PrepaidHandler = class PrepaidHandler extends Handler return @sendDatabaseError(res, err) if err @sendSuccess(res, prepaid.toObject()) - postRedeemerAPI: (req, res, prepaidID) -> - return @sendForbiddenError(res) if prepaidID.toString() < cutoffID.toString() - return @sendMethodNotAllowed(res, 'You may only POST redeemers.') if req.method isnt 'POST' - return @sendBadInputError(res, 'Need an object with a userID') unless req.body?.userID - Prepaid.findById(prepaidID).exec (err, prepaid) => - return @sendDatabaseError(res, err) if err - return @sendNotFoundError(res) if not prepaid - return @sendForbiddenError(res) if prepaid.get('creator').toString() isnt req.user.id - return @sendForbiddenError(res) if prepaid.get('redeemers')? and _.size(prepaid.get('redeemers')) >= prepaid.get('maxRedeemers') - return @sendForbiddenError(res) unless prepaid.get('type') is 'course' - return @sendForbiddenError(res) if prepaid.get('properties')?.endDate < new Date() - User.findById(req.body.userID).exec (err, user) => - return @sendDatabaseError(res, err) if err - return @sendNotFoundError(res, 'User for given ID not found') if not user - return @sendSuccess(res, @formatEntity(req, prepaid)) if user.get('coursePrepaidID') - return @sendForbiddenError(res, 'Teachers may not be enrolled') if user.isTeacher() - userID = user.get('_id') - - query = - _id: prepaid.get('_id') - 'redeemers.userID': { $ne: user.get('_id') } - $where: "this.maxRedeemers > 0 && (!this.redeemers || this.redeemers.length < #{prepaid.get('maxRedeemers')})" - update = { $push: { redeemers : { date: new Date(), userID: userID } }} - Prepaid.update query, update, (err, result) => - return @sendDatabaseError(res, err) if err - if result.nModified is 0 - @logError(req.user, "POST prepaid redeemer lost race on maxRedeemers") - return @sendForbiddenError(res) - - user.set('coursePrepaidID', prepaid.get('_id')) - user.set('role', 'student') if not user.get('role') - user.save (err, user) => - return @sendDatabaseError(res, err) if err - # return prepaid with new redeemer added locally - redeemers = _.clone(prepaid.get('redeemers') or []) - redeemers.push({ date: new Date(), userID: userID }) - prepaid.set('redeemers', redeemers) - @sendSuccess(res, @formatEntity(req, prepaid)) - createPrepaid: (user, type, maxRedeemers, properties, done) -> Prepaid.generateNewCode (code) => return done('Database error.') unless code @@ -254,23 +214,6 @@ PrepaidHandler = class PrepaidHandler extends Handler slack.sendSlackMessage msg, ['tower'] done(null, prepaid) - get: (req, res) -> - if creator = req.query.creator - return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or creator is req.user.id) - return @sendBadInputError(res, 'Bad creator') unless utils.isID creator - q = { - _id: {$gt: cutoffID} - creator: mongoose.Types.ObjectId(creator) - type: 'course' - } - Prepaid.find q, (err, prepaids) => - return @sendDatabaseError(res, err) if err - documents = [] - for prepaid in prepaids - documents.push(@formatEntity(req, prepaid)) unless prepaid.get('properties')?.endDate < new Date() - return @sendSuccess(res, documents) - else - super(arguments...) makeNewInstance: (req) -> prepaid = super(req) diff --git a/server/handlers/user_handler.coffee b/server/handlers/user_handler.coffee index 4bf97d94d..431e6dc93 100644 --- a/server/handlers/user_handler.coffee +++ b/server/handlers/user_handler.coffee @@ -43,8 +43,8 @@ UserHandler = class UserHandler extends Handler props.push 'jobProfileApproved', 'jobProfileNotes','jobProfileApprovedDate' if req.user.isAdmin() # Admins naturally edit these props.push @privateProperties... if req.user.isAdmin() # Admins are mad with power if not req.user.isAdmin() - if document.isTeacher() and req.body.role not in User.teacherRoles - props = _.without props, 'role' + if document.isTeacher() and req.body.role not in User.teacherRoles + props = _.without props, 'role' props formatEntity: (req, document, publicOnly=false) => diff --git a/server/lib/closeIO.coffee b/server/lib/closeIO.coffee index 0ce851dbc..23fbb3306 100644 --- a/server/lib/closeIO.coffee +++ b/server/lib/closeIO.coffee @@ -48,3 +48,24 @@ module.exports = else if leads.data?.length > 1 return done('ERROR: multiple leads returned for ' + email + ' ' + leads.data.length) return done() + + getSalesContactEmail: (email, done) -> + try + # NOTE: does not work on + email addresses due to Close.io API bug + uri = "https://#{apiKey}:X@app.close.io/api/v1/lead/?query=email_address:#{email}" + request.get uri, (error, response, body) => + return done(error) if error + leads = JSON.parse(body) + return done("Unexpected leads format: " + body) unless leads.data? + return done(null, config.mail.supportSchools) unless leads.data?.length > 0 + lead = leads.data[0] + uri = "https://#{apiKey}:X@app.close.io/api/v1/activity/?lead_id=#{lead.id}" + request.get uri, (error, response, body) => + return done(error) if error + activities = JSON.parse(body) + return done("Unexpected activities format: " + body) unless activities.data? + for activity in activities.data when activity._type is 'Email' + return done(null, activity.sender) if /@codecombat\.com/ig.test(activity.sender) + return done(null, config.mail.supportSchools) + catch error + return done(error, config.mail.supportSchools) diff --git a/server/middleware/auth.coffee b/server/middleware/auth.coffee index 904c094b2..9f3c2f756 100644 --- a/server/middleware/auth.coffee +++ b/server/middleware/auth.coffee @@ -98,12 +98,7 @@ module.exports = user = req.body.user throw new errors.UnprocessableEntity('Specify an id, username or email to espionage.') unless user - if utils.isID(user) - query = {_id: mongoose.Types.ObjectId(user)} - else - user = user.toLowerCase() - query = $or: [{nameLower: user}, {emailLower: user}] - user = yield User.findOne(query) + user = yield User.search(user) amActually = req.user throw new errors.NotFound() unless user req.loginAsync = Promise.promisify(req.login) @@ -210,4 +205,4 @@ module.exports = if originalName is name res.send 200, response else - throw new errors.Conflict('Name is taken', response) \ No newline at end of file + throw new errors.Conflict('Name is taken', response) diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee index a106ea528..ece79cf6c 100644 --- a/server/middleware/classrooms.coffee +++ b/server/middleware/classrooms.coffee @@ -1,6 +1,7 @@ _ = require 'lodash' utils = require '../lib/utils' errors = require '../commons/errors' +schemas = require '../../app/schemas/schemas' wrap = require 'co-express' Promise = require 'bluebird' database = require '../commons/database' @@ -15,6 +16,17 @@ User = require '../models/User' CourseInstance = require '../models/CourseInstance' module.exports = + fetchByCode: wrap (req, res, next) -> + code = req.query.code + return next() unless code + classroom = yield Classroom.findOne({ code: code.toLowerCase() }).select('name ownerID aceConfig') + if not classroom + throw new errors.NotFound('Classroom not found.') + classroom = classroom.toObject() + # Tack on the teacher's name for display to the user + owner = (yield User.findOne({ _id: mongoose.Types.ObjectId(classroom.ownerID) }).select('name')).toObject() + res.status(200).send({ data: classroom, owner } ) + getByOwner: wrap (req, res, next) -> options = req.query ownerID = options.ownerID @@ -151,7 +163,7 @@ module.exports = code = req.body.code.toLowerCase() classroom = yield Classroom.findOne({code: code}) if not classroom - throw new errors.NotFound(res) + throw new errors.NotFound('Classroom not found.') members = _.clone(classroom.get('members')) if _.any(members, (memberID) -> memberID.equals(req.user._id)) return res.send(classroom.toObject({req: req})) @@ -174,3 +186,22 @@ module.exports = yield CourseInstance.update({_id: {$in: freeCourseInstanceIDs}}, { $addToSet: { members: req.user._id }}) yield User.update({ _id: req.user._id }, { $addToSet: { courseInstances: { $each: freeCourseInstanceIDs } } }) res.send(classroom.toObject({req: req})) + + setStudentPassword: wrap (req, res, next) -> + newPassword = req.body.password + { classroomID, memberID } = req.params + teacherID = req.user.id + return next() if teacherID is memberID or not newPassword + ownedClassrooms = yield Classroom.find({ ownerID: mongoose.Types.ObjectId(teacherID) }) + ownedStudentIDs = _.flatten ownedClassrooms.map (c) -> + c.get('members').map (id) -> + id.toString() + return next() unless memberID in ownedStudentIDs + student = yield User.findById(memberID) + if student.get('emailVerified') + return next new errors.Forbidden("Can't reset password for a student that has verified their email address.") + { valid, error } = tv4.validateResult(newPassword, schemas.passwordString) + unless valid + throw new errors.UnprocessableEntity(error.message) + yield student.update({ $set: { passwordHash: User.hashPassword(newPassword) } }) + res.status(200).send({}) diff --git a/server/middleware/course-instances.coffee b/server/middleware/course-instances.coffee index acf06fb60..b7cee43f4 100644 --- a/server/middleware/course-instances.coffee +++ b/server/middleware/course-instances.coffee @@ -10,6 +10,8 @@ Course = require '../models/Course' User = require '../models/User' Level = require '../models/Level' parse = require '../commons/parse' +{objectIdFromTimestamp} = require '../lib/utils' +Prepaid = require '../models/Prepaid' module.exports = addMembers: wrap (req, res) -> @@ -42,13 +44,13 @@ module.exports = throw new errors.Forbidden('You must own the classroom to add members') # Only the enrolled users - users = yield User.find({ _id: { $in: userIDs }}).select('coursePrepaidID') - usersArePrepaid = _.all((user.get('coursePrepaidID') for user in users)) + users = yield User.find({ _id: { $in: userIDs }}).select('coursePrepaid coursePrepaidID') # TODO: remove coursePrepaidID once migrated + usersAreEnrolled = _.all((user.isEnrolled() for user in users)) course = yield Course.findById courseInstance.get('courseID') throw new errors.NotFound('Course referenced by course instance not found') unless course - if not (course.get('free') or usersArePrepaid) + if not (course.get('free') or usersAreEnrolled) throw new errors.PaymentRequired('Cannot add users to a course instance until they are added to a prepaid') userObjectIDs = (mongoose.Types.ObjectId(userID) for userID in userIDs) @@ -96,7 +98,7 @@ module.exports = throw new errors.NotFound('Level original ObjectId not found in Classroom courses') if not nextLevelOriginal - res.status(200).send({}) + return res.status(200).send({}) dbq = Level.findOne({original: mongoose.Types.ObjectId(nextLevelOriginal)}) dbq.sort({ 'version.major': -1, 'version.minor': -1 }) @@ -123,3 +125,28 @@ module.exports = classroom = classroom.toObject({req: req}) res.status(200).send(classroom) + + + fetchRecent: wrap (req, res) -> + query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]} + query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay? + query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay? + courseInstances = yield CourseInstance.find(query, {courseID: 1, members: 1, ownerID: 1}) + + userIDs = [] + for courseInstance in courseInstances + if members = courseInstance.get('members') + userIDs.push(userID) for userID in members + users = yield User.find({_id: {$in: userIDs}}, {coursePrepaid: 1, coursePrepaidID: 1}) + + prepaidIDs = [] + for user in users + if prepaidID = user.get('coursePrepaid') + prepaidIDs.push(prepaidID._id) + prepaids = yield Prepaid.find({_id: {$in: prepaidIDs}}, {properties: 1}) + + res.send({ + courseInstances: (courseInstance.toObject({req: req}) for courseInstance in courseInstances) + students: (user.toObject({req: req}) for user in users) + prepaids: (prepaid.toObject({req: req}) for prepaid in prepaids) + }) diff --git a/server/middleware/index.coffee b/server/middleware/index.coffee index cc772d808..779a19cf0 100644 --- a/server/middleware/index.coffee +++ b/server/middleware/index.coffee @@ -8,8 +8,10 @@ module.exports = courses: require './courses' files: require './files' healthcheck: require './healthcheck' + levels: require './levels' named: require './named' patchable: require './patchable' + prepaids: require './prepaids' rest: require './rest' trialRequests: require './trial-requests' users: require './users' diff --git a/server/middleware/levels.coffee b/server/middleware/levels.coffee new file mode 100644 index 000000000..a7c93560c --- /dev/null +++ b/server/middleware/levels.coffee @@ -0,0 +1,84 @@ +wrap = require 'co-express' +errors = require '../commons/errors' +Level = require '../models/Level' +LevelSession = require '../models/LevelSession' +CourseInstance = require '../models/CourseInstance' +Classroom = require '../models/Classroom' +Course = require '../models/Course' +database = require '../commons/database' + +module.exports = + upsertSession: wrap (req, res) -> + level = yield database.getDocFromHandle(req, Level) + if not level + throw new errors.NotFound('Level not found.') + + sessionQuery = + level: + original: level.get('original').toString() + majorVersion: level.get('version').major + creator: req.user.id + + if req.query.team? + sessionQuery.team = req.query.team + + session = yield LevelSession.findOne(sessionQuery) + if session + return res.send(session.toObject({req: req})) + + attrs = sessionQuery + _.extend(attrs, { + state: + complete: false + scripts: + currentScript: null # will not save empty objects + permissions: [ + {target: req.user.id, access: 'owner'} + {target: 'public', access: 'write'} + ] + codeLanguage: req.user.get('aceConfig')?.language ? 'python' + }) + + if level.get('type') in ['course', 'course-ladder'] or req.query.course? + + # Find the course and classroom that has assigned this level, verify access + courseInstances = yield CourseInstance.find({members: req.user._id}) + classroomIDs = (courseInstance.get('classroomID') for courseInstance in courseInstances) + classroomIDs = _.filter _.uniq classroomIDs, false, (objectID='') -> objectID.toString() + classrooms = yield Classroom.find({ _id: { $in: classroomIDs }}) + classroomWithLevel = null + courseID = null + classroomMap = {} + classroomMap[classroom.id] = classroom for classroom in classrooms + levelOriginal = level.get('original') + for courseInstance in courseInstances + classroomID = courseInstance.get('classroomID') + continue unless classroomID + classroom = classroomMap[classroomID.toString()] + courseID = courseInstance.get('courseID') + classroomCourse = _.find(classroom.get('courses'), (c) -> c._id.equals(courseID)) + for courseLevel in classroomCourse.levels + if courseLevel.original.equals(levelOriginal) + classroomWithLevel = classroom + break + break if classroomWithLevel + + unless classroomWithLevel + throw new errors.PaymentRequired('You must be in a course which includes this level to play it') + + course = yield Course.findById(courseID).select('free') + unless course.get('free') or req.user.isEnrolled() + throw new errors.PaymentRequired('You must be enrolled to access this content') + + lang = classroomWithLevel.get('aceConfig')?.language + attrs.codeLanguage = lang if lang + + else + requiresSubscription = level.get('requiresSubscription') or (req.user.isOnPremiumServer() and level.get('campaign') and not (level.slug in ['dungeons-of-kithgard', 'gems-in-the-deep', 'shadow-guard', 'forgetful-gemsmith', 'signs-and-portents', 'true-names'])) + canPlayAnyway = req.user.isPremium() or level.get 'adventurer' + if requiresSubscription and not canPlayAnyway + throw new errors.PaymentRequired('This level requires a subscription to play') + + session = new LevelSession(attrs) + yield session.save() + res.send(session.toObject({req: req})) diff --git a/server/middleware/prepaids.coffee b/server/middleware/prepaids.coffee new file mode 100644 index 000000000..09593e050 --- /dev/null +++ b/server/middleware/prepaids.coffee @@ -0,0 +1,110 @@ +wrap = require 'co-express' +errors = require '../commons/errors' +database = require '../commons/database' +Prepaid = require '../models/Prepaid' +User = require '../models/User' +mongoose = require 'mongoose' + +cutoffDate = new Date(2015,11,11) +cutoffID = mongoose.Types.ObjectId(Math.floor(cutoffDate/1000).toString(16)+'0000000000000000') + +module.exports = + logError: (user, msg) -> + console.warn "Prepaid Error: [#{user.get('slug')} (#{user._id})] '#{msg}'" + + + post: wrap (req, res) -> + validTypes = ['course'] + unless req.body.type in validTypes + throw new errors.UnprocessableEntity("type must be on of: #{validTypes}.") + # TODO: deprecate or refactor other prepaid types + + if req.body.creator + user = yield User.search(req.body.creator) + if not user + throw new errors.NotFound('User not found') + req.body.creator = user.id + + prepaid = database.initDoc(req, Prepaid) + database.assignBody(req, prepaid) + prepaid.set('code', yield Prepaid.generateNewCodeAsync()) + prepaid.set('redeemers', []) + database.validateDoc(prepaid) + yield prepaid.save() + res.status(201).send(prepaid.toObject()) + + + redeem: wrap (req, res) -> + if not req.user?.isTeacher() + throw new errors.Forbidden('Must be a teacher to use licenses') + + prepaid = yield database.getDocFromHandle(req, Prepaid) + if not prepaid + throw new errors.NotFound('Prepaid not found.') + + if prepaid._id.getTimestamp().getTime() < cutoffDate.getTime() + throw new errors.Forbidden('Cannot redeem from prepaids older than November 11, 2015') + unless prepaid.get('creator').equals(req.user._id) + throw new errors.Forbidden('You may not redeem licenses from this prepaid') + if prepaid.get('redeemers')? and _.size(prepaid.get('redeemers')) >= prepaid.get('maxRedeemers') + throw new errors.Forbidden('This prepaid is exhausted') + unless prepaid.get('type') is 'course' + throw new errors.Forbidden('This prepaid is not of type "course"') + if prepaid.get('endDate') and new Date(prepaid.get('endDate')) < new Date() + throw new errors.Forbidden('This prepaid is expired') + + user = yield User.findById(req.body?.userID) + if not user + throw new errors.NotFound('User not found.') + + if user.isEnrolled() + return res.status(200).send(prepaid.toObject({req: req})) + if user.isTeacher() + throw new errors.Forbidden('Teachers may not be enrolled') + + query = + _id: prepaid._id + 'redeemers.userID': { $ne: user._id } + $where: "this.maxRedeemers > 0 && (!this.redeemers || this.redeemers.length < #{prepaid.get('maxRedeemers')})" + update = { $push: { redeemers : { date: new Date(), userID: user._id } }} + result = yield Prepaid.update(query, update) + if result.nModified is 0 + @logError(req.user, "POST prepaid redeemer lost race on maxRedeemers") + throw new errors.Forbidden('This prepaid is exhausted') + + update = { + $set: { + coursePrepaid: { + _id: prepaid._id + startDate: prepaid.get('startDate') + endDate: prepaid.get('endDate') + } + } + } + if not user.get('role') + update.$set.role = 'student' + yield user.update(update) + + # return prepaid with new redeemer added locally + redeemers = _.clone(prepaid.get('redeemers') or []) + redeemers.push({ date: new Date(), userID: user._id }) + prepaid.set('redeemers', redeemers) + res.status(201).send(prepaid.toObject({req: req})) + + fetchByCreator: wrap (req, res, next) -> + creator = req.query.creator + return next() if not creator + + unless req.user.isAdmin() or creator is req.user.id + throw new errors.Forbidden('Must be logged in as given creator') + unless database.isID(creator) + throw new errors.UnprocessableEntity('Invalid creator') + + q = { + _id: { $gt: cutoffID } + creator: mongoose.Types.ObjectId(creator) + type: 'course' + } + + prepaids = yield Prepaid.find(q) + res.send((prepaid.toObject({req: req}) for prepaid in prepaids)) diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index 7a7505920..b334ca8f2 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -6,6 +6,7 @@ Promise = require 'bluebird' parse = require '../commons/parse' request = require 'request' mongoose = require 'mongoose' +sendwithus = require '../sendwithus' User = require '../models/User' Classroom = require '../models/Classroom' @@ -57,3 +58,39 @@ module.exports = yield User.update({ _id: userID }, { $set: { "role": "student" } }) user = yield User.findById req.user.id res.status(200).send(user.toObject({req: req})) + + verifyEmailAddress: wrap (req, res, next) -> + user = yield User.findOne({ _id: mongoose.Types.ObjectId(req.params.userID) }) + [timestamp, hash] = req.params.verificationCode.split(':') + unless user + throw new errors.UnprocessableEntity('User not found') + unless req.params.verificationCode is user.verificationCode(timestamp) + throw new errors.UnprocessableEntity('Verification code does not match') + yield User.update({ _id: user.id }, { emailVerified: true }) + res.status(200).send({ role: user.get('role') }) + + resetEmailVerifiedFlag: wrap (req, res, next) -> + newEmail = req.body.email + _id = mongoose.Types.ObjectId(req.body._id) + if newEmail + user = yield User.findOne({ _id }) + oldEmail = user.get('email') + if newEmail isnt oldEmail + yield User.update({ _id }, { $set: { emailVerified: false } }) + next() + + sendVerificationEmail: wrap (req, res, next) -> + user = yield User.findById(req.params.userID) + timestamp = (new Date).getTime() + if not user + throw new errors.NotFound('User not found') + context = + email_id: sendwithus.templates.verify_email + recipient: + address: user.get('email') + name: user.broadName() + email_data: + name: user.broadName() + verify_link: "http://codecombat.com/user/#{user._id}/verify/#{user.verificationCode(timestamp)}" + sendwithus.api.send context, (err, result) -> + res.status(200).send({}) diff --git a/server/models/LevelSession.coffee b/server/models/LevelSession.coffee index 29ecccda4..8edecb749 100644 --- a/server/models/LevelSession.coffee +++ b/server/models/LevelSession.coffee @@ -90,4 +90,20 @@ LevelSessionSchema.statics.editableProperties = ['multiplayer', 'players', 'code 'browser'] LevelSessionSchema.statics.jsonSchema = jsonschema +LevelSessionSchema.set('toObject', { + transform: (doc, ret, options) -> + req = options.req + return ret unless req # TODO: Make deleting properties the default, but the consequences are far reaching + + submittedCode = doc.get('submittedCode') + unless req.user?.isAdmin() or req.user?.id is doc.get('creator') or ('employer' in (req.user?.get('permissions') ? [])) or not doc.get('submittedCode') # TODO: only allow leaderboard access to non-top-5 solutions + ret = _.omit ret, LevelSession.privateProperties + if req.query.interpret + plan = submittedCode[if doc.get('team') is 'humans' then 'hero-placeholder' else 'hero-placeholder-1']?.plan ? '' + plan = LZString.compressToUTF16 plan + ret.interpret = plan + ret.code = submittedCode + return ret +}) + module.exports = LevelSession = mongoose.model('level.session', LevelSessionSchema, 'level.sessions') diff --git a/server/models/Prepaid.coffee b/server/models/Prepaid.coffee index 26636c2a5..17f7e497e 100644 --- a/server/models/Prepaid.coffee +++ b/server/models/Prepaid.coffee @@ -1,13 +1,21 @@ +Promise = require 'bluebird' mongoose = require 'mongoose' config = require '../../server_config' PrepaidSchema = new mongoose.Schema { creator: mongoose.Schema.Types.ObjectId }, {strict: false, minimize: false,read:config.mongo.readpref} +co = require 'co' +jsonSchema = require '../../app/schemas/models/prepaid.schema' PrepaidSchema.index({code: 1}, { unique: true }) PrepaidSchema.index({'redeemers.userID': 1}) +PrepaidSchema.index({owner: 1, endDate: 1}, { sparse: true }) + +PrepaidSchema.statics.DEFAULT_START_DATE = new Date(2016,4,15).toISOString() +PrepaidSchema.statics.DEFAULT_END_DATE = new Date(2017,5,1).toISOString() PrepaidSchema.statics.generateNewCode = (done) -> + # Deprecated for not following Node callback convention. TODO: Remove tryCode = -> code = _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('') Prepaid.findOne code: code, (err, prepaid) -> @@ -15,6 +23,14 @@ PrepaidSchema.statics.generateNewCode = (done) -> return done(code) unless prepaid tryCode() tryCode() + +PrepaidSchema.statics.generateNewCodeAsync = co.wrap (done) -> + code = null + while true + code = _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('') + prepaid = yield Prepaid.findOne({code: code}) + break if not prepaid + return code PrepaidSchema.pre('save', (next) -> @set('exhausted', @get('maxRedeemers') <= _.size(@get('redeemers'))) @@ -27,10 +43,17 @@ PrepaidSchema.pre('save', (next) -> ) PrepaidSchema.post 'init', (doc) -> - doc.set('maxRedeemers', parseInt(doc.get('maxRedeemers'))) + doc.set('maxRedeemers', parseInt(doc.get('maxRedeemers') ? 0)) + if @get('type') is 'course' + if not @get('startDate') + @set('startDate', Prepaid.DEFAULT_START_DATE) + if not @get('endDate') + @set('endDate', Prepaid.DEFAULT_END_DATE) PrepaidSchema.statics.postEditableProperties = [ - 'creator', 'maxRedeemers', 'properties', 'type' + 'creator', 'maxRedeemers', 'properties', 'type', 'startDate', 'endDate' ] +PrepaidSchema.statics.editableProperties = [] +PrepaidSchema.statics.jsonSchema = jsonSchema module.exports = Prepaid = mongoose.model('prepaid', PrepaidSchema) diff --git a/server/models/TrialRequest.coffee b/server/models/TrialRequest.coffee index ca0c6622d..8896c9bc4 100644 --- a/server/models/TrialRequest.coffee +++ b/server/models/TrialRequest.coffee @@ -1,4 +1,3 @@ -closeIO = require '../lib/closeIO' log = require 'winston' mongoose = require 'mongoose' config = require '../../server_config' diff --git a/server/models/User.coffee b/server/models/User.coffee index ed42fc5ed..26c429517 100644 --- a/server/models/User.coffee +++ b/server/models/User.coffee @@ -8,6 +8,7 @@ plugins = require '../plugins/plugins' AnalyticsUsersActive = require './AnalyticsUsersActive' Classroom = require '../models/Classroom' languages = require '../routes/languages' +_ = require 'lodash' config = require '../../server_config' stripe = require('stripe')(config.stripe.secretKey) @@ -34,11 +35,22 @@ UserSchema.index({'siteref': 1}, {name: 'siteref index', sparse: true}) UserSchema.index({'schoolName': 1}, {name: 'schoolName index', sparse: true}) UserSchema.index({'country': 1}, {name: 'country index', sparse: true}) UserSchema.index({'role': 1}, {name: 'role index', sparse: true}) +UserSchema.index({'coursePrepaid._id': 1}, {name: 'course prepaid id index', sparse: true}) UserSchema.post('init', -> @set('anonymous', false) if @get('email') ) +UserSchema.methods.broadName = -> + return '(deleted)' if @get('deleted') + name = @get('name') + return name if name + name = _.filter([@get('firstName'), @get('lastName')]).join(' ') + return name if name + [emailName, emailDomain] = @get('email').split('@') + return emailName if emailName + return 'Anoner' + UserSchema.methods.isInGodMode = -> p = @get('permissions') return p and 'godmode' in p @@ -67,6 +79,9 @@ UserSchema.statics.teacherRoles = ['teacher', 'technology coordinator', 'advisor UserSchema.methods.isTeacher = -> return @get('role') in User.teacherRoles +UserSchema.methods.isStudent = -> + return @get('role') is 'student' + UserSchema.methods.getUserInfo = -> id: @get('_id') email: if @get('anonymous') then 'Unregistered User' else @get('email') @@ -92,6 +107,15 @@ UserSchema.methods.trackActivity = (activityName, increment) -> activity[activityName].last = now @set 'activity', activity activity + +UserSchema.statics.search = (term, done) -> + utils = require '../lib/utils' + if utils.isID(term) + query = {_id: mongoose.Types.ObjectId(term)} + else + term = term.toLowerCase() + query = $or: [{nameLower: term}, {emailLower: term}] + return User.findOne(query).exec(done) emailNameMap = generalNews: 'announcement' @@ -242,13 +266,18 @@ UserSchema.methods.register = (done) -> @set 'name', uniqueName done() else done() - if @isEmailSubscriptionEnabled 'generalNews' - data = - email_id: sendwithus.templates.welcome_email - recipient: - address: @get 'email' - sendwithus.api.send data, (err, result) -> - log.error "sendwithus post-save error: #{err}, result: #{result}" if err + { welcome_email_student, welcome_email_user } = sendwithus.templates + timestamp = (new Date).getTime() + data = + email_id: if @isStudent() then welcome_email_student else welcome_email_user + recipient: + address: @get('email') + name: @broadName() + email_data: + name: @broadName() + verify_link: "http://codecombat.com/user/#{@_id}/verify/#{@verificationCode(timestamp)}" + sendwithus.api.send data, (err, result) -> + log.error "sendwithus post-save error: #{err}, result: #{result}" if err @saveActiveUser 'register' UserSchema.methods.hasSubscription = -> @@ -272,6 +301,12 @@ UserSchema.methods.level = -> a = 5 b = c = 100 if xp > 0 then Math.floor(a * Math.log((1 / b) * (xp + c))) + 1 else 1 + +UserSchema.methods.isEnrolled = -> + coursePrepaid = @get('coursePrepaid') + return false unless coursePrepaid + return true unless coursePrepaid.endDate + return coursePrepaid.endDate > new Date().toISOString() UserSchema.statics.saveActiveUser = (id, event, done=null) -> # TODO: Disabling this until we know why our app servers CPU grows out of control. @@ -331,9 +366,18 @@ UserSchema.post 'save', (doc) -> doc.newsSubsChanged = not _.isEqual(_.pick(doc.get('emails'), mail.NEWS_GROUPS), _.pick(doc.startingEmails, mail.NEWS_GROUPS)) UserSchema.statics.updateServiceSettings(doc) + UserSchema.post 'init', (doc) -> doc.wasTeacher = doc.isTeacher() doc.startingEmails = _.cloneDeep(doc.get('emails')) + if @get('coursePrepaidID') and not @get('coursePrepaid') + Prepaid = require './Prepaid' + @set('coursePrepaid', { + _id: @get('coursePrepaidID') + startDate: Prepaid.DEFAULT_START_DATE + endDate: Prepaid.DEFAULT_END_DATE + }) + @set('coursePrepaidID', undefined) UserSchema.statics.hashPassword = (password) -> password = password.toLowerCase() @@ -341,11 +385,17 @@ UserSchema.statics.hashPassword = (password) -> shasum.update(salt + password) shasum.digest('hex') +UserSchema.methods.verificationCode = (timestamp) -> + { _id, email } = this.toObject() + shasum = crypto.createHash('sha256') + hash = shasum.update(timestamp + salt + _id + email).digest('hex') + return "#{timestamp}:#{hash}" + UserSchema.statics.privateProperties = [ 'permissions', 'email', 'mailChimp', 'firstName', 'lastName', 'gender', 'facebookID', 'gplusID', 'music', 'volume', 'aceConfig', 'employerAt', 'signedEmployerAgreement', 'emailSubscriptions', 'emails', 'activity', 'stripe', 'stripeCustomerID', 'chinaVersion', 'country', - 'schoolName', 'ageRange', 'role' + 'schoolName', 'ageRange', 'role', 'enrollmentRequestSent' ] UserSchema.statics.jsonSchema = jsonschema UserSchema.statics.editableProperties = [ @@ -353,7 +403,8 @@ UserSchema.statics.editableProperties = [ 'firstName', 'lastName', 'gender', 'ageRange', 'facebookID', 'gplusID', 'emails', 'testGroupNumber', 'music', 'hourOfCode', 'hourOfCodeComplete', 'preferredLanguage', 'wizard', 'aceConfig', 'autocastDelay', 'lastLevel', 'jobProfile', 'savedEmployerFilterAlerts', - 'heroConfig', 'iosIdentifierForVendor', 'siteref', 'referrer', 'schoolName', 'role', 'birthday' + 'heroConfig', 'iosIdentifierForVendor', 'siteref', 'referrer', 'schoolName', 'role', 'birthday', + 'enrollmentRequestSent' ] UserSchema.statics.serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP'] diff --git a/server/queues/scoring/scoringUtils.coffee b/server/queues/scoring/scoringUtils.coffee index 2a8e0cd33..a5fe8acbe 100644 --- a/server/queues/scoring/scoringUtils.coffee +++ b/server/queues/scoring/scoringUtils.coffee @@ -30,7 +30,7 @@ module.exports.sendResponseObject = (res, object) -> module.exports.formatSessionInformation = (session) -> heroID = if session.team is 'ogres' then 'hero-placeholder-1' else 'hero-placeholder' submittedCode = {} - submittedCode[heroID] = plan: LZString.compressToUTF16(session.submittedCode[heroID]?.plan ? '') + submittedCode[heroID] = plan: LZString.compressToUTF16(session.submittedCode?[heroID]?.plan ? '') _id: session._id sessionID: session._id diff --git a/server/routes/contact.coffee b/server/routes/contact.coffee index c1b6cf132..9265fff40 100644 --- a/server/routes/contact.coffee +++ b/server/routes/contact.coffee @@ -5,11 +5,12 @@ sendwithus = require '../sendwithus' async = require 'async' LevelSession = require '../models/LevelSession' moment = require 'moment' +closeIO = require '../lib/closeIO' module.exports.setup = (app) -> app.post '/contact', (req, res) -> return res.end() unless req.user - #log.info "Sending mail from #{req.body.email} saying #{req.body.message}" + # log.info "Sending mail from #{req.body.email} saying #{req.body.message}" createMailContext req, (context) -> sendwithus.api.send context, (err, result) -> if err @@ -32,27 +33,36 @@ createMailContext = (req, done) -> #{message} -- - <a href='http://codecombat.com/user/#{user.get('slug') or user.get('_id')}'>#{user.get('name') or 'Anonymous'}</a> - Level #{level}#{if teacher then ' - Teacher' else ''}#{if premium then ' - Subscriber' else ''}#{if country then ' - ' + country else ''} + http://codecombat.com/user/#{user.get('slug') or user.get('_id')} + #{user.get('name') or 'Anonymous'} - Level #{level}#{if teacher then ' - Teacher' else ''}#{if premium then ' - Subscriber' else ''}#{if country then ' - ' + country else ''} """ if req.body.browser content += "\n#{req.body.browser} - #{req.body.screenSize}" - address = switch - when teacher then config.mail.supportSchools + toAddress = switch when premium then config.mail.supportPremium else config.mail.supportPrimary + fromAddress = sender or user.get('email') + context = email_id: sendwithus.templates.plain_text_email recipient: - address: address + address: toAddress sender: address: config.mail.username - reply_to: sender or user.get('email') + reply_to: fromAddress name: user.get('name') email_data: - subject: "[CodeCombat] #{subject ? ('Feedback - ' + (sender or user.get('email')))}" + subject: "[CodeCombat] #{subject ? ('Feedback - ' + fromAddress)}" content: content - if recipientID and (user.isAdmin() or ('employer' in (user.get('permissions') ? []))) + if recipientID is 'schools@codecombat.com' or teacher + req.user.update({$set: { enrollmentRequestSent: true }}).exec(_.noop) if recipientID is 'schools@codecombat.com' + closeIO.getSalesContactEmail fromAddress, (err, salesContactEmail) -> + console.error "Error getting sales contact for #{sender}: #{err}" if err + context.recipient.address = salesContactEmail ? config.mail.supportSchools + context.sender.address = fromAddress + done context + else if recipientID and (user.isAdmin() or ('employer' in (user.get('permissions') ? []))) User.findById(recipientID, 'email').exec (err, document) -> if err log.error "Error looking up recipient to email from #{recipientID}: #{err}" if err diff --git a/server/routes/index.coffee b/server/routes/index.coffee index ba320a281..08a8552cb 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -55,11 +55,12 @@ module.exports.setup = (app) -> app.get('/db/campaign/-/overworld', mw.campaigns.fetchOverworld) app.post('/db/classroom', mw.classrooms.post) - app.get('/db/classroom', mw.classrooms.getByOwner) + app.get('/db/classroom', mw.classrooms.fetchByCode, mw.classrooms.getByOwner) app.get('/db/classroom/:handle/levels', mw.classrooms.fetchAllLevels) app.get('/db/classroom/:handle/courses/:courseID/levels', mw.classrooms.fetchLevelsForCourse) app.get('/db/classroom/:handle/member-sessions', mw.classrooms.fetchMemberSessions) app.get('/db/classroom/:handle/members', mw.classrooms.fetchMembers) # TODO: Use mw.auth? + app.post('/db/classroom/:classroomID/members/:memberID/reset-password', mw.classrooms.setStudentPassword) app.post('/db/classroom/:anything/members', mw.auth.checkLoggedIn(), mw.classrooms.join) app.get('/db/classroom/:handle', mw.auth.checkLoggedIn()) # TODO: Finish migrating route, adding now so 401 is returned @@ -72,14 +73,24 @@ module.exports.setup = (app) -> app.get('/db/course/:handle', mw.rest.getByHandle(Course)) app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel) + app.post('/db/course_instance/-/recent', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchRecent) app.get('/db/course_instance/:handle/levels/:levelOriginal/next', mw.courseInstances.fetchNextLevel) 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.put('/db/user/:handle', mw.users.resetEmailVerifiedFlag) app.delete('/db/user/:handle', mw.users.removeFromClassrooms) app.get('/db/user', mw.users.fetchByGPlusID, mw.users.fetchByFacebookID) app.put('/db/user/-/become-student', mw.users.becomeStudent) app.put('/db/user/-/remain-teacher', mw.users.remainTeacher) + app.post('/db/user/:userID/request-verify-email', mw.users.sendVerificationEmail) + app.post('/db/user/:userID/verify/:verificationCode', mw.users.verifyEmailAddress) # TODO: Finalize URL scheme + + app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession) + + app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator) + app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post) + app.post('/db/prepaid/:handle/redeemers', mw.prepaids.redeem) app.get '/db/products', require('./db/product').get diff --git a/server/sendwithus.coffee b/server/sendwithus.coffee index 90fcde935..6e781beda 100644 --- a/server/sendwithus.coffee +++ b/server/sendwithus.coffee @@ -18,7 +18,9 @@ if swuAPIKey module.exports.templates = parent_subscribe_email: 'tem_2APERafogvwKhmcnouigud' share_progress_email: 'tem_VHE3ihhGmVa3727qds9zY8' - welcome_email: 'utnGaBHuSU4Hmsi7qrAypU' + welcome_email_user: 'tem_z7Xvj3mtWYk6ec6aW7RwFk' + welcome_email_student: 'tem_4WYPZNLzs5wawMF9qUJXUH' + verify_email: 'tem_zJee6uRsRmzqzktzneCkCn' ladder_update_email: 'JzaZxf39A4cKMxpPZUfWy4' patch_created: 'tem_xhxuNosLALsizTNojBjNcL' change_made_notify_watcher: 'tem_7KVkfmv9SZETb25dtHbUtG' diff --git a/spec/helpers/helper.js b/spec/helpers/helper.js index 0f8a56f49..a0135dafb 100644 --- a/spec/helpers/helper.js +++ b/spec/helpers/helper.js @@ -36,7 +36,7 @@ if (database.generateMongoConnectionString() !== dbString) { throw Error('Stopping server tests because db connection string was not as expected.'); } -jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 120; // for long Stripe tests +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 10; // for long Stripe tests require('../server/common'); // Make sure global testing functions are set up var initialized = false; @@ -93,4 +93,4 @@ beforeEach(function(done) { initialized = true; done(); }); -}); \ No newline at end of file +}); diff --git a/spec/server/functional/contact.spec.coffee b/spec/server/functional/contact.spec.coffee new file mode 100644 index 000000000..0c18a201e --- /dev/null +++ b/spec/server/functional/contact.spec.coffee @@ -0,0 +1,26 @@ +utils = require '../utils' +sendwithus = require '../../../server/sendwithus' +request = require '../request' +User = require '../../../server/models/User' + +# TODO: need to update this test since /contact calls external Close.io API now +xdescribe 'POST /contact', -> + + beforeEach utils.wrap (done) -> + spyOn(sendwithus.api, 'send') + @teacher = yield utils.initUser({role: 'teacher'}) + yield utils.loginUser(@teacher) + done() + + describe 'when recipientID is "schools@codecombat.com"', -> + it 'sends to that email', utils.wrap (done) -> + [res, body] = yield request.postAsync({url: getURL('/contact'), json: { + sender: 'some@email.com' + message: 'A message' + recipientID: 'schools@codecombat.com' + }}) + expect(sendwithus.api.send).toHaveBeenCalled() + user = yield User.findById(@teacher.id) + yield new Promise((resolve) -> setTimeout(resolve, 10)) + expect(user.get('enrollmentRequestSent')).toBe(true) + done() diff --git a/spec/server/functional/course_instance.spec.coffee b/spec/server/functional/course_instance.spec.coffee index 9eff490cc..da89aa2b1 100644 --- a/spec/server/functional/course_instance.spec.coffee +++ b/spec/server/functional/course_instance.spec.coffee @@ -11,6 +11,7 @@ Campaign = require '../../../server/models/Campaign' Level = require '../../../server/models/Level' Prepaid = require '../../../server/models/Prepaid' request = require '../request' +moment = require 'moment' courseFixture = { name: 'Unnamed course' @@ -101,27 +102,19 @@ describe 'POST /db/course_instance', -> describe 'POST /db/course_instance/:id/members', -> beforeEach utils.wrap (done) -> - utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid]) + yield utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid, Campaign, Level]) @teacher = yield utils.initUser({role: 'teacher'}) + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + @level = yield utils.makeLevel({type: 'course'}) + @campaign = yield utils.makeCampaign({}, {levels: [@level]}) + @course = yield utils.makeCourse({free: true}, {campaign: @campaign}) + @student = yield utils.initUser({role: 'student'}) + @prepaid = yield utils.makePrepaid({creator: @teacher.id}) + members = [@student] yield utils.loginUser(@teacher) - courseData = _.extend({free: true}, courseFixture) - @course = yield new Course(courseData).save() - classroomData = _.extend({ownerID: @teacher._id}, classroomFixture) - @classroom = yield new Classroom(classroomData).save() - url = getURL('/db/course_instance') - data = { - name: 'Some Name' - courseID: @course.id - classroomID: @classroom.id - } - [res, body] = yield request.postAsync {uri: url, json: data} - @courseInstance = yield CourseInstance.findById res.body._id - @student = yield utils.initUser() - @prepaid = yield new Prepaid({ - type: 'course' - maxRedeemers: 10 - redeemers: [] - }).save() + @classroom = yield utils.makeClassroom({aceConfig: { language: 'javascript' }}, { members }) + @courseInstance = yield utils.makeCourseInstance({}, { @course, @classroom }) done() it 'adds an array of members to the given CourseInstance', utils.wrap (done) -> @@ -135,8 +128,6 @@ describe 'POST /db/course_instance/:id/members', -> done() it 'adds a member to the given CourseInstance', utils.wrap (done) -> - @classroom.set('members', [@student._id]) - yield @classroom.save() url = getURL("/db/course_instance/#{@courseInstance.id}/members") [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} expect(res.statusCode).toBe(200) @@ -145,8 +136,6 @@ describe 'POST /db/course_instance/:id/members', -> done() it 'adds the CourseInstance id to the user', utils.wrap (done) -> - @classroom.set('members', [@student._id]) - yield @classroom.save() url = getURL("/db/course_instance/#{@courseInstance.id}/members") [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} user = yield User.findById(@student.id) @@ -154,14 +143,14 @@ describe 'POST /db/course_instance/:id/members', -> done() it 'return 403 if the member is not in the classroom', utils.wrap (done) -> + @classroom.set('members', []) + yield @classroom.save() url = getURL("/db/course_instance/#{@courseInstance.id}/members") [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} expect(res.statusCode).toBe(403) done() it 'returns 403 if the user does not own the course instance and is not adding self', utils.wrap (done) -> - @classroom.set('members', [@student._id]) - yield @classroom.save() otherUser = yield utils.initUser() yield utils.loginUser(otherUser) url = getURL("/db/course_instance/#{@courseInstance.id}/members") @@ -171,9 +160,7 @@ describe 'POST /db/course_instance/:id/members', -> it 'returns 200 if the user is a member of the classroom and is adding self', -> - it 'return 402 if the course is not free and the user is not in a prepaid', utils.wrap (done) -> - @classroom.set('members', [@student._id]) - yield @classroom.save() + it 'return 402 if the course is not free and the user is not enrolled', utils.wrap (done) -> @course.set('free', false) yield @course.save() url = getURL("/db/course_instance/#{@courseInstance.id}/members") @@ -181,9 +168,17 @@ describe 'POST /db/course_instance/:id/members', -> expect(res.statusCode).toBe(402) done() - it 'works if the course is not free and the user is in a prepaid', utils.wrap (done) -> - @classroom.set('members', [@student._id]) - yield @classroom.save() + it 'works if the course is not free and the user is enrolled', utils.wrap (done) -> + @course.set('free', false) + yield @course.save() + @student.set('coursePrepaid', _.pick(@prepaid.toObject(), '_id', 'startDate', 'endDate')) + yield @student.save() + url = getURL("/db/course_instance/#{@courseInstance.id}/members") + [res, body] = yield request.postAsync {uri: url, json: {userID: @student.id}} + expect(res.statusCode).toBe(200) + done() + + it 'works if the course is not free and the user is enrolled but is not migrated', utils.wrap (done) -> @course.set('free', false) yield @course.save() @student.set('coursePrepaidID', @prepaid._id) @@ -365,3 +360,56 @@ describe 'GET /db/course_instance/:handle/classroom', -> [res, body] = yield request.getAsync(@url, {json: true}) expect(res.statusCode).toBe(403) done() + +describe 'POST /db/course_instance/-/recent', -> + + url = getURL('/db/course_instance/-/recent') + + beforeEach utils.wrap (done) -> + yield utils.clearModels([CourseInstance, Course, User, Classroom, Prepaid, Campaign, Level]) + @teacher = yield utils.initUser({role: 'teacher'}) + @admin = yield utils.initAdmin() + yield utils.loginUser(@admin) + @campaign = yield utils.makeCampaign() + @course = yield utils.makeCourse({free: true}, {campaign: @campaign}) + @student = yield utils.initUser({role: 'student'}) + @prepaid = yield utils.makePrepaid({creator: @teacher.id}) + members = [@student] + yield utils.loginUser(@teacher) + @classroom = yield utils.makeClassroom({aceConfig: { language: 'javascript' }}, { members }) + @courseInstance = yield utils.makeCourseInstance({}, { @course, @classroom, members }) + [res, body] = yield request.postAsync({url: getURL("/db/prepaid/#{@prepaid.id}/redeemers"), json: { userID: @student.id} }) + yield utils.loginUser(@admin) + done() + + it 'returns all non-HoC course instances and their related users and prepaids', utils.wrap (done) -> + [res, body] = yield request.postAsync(url, { json: true }) + expect(res.statusCode).toBe(200) + expect(res.body.courseInstances[0]._id).toBe(@courseInstance.id) + expect(res.body.students[0]._id).toBe(@student.id) + expect(res.body.prepaids[0]._id).toBe(@prepaid.id) + done() + + it 'returns course instances within a specified range', utils.wrap (done) -> + startDay = moment().subtract(1, 'day').format('YYYY-MM-DD') + endDay = moment().add(1, 'day').format('YYYY-MM-DD') + [res, body] = yield request.postAsync(url, { json: { startDay, endDay } }) + expect(res.body.courseInstances.length).toBe(1) + + startDay = moment().add(1, 'day').format('YYYY-MM-DD') + endDay = moment().add(2, 'day').format('YYYY-MM-DD') + [res, body] = yield request.postAsync(url, { json: { startDay, endDay } }) + expect(res.body.courseInstances.length).toBe(0) + + startDay = moment().subtract(2, 'day').format('YYYY-MM-DD') + endDay = moment().subtract(1, 'day').format('YYYY-MM-DD') + [res, body] = yield request.postAsync(url, { json: { startDay, endDay } }) + expect(res.body.courseInstances.length).toBe(0) + + done() + + it 'returns 403 if not an admin', utils.wrap (done) -> + yield utils.loginUser(@teacher) + [res, body] = yield request.postAsync(url, { json: true }) + expect(res.statusCode).toBe(403) + done() diff --git a/spec/server/functional/level.spec.coffee b/spec/server/functional/level.spec.coffee index 48d7cb31d..5a0e9cdb7 100644 --- a/spec/server/functional/level.spec.coffee +++ b/spec/server/functional/level.spec.coffee @@ -6,6 +6,9 @@ CourseInstance = require '../../../server/models/CourseInstance' Level = require '../../../server/models/Level' User = require '../../../server/models/User' request = require '../request' +utils = require '../utils' +moment = require 'moment' +mongoose = require 'mongoose' describe 'Level', -> @@ -38,85 +41,146 @@ describe 'Level', -> done() -describe 'GET /db/level/<id>/session', -> +describe 'GET /db/level/:handle/session', -> - describe 'when level is a course level', -> + describe 'when level IS a course level', -> - levelID = null + beforeEach utils.wrap (done) -> + yield utils.clearModels([Campaign, Course, CourseInstance, Level, User]) + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + @level = yield utils.makeLevel({type: 'course'}) + + # To ensure test compares original, not id, make them different. TODO: Make factories do this normally? + @level.set('original', new mongoose.Types.ObjectId()) + @level.save() + + @campaign = yield utils.makeCampaign({}, {levels: [@level]}) + @course = yield utils.makeCourse({free: true}, {campaign: @campaign}) + @student = yield utils.initUser({role: 'student'}) + members = [@student] + teacher = yield utils.initUser({role: 'teacher'}) + yield utils.loginUser(teacher) + @classroom = yield utils.makeClassroom({aceConfig: { language: 'javascript' }}, { members }) + @courseInstance = yield utils.makeCourseInstance({}, { @course, @classroom, members }) + @url = getURL("/db/level/#{@level.id}/session") + yield utils.loginUser(@student) + done() + + it 'creates a new session if the user is in a course with that level', utils.wrap (done) -> + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(200) + expect(body.codeLanguage).toBe('javascript') + done() + + it 'works if the classroom has no aceConfig', utils.wrap (done) -> + @classroom.set('aceConfig', undefined) + yield @classroom.save() + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(200) + expect(body.codeLanguage).toBe('python') + done() + + it 'does not break if the user has a courseInstance without an associated classroom', utils.wrap (done) -> + yield @courseInstance.update({$unset: {classroomID: ''}}) + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(402) + done() - it 'sets up a course instance', (done) -> - - clearModels [Campaign, Course, CourseInstance, Level, User], (err) -> - - loginAdmin (admin) -> - - url = getURL('/db/level') - body = - name: 'Course Level' - type: 'course' - permissions: simplePermissions - - request.post {uri: url, json: body }, (err, res, level) -> - levelID = level._id - - url = getURL('/db/campaign') - body = - name: 'Course Campaign' - levels: {} - body.levels[level.original] = { 'original': level.original } - - request.post { uri: url, json: body }, (err, res, campaign) -> - - course = new Course({ - name: 'Test Course' - campaignID: ObjectId(campaign._id) - }) - - course.save (err) -> - - expect(err).toBeNull() - - loginJoe (joe) -> - - classroom = new Classroom({ - name: 'Test Classroom' - members: [ joe.get('_id') ] - aceConfig: { language: 'javascript' } - }) - - classroom.save (err, classroom) -> - - expect(err).toBeNull() - - courseInstance = new CourseInstance({ - name: 'Course Instance' - members: [ - joe.get('_id') - ] - courseID: ObjectId(course.id) - classroomID: ObjectId(classroom.id) - }) + it 'returns 402 if the user is not in a course with that level', utils.wrap (done) -> + otherStudent = yield utils.initUser({role: 'student'}) + yield utils.loginUser(otherStudent) + [res, body] = yield request.getAsync({ uri: @url, json: true }) + expect(res.statusCode).toBe(402) + expect(res.body.message).toBe('You must be in a course which includes this level to play it') + done() + + describe 'when the course is not free', -> - courseInstance.save (err) -> - - expect(err).toBeNull() - done() + beforeEach utils.wrap (done) -> + @course.set({free: false}) + yield @course.save() + done() + + it 'returns 402 if the user is not enrolled', utils.wrap (done) -> + [res, body] = yield request.getAsync({ uri: @url, json: true }) + expect(res.statusCode).toBe(402) + expect(res.body.message).toBe('You must be enrolled to access this content') + done() + + it 'creates the session if the user is enrolled', utils.wrap (done) -> + @student.set({ + coursePrepaid: { + _id: {} + startDate: moment().subtract(1, 'month').toISOString() + endDate: moment().add(1, 'month').toISOString() + } + }) + @student.save() + [res, body] = yield request.getAsync({ uri: @url, json: true }) + expect(res.statusCode).toBe(200) + done() - it 'creates a new session if the user is in a course with that level', (done) -> - loginJoe (joe) -> + it 'returns 402 if the user\'s license is expired', utils.wrap (done) -> + @student.set({ + coursePrepaid: { + _id: {} + startDate: moment().subtract(2, 'month').toISOString() + endDate: moment().subtract(1, 'month').toISOString() + } + }) + @student.save() + [res, body] = yield request.getAsync({ uri: @url, json: true }) + expect(res.statusCode).toBe(402) + expect(res.body.message).toBe('You must be enrolled to access this content') + done() + + + describe 'when the level is NOT a course level', -> + + beforeEach utils.wrap (done) -> + yield utils.clearModels([Level, User]) + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + @level = yield utils.makeLevel() + + @player = yield utils.initUser() + yield utils.loginUser(@player) + @url = getURL("/db/level/#{@level.id}/session") + done() + + it 'idempotently creates and returns a session for that level', utils.wrap (done) -> + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(200) + sessionID = body._id + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(body._id).toBe(sessionID) + done() + + describe 'when the level is not free', -> + beforeEach utils.wrap (done) -> + yield @level.update({$set: {requiresSubscription: true}}) + done() + + it 'returns 402 for normal users', utils.wrap (done) -> + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(402) + done() + + it 'returns 200 for admins', utils.wrap (done) -> + yield @player.update({$set: {permissions: ['admin']}}) + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(200) + done() - url = getURL("/db/level/#{levelID}/session") + it 'returns 200 for adventurer levels', utils.wrap (done) -> + yield @level.update({$set: {adventurer: true}}) + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(200) + done() - request.get { uri: url, json: true }, (err, res, body) -> - expect(res.statusCode).toBe(200) - expect(body.codeLanguage).toBe('javascript') - done() - - it 'does not create a new session if the user is not in a course with that level', (done) -> - loginSam (sam) -> - - url = getURL("/db/level/#{levelID}/session") - - request.get { uri: url }, (err, res, body) -> - expect(res.statusCode).toBe(402) - done() + it 'returns 200 for subscribed users', utils.wrap (done) -> + yield @player.update({$set: {stripe: {free: true}}}) + [res, body] = yield request.getAsync { uri: @url, json: true } + expect(res.statusCode).toBe(200) + done() diff --git a/spec/server/functional/level_component.spec.coffee b/spec/server/functional/level_component.spec.coffee index d837516b2..a8e7fd924 100644 --- a/spec/server/functional/level_component.spec.coffee +++ b/spec/server/functional/level_component.spec.coffee @@ -1,6 +1,7 @@ require '../common' Level = require '../../../server/models/Level' LevelComponent = require '../../../server/models/LevelComponent' +User = require '../../../server/models/User' request = require '../request' describe 'LevelComponent', -> @@ -20,7 +21,7 @@ describe 'LevelComponent', -> url = getURL('/db/level.component') it 'preparing test : clears things first.', (done) -> - clearModels [Level, LevelComponent], (err) -> + clearModels [Level, LevelComponent, User], (err) -> expect(err).toBeNull() done() diff --git a/spec/server/functional/prepaid.spec.coffee b/spec/server/functional/prepaid.spec.coffee index ee25f7613..4baacae41 100644 --- a/spec/server/functional/prepaid.spec.coffee +++ b/spec/server/functional/prepaid.spec.coffee @@ -13,9 +13,191 @@ Course = require '../../../server/models/Course' CourseInstance = require '../../../server/models/CourseInstance' request = require '../request' +describe 'POST /db/prepaid', -> + beforeEach utils.wrap (done) -> + yield utils.clearModels([User, Prepaid]) + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + done() + + it 'creates a new prepaid for type "course"', utils.wrap (done) -> + user = yield utils.initUser() + [res, body] = yield request.postAsync({url: getURL('/db/prepaid'), json: { + type: 'course' + creator: user.id + }}) + expect(res.statusCode).toBe(201) + prepaid = yield Prepaid.findById(res.body._id) + expect(prepaid).toBeDefined() + expect(prepaid.get('creator').equals(user._id)).toBe(true) + expect(prepaid.get('code')).toBeDefined() + done() + + it 'does not work for non-admins', utils.wrap (done) -> + user = yield utils.initUser() + yield utils.loginUser(user) + [res, body] = yield request.postAsync({url: getURL('/db/prepaid'), json: { + type: 'course' + creator: user.id + }}) + expect(res.statusCode).toBe(403) + done() + + it 'accepts start and end dates', utils.wrap (done) -> + user = yield utils.initUser() + [res, body] = yield request.postAsync({url: getURL('/db/prepaid'), json: { + type: 'course' + creator: user.id + startDate: new Date().toISOString(2001,1,1) + endDate: new Date().toISOString(2010,1,1) + }}) + expect(res.statusCode).toBe(201) + prepaid = yield Prepaid.findById(res.body._id) + expect(prepaid).toBeDefined() + expect(prepaid.get('startDate')).toBeDefined() + expect(prepaid.get('endDate')).toBeDefined() + done() + + +describe 'GET /db/prepaid/:handle', -> + it 'populates startDate and endDate with default values', utils.wrap (done) -> + prepaid = new Prepaid({type: 'course' }) + yield prepaid.save() + [res, body] = yield request.getAsync({url: getURL("/db/prepaid/#{prepaid.id}"), json: true}) + expect(body.endDate).toBe(Prepaid.DEFAULT_END_DATE) + expect(body.startDate).toBe(Prepaid.DEFAULT_START_DATE) + done() + + +describe 'POST /db/prepaid/:handle/redeemers', -> + + beforeEach utils.wrap (done) -> + yield utils.clearModels([Course, CourseInstance, Payment, Prepaid, User]) + @teacher = yield utils.initUser({role: 'teacher'}) + @admin = yield utils.initAdmin() + yield utils.loginUser(@admin) + @prepaid = yield utils.makePrepaid({ creator: @teacher.id }) + yield utils.loginUser(@teacher) + @student = yield utils.initUser() + @url = getURL("/db/prepaid/#{@prepaid.id}/redeemers") + done() + + it 'adds a given user to the redeemers property', utils.wrap (done) -> + [res, body] = yield request.postAsync {uri: @url, json: { userID: @student.id } } + expect(body.redeemers.length).toBe(1) + expect(res.statusCode).toBe(201) + prepaid = yield Prepaid.findById(body._id) + expect(prepaid.get('redeemers').length).toBe(1) + @student = yield User.findById(@student.id) + expect(@student.get('coursePrepaid')._id.equals(@prepaid._id)).toBe(true) + expect(@student.get('role')).toBe('student') + done() + + it 'returns 403 if maxRedeemers is reached', utils.wrap (done) -> + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + prepaid = yield utils.makePrepaid({ creator: @teacher.id, maxRedeemers: 0 }) + url = getURL("/db/prepaid/#{prepaid.id}/redeemers") + yield utils.loginUser(@teacher) + [res, body] = yield request.postAsync({uri: url, json: { userID: @student.id } }) + expect(res.statusCode).toBe(403) + expect(res.body.message).toBe('This prepaid is exhausted') + done() + + it 'returns 403 unless the user is the "creator"', utils.wrap (done) -> + @otherTeacher = yield utils.initUser({role: 'teacher'}) + yield utils.loginUser(@otherTeacher) + [res, body] = yield request.postAsync({uri: @url, json: { userID: @student.id } }) + expect(res.statusCode).toBe(403) + expect(res.body.message).toBe('You may not redeem licenses from this prepaid') + done() + + it 'returns 403 if the prepaid is expired', utils.wrap (done) -> + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + prepaid = yield utils.makePrepaid({ creator: @teacher.id, endDate: moment().subtract(1, 'month').toISOString() }) + url = getURL("/db/prepaid/#{prepaid.id}/redeemers") + yield utils.loginUser(@teacher) + [res, body] = yield request.postAsync({uri: url, json: { userID: @student.id } }) + expect(res.statusCode).toBe(403) + expect(res.body.message).toBe('This prepaid is expired') + done() + + it 'is idempotent across prepaids collection', utils.wrap (done) -> + student = yield utils.initUser({ coursePrepaid: { _id: new Prepaid()._id } }) + [res, body] = yield request.postAsync({uri: @url, json: { userID: student.id } }) + expect(res.statusCode).toBe(200) + expect(body.redeemers.length).toBe(0) + done() + + it 'is idempotent to itself', utils.wrap (done) -> + [res, body] = yield request.postAsync({uri: @url, json: { userID: @student.id } }) + expect(body.redeemers?.length).toBe(1) + expect(res.statusCode).toBe(201) + [res, body] = yield request.postAsync({uri: @url, json: { userID: @student.id } }) + expect(body.redeemers?.length).toBe(1) + expect(res.statusCode).toBe(200) + prepaid = yield Prepaid.findById(body._id) + expect(prepaid.get('redeemers').length).toBe(1) + student = yield User.findById(@student.id) + expect(student.get('coursePrepaid')._id.equals(@prepaid._id)).toBe(true) + done() + + it 'updates the user if their license is expired', utils.wrap (done) -> + yield utils.loginUser(@admin) + prepaid = yield utils.makePrepaid({ + creator: @teacher.id + startDate: moment().subtract(2, 'month').toISOString() + endDate: moment().subtract(1, 'month').toISOString() + }) + @student.set('coursePrepaid', _.pick(prepaid.toObject(), '_id', 'startDate', 'endDate')) + yield @student.save() + yield utils.loginUser(@teacher) + [res, body] = yield request.postAsync {uri: @url, json: { userID: @student.id } } + expect(body.redeemers.length).toBe(1) + expect(res.statusCode).toBe(201) + student = yield User.findById(@student.id) + expect(student.get('coursePrepaid')._id.equals(@prepaid._id)).toBe(true) + done() + + +describe 'GET /db/prepaid?creator=:id', -> + beforeEach utils.wrap (done) -> + yield utils.clearModels([Course, CourseInstance, Payment, Prepaid, User]) + @teacher = yield utils.initUser({role: 'teacher'}) + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + @prepaid = yield utils.makePrepaid({ creator: @teacher.id }) + @otherPrepaid = yield utils.makePrepaid({ creator: admin.id }) + @expiredPrepaid = yield utils.makePrepaid({ creator: @teacher.id, endDate: moment().subtract(1, 'month').toISOString() }) + @unmigratedPrepaid = yield utils.makePrepaid({ creator: @teacher.id }) + yield @unmigratedPrepaid.update({$unset: { endDate: '', startDate: '' }}) + yield utils.loginUser(@teacher) + done() + + it 'return all prepaids for the creator', utils.wrap (done) -> + url = getURL("/db/prepaid?creator=#{@teacher.id}") + [res, body] = yield request.getAsync({uri: url, json: true}) + expect(res.statusCode).toBe(200) + expect(res.body.length).toEqual(3) + if _.any((prepaid._id is @otherPrepaid.id for prepaid in res.body)) + fail('Found the admin prepaid in response') + for prepaid in res.body + unless prepaid.startDate and prepaid.endDate + fail('All prepaids should have start and end dates') + expect(res.body[0]._id).toBe(@prepaid.id) + done() + + it 'returns 403 if the user tries to view another user\'s prepaids', utils.wrap (done) -> + anotherUser = yield utils.initUser() + url = getURL("/db/prepaid?creator=#{anotherUser.id}") + [res, body] = yield request.getAsync({uri: url, json: true}) + expect(res.statusCode).toBe(403) + done() + + describe '/db/prepaid', -> prepaidURL = getURL('/db/prepaid') - prepaidCreateURL = getURL('/db/prepaid/-/create') headers = {'X-Change-Plan': 'true'} @@ -53,257 +235,6 @@ describe '/db/prepaid', -> throw err if err done() - describe 'POST /db/prepaid/<id>/redeemers', -> - - it 'adds a given user to the redeemers property', (done) -> - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 1, - redeemers: [], - creator: user1.get('_id') - code: 0 - type: 'course' - }) - prepaid.save (err, prepaid) -> - otherUser = new User() - otherUser.save (err, otherUser) -> - url = getURL("/db/prepaid/#{prepaid.id}/redeemers") - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(body.redeemers.length).toBe(1) - expect(res.statusCode).toBe(200) - prepaid = Prepaid.findById body._id, (err, prepaid) -> - expect(err).toBeNull() - expect(prepaid.get('redeemers').length).toBe(1) - User.findById otherUser.id, (err, user) -> - expect(user.get('coursePrepaidID').equals(prepaid.get('_id'))).toBe(true) - expect(user.get('role')).toBe('student') - done() - - it 'does not allow more redeemers than maxRedeemers', (done) -> - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 0, - redeemers: [], - creator: user1.get('_id') - code: 1 - type: 'course' - }) - prepaid.save (err, prepaid) -> - otherUser = new User() - otherUser.save (err, otherUser) -> - url = getURL("/db/prepaid/#{prepaid.id}/redeemers") - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(res.statusCode).toBe(403) - done() - - it 'only allows the owner of the prepaid to add redeemers', (done) -> - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 1000, - redeemers: [], - creator: user1.get('_id') - code: 2 - type: 'course' - }) - prepaid.save (err, prepaid) -> - loginNewUser (user2) -> - otherUser = new User() - otherUser.save (err, otherUser) -> - url = getURL("/db/prepaid/#{prepaid.id}/redeemers") - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(res.statusCode).toBe(403) - done() - - it 'is idempotent across prepaids collection', (done) -> - loginNewUser (user1) -> - otherUser = new User({ - 'coursePrepaidID': new ObjectId() - }) - otherUser.save (err, otherUser) -> - prepaid1 = new Prepaid({ - redeemers: [{userID: otherUser.get('_id')}], - code: 3 - type: 'course' - }) - prepaid1.save (err, prepaid1) -> - otherUser.set 'coursePrepaidID', prepaid1.id - otherUser.save (err, otherUser) -> - prepaid2 = new Prepaid({ - maxRedeemers: 10, - redeemers: [], - creator: user1.get('_id') - code: 4 - type: 'course' - }) - prepaid2.save (err, prepaid2) -> - url = getURL("/db/prepaid/#{prepaid2.id}/redeemers") - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(res.statusCode).toBe(200) - expect(body.redeemers.length).toBe(0) - done() - - it 'is idempotent to itself for a user other than the creator', (done) -> - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 2, - redeemers: [], - creator: user1.get('_id') - code: 0 - type: 'course' - }) - prepaid.save (err, prepaid) -> - otherUser = new User() - otherUser.save (err, otherUser) -> - url = getURL("/db/prepaid/#{prepaid.id}/redeemers") - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(body.redeemers?.length).toBe(1) - expect(res.statusCode).toBe(200) - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(body.redeemers?.length).toBe(1) - expect(res.statusCode).toBe(200) - prepaid = Prepaid.findById body._id, (err, prepaid) -> - expect(err).toBeNull() - expect(prepaid.get('redeemers').length).toBe(1) - User.findById otherUser.id, (err, user) -> - expect(user.get('coursePrepaidID').equals(prepaid.get('_id'))).toBe(true) - done() - - it 'is idempotent to itself for the creator', (done) -> - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 2, - redeemers: [], - creator: user1.get('_id') - code: 0 - type: 'course' - }) - prepaid.save (err, prepaid) -> - otherUser = new User() - otherUser.save (err, otherUser) -> - url = getURL("/db/prepaid/#{prepaid.id}/redeemers") - redeemer = { userID: user1.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(body.redeemers?.length).toBe(1) - expect(res.statusCode).toBe(200) - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(body.redeemers?.length).toBe(1) - expect(res.statusCode).toBe(200) - prepaid = Prepaid.findById body._id, (err, prepaid) -> - expect(err).toBeNull() - expect(prepaid.get('redeemers').length).toBe(1) - User.findById user1.id, (err, user) -> - expect(user.get('coursePrepaidID').equals(prepaid.get('_id'))).toBe(true) - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(body.redeemers?.length).toBe(2) - expect(res.statusCode).toBe(200) - done() - - it 'return terminal prepaids', (done) -> - endDate = new Date() - endDate.setUTCMonth(endDate.getUTCMonth() + 2) - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 500, - redeemers: [], - creator: user1.get('_id') - type: 'course' - properties: - endDate: endDate - }) - prepaid.save (err, prepaid) -> - expect(err).toBeNull() - url = getURL("/db/prepaid?creator=#{user1.id}") - request.get {uri: url}, (err, res, body) -> - expect(res.statusCode).toBe(200) - documents = JSON.parse(body) - expect(documents.length).toEqual(1) - return done() unless documents.length is 1 - expect(documents[0]?.properties?.endDate).toEqual(endDate.toISOString()) - done() - - it 'do not return expired terminal prepaids', (done) -> - endDate = new Date() - endDate.setUTCMonth(endDate.getUTCMonth() - 1) - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 500, - redeemers: [], - creator: user1.get('_id') - type: 'course' - properties: - endDate: endDate - }) - prepaid.save (err, prepaid) -> - expect(err).toBeNull() - url = getURL("/db/prepaid?creator=#{user1.id}") - request.get {uri: url}, (err, res, body) -> - expect(res.statusCode).toBe(200) - documents = JSON.parse(body) - expect(documents.length).toEqual(0) - done() - - it 'redeem terminal prepaids', (done) -> - endDate = new Date() - endDate.setUTCMonth(endDate.getUTCMonth() + 2) - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 500, - redeemers: [], - creator: user1.get('_id') - type: 'course' - properties: - endDate: endDate - }) - prepaid.save (err, prepaid) -> - expect(err).toBeNull() - otherUser = new User() - otherUser.save (err, otherUser) -> - url = getURL("/db/prepaid/#{prepaid.id}/redeemers") - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(body.redeemers?.length).toBe(1) - expect(res.statusCode).toBe(200) - return done() unless res.statusCode is 200 - prepaid = Prepaid.findById body._id, (err, prepaid) -> - expect(err).toBeNull() - expect(prepaid.get('redeemers').length).toBe(1) - User.findById otherUser.id, (err, user) -> - expect(user.get('coursePrepaidID').equals(prepaid.get('_id'))).toBe(true) - done() - - it 'do not redeem expired terminal prepaids', (done) -> - endDate = new Date() - endDate.setUTCMonth(endDate.getUTCMonth() - 1) - loginNewUser (user1) -> - prepaid = new Prepaid({ - maxRedeemers: 500, - redeemers: [], - creator: user1.get('_id') - type: 'course' - properties: - endDate: endDate - }) - prepaid.save (err, prepaid) -> - expect(err).toBeNull() - otherUser = new User() - otherUser.save (err, otherUser) -> - url = getURL("/db/prepaid/#{prepaid.id}/redeemers") - redeemer = { userID: otherUser.id } - request.post {uri: url, json: redeemer }, (err, res, body) -> - expect(res.statusCode).toBe(403) - done() - - it 'Clear database', (done) -> - clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) -> - throw err if err - done() - it 'Anonymous creates prepaid code', (done) -> createPrepaid 'subscription', 1, 0, (err, res, body) -> expect(err).toBeNull() @@ -767,14 +698,14 @@ describe '/db/prepaid', -> # Spawn all requests at once! requests = [] - options = { + options = { url: getURL('/db/subscription/-/subscribe_prepaid') json: { ppc: prepaid.code } } for thread in threads requests.push(thread.request.postAsync(options)) - # Wait until all requests finish, make sure all but one succeeded + # Wait until all requests finish, make sure all but one succeeded responses = yield requests redeemed = _.size(_.where(responses, {statusCode: 200})) errors = _.size(_.where(responses, {statusCode: 403})) diff --git a/spec/server/functional/user.spec.coffee b/spec/server/functional/user.spec.coffee index f3d892e96..ea3bf026e 100644 --- a/spec/server/functional/user.spec.coffee +++ b/spec/server/functional/user.spec.coffee @@ -3,6 +3,7 @@ utils = require '../utils' urlUser = '/db/user' User = require '../../../server/models/User' Classroom = require '../../../server/models/Classroom' +Prepaid = require '../../../server/models/Prepaid' request = require '../request' describe 'POST /db/user', -> @@ -234,6 +235,18 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl classroom = yield Classroom.findById(classroom.id) expect(classroom.get('members').length).toBe(0) done() + + it 'changes the role regardless of emailVerified', utils.wrap (done) -> + user = yield utils.initUser() + user.set('emailVerified', true) + yield user.save() + yield utils.loginUser(user) + attrs = user.toObject() + attrs.role = 'teacher' + [res, body] = yield request.putAsync { uri: getURL('/db/user/'+user.id), json: attrs } + user = yield User.findById(user.id) + expect(user.get('role')).toBe('teacher') + done() it 'ignores attempts to change away from a teacher role', utils.wrap (done) -> user = yield utils.initUser() @@ -511,6 +524,18 @@ describe 'GET /db/user', -> # Add to the test case above an extra data check xit 'can fetch another user with restricted fields' + + +describe 'GET /db/user/:handle', -> + it 'populates coursePrepaid from coursePrepaidID', utils.wrap (done) -> + course = yield utils.makeCourse() + user = yield utils.initUser({coursePrepaidID: course.id}) + [res, body] = yield request.getAsync({url: getURL("/db/user/#{user.id}"), json: true}) + expect(res.statusCode).toBe(200) + expect(res.body.coursePrepaid._id).toBe(course.id) + expect(res.body.coursePrepaid.startDate).toBe(Prepaid.DEFAULT_START_DATE) + done() + describe 'DELETE /db/user', -> it 'can delete a user', utils.wrap (done) -> diff --git a/spec/server/unit/user.spec.coffee b/spec/server/unit/user.spec.coffee index 2651ef591..84b28eeb6 100644 --- a/spec/server/unit/user.spec.coffee +++ b/spec/server/unit/user.spec.coffee @@ -55,3 +55,13 @@ describe 'User', -> classicUser.set('permissions', ['user']) expect(classicUser.isAdmin()).toBeFalsy() done() + + describe '.verificationCode(timestamp)', -> + it 'returns a timestamp and a hash', (done) -> + user = new User() + now = new Date() + code = user.verificationCode(now.getTime()) + expect(code).toMatch(/[0-9]{13}:[0-9a-f]{64}/) + [timestamp, hash] = code.split(':') + expect(new Date(parseInt(timestamp))).toEqual(now) + done() diff --git a/spec/server/utils.coffee b/spec/server/utils.coffee index e17e0f6cc..8bedcc681 100644 --- a/spec/server/utils.coffee +++ b/spec/server/utils.coffee @@ -6,6 +6,11 @@ User = require '../../server/models/User' Level = require '../../server/models/Level' Achievement = require '../../server/models/Achievement' Campaign = require '../../server/models/Campaign' +Course = require '../../server/models/Course' +Prepaid = require '../../server/models/Prepaid' +Classroom = require '../../server/models/Classroom' +CourseInstance = require '../../server/models/CourseInstance' +moment = require 'moment' campaignSchema = require '../../app/schemas/models/campaign.schema' campaignLevelProperties = _.keys(campaignSchema.properties.levels.additionalProperties.properties) campaignAdjacentCampaignProperties = _.keys(campaignSchema.properties.adjacentCampaigns.additionalProperties.properties) @@ -124,4 +129,60 @@ module.exports = mw = request.post { uri: getURL('/db/campaign'), json: data }, (err, res) -> return done(err) if err - Campaign.findById(res.body._id).exec done \ No newline at end of file + Campaign.findById(res.body._id).exec done + + makeCourse: (data={}, sources={}) -> + + if sources.campaign and not data.campaignID + data.campaignID = sources.campaign._id + + course = new Course(data) + return course.save() + + makePrepaid: Promise.promisify (data, sources, done) -> + args = Array.from(arguments) + [done, [data, sources]] = [args.pop(), args] + + data = _.extend({}, { + type: 'course' + maxRedeemers: 9001 + endDate: moment().add(1, 'month').toISOString() + startDate: new Date().toISOString() + }, data) + + request.post { uri: getURL('/db/prepaid'), json: data }, (err, res) -> + return done(err) if err + expect(res.statusCode).toBe(201) + Prepaid.findById(res.body._id).exec done + + makeClassroom: (data={}, sources={}) -> co -> + data = _.extend({}, { + name: _.uniqueId('Classroom ') + }, data) + + [res, body] = yield request.postAsync { uri: getURL('/db/classroom'), json: data } + expect(res.statusCode).toBe(201) + classroom = yield Classroom.findById(res.body._id) + if sources.members + classroom.set('members', _.map(sources.members, '_id')) + yield classroom.save() + return classroom + + makeCourseInstance: (data={}, sources={}) -> co -> + if sources.course and not data.courseID + data.courseID = sources.course.id + if sources.classroom and not data.classroomID + data.classroomID = sources.classroom.id + + [res, body] = yield request.postAsync({ uri: getURL('/db/course_instance'), json: data }) + expect(res.statusCode).toBe(200) + courseInstance = yield CourseInstance.findById(res.body._id) + if sources.members + userIDs = _.map(sources.members, 'id') + [res, body] = yield request.postAsync({ + url: getURL("/db/course_instance/#{courseInstance.id}/members") + json: { userIDs: userIDs } + }) + expect(res.statusCode).toBe(200) + courseInstance = yield CourseInstance.findById(res.body._id) + return courseInstance diff --git a/test/app/factories.coffee b/test/app/factories.coffee index 50ad8a0c9..28000afd8 100644 --- a/test/app/factories.coffee +++ b/test/app/factories.coffee @@ -9,6 +9,7 @@ Achievement = require 'models/Achievement' EarnedAchievement = require 'models/EarnedAchievement' ThangType = require 'models/ThangType' Users = require 'collections/Users' +Prepaid = require 'models/Prepaid' module.exports = { @@ -36,7 +37,7 @@ module.exports = { }, attrs) return new Level(attrs) - makeUser: (attrs) -> + makeUser: (attrs, sources={}) -> _id = _.uniqueId('user_') attrs = _.extend({ _id: _id @@ -45,6 +46,10 @@ module.exports = { anonymous: false name: _.string.humanize(_id) }, attrs) + + if sources.prepaid and not attrs.coursePrepaid + attrs.coursePrepaid = sources.prepaid.pick('_id', 'startDate', 'endDate') + return new User(attrs) makeClassroom: (attrs, sources={}) -> @@ -148,6 +153,38 @@ module.exports = { }, attrs) return new ThangType(attrs) + makePrepaid: (attrs, sources={}) -> + _id = _.uniqueId('prepaid_') + attrs = _.extend({}, { + _id + type: 'course' + maxRedeemers: 10 + endDate: moment().add(1, 'month').toISOString() + startDate: moment().subtract(1, 'month').toISOString() + }, attrs) + + if not attrs.redeemers + redeemers = sources.redeemers or new Users() + attrs.redeemers = ({ + userID: redeemer.id + date: moment().subtract(1, 'month').toISOString() + } for redeemer in redeemers.models) + + return new Prepaid(attrs) + + makeTrialRequest: (attrs, sources={}) -> + _id = _.uniqueId('trial_request_') + attrs = _.extend({}, { + _id + properties: { + firstName: 'Mr' + lastName: 'Professorson' + name: 'Mr Professorson' + email: 'an@email.com' + phoneNumber: '555-555-5555' + organization: 'Greendale' + } + }, attrs) } diff --git a/test/app/views/courses/EnrollmentsView.spec.coffee b/test/app/views/courses/EnrollmentsView.spec.coffee new file mode 100644 index 000000000..75ba21deb --- /dev/null +++ b/test/app/views/courses/EnrollmentsView.spec.coffee @@ -0,0 +1,102 @@ +EnrollmentsView = require 'views/courses/EnrollmentsView' +Courses = require 'collections/Courses' +Prepaids = require 'collections/Prepaids' +Users = require 'collections/Users' +Classrooms = require 'collections/Classrooms' +factories = require 'test/app/factories' +TeachersContactModal = require 'views/teachers/TeachersContactModal' + +describe 'EnrollmentsView', -> + + beforeEach (done) -> + me.set('anonymous', false) + me.set('role', 'teacher') + me.set('enrollmentRequestSent', false) + @view = new EnrollmentsView() + + # Make three classrooms, sharing users from a pool of 10, 5 of which are enrolled + prepaid = factories.makePrepaid() + students = new Users(_.times(10, (i) -> + factories.makeUser({}, { prepaid: if i%2 then prepaid else null })) + ) + + userSlices = [ + new Users(students.slice(0, 5)) + new Users(students.slice(3, 8)) + new Users(students.slice(7, 10)) + ] + + classrooms = new Classrooms(factories.makeClassroom({}, {members: userSlice}) for userSlice in userSlices) + @view.classrooms.fakeRequests[0].respondWith({ status: 200, responseText: classrooms.stringify() }) + for request, i in @view.members.fakeRequests + request.respondWith({status: 200, responseText: userSlices[i].stringify()}) + + # Make prepaids of various status + prepaids = new Prepaids([ + factories.makePrepaid({}, {redeemers: new Users(_.times(5, -> factories.makeUser()))}) + factories.makePrepaid() + factories.makePrepaid({ # pending + startDate: moment().add(2, 'months').toISOString() + endDate: moment().add(14, 'months').toISOString() + }) + factories.makePrepaid( # empty + { maxRedeemers: 2 }, + {redeemers: new Users(_.times(2, -> factories.makeUser()))} + ) + ]) + @view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: prepaids.stringify() }) + + # Make a few courses, one free + courses = new Courses([ + factories.makeCourse({free: true}) + factories.makeCourse({free: false}) + factories.makeCourse({free: false}) + factories.makeCourse({free: false}) + ]) + @view.courses.fakeRequests[0].respondWith({ status: 200, responseText: courses.stringify() }) + + jasmine.demoEl(@view.$el) + window.view = @view + @view.supermodel.once 'loaded-all', done + + + it 'shows how many courses there are which enrolled students will have access to', -> + expect(_.contains(@view.$('#enrollments-blurb').text(), '2–4')).toBe(true) + if @view.$('#actions-col').length isnt 1 + fail('There should be an #action-col, other tests depend on it.') + + describe '"Get Licenses" area', -> + + describe '"Contact Us" button', -> + it 'opens a TeachersContactModal, passing in the number of licenses', -> + spyOn(@view, 'openModalView') + @view.state.set('numberOfStudents', 20) + @view.$('#contact-us-btn').click() + expect(view.openModalView).toHaveBeenCalled() + args = view.openModalView.calls.argsFor(0) + expect(args[0] instanceof TeachersContactModal).toBe(true) + expect(args[0].enrollmentsNeeded).toBe(20) + + describe 'when the teacher has made contact', -> + beforeEach -> + me.set('enrollmentRequestSent', true) + @view.render() + + it 'shows confirmation and a mailto link to schools@codecombat.com', -> + if not @view.$('#request-sent-btn').length + fail('Request button not found.') + if not @view.$('#enrollment-request-sent-blurb').length + fail('License request sent blurb not found.') + # TODO: Figure out why this fails in Travis. Seems like it's not loading en locale +# if not @view.$('a[href="mailto:schools@codecombat.com"]').length +# fail('Mailto: link not found.') + + describe 'when there are no prepaids to show', -> + beforeEach (done) -> + @view.prepaids.reset([]) + @view.updatePrepaidGroups() + _.defer(done) + + it 'fills the void with the rest of the page content', -> + expect(@view.$('#actions-col').length).toBe(0) + diff --git a/test/app/views/courses/TeachersContactModal.spec.coffee b/test/app/views/courses/TeachersContactModal.spec.coffee new file mode 100644 index 000000000..69552323e --- /dev/null +++ b/test/app/views/courses/TeachersContactModal.spec.coffee @@ -0,0 +1,50 @@ +TeachersContactModal = require 'views/teachers/TeachersContactModal' +TrialRequests = require 'collections/TrialRequests' +factories = require 'test/app/factories' + +describe 'TeachersContactModal', -> + beforeEach (done) -> + @modal = new TeachersContactModal({ enrollmentsNeeded: 10 }) + @modal.render() + trialRequests = new TrialRequests([factories.makeTrialRequest()]) + @modal.trialRequests.fakeRequests[0].respondWith({ status: 200, responseText: trialRequests.stringify() }) + @modal.supermodel.once('loaded-all', done) + jasmine.demoModal(@modal) + + it 'shows an error when the email is invalid and the form is submitted', -> + @modal.$('input[name="email"]').val('not an email') + @modal.$('form').submit() + expect(@modal.$('input[name="email"]').closest('.form-group').hasClass('has-error')).toBe(true) + + it 'shows an error when the message is empty and the form is submitted', -> + @modal.$('textarea[name="message"]').val('') + @modal.$('form').submit() + expect(@modal.$('textarea[name="message"]').closest('.form-group').hasClass('has-error')).toBe(true) + + describe 'submit form', -> + beforeEach -> + @modal.$('form').submit() + + it 'disables inputs', -> + for el in @modal.$('button, input, textarea') + expect($(el).is(':disabled')).toBe(true) + + describe 'failed contact', -> + beforeEach -> + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({status: 500}) + + it 'shows an error', -> + expect(@modal.$('.alert-danger').length).toBe(1) + + describe 'successful contact', -> + beforeEach -> + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({status: 200, responseText: '{}'}) + + it 'shows a success message', -> + expect(@modal.$('.alert-success').length).toBe(1) + + it 'disables the submit button', -> + expect(@modal.$('#submit-btn').is(':disabled')).toBe(true) + diff --git a/test/app/views/teachers/ActivateLicensesModal.spec.coffee b/test/app/views/teachers/ActivateLicensesModal.spec.coffee index 518f32786..2d7ee2d2a 100644 --- a/test/app/views/teachers/ActivateLicensesModal.spec.coffee +++ b/test/app/views/teachers/ActivateLicensesModal.spec.coffee @@ -1,58 +1,68 @@ ActivateLicensesModal = require 'views/courses/ActivateLicensesModal' +Classrooms = require 'collections/Classrooms' +Courses = require 'collections/Courses' +Levels = require 'collections/Levels' +Prepaids = require 'collections/Prepaids' Users = require 'collections/Users' forms = require 'core/forms' +factories = require 'test/app/factories' # Needs some fixing -xdescribe 'ActivateLicensesModal', -> - - @modal = null - - me = require 'test/app/fixtures/teacher' - prepaids = require 'test/app/fixtures/prepaids' - classrooms = require 'test/app/fixtures/classrooms/unarchived-classrooms' - users = require 'test/app/fixtures/students' - responses = { - '/db/prepaid': prepaids.toJSON() - '/db/classroom': classrooms.toJSON() - # '/members': users.toJSON() # TODO: Respond with different ones for different classrooms - } - - makeModal = (options) -> - (done) -> - @selectedUsers = new Users(@users.models.slice(0,(options?.numSelected or 3))) - @modal = new ActivateLicensesModal({ - @classroom, @users, @selectedUsers +describe 'ActivateLicensesModal', -> + + beforeEach (done) -> + @members = new Users(_.times(4, (i) -> factories.makeUser())) + @classrooms = new Classrooms([ + factories.makeClassroom({}, { @members }) + factories.makeClassroom() + ]) + selectedUsers = new Users(@members.slice(0,3)) + options = _.extend({}, { + classroom: @classrooms.first(), @classrooms, users: @members, selectedUsers + }, options) + @modal = new ActivateLicensesModal(options) + @prepaidThatExpiresSooner = factories.makePrepaid({maxRedeemers: 1, endDate: moment().add(1, 'month').toISOString()}) + @prepaidThatExpiresLater = factories.makePrepaid({maxRedeemers: 1, endDate: moment().add(2, 'months').toISOString()}) + prepaids = new Prepaids([ + # empty + factories.makePrepaid({maxRedeemers: 0, endDate: moment().add(1, 'day').toISOString()}) + + # expired + factories.makePrepaid({maxRedeemers: 10, endDate: moment().subtract(1, 'day').toISOString()}) + + # pending + factories.makePrepaid({ + maxRedeemers: 100 + startDate: moment().add(1, 'month').toISOString() + endDate: moment().add(2, 'months').toISOString() }) - jasmine.Ajax.requests.sendResponses(responses) - _.filter(jasmine.Ajax.requests.all().slice(), (request) -> - /\/db\/classroom\/.*\/members/.test(request.url) and request.readyState < 4 - ).forEach (request) -> - request.respondWith(users.toJSON) - # debugger - - jasmine.demoModal(@modal) - _.defer done - - beforeEach -> - @classroom = classrooms.get('active-classroom') - @users = require 'test/app/fixtures/students' - - afterEach -> - @modal.stopListening() + + # these should be used + @prepaidThatExpiresSooner + @prepaidThatExpiresLater + ]) + @modal.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: prepaids.stringify() }) + @modal.classrooms.fakeRequests[0].respondWith({ + status: 200 + responseText: @classrooms.stringify() + }) + @modal.classrooms.first().users.fakeRequests[0].respondWith({ + status: 200 + responseText: @members.stringify() + }) + + jasmine.demoModal(@modal) + _.defer done describe 'the class dropdown', -> - beforeEach makeModal() + it 'contains an All Students option', -> + expect(@modal.$('select option:last-child').data('i18n')).toBe('teacher.all_students') - # punted indefinitely - xit 'should contain an All Students option', -> - expect(@modal.$('select option:last-child').html()).toBe('All Students') + it 'displays the current classname', -> + expect(@modal.$('option:selected').html()).toBe(@classrooms.first().get('name')) - it 'should display the current classname', -> - expect(@modal.$('option:selected').html()).toBe('Teacher Zero\'s Classroomiest Classroom') - - it 'should contain all of the teacher\'s classes' - - it 'shouldn\'t contain anyone else\'s classrooms' + it 'contains all of the teacher\'s classes', -> + expect(@modal.$('select option').length).toBe(3) # including 'All Students' options describe 'the checklist of students', -> it 'should separate the unenrolled from the enrolled students' @@ -63,28 +73,42 @@ xdescribe 'ActivateLicensesModal', -> describe 'the credits availble count', -> - beforeEach makeModal() it 'should match the number of unused prepaids', -> expect(@modal.$('#total-available').html()).toBe('2') describe 'the Enroll button', -> - beforeEach makeModal() it 'should show the number of selected students', -> expect(@modal.$('#total-selected-span').html()).toBe('3') it 'should fire off one request when clicked' - describe 'when the teacher has enough enrollments', -> - beforeEach makeModal({ numSelected: 2 }) + describe 'when the teacher has enough licenses', -> + beforeEach -> + selected = @modal.state.get('selectedUsers') + selected.remove(selected.first()) + it 'should be enabled', -> expect(@modal.$('#activate-licenses-btn').hasClass('disabled')).toBe(false) + + describe 'when clicked', -> + beforeEach -> + @modal.$('form').submit() + + it 'enrolls the selected students with the soonest-to-expire, available prepaid', -> + request = jasmine.Ajax.requests.mostRecent() + if request.url.indexOf(@prepaidThatExpiresSooner.id) is -1 + fail('The first prepaid should be the prepaid that expires sooner') + request.respondWith({ status: 200, responseText: '{ "redeemers": [{}] }' }) + request = jasmine.Ajax.requests.mostRecent() + if request.url.indexOf(@prepaidThatExpiresLater.id) is -1 + fail('The second prepaid should be the prepaid that expires later') - describe 'when the teacher doesn\'t have enough enrollments', -> + describe 'when the teacher doesn\'t have enough licenses', -> it 'should be disabled', -> expect(@modal.$('#activate-licenses-btn').hasClass('disabled')).toBe(true) describe 'the Purchase More button', -> - it 'should redirect to the enrollment purchasing page' + it 'should redirect to the license purchasing page' @@ -98,7 +122,7 @@ xdescribe 'ActivateLicensesModal', -> # it 'should display the correct total number of credits', -> # expect(@modal.$('#total-available').html()).toBe('2') # - # it 'should be disabled when teacher doesn\'t have enough enrollments', -> + # it 'should be disabled when teacher doesn\'t have enough licenses', -> # expect(@modal.$('#total-available').html()).toBe('2') # # diff --git a/test/app/views/teachers/EditStudentModal.spec.coffee b/test/app/views/teachers/EditStudentModal.spec.coffee new file mode 100644 index 000000000..3535f9db8 --- /dev/null +++ b/test/app/views/teachers/EditStudentModal.spec.coffee @@ -0,0 +1,71 @@ +EditStudentModal = require 'views/teachers/EditStudentModal' +User = require 'models/User' +factories = require 'test/app/factories' + +describe 'EditStudentModal', -> + + user = null + modal = null + email = "test@example.com" + newPassword = "new password" + + describe 'for a verified user', -> + beforeEach (done) -> + user = factories.makeUser({ email, emailVerified: true }) + classroom = factories.makeClassroom() + modal = new EditStudentModal({ user, classroom }) + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ status: 200, responseText: JSON.stringify(user) }) + jasmine.demoModal(modal) + modal.render() + _.defer done + + it 'has a button to send a password reset email', -> + if modal.$('.send-recovery-email-btn').length < 1 + fail "Expected there to be a Send Recovery Email button" + + it 'sends the verification email request', -> + modal.$('.send-recovery-email-btn').click() + request = jasmine.Ajax.requests.mostRecent() + expect(request.params).toEqual("email=#{encodeURIComponent(email)}") + + it 'updates the button after the request is sent', -> + modal.$('.send-recovery-email-btn').click() + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ status: 200, responseText: "{}" }) + expect(modal.$('.send-recovery-email-btn [data-i18n]').data('i18n')).toEqual('teacher.email_sent') + + describe 'for an unverified user', -> + beforeEach (done) -> + user = factories.makeUser({ email , emailVerified: false }) + classroom = factories.makeClassroom() + modal = new EditStudentModal({ user, classroom }) + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ status: 200, responseText: JSON.stringify(user) }) + jasmine.demoModal(modal) + modal.render() + _.defer done + + it "has a new password field", -> + if modal.$('.new-password-input').length < 1 + fail "Expected there to be a new password input field" + + it "has a change password button", -> + if modal.$('.change-password-btn').length < 1 + fail "Expected there to be a Change Password button" + + describe 'when you click the button', -> + it 'sends a request', -> + modal.$('.change-password-btn').click() + request = jasmine.Ajax.requests.mostRecent() + expect(request).toBeDefined() + + xit 'updates the button', -> + request1 = jasmine.Ajax.requests.mostRecent() + fail "Expected a request to be sent" unless request1 + modal.$('.new-password-input').val(newPassword).change().trigger('input') + modal.$('.change-password-btn').click() + request2 = jasmine.Ajax.requests.mostRecent() + expect(request1).not.toBe(request2) + request1?.respondWith({ status: 200, responseText: JSON.stringify(user) }) + expect(modal.$('.change-password-btn [data-i18n]').data('i18n')).toEqual('teacher.changed') diff --git a/test/app/views/teachers/TeacherClassView.spec.coffee b/test/app/views/teachers/TeacherClassView.spec.coffee index 7c66f5bbd..57a5af175 100644 --- a/test/app/views/teachers/TeacherClassView.spec.coffee +++ b/test/app/views/teachers/TeacherClassView.spec.coffee @@ -25,7 +25,16 @@ describe 'TeacherClassView', -> me = factories.makeUser({}) @courses = new Courses([factories.makeCourse({name: 'First Course'}), factories.makeCourse({name: 'Second Course'})]) - @students = new Users(_.times(2, -> factories.makeUser())) + available = factories.makePrepaid() + expired = factories.makePrepaid({endDate: moment().subtract(1, 'day').toISOString()}) + @students = new Users([ + factories.makeUser({name: 'Abner'}) + factories.makeUser({name: 'Abigail'}) + factories.makeUser({name: 'Abby'}, {prepaid: available}) + factories.makeUser({name: 'Ben'}, {prepaid: available}) + factories.makeUser({name: 'Ned'}, {prepaid: expired}) + factories.makeUser({name: 'Ebner'}, {prepaid: expired}) + ]) @levels = new Levels(_.times(2, -> factories.makeLevel())) @classroom = factories.makeClassroom({}, { @courses, members: @students, levels: [@levels, new Levels()] }) @courseInstances = new CourseInstances([ @@ -42,7 +51,7 @@ describe 'TeacherClassView', -> {level, creator: @finishedStudent}) ) sessions.push(factories.makeLevelSession( - {state: {complete: false}}, + {state: {complete: true}}, {level: @levels.first(), creator: @unfinishedStudent}) ) @levelSessions = new LevelSessions(sessions) @@ -66,6 +75,9 @@ describe 'TeacherClassView', -> # it "shows the classroom's join code" describe 'the Students tab', -> + beforeEach -> + @view.state.set('activeTab', '#students-tab') + # it 'shows all of the students' # it 'sorts correctly by Name' # it 'sorts correctly by Progress' @@ -89,7 +101,15 @@ describe 'TeacherClassView', -> # it 'still shows the correct Course Overview progress' # + describe 'the Enrollment Status tab', -> + beforeEach -> + @view.state.set('activeTab', '#enrollment-status-tab') - - - + describe 'Enroll button', -> + it 'calls enrollStudents with that user when clicked', -> + spyOn(@view, 'enrollStudents') + @view.$('.enroll-student-button:first').click() + expect(@view.enrollStudents).toHaveBeenCalled() + users = @view.enrollStudents.calls.argsFor(0)[0] + expect(users.size()).toBe(1) + expect(users.first().id).toBe(@view.students.first().id)