diff --git a/README.md b/README.md index 1e79276a0..3a9992acd 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Whether you're novice or pro, the CodeCombat team is ready to help you implement ![Catherine Weresow](http://codecombat.com/images/pages/about/cat_small.png) ![Maka Gradin](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Maka%20Gradin/maka_gradin_100.png) ![Rob Blanckaert](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rob%20Blanckaert/rob_blanckaert_100.png) +![Josh Callebaut](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Josh%20Callebaut/josh_callebaut_100.png) ![Michael Schmatz](http://codecombat.com/images/pages/about/michael_small.png) ![Josh Lee](http://codecombat.com/images/pages/about/josh_small.png) ![Alex Cotsarelis](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Cotsarelis/alex_100.png) diff --git a/app/assets/images/pages/about/josh_c_small.png b/app/assets/images/pages/about/josh_c_small.png new file mode 100644 index 000000000..6a04f9e2e Binary files /dev/null and b/app/assets/images/pages/about/josh_c_small.png differ diff --git a/app/core/Router.coffee b/app/core/Router.coffee index 25bca2229..332190b41 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -66,8 +66,9 @@ module.exports = class CocoRouter extends Backbone.Router 'courses/mock1/enroll/:courseID': go('courses/mock1/CourseEnrollView') 'courses/mock1/:courseID': go('courses/mock1/CourseDetailsView') 'courses': go('courses/CoursesView') - 'courses/students': go('courses/CoursesView') - 'courses/teachers': go('courses/CoursesView') + 'courses/students': go('courses/StudentCoursesView') + 'courses/teachers': go('courses/TeacherCoursesView') + 'courses/purchase': go('courses/PurchaseCoursesView') 'courses/enroll(/:courseID)': go('courses/CourseEnrollView') 'courses/:courseID(/:courseInstanceID)': go('courses/CourseDetailsView') @@ -97,7 +98,7 @@ module.exports = class CocoRouter extends Backbone.Router 'github/*path': 'routeToServer' - 'hoc': go('courses/CoursesView') + 'hoc': go('courses/HourOfCodeView') 'i18n': go('i18n/I18NHomeView') 'i18n/thang/:handle': go('i18n/I18NEditThangTypeView') diff --git a/app/core/d3_utils.coffee b/app/core/d3_utils.coffee new file mode 100644 index 000000000..fe9882a0b --- /dev/null +++ b/app/core/d3_utils.coffee @@ -0,0 +1,128 @@ +# Caller needs require 'vendor/d3' + +module.exports.createContiguousDays = (timeframeDays, skipToday=true) -> + # Return list of last 'timeframeDays' contiguous days in yyyy-mm-dd format + days = [] + currentDate = new Date() + currentDate.setUTCDate(currentDate.getUTCDate() - timeframeDays) + currentDate.setUTCDate(currentDate.getUTCDate() - 1) if skipToday + for i in [0...timeframeDays] + currentDay = currentDate.toISOString().substr(0, 10) + days.push(currentDay) + currentDate.setUTCDate(currentDate.getUTCDate() + 1) + days + +module.exports.createLineChart = (containerSelector, chartLines) -> + # Creates a line chart within 'containerSelector' based on chartLines + return unless chartLines?.length > 0 and containerSelector + + margin = 20 + keyHeight = 20 + xAxisHeight = 20 + yAxisWidth = 40 + containerWidth = $(containerSelector).width() + containerHeight = $(containerSelector).height() + + yScaleCount = 0 + yScaleCount++ for line in chartLines when line.showYScale + svg = d3.select(containerSelector).append("svg") + .attr("width", containerWidth) + .attr("height", containerHeight) + width = containerWidth - margin * 2 - yAxisWidth * yScaleCount + height = containerHeight - margin * 2 - xAxisHeight - keyHeight * chartLines.length + currentLine = 0 + currentYScale = 0 + + # Horizontal guidelines + marks = (Math.round(i * height / 5) for i in [1..5]) + yRange = d3.scale.linear().range([height, 0]).domain([0, height]) + svg.selectAll(".line") + .data(marks) + .enter() + .append("line") + .attr("x1", margin + yAxisWidth * yScaleCount) + .attr("y1", (d) -> margin + yRange(d)) + .attr("x2", margin + yAxisWidth * yScaleCount + width) + .attr("y2", (d) -> margin + yRange(d)) + .attr("stroke", 'gray') + .style("opacity", "0.3") + + for line in chartLines + # continue unless line.enabled + xRange = d3.scale.linear().range([0, width]).domain([d3.min(line.points, (d) -> d.x), d3.max(line.points, (d) -> d.x)]) + yRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max]) + + # x-Axis + if currentLine is 0 + startDay = new Date(line.points[0].day) + endDay = new Date(line.points[line.points.length - 1].day) + xAxisRange = d3.time.scale() + .domain([startDay, endDay]) + .range([0, width]) + xAxis = d3.svg.axis() + .scale(xAxisRange) + svg.append("g") + .attr("class", "x axis") + .call(xAxis) + .selectAll("text") + .attr("dy", ".35em") + .attr("transform", "translate(" + (margin + yAxisWidth) + "," + (height + margin) + ")") + .style("text-anchor", "start") + + if line.showYScale + # y-Axis + yAxisRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max]) + yAxis = d3.svg.axis() + .scale(yRange) + .orient("left") + svg.append("g") + .attr("class", "y axis") + .attr("transform", "translate(" + (margin + yAxisWidth * currentYScale) + "," + margin + ")") + .style("color", line.lineColor) + .call(yAxis) + .selectAll("text") + .attr("y", 0) + .attr("x", 0) + .attr("fill", line.lineColor) + .style("text-anchor", "start") + currentYScale++ + + # Key + svg.append("line") + .attr("x1", margin) + .attr("y1", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2) + .attr("x2", margin + 40) + .attr("y2", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2) + .attr("stroke", line.lineColor) + .attr("class", "key-line") + svg.append("text") + .attr("x", margin + 40 + 10) + .attr("y", margin + height + xAxisHeight + keyHeight * currentLine + (keyHeight + 10) / 2) + .attr("fill", line.lineColor) + .attr("class", "key-text") + .text(line.description) + + # Path and points + svg.selectAll(".circle") + .data(line.points) + .enter() + .append("circle") + .attr("transform", "translate(" + (margin + yAxisWidth * yScaleCount) + "," + margin + ")") + .attr("cx", (d) -> xRange(d.x)) + .attr("cy", (d) -> yRange(d.y)) + .attr("r", 2) + .attr("fill", line.lineColor) + .attr("stroke-width", 1) + .attr("class", "graph-point") + .attr("data-pointid", (d) -> "#{line.lineID}#{d.x}") + d3line = d3.svg.line() + .x((d) -> xRange(d.x)) + .y((d) -> yRange(d.y)) + .interpolate("linear") + svg.append("path") + .attr("d", d3line(line.points)) + .attr("transform", "translate(" + (margin + yAxisWidth * yScaleCount) + "," + margin + ")") + .style("stroke-width", line.strokeWidth) + .style("stroke", line.lineColor) + .style("fill", "none") + currentLine++ diff --git a/app/core/services/stripe.coffee b/app/core/services/stripe.coffee index 785ba194e..173196bb7 100644 --- a/app/core/services/stripe.coffee +++ b/app/core/services/stripe.coffee @@ -6,6 +6,10 @@ module.exports = handler = StripeCheckout.configure({ email: me.get('email') image: "https://codecombat.com/images/pages/base/logo_square_250.png" token: (token) -> + console.log 'trigger?', handler.trigger + handler.trigger 'received-token', { token: token } Backbone.Mediator.publish 'stripe:received-token', { token: token } locale: 'auto' }) + +_.extend(handler, Backbone.Events) \ No newline at end of file diff --git a/app/core/utils.coffee b/app/core/utils.coffee index 94cbb152b..782d487d7 100644 --- a/app/core/utils.coffee +++ b/app/core/utils.coffee @@ -248,3 +248,39 @@ module.exports.getPrepaidCodeAmount = getPrepaidCodeAmount = (price=999, users=0 return 0 unless users > 0 and months > 0 total = price * users * months total + +module.exports.filterMarkdownCodeLanguages = (text) -> + currentLanguage = me.get('aceConfig')?.language or 'python' + excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'io'], currentLanguage + exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm' + text.replace exclusionRegex, '' + +module.exports.aceEditModes = aceEditModes = + 'javascript': 'ace/mode/javascript' + 'coffeescript': 'ace/mode/coffee' + 'python': 'ace/mode/python' + 'clojure': 'ace/mode/clojure' + 'lua': 'ace/mode/lua' + 'io': 'ace/mode/text' + +module.exports.initializeACE = (el, codeLanguage) -> + contents = $(el).text().trim() + editor = ace.edit el + editor.setOptions maxLines: Infinity + editor.setReadOnly true + editor.setTheme 'ace/theme/textmate' + editor.setShowPrintMargin false + editor.setShowFoldWidgets false + editor.setHighlightActiveLine false + editor.setHighlightActiveLine false + editor.setBehavioursEnabled false + editor.renderer.setShowGutter false + editor.setValue contents + editor.clearSelection() + session = editor.getSession() + session.setUseWorker false + session.setMode aceEditModes[codeLanguage] + session.setWrapLimitRange null + session.setUseWrapMode true + session.setNewLineMode 'unix' + return editor diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 5b4674098..0a3f008b4 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -603,8 +603,8 @@ rob_blurb: "Codes things and stuff" josh_c_title: "Game Designer" josh_c_blurb: "Designs games" - carlos_title: "Region Manager" - carlos_blurb: "CodeCombat Brazil" + carlos_title: "Region Manager, Brazil" + carlos_blurb: "Celery Man" teachers: more_info: "More Info for Teachers" diff --git a/app/locale/uk.coffee b/app/locale/uk.coffee index 824c059d1..471a5a333 100644 --- a/app/locale/uk.coffee +++ b/app/locale/uk.coffee @@ -1,6 +1,6 @@ module.exports = nativeDescription: "Українська", englishDescription: "Ukrainian", translation: home: - slogan: "Навчіться програмувати, граючи у гру" + slogan: "Навчіться програмувати граючи" no_ie: "На жаль, CodeCombat не працює в IE8 та старіших версіях!" # Warning that only shows up in IE8 and older no_mobile: "CodeCombat не призначений для мобільних пристроїв і може не працювати!" # Warning that shows up on mobile devices play: "Грати" # The big play button that opens up the campaign view. @@ -31,7 +31,7 @@ module.exports = nativeDescription: "Українська", englishDescription: contact: "Контакти" twitter_follow: "Фоловити" teachers: "Учителям" -# careers: "Careers" + careers: "Робота" modal: close: "Закрити" @@ -74,7 +74,7 @@ module.exports = nativeDescription: "Українська", englishDescription: anonymous: "Гравець-анонім" level_difficulty: "Складність: " campaign_beginner: "Кампанія для початківців" - awaiting_levels_adventurer_prefix: "Ми випускаємо 5 рівнів на тиждень." # {change} + awaiting_levels_adventurer_prefix: "Ми щотижня додаємо нові рівні." awaiting_levels_adventurer: "Увійди як Шукач пригод" awaiting_levels_adventurer_suffix: "стань одним з перших, хто їх спробує." adjust_volume: "Підлаштувати гучність" @@ -84,12 +84,12 @@ module.exports = nativeDescription: "Українська", englishDescription: # campaign_old_multiplayer_description: "Relics of a more civilized age. No simulations are run for these older, hero-less multiplayer arenas." share_progress_modal: - blurb: "Ви робите великі успіхи! Розкажіть кому-небудь, як багато ви вивчили з CodeCombat." # {change} + blurb: "У тебе гарно виходить! Розкажи своїм батькам як багато ти знаєш завдяки CodeCombat." email_invalid: "Невірна електронна адреса." form_blurb: "Введіть їхні електронні адреси, і ми покажемо ім!" form_label: "Електронна адреса" placeholder: "електронна адреса" - title: "Досконала робота, Учень" + title: "Досконала робота, учню" login: sign_up: "створення акаунту" @@ -161,7 +161,7 @@ module.exports = nativeDescription: "Українська", englishDescription: withdrawn: "Відкликано" accept: "Прийняти" reject: "Відхилити" -# withdraw: "Withdraw" + withdraw: "Відкликати" submitter: "Відправник" submitted: "Відправлено" commit_msg: "Доручити повідомлення" @@ -343,7 +343,7 @@ module.exports = nativeDescription: "Українська", englishDescription: tip_free_your_mind: "Нео, ти повинен усе подолати. Страх... сумніви і невіра. Звільни від них свій розум. - Морфіус" tip_strong_opponents: "Навіть наймогутніший суперник має свою слабкість. - Ітачі Учіха" tip_paper_and_pen: "Перш ніж почати програмувати, ви завжди можете спробувати з аркушем паперу і ручкою." -# tip_solve_then_write: "First, solve the problem. Then, write the code. - John Johnson" + tip_solve_then_write: "Спершу вирішуй проблему, а потім - пиши код. - Джон Джонсон" game_menu: inventory_tab: "Інвентар" @@ -404,9 +404,9 @@ module.exports = nativeDescription: "Українська", englishDescription: subscribe: comparison_blurb: "Відточіть свої навички завдяки підписці на CodeCombat!" - feature1: "Більше 60 основних рівней на просторах 4 світів" # {change} - feature2: "7 могутніх нових героїв з унікальними здібностями!" # {change} - feature3: "Більше 30 бонусних рівнів" # {change} + feature1: "Більше 110 основних рівней на просторах 4 світів" + feature2: "10 могутніх нових героїв з унікальними здібностями!" + feature3: "Більше 80-ти бонусних рівнів" feature4: "3500 бонусних самоцвітів кожного місяця!" feature5: "Навчальні відеоролики" feature6: "Екслюзивна підтримка по електронній пошті" @@ -432,8 +432,8 @@ module.exports = nativeDescription: "Українська", englishDescription: parent_email_sent: "Лист відправлено!" parent_email_title: "Яка в твоїх батьків електронна адреса?" parents: "Батькам" - parents_title: "Ваша дитина вчитиметься програмувати." # {change} - parents_blurb1: "Разом з CodeCombat Ваша дитина писатиме реальний код. Почне з простих команд та поступово буде розвиватись до складніших тем." + parents_title: "Дорога мамо/батьку, ваша дитина вчиться програмувати. Чи допоможите ви їй продовжити цю спрову?" # {change} + parents_blurb1: "Разом з CodeCombat Ваша дитина писатиме реальний код. Почне з простих команд та поступово буде розвиватись до складніших тем." # {change} parents_blurb1a: "Коп'ютерне програмування є необхідними вмінням, що ваша дитина беззаперечно використовуватиме у дорослому віці. До 2020 року 77% професій потребуватимуть базових навичок у програмному забезпечені, а програмісти надзвичайно потрібні у всьому світі. Чи знали ви, що Комп'ютерні Науки - це найбільш високооплачувана університетьська спеціальність?" parents_blurb2: "За 9.99$ на місяць, вона отримуватиме нові завдання щотижня та персональні листи підтримки від професійних програмістів." # {change} parents_blurb3: "Жодного ризику: 100% гарантія повернення грошей, легке скасування абонементу одним кліком." @@ -455,7 +455,7 @@ module.exports = nativeDescription: "Українська", englishDescription: sale_continue: "Готовий продовжити пригоди?" sale_limited_time: "Обмежена пропозиція!" sale_new_heroes: "Нові герої!" -# sale_title: "Back to School Sale" + sale_title: "Дошкільні знижки" sale_view_button: "Купити 1 рік підписки на" stripe_description: "Щомісячний абонемент" stripe_description_year_sale: "1 рік підписки (35% знижка)" @@ -473,7 +473,7 @@ module.exports = nativeDescription: "Українська", englishDescription: managed_subs_desc_2: "Одержувачі повинні мати обліковий запис CodeCombat, пов'язаний з вказаною Вами адресою електронної пошти." group_discounts: "Групові знижки" group_discounts_1: "Ми також пропонуємо знижки для пакетних передплат." - group_discounts_1st: "1-ий абонемент (включає Ваш)" # {change} + group_discounts_1st: "1-ий абонемент" group_discounts_full: "Повна ціна" group_discounts_2nd: "2-11 абонементи" group_discounts_20: "Знижка 20%" @@ -485,7 +485,7 @@ module.exports = nativeDescription: "Українська", englishDescription: users_subscribed: "Підписані користувачі:" no_users_subscribed: "Користувачі не підписані, будь ласка, перевірте Ваші ел. адреси." current_recipients: "Поточні отримувачі" - unsubscribing: "Скасування передплати..." # {change} + unsubscribing: "Триває скасування підписки..." subscribe_prepaid: "Натисніть Підписатися щоб використовувати передплачені коди" using_prepaid: "Використати передплачений код для щомісячної підписки" @@ -583,15 +583,15 @@ module.exports = nativeDescription: "Українська", englishDescription: press_paragraph_1_link: "набору-для-преси" press_paragraph_1_suffix: ". Всі логотипи та зображення можна використовувати, не зв'язуючись із нами напряму." team: "Команда" - george_title: "Виконавчий директор" # {change} + george_title: "Співзасновник" george_blurb: "Бізнесмен" - scott_title: "Програміст" # {change} + scott_title: "Співзасновник" scott_blurb: "Розумник" - nick_title: "Програміст" # {change} + nick_title: "Співзасновник" nick_blurb: "Ґуру мотивації" michael_title: "Програміст" michael_blurb: "Сисадмін" - matt_title: "Програміст" # {change} + matt_title: "Співзасновник" matt_blurb: "Велосипедист" cat_title: "Головний ремісник" cat_blurb: "Маг повітря" @@ -603,7 +603,7 @@ module.exports = nativeDescription: "Українська", englishDescription: retrostyle_blurb: "Ігри в стилі ретро" teachers: -# more_info: "More Info for Teachers" + more_info: "Додаткова інформація для вчителів" intro_1: "CodeCombat - це онлайн гра, що вчить програмуванню. Студенти пишуть код на реальних мовах програмування." intro_2: "Досвід не потрібен!" free_title: "Скільки це коштує?" @@ -617,12 +617,12 @@ module.exports = nativeDescription: "Українська", englishDescription: teacher_subs_3: "щоб налаштувати підписку." sub_includes_title: "Що входить у підписку?" sub_includes_1: "На додаток до 110+ основних рівнів, студенти з щомісячною підпискою отримають доступ до цих додаткових функцій:" - sub_includes_2: "70 + рівнів практики" # {change} + sub_includes_2: "80+ рівнів практики" sub_includes_3: "Відео уроки" sub_includes_4: "Преміум підтримка по електронній пошті" sub_includes_5: "10 нових героїв з унікальними навичками для оволодіння" - sub_includes_6: "3500 бонусних дорогоцінних каменів кожен місяць" - sub_includes_7: "Приватні Клани" + sub_includes_6: "3500 бонусних самоцвітів кожен місяць" + sub_includes_7: "Приватні клани" monitor_progress_title: "Як мені стежити за прогресом студентів?" monitor_progress_1: "Прогрес студентів може бути відстежити, створивши" monitor_progress_2: "для вашого класу." @@ -651,8 +651,8 @@ module.exports = nativeDescription: "Українська", englishDescription: more_info_2: "вчительський форум" more_info_3: "є гарним місцем для спілкування із колегами-педагогами, котрі використовують CodeCombat." sys_requirements_title: "Системні вимоги" - sys_requirements_1: "Оскільки CodeCombat — це гра, для нормальної роботи він вимагає у комп'ютерів більше, ніж відео чи текстові посібники. Ми оптимізували його для швидкої роботи в усіх сучасних браузерах і на старіших машинах, щоб кожен міг грати. І ось наші підказки, як отримати від CodeCombat якнайбільше:" # {change} - sys_requirements_2: "Використовуйте новіші версії Chrome або Firefox." # {change} + sys_requirements_1: "Сучасний веб-переглядач. Остання версія Chrome, Firefox або Safari. Internet Explorer 9 та вище." + sys_requirements_2: "CodeCombat наразі не підтримується на iPad." teachers_survey: title: "Анкета вчителя" @@ -727,8 +727,8 @@ module.exports = nativeDescription: "Українська", englishDescription: admin: "Aдмін" new_password: "Новий пароль" new_password_verify: "Підтвердження паролю" - type_in_email: "Введіть свій email, щоб підтвердити вилучення" # {change} - type_in_password: "Так само введіть ваш пароль." + type_in_email: "Введіть свій email, аби підтвердити вилучення екаунту." + type_in_password: "Також, введіть свій пароль." email_subscriptions: "Email-підписки" email_subscriptions_none: "Жодних підписок." email_announcements: "Оголошення" @@ -840,16 +840,16 @@ module.exports = nativeDescription: "Українська", englishDescription: last_played: "Остання гра" leagues_explanation: "Грайте в лізі проти інших членів клану на мультіплєєрній арені." # track_concepts1: "Track concepts" -# track_concepts2a: "learned by each student" -# track_concepts2b: "learned by each member" + track_concepts2a: "вивчено усіма студентами" + track_concepts2b: "вивчено усіма учасниками" # 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_concepts5: "рішення" # track_concepts6a: "Sort students by name or progress" # track_concepts6b: "Sort members by name or progress" -# track_concepts7: "Requires invitation" + track_concepts7: "Потребує запрошення" # track_concepts8: "to join" # private_require_sub: "Private clans require a subscription to create or join." @@ -1183,11 +1183,11 @@ module.exports = nativeDescription: "Українська", englishDescription: rules: "Правила" winners: "Переможці" league: "Ліга" -# red_ai: "Red AI" # "Red AI Wins", at end of multiplayer match playback -# blue_ai: "Blue AI" -# wins: "Wins" # At end of multiplayer match playback -# humans: "Red" # Ladder page display team name -# ogres: "Blue" + red_ai: "Червоний ШІ" # "Red AI Wins", at end of multiplayer match playback + blue_ai: "Синій ШІ" + wins: "переміг" # At end of multiplayer match playback + humans: "Червоний" # Ladder page display team name + ogres: "Синій" user: stats: "Статистика" @@ -1258,22 +1258,22 @@ module.exports = nativeDescription: "Українська", englishDescription: retrying: "Помилка сервера, повторна спроба." success: "Успішно оплачено. Дякуємо!" -# account_prepaid: + account_prepaid: # purchase_code: "Purchase a Subscription Code" # 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" -# purchase_total: "Total" + users: "Користувачі" + months: "Місяці" + purchase_total: "Загалом" # purchase_button: "Submit Purchase" # your_codes: "Your Codes" # redeem_codes: "Redeem a Subscription Code" # prepaid_code: "Prepaid Code" # lookup_code: "Lookup prepaid code" -# apply_account: "Apply to your account" + apply_account: "Застосувати до свого екаунту" # copy_link: "You can copy the code's link and send it to someone." -# quantity: "Quantity" + quantity: "Кількіть" # redeemed: "Redeemed" # no_codes: "No codes yet!" @@ -1350,12 +1350,12 @@ module.exports = nativeDescription: "Українська", englishDescription: arrays: "Масиви" basic_syntax: "Базовий синтаксис" boolean_logic: "Булева логіка" -# break_statements: "Break Statements" + break_statements: "Оператори зупинки" classes: "Класи" -# continue_statements: "Continue Statements" + continue_statements: "Оператори продовження" for_loops: "Цикл For" functions: "Функції" -# graphics: "Graphics" + graphics: "Графіка" if_statements: "Умовні оператори" input_handling: "Обробка введення" math_operations: "Математичні операції" @@ -1490,7 +1490,7 @@ module.exports = nativeDescription: "Українська", englishDescription: next_photo: "додайте необов’язкове професійне фото." next_active: "відзначте що Ви у пошуках пропозицій, щобвідображатися у пошуку." example_blog: "Блог" - example_personal_site: "Особиста Сторінка" + example_personal_site: "Персональний сайт" links_header: "Особисті Посилання" links_blurb: "Посилання на інші сторінки або профілі, які б ви хотіли вказати. Наприклад: аккаунт на GitHub'і, LinkedIn, або ваш блог. " links_name: "Назва посилання" diff --git a/app/models/Classroom.coffee b/app/models/Classroom.coffee new file mode 100644 index 000000000..c275a6e67 --- /dev/null +++ b/app/models/Classroom.coffee @@ -0,0 +1,7 @@ +CocoModel = require './CocoModel' +schema = require 'schemas/models/classroom.schema' + +module.exports = class Classroom extends CocoModel + @className: 'Classroom' + @schema: schema + urlRoot: '/db/classroom' diff --git a/app/models/Prepaid.coffee b/app/models/Prepaid.coffee index eea054326..eca632371 100644 --- a/app/models/Prepaid.coffee +++ b/app/models/Prepaid.coffee @@ -12,3 +12,9 @@ module.exports = class Prepaid extends CocoModel for redeemer in @get('redeemers') return redeemer.date if redeemer.userID is userID return null + + initialize: -> + @listenTo @, 'add', -> + maxRedeemers = @get('maxRedeemers') + if _.isString(maxRedeemers) + @set 'maxRedeemers', parseInt(maxRedeemers) diff --git a/app/schemas/models/classroom.schema.coffee b/app/schemas/models/classroom.schema.coffee new file mode 100644 index 000000000..49d655076 --- /dev/null +++ b/app/schemas/models/classroom.schema.coffee @@ -0,0 +1,14 @@ +c = require './../schemas' + +ClassroomSchema = c.object {title: 'Classroom', required: ['name']} +c.extendNamedProperties ClassroomSchema # name first + +_.extend ClassroomSchema.properties, + members: c.array {title: 'Members'}, c.objectId() + ownerID: c.objectId() + description: {type: 'string'} + code: c.shortString(title: "Unique code to redeem") + +c.extendBasicProperties ClassroomSchema, 'Classroom' + +module.exports = ClassroomSchema diff --git a/app/schemas/models/course.schema.coffee b/app/schemas/models/course.schema.coffee index 5a5e11524..a4c6d1249 100644 --- a/app/schemas/models/course.schema.coffee +++ b/app/schemas/models/course.schema.coffee @@ -8,7 +8,8 @@ _.extend CourseSchema.properties, concepts: c.array {title: 'Programming Concepts', uniqueItems: true}, c.concept description: {type: 'string'} duration: {type: 'number', description: 'Approximate hours of content'} - pricePerSeat: {type: 'number', description: 'Price per seat in USD cents.'} + pricePerSeat: {type: 'number', description: 'Price per seat in USD cents.'} # deprecated + free: { type: 'boolean' } screenshot: c.url {title: 'URL', description: 'Link to course screenshot.'} c.extendBasicProperties CourseSchema, 'Course' diff --git a/app/schemas/models/course_instance.schema.coffee b/app/schemas/models/course_instance.schema.coffee index ad37b8a31..5c65d559c 100644 --- a/app/schemas/models/course_instance.schema.coffee +++ b/app/schemas/models/course_instance.schema.coffee @@ -1,16 +1,23 @@ c = require './../schemas' -CourseInstanceSchema = c.object {title: 'Course Instance'} +CourseInstanceSchema = c.object { + title: 'Course Instance' +# required: [ +# 'courseID', 'classroomID', 'members', 'ownerID', 'aceConfig' +# ] +} _.extend CourseInstanceSchema.properties, courseID: c.objectId() - description: {type: 'string'} + classroomID: c.objectId() + description: {type: 'string'} # deprecated in favor of classrooms? members: c.array {title: 'Members'}, c.objectId() - name: {type: 'string'} + name: {type: 'string'} # deprecated in favor of classrooms? ownerID: c.objectId() - prepaidID: c.objectId() + prepaidID: c.objectId() # deprecated aceConfig: language: {type: 'string', 'enum': ['python', 'javascript']} + hourOfCode: { type: 'boolean' } c.extendBasicProperties CourseInstanceSchema, 'CourseInstance' diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee index 66a3bddb8..e2caf8b87 100644 --- a/app/schemas/models/level_session.coffee +++ b/app/schemas/models/level_session.coffee @@ -79,15 +79,15 @@ _.extend LevelSessionSchema.properties, currentScriptOffset: type: 'number' - selected: + selected: # Not tracked any more, delete with old level types type: [ 'null' 'string' ] playing: - type: 'boolean' # Not tracked any more + type: 'boolean' # Not tracked any more, delete with old level types frame: - type: 'number' # Not tracked any more + type: 'number' # Not tracked any more, delete with old level types thangs: # ... what is this? Is this used? type: 'object' additionalProperties: diff --git a/app/schemas/models/prepaid.schema.coffee b/app/schemas/models/prepaid.schema.coffee index e8729eb3a..51be47d1d 100644 --- a/app/schemas/models/prepaid.schema.coffee +++ b/app/schemas/models/prepaid.schema.coffee @@ -10,6 +10,7 @@ PrepaidSchema = c.object({title: 'Prepaid', required: ['creator', 'type']}, { code: c.shortString(title: "Unique code to redeem") type: { type: 'string' } properties: {type: 'object'} + exhausted: { type: 'boolean' } }) c.extendBasicProperties(PrepaidSchema, 'prepaid') diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 8955bafb6..52f93e179 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -320,6 +320,9 @@ _.extend UserSchema.properties, courseID: c.objectId({}) courseInstanceID: c.objectId({}) } + coursePrepaidID: c.objectId({ + description: 'Prepaid which has paid for this user\'s course access' + }) c.extendBasicProperties UserSchema, 'user' diff --git a/app/styles/admin/analytics.sass b/app/styles/admin/analytics.sass index 04f72ee5e..14df067d2 100644 --- a/app/styles/admin/analytics.sass +++ b/app/styles/admin/analytics.sass @@ -14,3 +14,15 @@ font-size: 70pt .description font-size: 8pt + + .line-chart-container + height: 500px + width: 100% + .x.axis + font-size: 9pt + path + display: none + .y.axis + font-size: 9pt + path + display: none diff --git a/app/styles/courses/courses-view.sass b/app/styles/courses/courses-view.sass new file mode 100644 index 000000000..efa5e1bdf --- /dev/null +++ b/app/styles/courses/courses-view.sass @@ -0,0 +1,3 @@ +#courses-view + .row + margin-top: 40px \ No newline at end of file diff --git a/app/styles/courses/courses.sass b/app/styles/courses/courses.sass deleted file mode 100644 index 390d0fcea..000000000 --- a/app/styles/courses/courses.sass +++ /dev/null @@ -1,85 +0,0 @@ -#courses-view - - .logged_out - font-size: 24px - - .signup-button - background: red - color: white - font-size: 18px - font-variant: small-caps - line-height: 27px - text-transform: uppercase - margin-right: 20px - - .login-button - background: white - color: black - font-size: 18px - font-variant: small-caps - line-height: 27px - text-transform: uppercase - - .center - text-align: center - - .code-input - width: 100% - - .course-image - width: 100% - - .course-panel - margin: 20px - - .faq-blurb - font-size: 14px - - .continue-dialog .modal-dialog - background-color: white - max-width: 400px - - .instruction-label - font-size: 14pt - .or - margin-bottom: 20px - font-size: 14pt - - .center - text-align: center - - .concepts-container - width: 200px - - .contact-container - margin-top: 20px - text-align: center - - .info-container - margin: 0% 10% - font-size: 18px - - .monitoring-img-container - margin-top: 10px - - .praise-caption - font-size: 14px - - .praise-quote - font-size: 20px - font-style: italic - - .progress-container - font-size: 20px - - .img-quote - height: 160px - - .popover - z-index: 1050 - min-width: 400px - - h3 - background: transparent - border: 0 - font-size: 30px diff --git a/app/styles/courses/hour-of-code-view.sass b/app/styles/courses/hour-of-code-view.sass new file mode 100644 index 000000000..0b93d70b2 --- /dev/null +++ b/app/styles/courses/hour-of-code-view.sass @@ -0,0 +1 @@ +//#hour-of-code-view diff --git a/app/styles/courses/student-courses-view.sass b/app/styles/courses/student-courses-view.sass new file mode 100644 index 000000000..b0045a501 --- /dev/null +++ b/app/styles/courses/student-courses-view.sass @@ -0,0 +1 @@ +//#student-courses-view \ No newline at end of file diff --git a/app/styles/courses/teacher-courses-view.sass b/app/styles/courses/teacher-courses-view.sass new file mode 100644 index 000000000..f0bcbabf9 --- /dev/null +++ b/app/styles/courses/teacher-courses-view.sass @@ -0,0 +1,21 @@ +#teacher-courses-view + margin-bottom: 50px + + img.media-object + width: 300px + + #fixed-area + position: fixed + bottom: 0 + left: 0 + right: 0 + + .well + margin-bottom: 0 + padding: 5px + + .col-sm-5 + padding-top: 8px + + .progress + margin-bottom: 0 \ No newline at end of file diff --git a/app/styles/play/level.sass b/app/styles/play/level.sass index 82ad555d4..ad2a515c5 100644 --- a/app/styles/play/level.sass +++ b/app/styles/play/level.sass @@ -72,7 +72,13 @@ $level-resize-transition-time: 0.5s width: 55% position: relative overflow: hidden - @include transition($level-resize-transition-time ease-out) + @include transition(all $level-resize-transition-time ease-out, z-index 1.2s linear) + z-index: 0 + + &.preview-overlay + z-index: 20 + #goals-view + visibility: hidden canvas#webgl-surface background-color: #333 diff --git a/app/styles/play/level/loading.sass b/app/styles/play/level/loading.sass index 10c62bee3..57e02fd0e 100644 --- a/app/styles/play/level/loading.sass +++ b/app/styles/play/level/loading.sass @@ -8,24 +8,48 @@ background-position: top $backgroundPosition background-size: contain +$UNVEIL_TIME: 1.2s + #level-loading-view width: 100% height: 100% position: absolute z-index: 20 - $UNVEIL_TIME: 1.2s &.unveiled pointer-events: none - - .loading-details + + &.preview-screen + background-color: rgba(0, 0, 0, 0.5) + + .left-wing, .right-wing + width: 100% + height: 100% + position: absolute + pointer-events: none + + .left-wing + @include wing-background('/images/level/loading_left_wing_1920.jpg', right) + @media screen and ( max-width: 1366px ) + @include wing-background('/images/level/loading_left_wing_1366.jpg', right) + left: -50% + @include transition(all $UNVEIL_TIME ease) + + .right-wing + @include wing-background('/images/level/loading_right_wing_1920.jpg', left) + @media screen and ( max-width: 1366px ) + @include wing-background('/images/level/loading_right_wing_1366.jpg', left) + right: -50% + @include transition(all $UNVEIL_TIME ease) + + #loading-details position: absolute top: 86px - left: 50% + right: 50% $WIDTH: 450px width: $WIDTH height: 450px - margin-left: (-$WIDTH / 2) + margin-right: (-$WIDTH / 2) z-index: 100 background: transparent url(/images/level/code_editor_background.png) no-repeat background-size: 100% 100% @@ -34,9 +58,22 @@ padding: 80px 80px 40px 80px text-align: center // http://matthewlein.com/ceaser/ Bounce down a bit, then snap up. - @include transition(top $UNVEIL_TIME cubic-bezier(0.285, -0.595, 0.670, -0.600)) + @include transition($UNVEIL_TIME cubic-bezier(0.285, -0.595, 0.670, -0.600)) font-family: 'Open Sans Condensed' + &.preview + top: 0 + right: 0 + margin-right: 0 + width: 45% + height: auto + pointer-events: all + @include transition($UNVEIL_TIME ease-in-out) + + padding: 80px 70px 40px 50px + .progress-or-start-container.intro-footer + bottom: 30px + .level-loading-goals text-align: left @@ -49,12 +86,22 @@ font-size: 20px color: black + .intro-doc + text-align: left + font-size: 16px + //overflow-y: scroll // bad scroll bars + overflow: hidden + + img + max-width: 100% + .progress-or-start-container position: absolute bottom: 95px - width: 325px height: 80px left: 48px + right: 77px + @include transition(bottom $UNVEIL_TIME ease-out) .load-progress width: 100% @@ -131,21 +178,7 @@ width: 401px color: #666 - .left-wing, .right-wing - width: 100% - height: 100% - position: absolute - - .left-wing - @include wing-background('/images/level/loading_left_wing_1920.jpg', right) - @media screen and ( max-width: 1366px ) - @include wing-background('/images/level/loading_left_wing_1366.jpg', right) - left: -50% - @include transition(all $UNVEIL_TIME ease) - - .right-wing - @include wing-background('/images/level/loading_right_wing_1920.jpg', left) - @media screen and ( max-width: 1366px ) - @include wing-background('/images/level/loading_right_wing_1366.jpg', left) - right: -50% - @include transition(all $UNVEIL_TIME ease) + &.preview #tip-wrapper + left: 48px + right: 77px + width: auto diff --git a/app/styles/play/menu/guide-view.sass b/app/styles/play/menu/guide-view.sass index 503097adf..25ce5b57b 100644 --- a/app/styles/play/menu/guide-view.sass +++ b/app/styles/play/menu/guide-view.sass @@ -52,3 +52,14 @@ border-image: url(/images/level/code_toolbar_submit_button_zazz_pressed.png) 14 20 20 20 fill round padding: 2px 0 0 2px color: white + +#guide-view + pre.ace_editor + padding: 2px 4px + border-radius: 4px + background-color: #f9f2f4 + font-size: 12px + font-family: Monaco, Menlo, Ubuntu Mono, Consolas, "source-code-pro", monospace !important + + .ace_cursor, .ace_bracket + display: none diff --git a/app/templates/about.jade b/app/templates/about.jade index 7a086a093..1f5a98f07 100644 --- a/app/templates/about.jade +++ b/app/templates/about.jade @@ -184,8 +184,8 @@ block content | Compiler Engineer p(data-i18n="about.rob_blurb") | Codes things and stuff. - - img(src="/images/pages/about/placeholder.png").img-thumbnail + + img(src="/images/pages/about/josh_c_small.png").img-thumbnail .team_bio h4.team_name | Josh Callebaut @@ -201,6 +201,6 @@ block content h4.team_name | Carlos Maia p(data-i18n="about.carlos_title") - | Region Manager + | Region Manager, Brazil p(data-i18n="about.carlos_blurb") - | CodeCombat Brazil + | Celery Man diff --git a/app/templates/admin/administer-user-modal.jade b/app/templates/admin/administer-user-modal.jade index 75a6991b2..733825c4e 100644 --- a/app/templates/admin/administer-user-modal.jade +++ b/app/templates/admin/administer-user-modal.jade @@ -31,7 +31,24 @@ block modal-body-content select.form-control#coupon-select for couponOption in coupons option(value=couponOption.id selected=coupon===couponOption.id)= couponOption.format - + 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 - button#save-changes.btn.btn-primary Save Changes + diff --git a/app/templates/admin/analytics.jade b/app/templates/admin/analytics.jade index 6c2fc8f4a..87de02c12 100644 --- a/app/templates/admin/analytics.jade +++ b/app/templates/admin/analytics.jade @@ -1,7 +1,9 @@ extends /templates/base block content - + + //- NOTE: do not localize / i18n + if me.isAdmin() .container-fluid .row @@ -18,6 +20,21 @@ block content div.description 30-day Active Users div.count= activeUsers[0].monthlyCount + h3 KPI 60 days + .kpi-recent-chart.line-chart-container + + h3 KPI 300 days + .kpi-chart.line-chart-container + + h3 Active Classes 90 days + .active-classes-chart.line-chart-container + + h3 Recurring Revenue 90 days + .recurring-revenue-chart.line-chart-container + + h3 Active Users 90 days + .active-users-chart.line-chart-container + h1 Active Classes table.table.table-striped.table-condensed tr diff --git a/app/templates/courses/course-details.jade b/app/templates/courses/course-details.jade index 0acd767cd..dcd5ea98d 100644 --- a/app/templates/courses/course-details.jade +++ b/app/templates/courses/course-details.jade @@ -32,11 +32,16 @@ block content h1(data-i18n="common.loading") Loading... else h1 - if courseInstance.get('name') - | #{courseInstance.get('name')} - else - span(data-i18n='courses.unnamed_class') - small.spl (#{course.get('name')}) + | #{course.get('name')} + small.spl + if courseInstance.get('name') + | (#{courseInstance.get('name')}) + else if view.classroom.get('name') + | (#{view.classroom.get('name')}) + else + | ( + span(data-i18n='courses.unnamed_class') + | ) if !view.owner.isNew() p @@ -48,18 +53,17 @@ block content if courseInstance.get('description') each line in courseInstance.get('description').split('\n') div= line - if adminMode && courseInstance - +settings-dialog - p - button.btn.btn-xs(data-toggle='modal', data-target='#settingsModal', data-i18n="courses.edit_settings") + // TODO: migrate these settings to classrooms + //if adminMode && courseInstance + // +settings-dialog + // p + // button.btn.btn-xs(data-toggle='modal', data-target='#settingsModal', data-i18n="courses.edit_settings") div.well.well-sm(role='tabpanel') ul.nav.nav-pills(role='tablist') if adminMode li.active(role='presentation') a(href='#progress', aria-controls='progress', role='tab', data-toggle='tab', data-i18n="courses.progress") - li(role='presentation') - a(href='#invite', aria-controls='invite', role='tab', data-toggle='tab', data-i18n="courses.add_students") li(role='presentation') a(href='#levels', aria-controls='levels', role='tab', data-toggle='tab', data-i18n="nav.play") else @@ -71,8 +75,6 @@ block content if adminMode .tab-pane.active#progress(role='tabpanel') +progress-tab - .tab-pane#invite(role='tabpanel') - +invite-tab .tab-pane#levels(role='tabpanel') +levels-tab else @@ -261,28 +263,6 @@ mixin progress-members-popup-started(i, level) if adminMode strong(data-i18n="clans.view_solution") -mixin invite-tab - p(data-i18n="courses.invite_students") - h3(data-i18n="courses.invite_link_header") - p(data-i18n="courses.invite_link_p_1") - .alert.alert-info - strong= document.location.origin + "/courses/students?_ppc=" + view.prepaid.get('code') - p(data-i18n="courses.invite_link_p_2") - .form - .form-group - textarea#invite-emails-textarea.form-control - .help-block(data-i18n="courses.enter_emails") - .form-group - button#invite-btn.btn.btn-success(data-i18n="courses.send_invites") - #invite-emails-sending-alert.alert.alert-info.hide(data-i18n="common.sending") - #invite-emails-success-alert.alert.alert-success.hide(data-i18n="play_level.done") - - if view.prepaid.loaded && pricePerSeat > 0 - h3 Class Capacity - p - span.spr(data-i18n="courses.capacity_used") - span #{view.prepaid.get('redeemers').length} / #{view.prepaid.get('maxRedeemers')}. - mixin levels-tab table.table.table-striped.table-condensed thead diff --git a/app/templates/courses/courses-view.jade b/app/templates/courses/courses-view.jade new file mode 100644 index 000000000..3de761b2f --- /dev/null +++ b/app/templates/courses/courses-view.jade @@ -0,0 +1,12 @@ +extends /templates/base + +block content + + h1.text-center Welcome to CodeCombat Courses + + .row + .col-sm-6.text-center + a(href="/courses/students").btn.btn-default Students Click Here + + .col-sm-6.text-center + a(href="/courses/teachers").btn.btn-default Teachers Click Here \ No newline at end of file diff --git a/app/templates/courses/courses.jade b/app/templates/courses/courses.jade deleted file mode 100644 index 0636debb8..000000000 --- a/app/templates/courses/courses.jade +++ /dev/null @@ -1,236 +0,0 @@ -extends /templates/base - -block content - - div(style='border-bottom: 1px solid black') - span *UNDER CONSTRUCTION, please send feedback to - a.spl(href='mailto:team@codecombat.com') team@codecombat.com - - br - - .hidden-md.hidden-lg - .alert.alert-danger Courses not supported on mobile devices. - - .hidden-xs.hidden-sm - if state === 'enrolling' - .alert.alert-info Enrolling in course.. - else if state === 'ppc_logged_out' - .alert.alert-danger.logged_out Create account or log in to join this course. - button.btn.btn-sm.btn-primary.header-font.signup-button(data-i18n="login.sign_up") - button.btn.btn-sm.btn-default.header-font.login-button(data-i18n="login.log_in") - else - if state === 'unknown_error' - .alert.alert-danger.alert-dismissible= stateMessage - - if hocLandingPage - +hoc-landing - else - if view.courseInstances.size() - +course-instance-list - - if studentMode - +student-main - else - if hocMode - +teacher-hoc - else - +teacher-main - .container-fluid - - var i = 0 - while i < courses.length - .row - +course-block(courses[i], instances) - - i++ - if i < courses.length - +course-block(courses[i], instances) - - i++ - - -mixin hoc-landing - h1.center Welcome to CodeCombat's Hour of Code! - br - .container-fluid - .row - .col-md-6.center - button.btn.btn-lg.btn-success.btn-student(data-i18n="courses.students_click") - .col-md-6.center - button.btn.btn-lg.btn-default.btn-teacher(data-i18n="courses.teachers_click") - -mixin course-instance-list - h1.center Courses You Are In - .row - .col-md-10.col-md-offset-1 - .list-group - for courseInstance in view.courseInstances.models - - var course = view.courses.get(courseInstance.get('courseID')); - .list-group-item - .list-group-item-heading - h3 - a(href="/courses/#{course.id}/#{courseInstance.id}") - span.spr #{courseInstance.get('name')} - small (#{course.get('name')}) - p= courseInstance.get('description') - -mixin student-main - button.btn.btn-warning.btn-teacher(data-i18n="courses.teachers_click") - h1.center(data-i18n="courses.courses_on_coco") - -mixin teacher-hoc - button.btn.btn-warning.btn-student(data-i18n="courses.students_click") - h1.center Welcome to CodeCombat's Hour of Code! - p - strong How to use CodeCombat with your students: - ol - li Click the green 'Get FREE course' button below - li Follow the enrollment instructions - li Add students via the 'Add Students' tab - p - span.spr If you have any problems, please email - a(href='mailto:team@codecombat.com') team@codecombat.com - br - -mixin teacher-main - button.btn.btn-warning.btn-student(data-i18n="courses.students_click") - h1.center(data-i18n="courses.courses_on_coco") - .info-container - p(data-i18n="courses.designed_to") - .container-fluid - .row - .col-md-6 - ul - li(data-i18n="courses.more_in_less") - li(data-i18n="courses.no_experience") - li(data-i18n="courses.easy_monitor") - - p(data-i18n="courses.purchase_for_class") - p.faq-blurb - span.spr(data-i18n="courses.see_the") - a.courses-faq(data-i18n="courses.faq") - span.spl(data-i18n="courses.more_info") - .col-md-6 - img.img-quote(src="/images/pages/courses/coco_complab.png") - p - .well.well-sm - div.praise-quote "#{praise.quote}" - div.praise-caption - #{praise.source} - - //- h1.center(data-i18n="courses.free_trial") - //- .info-container - //- p - //- span.spr(data-i18n="teachers.teacher_subs_1") - //- a(href='/teachers/freetrial', data-i18n="teachers.teacher_subs_2") - //- span.spl(data-i18n="courses.get_access") - - h2.center(data-i18n="courses.choose_course") - -mixin student-dialog(course) - .modal.continue-dialog(id="continueModal#{course.id}") - .modal-dialog - .modal-header - button.close(data-dismiss='modal') - span × - h3.modal-title= course.get('name') - .modal-body - .container-fluid - .row.button-row - .col-md-12 - .well.well-sm - p - div.instruction-label(data-i18n="courses.enter_code") - .container-fluid - .row.student-dialog-state-row - .col-md-12 - if view.state === 'enrolling-by-modal' - .progress.progress-striped.active - .progress-bar(style="width: 100%") - else if view.state === 'unknown_error' - .alert.alert-danger= view.stateMessage - .row - .col-md-8 - input.code-input(type='text', data-course-id="#{course.id}", data-i18n="[placeholder]courses.enter_code1", placeholder="Enter unlock code") - .col-md-4 - button.btn.btn-success.btn-enroll(data-course-id="#{course.id}", data-i18n="courses.enroll") - if hocMode && course.get('pricePerSeat') === 0 || me.isAdmin() - .row.button-row.center.row-pick-class - .col-md-12 - br - div.or(data-i18n="courses.or") - .row.button-row.center - .col-md-12 - button.btn.btn-success.btn-lg.btn-hoc-student-continue(data-course-id="#{course.id}") Continue by yourself - -mixin teacher-dialog(course) - .modal.continue-dialog(id="continueModal#{course.id}") - .modal-dialog - .modal-header - button.close(data-dismiss='modal') - span × - h3.modal-title= course.get('name') - .modal-body - .container-fluid - if enrolledCourses[course.id] - .row.button-row.row-pick-class - .col-md-12 - .well.well-sm - p - div.instruction-label(data-i18n="courses.pick_from_classes") - .container-fluid - .row - .col-md-8 - select.form-control.select-session(data-course-id="#{course.id}") - each inst in instances - if inst.get('courseID') == course.id - if inst.get('name') - option(value="#{inst.id}")= inst.get('name') - else - option(value="#{inst.id}", data-i18n="courses.unnamed") - .col-md-4 - button.btn.btn-success.btn-enter(data-course-id="#{course.id}", data-i18n="courses.enter") - .row.button-row.center.row-pick-class - .col-md-12 - div.or(data-i18n="courses.or") - .row.button-row.center - .col-md-12 - if course.get('pricePerSeat') === 0 || me.isAdmin() - button.btn.btn-success.btn-lg.btn-buy(data-course-id="#{course.id}") Start new class - else - button.btn.btn-success.btn-lg.btn-buy(data-course-id="#{course.id}", data-i18n="courses.buy_course1") - -mixin course-block(course) - if studentMode - +student-dialog(course) - else - +teacher-dialog(course) - .col-md-6 - .well.panel.course-panel(class=enrolledCourses[course.id] ? 'panel-success' : 'panel-info') - .panel-heading - .panel-title - span.spr #{course.get('name')} - strong #{enrolledCourses[course.id] ? '[ enrolled ]' : ''} - .panel-body - .container-fluid - .row - .col-md-12 - p - img.course-image(src="#{course.get('screenshot')}") - .row.button-row - .col-md-6 - strong(data-i18n="courses.topics") - ul - each concept in course.get('concepts') - li(data-i18n="concepts." + concept) - strong - span.spr(data-i18n="courses.hours_content") - span #{course.get('duration')} - .col-md-6.center(style='margin-top: 40px;') - if studentMode - if enrolledCourses[course.id] - a.btn.btn-lg.btn-success.btn-continue(href="/courses/#{course.id}?student=true", data-i18n="common.continue") - else - button.btn.btn-lg.btn-success.btn-continue(data-toggle='modal', data-target="#continueModal#{course.id}", data-i18n="courses.enter") Enter - else if enrolledCourses[course.id] - button.btn.btn-lg.btn-success.btn-continue(data-toggle='modal', data-target="#continueModal#{course.id}", data-i18n="common.continue") - else if course.get('pricePerSeat') === 0 || me.isAdmin() - button.btn.btn-lg.btn-success.btn-buy(data-course-id="#{course.id}", data-i18n='courses.get_free') - else - button.btn.btn-lg.btn-success.btn-buy(data-course-id="#{course.id}", data-i18n='courses.buy_course') diff --git a/app/templates/courses/hour-of-code-view.jade b/app/templates/courses/hour-of-code-view.jade new file mode 100644 index 000000000..956975119 --- /dev/null +++ b/app/templates/courses/hour-of-code-view.jade @@ -0,0 +1,11 @@ +extends /templates/base + +block content + h1.text-center Welcome to CodeCombat's Hour of Code! + br + .container-fluid + .row + .col-md-6.text-center + button#student-btn.btn.btn-lg.btn-success(data-i18n="courses.students_click") + .col-md-6.text-center + a.btn.btn-lg.btn-default(data-i18n="courses.teachers_click", href="/courses/teachers?hoc=true") \ No newline at end of file diff --git a/app/templates/courses/invite-to-classroom-modal.jade b/app/templates/courses/invite-to-classroom-modal.jade new file mode 100644 index 000000000..e98d34b85 --- /dev/null +++ b/app/templates/courses/invite-to-classroom-modal.jade @@ -0,0 +1,22 @@ +extends /templates/core/modal-base + +block modal-header-content + h2 Invite Students to Classroom + h3= view.classroom.get('name') + +block modal-body-content + p(data-i18n="courses.invite_students") + h3(data-i18n="courses.invite_link_header") + p(data-i18n="courses.invite_link_p_1") + .alert.alert-info + + strong= document.location.origin + "/courses/students?_cc=" + view.classroom.get('code') + p(data-i18n="courses.invite_link_p_2") + .form + .form-group + textarea#invite-emails-textarea.form-control + .help-block(data-i18n="courses.enter_emails") + .form-group + button#send-invites-btn.btn.btn-success(data-i18n="courses.send_invites") + #invite-emails-sending-alert.alert.alert-info.hide(data-i18n="common.sending") + #invite-emails-success-alert.alert.alert-success.hide(data-i18n="play_level.done") diff --git a/app/templates/courses/purchase-courses-view.jade b/app/templates/courses/purchase-courses-view.jade new file mode 100644 index 000000000..dc0193cbe --- /dev/null +++ b/app/templates/courses/purchase-courses-view.jade @@ -0,0 +1,41 @@ +extends /templates/base + +block content + + if view.state === 'purchasing' + p.text-center Purchasing... + .progress.progress-striped.active + .progress-bar(style="width: 100%") + + else if view.state === 'purchased' + p Thank you for your purchase! You can now assign (more) students to paid courses. + + p.text-center + a(href="/courses/teachers") Return to course management. + + else + h3.text-center Purchase Courses for Students + + if view.state === 'error' + .alert.alert-danger= view.stateMessage + + .form-horizontal + .form-group + label.col-sm-3.control-label Students + .col-sm-6 + input#students-input.form-control( + placeholder='' + value=view.numberOfStudents + type='number' + ) + .help-block Each student will have access to all courses. + + #price-form-group.form-group + label.col-sm-3.control-label Price + .col-sm-6 + .form-control-static + | #{view.getPriceString()} ($#{view.pricePerStudent.toFixed(2)} per student) + + .form-group + .col-sm-offset-3.col-sm-10 + button#purchase-btn.btn.btn-primary Purchase \ No newline at end of file diff --git a/app/templates/courses/student-courses-view.jade b/app/templates/courses/student-courses-view.jade new file mode 100644 index 000000000..55c471d33 --- /dev/null +++ b/app/templates/courses/student-courses-view.jade @@ -0,0 +1,70 @@ +extends /templates/base + +block content + + p To join a class, ask your teacher for an unlock code. + + #join-classroom-form.form-horizontal + .form-group + .col-sm-2 + button#join-class-btn.btn.btn-default.btn-block(disabled=view.state==='enrolling') Join Class + .col-sm-6 + input#classroom-code-input.form-control( + placeholder='', + value=view.classCode, + disabled=view.state==='enrolling') + + if view.state === 'enrolling' + .progress.progress-striped.active + .progress-bar(style="width: 100%") Joining class + + if view.state === 'unknown_error' + .alert.alert-danger= view.stateMessage + + - var justJoinedCourseInstance = view.courseInstances.find(function(ci) { return ci.justJoined; }); + if justJoinedCourseInstance + - var course = view.courses.get(justJoinedCourseInstance.get('courseID')); + - var classroom = view.classrooms.get(justJoinedCourseInstance.get('classroomID')); + if course && classroom + .alert.alert-info + span.spr Successfully joined "#{classroom.get('name')}"! + a(href="/courses/#{course.id}/#{justJoinedCourseInstance.id}") + strong Click here to start taking "#{course.get('name')}". + + .panel.panel-default + .panel-heading + .panel-title My Courses + + .list-group + .list-group-item + .row + .col-sm-3 + strong Classroom + .col-sm-3 + strong Course + for courseInstance in view.courseInstances.models + - var classroom = view.classrooms.get(courseInstance.get('classroomID')) + - var course = view.courses.get(courseInstance.get('courseID')) + if !(classroom && course) + - continue; + + .list-group-item + .row + .col-sm-3 + if classroom + | #{classroom.get('name')} + .col-sm-3 + if course + | #{course.get('name')} + .col-sm-6 + a.btn.btn-default.btn-sm(href="/courses/#{course.id}/#{courseInstance.id}") Enter + + .panel.panel-default + .panel-heading + .panel-title My Classes + .list-group + for classroom in view.classrooms.models + .list-group-item + .row + .col-sm-3= classroom.get('name') + .col-sm-9= classroom.get('description') \ No newline at end of file diff --git a/app/templates/courses/teacher-courses-view.jade b/app/templates/courses/teacher-courses-view.jade new file mode 100644 index 000000000..4724fb692 --- /dev/null +++ b/app/templates/courses/teacher-courses-view.jade @@ -0,0 +1,178 @@ +extends /templates/base + +block content + + if view.hoc + h1 Welcome to Hour of Code! + + p + | Thank you for choosing CodeCombat for your students. + span.spr.spl To get your kids started, simply send them to + a(href="/hoc") https://codecombat.com/hoc + span . + + p + | If you'd like to use our courses system to view their progress: + + ol + li Login/create your account if you have not already. + li Create a classroom on this page. + li Invite your students to the classroom. + + p + | You can invite your students even if they've already started playing CodeCombat. + + p + span.spr If you have any problems, please email + a(href="mailto:team@codecombat.com") team@codecombat.com + span . + + if !me.isAnonymous() + ul.nav.nav-tabs(role='tablist') + li.active(role='presentation') + a(href="#courses-tab-pane" aria-controls="courses" role="tab" data-toggle="tab") Courses + li(role='presentation') + a(href="#manage-tab-pane" aria-controls="manage" role="tab" data-toggle="tab") Manage + + .tab-content + #courses-tab-pane.tab-pane.well.active + h3 Your Courses + - var courseInstances = view.courseInstances.sliceWithMembers(); + + if me.isAnonymous() + .alert.alert-info + strong Please click "Create Account" or "Log In" above to view and manage your courses. + + else if !_.size(courseInstances) + .alert.alert-info + span.spr You currently have no students assigned to courses. + a#manage-tab-link Go to the manage tab to get set up. + + else + table.table + tr + th Class + th Course + th Size + th + for courseInstance in courseInstances + tr + td + - var classroom = view.classrooms.get(courseInstance.get('classroomID')); + if classroom + | #{classroom.get('name')} + td + - var course = view.courses.get(courseInstance.get('courseID')) + if course + | #{course.get('name')} + td= _.size(courseInstance.get('members')) + td + a.btn.btn-primary.btn-sm(href='/courses/#{courseInstance.get("courseID")}/#{courseInstance.id}') Enter + + h3 Available Courses + + for course in view.courses.models + .media + .pull-left + img.media-object(src=course.get('screenshot')) + .media-body + h3.media-heading + span.spr= course.get('name') + if course.get('free') + em (free!) + p= course.get('description') + p + strong.spr Concepts: + span= (course.get('concepts') || []).join(', ') + p + strong.spr Length: + span #{course.get('duration') || 0} hours + + + + #manage-tab-pane.tab-pane.well + + p Create a class and add students to it. + + - var totalRedeemers = view.prepaids.totalRedeemers(); + - var totalMaxRedeemers = view.prepaids.totalMaxRedeemers(); + + .text-right + span.spr Used paid seats: #{totalRedeemers}/#{totalMaxRedeemers} + a.btn.btn-default.btn-xs(href="/courses/purchase") Add + + for classroom in view.classrooms.models + h2= classroom.get('name') + + - var courseInstances = view.courseInstances.where({classroomID: classroom.id}) + + if classroom.saving || classroom.filling + .progress.progress-striped.active + .progress-bar(style="width: 100%") + + else + table.table + tr + th Student + for courseInstance in courseInstances + th + if courseInstance.course + | #{courseInstance.course.get('name')} + + if !_.size(classroom.get('members')) + tr + td(colspan=1+view.courses.size()) + em No students in this class yet. + + for member in classroom.get('members') || [] + - var user = view.members.get(member); + if !user + - continue; + tr + td= user.get('name') + for courseInstance in courseInstances + td + if _.contains(courseInstance.get('members'), user.id) + span.glyphicon.glyphicon-ok + else + input.course-instance-membership-checkbox( + type='checkbox' + data-course-instance-id=courseInstance.id + data-user-id=user.id + ) + + button.add-students-btn.btn.btn-sm(data-classroom-id=classroom.id) Add Students + + hr + + .row + .col-sm-3.col-sm-offset-3 + button#create-new-class-btn.btn.btn-default.btn-block Create New Class + .col-sm-3 + input#new-classroom-name-input.form-control(placeholder='new class name') + + #fixed-area + .container + .row.well + if view.state === 'saving-changes' + p Saving changes + - var total = view.membershipAdditions.originalSize + view.usersToRedeem.originalSize; + - var left = view.membershipAdditions.size() + view.usersToRedeem.size(); + - var pct = Math.max(10, (100 * (total - left) / total)).toFixed(1) + '%'; + .progress.progress-striped.active + .progress-bar(style="width: #{pct}") + else + - var seatsLeft = totalMaxRedeemers - totalRedeemers - view.usersToRedeem.size(); + if seatsLeft < 0 + .alert.alert-danger + span.spr You do not have enough seats to accommodate all students you have selected. + a(href="/courses/purchase") Buy more seats. + else + .col-sm-2 + button#save-changes-btn.btn.btn-primary.btn-block(disabled=!view.numCourseInstancesToAddTo) Save Changes + .col-sm-5 + | Students to add to courses: #{view.numCourseInstancesToAddTo || 0} + .col-sm-5 + | Seats to expend: #{view.usersToRedeem.size()} (will have #{seatsLeft} seats left) + +block footer \ No newline at end of file diff --git a/app/templates/play/level/level_loading.jade b/app/templates/play/level/level_loading.jade index 693cfc297..71ef46fd3 100644 --- a/app/templates/play/level/level_loading.jade +++ b/app/templates/play/level/level_loading.jade @@ -2,7 +2,7 @@ .right-wing -.loading-details.loading-container +#loading-details.loading-container .level-loading-goals.secret .goals-title(data-i18n="play_level.goals") Goals @@ -10,6 +10,8 @@ .errors + .intro-doc + .progress-or-start-container button.start-level-button.btn.btn-lg.btn-success.btn-illustrated.header-font.needsclick(data-i18n="play_level.loading_start") Start Level diff --git a/app/views/admin/AdministerUserModal.coffee b/app/views/admin/AdministerUserModal.coffee index 171fae00e..af7bfc127 100644 --- a/app/views/admin/AdministerUserModal.coffee +++ b/app/views/admin/AdministerUserModal.coffee @@ -1,6 +1,7 @@ ModalView = require 'views/core/ModalView' template = require 'templates/admin/administer-user-modal' User = require 'models/User' +Prepaid = require 'models/Prepaid' module.exports = class AdministerUserModal extends ModalView id: "administer-user-modal" @@ -9,6 +10,7 @@ module.exports = class AdministerUserModal extends ModalView events: 'click #save-changes': 'onSaveChanges' + 'click #add-seats-btn': 'onClickAddSeatsButton' constructor: (options, @userHandle) -> super(options) @@ -58,3 +60,18 @@ module.exports = class AdministerUserModal extends ModalView options = {} options.success = => @hide() @user.patch(options) + + onClickAddSeatsButton: -> + maxRedeemers = parseInt(@$('#seats-input').val()) + return unless maxRedeemers and maxRedeemers > 0 + prepaid = new Prepaid({ + maxRedeemers: maxRedeemers + type: 'course' + creator: @user.id + }) + prepaid.save() + @state = 'creating-prepaid' + @renderSelectors('#prepaid-form') + @listenTo prepaid, 'sync', -> + @state = 'made-prepaid' + @renderSelectors('#prepaid-form') diff --git a/app/views/admin/AnalyticsView.coffee b/app/views/admin/AnalyticsView.coffee index 390c39673..17ea76eb0 100644 --- a/app/views/admin/AnalyticsView.coffee +++ b/app/views/admin/AnalyticsView.coffee @@ -1,3 +1,5 @@ +require 'vendor/d3' +d3Utils = require 'core/d3_utils' RootView = require 'views/core/RootView' template = require 'templates/admin/analytics' utils = require 'core/utils' @@ -5,86 +7,11 @@ utils = require 'core/utils' module.exports = class AnalyticsView extends RootView id: 'admin-analytics-view' template: template + lineColors: ['red', 'blue', 'green', 'purple', 'goldenrod', 'brown', 'darkcyan'] constructor: (options) -> super options - - @supermodel.addRequestResource('active_classes', { - url: '/db/analytics_perday/-/active_classes' - method: 'POST' - success: (data) => - @activeClassGroups = {} - dayEventsMap = {} - for activeClass in data - dayEventsMap[activeClass.day] ?= {} - dayEventsMap[activeClass.day]['Total'] = 0 - for event, val of activeClass.classes - @activeClassGroups[event] = true - dayEventsMap[activeClass.day][event] = val - dayEventsMap[activeClass.day]['Total'] += val - @activeClassGroups = Object.keys(@activeClassGroups) - @activeClassGroups.push 'Total' - for day of dayEventsMap - for event in @activeClassGroups - dayEventsMap[day][event] ?= 0 - @activeClasses = [] - for day of dayEventsMap - data = day: day, groups: [] - for group in @activeClassGroups - data.groups.push(dayEventsMap[day][group] ? 0) - @activeClasses.push data - @activeClasses.sort (a, b) -> b.day.localeCompare(a.day) - @render?() - }, 0).load() - - @supermodel.addRequestResource('active_users', { - url: '/db/analytics_perday/-/active_users' - method: 'POST' - success: (data) => - @activeUsers = data - @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) - @render?() - }, 0).load() - - @supermodel.addRequestResource('recurring_revenue', { - url: '/db/analytics_perday/-/recurring_revenue' - method: 'POST' - success: (data) => - @revenueGroups = {} - dayGroupCountMap = {} - for dailyRevenue in data - dayGroupCountMap[dailyRevenue.day] ?= {} - dayGroupCountMap[dailyRevenue.day]['Daily'] = 0 - for group, val of dailyRevenue.groups - @revenueGroups[group] = true - dayGroupCountMap[dailyRevenue.day][group] = val - dayGroupCountMap[dailyRevenue.day]['Daily'] += val - @revenueGroups = Object.keys(@revenueGroups) - @revenueGroups.push 'Daily' - @revenueGroups.push 'Monthly' - for day of dayGroupCountMap - for group in @revenueGroups - dayGroupCountMap[day][group] ?= 0 - @revenue = [] - for day of dayGroupCountMap - data = day: day, groups: [] - for group in @revenueGroups - data.groups.push(dayGroupCountMap[day][group] ? 0) - @revenue.push data - @revenue.sort (a, b) -> b.day.localeCompare(a.day) - monthlyValues = [] - - return unless @revenue.length > 0 - - for i in [@revenue.length-1..0] - dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 2] - monthlyValues.push(dailyTotal) - monthlyValues.shift() if monthlyValues.length > 30 - if monthlyValues.length is 30 - monthlyIndex = @revenue[i].groups.length - 1 - @revenue[i].groups[monthlyIndex] = _.reduce(monthlyValues, (s, num) -> s + num) - @render?() - }, 0).load() + @loadData() getRenderData: -> context = super() @@ -94,3 +21,308 @@ module.exports = class AnalyticsView extends RootView context.revenue = @revenue ? [] context.revenueGroups = @revenueGroups ? {} context + + afterRender: -> + super() + @createLineCharts() + + loadData: -> + @supermodel.addRequestResource('active_classes', { + url: '/db/analytics_perday/-/active_classes' + method: 'POST' + success: (data) => + # Organize data by day, then group + groupMap = {} + dayGroupMap = {} + for activeClass in data + dayGroupMap[activeClass.day] ?= {} + dayGroupMap[activeClass.day]['Total'] = 0 + for group, val of activeClass.classes + groupMap[group] = true + dayGroupMap[activeClass.day][group] = val + dayGroupMap[activeClass.day]['Total'] += val + @activeClassGroups = Object.keys(groupMap) + @activeClassGroups.push 'Total' + # Build list of active classes, where each entry is a day of individual group values + @activeClasses = [] + for day of dayGroupMap + dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}" + data = day: dashedDay, groups: [] + for group in @activeClassGroups + data.groups.push(dayGroupMap[day][group] ? 0) + @activeClasses.push data + @activeClasses.sort (a, b) -> b.day.localeCompare(a.day) + + @updateAllKPIChartData() + @updateActiveClassesChartData() + @render?() + }, 0).load() + + @supermodel.addRequestResource('active_users', { + url: '/db/analytics_perday/-/active_users' + method: 'POST' + success: (data) => + @activeUsers = data.map (a) -> + a.day = "#{a.day.substring(0, 4)}-#{a.day.substring(4, 6)}-#{a.day.substring(6, 8)}" + a + @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) + + @updateAllKPIChartData() + @updateActiveUsersChartData() + @render?() + }, 0).load() + + @supermodel.addRequestResource('recurring_revenue', { + url: '/db/analytics_perday/-/recurring_revenue' + method: 'POST' + success: (data) => + # Organize data by day, then group + groupMap = {} + dayGroupCountMap = {} + for dailyRevenue in data + dayGroupCountMap[dailyRevenue.day] ?= {} + dayGroupCountMap[dailyRevenue.day]['Daily'] = 0 + for group, val of dailyRevenue.groups + groupMap[group] = true + dayGroupCountMap[dailyRevenue.day][group] = val + dayGroupCountMap[dailyRevenue.day]['Daily'] += val + @revenueGroups = Object.keys(groupMap) + @revenueGroups.push 'Daily' + # Build list of recurring revenue entries, where each entry is a day of individual group values + @revenue = [] + for day of dayGroupCountMap + dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}" + data = day: dashedDay, groups: [] + for group in @revenueGroups + data.groups.push(dayGroupCountMap[day][group] ? 0) + @revenue.push data + @revenue.sort (a, b) -> b.day.localeCompare(a.day) + + return unless @revenue.length > 0 + + # Add monthly recurring revenue values + @revenueGroups.push 'Monthly' + monthlyValues = [] + for i in [@revenue.length-1..0] + dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 1] + monthlyValues.push(dailyTotal) + monthlyValues.shift() while monthlyValues.length > 30 + if monthlyValues.length is 30 + @revenue[i].groups.push(_.reduce(monthlyValues, (s, num) -> s + num)) + + @updateAllKPIChartData() + @updateRevenueChartData() + @render?() + }, 0).load() + + createLineChartPoints: (days, data) -> + points = [] + for entry, i in data + points.push + day: entry.day + y: entry.value + + # Trim points preceding days + for point, i in points + if point.day is days[0] + points.splice(0, i) + break + + # Ensure points for each day + for day, i in days + if points.length <= i or points[i].day isnt day + prevY = if i > 0 then points[i - 1].y else 0.0 + points.splice i, 0, + y: prevY + day: day + points[i].x = i + + points.splice(0, points.length - days.length) if points.length > days.length + points + + createLineCharts: -> + d3Utils.createLineChart('.kpi-recent-chart', @kpiRecentChartLines) + d3Utils.createLineChart('.kpi-chart', @kpiChartLines) + d3Utils.createLineChart('.active-classes-chart', @activeClassesChartLines) + d3Utils.createLineChart('.active-users-chart', @activeUsersChartLines) + d3Utils.createLineChart('.recurring-revenue-chart', @revenueChartLines) + + updateAllKPIChartData: -> + @kpiRecentChartLines = [] + @kpiChartLines = [] + @updateKPIChartData(60, @kpiRecentChartLines) + @updateKPIChartData(300, @kpiChartLines) + + updateKPIChartData: (timeframeDays, chartLines) -> + days = d3Utils.createContiguousDays(timeframeDays) + + if @activeClasses?.length > 0 + data = [] + for entry in @activeClasses + data.push + day: entry.day + value: entry.groups[entry.groups.length - 1] + data.reverse() + points = @createLineChartPoints(days, data) + chartLines.push + points: points + description: '30-day Active Classes' + lineColor: 'blue' + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: true + + if @revenue?.length > 0 + data = [] + for entry in @revenue + data.push + day: entry.day + value: entry.groups[entry.groups.length - 1] / 100000 + data.reverse() + points = @createLineChartPoints(days, data) + chartLines.push + points: points + description: '30-day Recurring Revenue (in thousands)' + lineColor: 'green' + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: true + + if @activeUsers?.length > 0 + data = [] + for entry in @activeUsers + break unless entry.monthlyCount + data.push + day: entry.day + value: entry.monthlyCount / 1000 + data.reverse() + points = @createLineChartPoints(days, data) + chartLines.push + points: points + description: '30-day Active Users (in thousands)' + lineColor: 'red' + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: true + + updateActiveClassesChartData: -> + @activeClassesChartLines = [] + return unless @activeClasses?.length + days = d3Utils.createContiguousDays(90) + + groupDayMap = {} + for entry in @activeClasses + for count, i in entry.groups + groupDayMap[@activeClassGroups[i]] ?= {} + groupDayMap[@activeClassGroups[i]][entry.day] ?= 0 + groupDayMap[@activeClassGroups[i]][entry.day] += count + + lines = [] + colorIndex = 0 + totalMax = 0 + for group, entries of groupDayMap + data = [] + for day, count of entries + data.push + day: day + value: count + data.reverse() + points = @createLineChartPoints(days, data) + @activeClassesChartLines.push + points: points + description: group.replace('Active classes ', '') + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + showYScale: group is 'Total' + totalMax = _.max(points, 'y').y if group is 'Total' + line.max = totalMax for line in @activeClassesChartLines + + updateActiveUsersChartData: -> + @activeUsersChartLines = [] + return unless @activeUsers?.length + days = d3Utils.createContiguousDays(90) + + dailyData = [] + monthlyData = [] + dausmausData = [] + colorIndex = 0 + for entry in @activeUsers + dailyData.push + day: entry.day + value: entry.dailyCount / 1000 + if entry.monthlyCount + monthlyData.push + day: entry.day + value: entry.monthlyCount / 1000 + dausmausData.push + day: entry.day + value: Math.round(entry.dailyCount / entry.monthlyCount * 100) + dailyData.reverse() + monthlyData.reverse() + dausmausData.reverse() + dailyPoints = @createLineChartPoints(days, dailyData) + monthlyPoints = @createLineChartPoints(days, monthlyData) + dausmausPoints = @createLineChartPoints(days, dausmausData) + @activeUsersChartLines.push + points: dailyPoints + description: 'Daily active users (in thousands)' + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(dailyPoints, 'y').y + showYScale: true + @activeUsersChartLines.push + points: monthlyPoints + description: 'Monthly active users (in thousands)' + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(monthlyPoints, 'y').y + showYScale: true + @activeUsersChartLines.push + points: dausmausPoints + description: 'DAUs/MAUs %' + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(dausmausPoints, 'y').y + showYScale: true + + updateRevenueChartData: -> + @revenueChartLines = [] + return unless @revenue?.length + days = d3Utils.createContiguousDays(90) + + groupDayMap = {} + for entry in @revenue + for count, i in entry.groups + groupDayMap[@revenueGroups[i]] ?= {} + groupDayMap[@revenueGroups[i]][entry.day] ?= 0 + groupDayMap[@revenueGroups[i]][entry.day] += count + + lines = [] + colorIndex = 0 + dailyMax = 0 + for group, entries of groupDayMap + data = [] + for day, count of entries + data.push + day: day + value: count / 100 + data.reverse() + points = @createLineChartPoints(days, data) + @revenueChartLines.push + points: points + description: group.replace('DRR ', '') + lineColor: @lineColors[colorIndex++ % @lineColors.length] + strokeWidth: 1 + min: 0 + max: _.max(points, 'y').y + showYScale: group in ['Daily', 'Monthly'] + dailyMax = _.max(points, 'y').y if group is 'Daily' + for line in @revenueChartLines when line.description isnt 'Monthly' + line.max = dailyMax diff --git a/app/views/contribute/ArchmageView.coffee b/app/views/contribute/ArchmageView.coffee index 7a428f5f5..93cdc9401 100644 --- a/app/views/contribute/ArchmageView.coffee +++ b/app/views/contribute/ArchmageView.coffee @@ -7,6 +7,7 @@ module.exports = class ArchmageView extends ContributeClassView contributorClassName: 'archmage' contributors: [ + {id: '547acbb2af18b03c0563fdb3', name: 'David Liu', github: 'trotod'} {id: '52ccfc9bd3eb6b5a4100b60d', name: 'Glen De Cauwsemaecker', github: 'GlenDC'} {id: '52bfc3ecb7ec628868001297', name: 'Tom Steinbrecher', github: 'TomSteinbrecher'} {id: '5272806093680c5817033f73', name: 'Sébastien Moratinos', github: 'smoratinos'} @@ -27,8 +28,8 @@ module.exports = class ArchmageView extends ContributeClassView {id: '531258b5e0789d4609614110', name: 'Ruben Vereecken', github: 'rubenvereecken'} {id: '5276ad5dcf83207a2801d3b4', name: 'Zach Martin', github: 'zachster01'} {id: '530df0cbc06854403ba67c15', name: 'Alexandru Caciulescu', github: 'Darredevil'} - {id: '5268d9baa39d7db617000b18', name: 'Thanish Muhammed', github: 'mnmtanish'} - {id: '53232f458e54704b074b271d', name: 'Bang Honam', github: 'walkingtospace'} + {id: '5268d9baa39d7db617000b18', name: 'Thanish Muhammed', github: 'mnmtanish'} + {id: '53232f458e54704b074b271d', name: 'Bang Honam', github: 'walkingtospace'} {id: '52d16c1dc931e2544d001daa', name: 'David Pendray', github: 'dpen2000'} {id: '53132ea1828a1706108ebb38', name: 'Dominik Kundel'} {id: '530eb29347a891b3518b3990', name: 'Ian Li'} diff --git a/app/views/core/CocoView.coffee b/app/views/core/CocoView.coffee index 6d9021c6e..ea7d8c602 100644 --- a/app/views/core/CocoView.coffee +++ b/app/views/core/CocoView.coffee @@ -132,6 +132,7 @@ module.exports = class CocoView extends Backbone.View context.translate = $.i18n.t context.view = @ context._ = _ + context.document = document context afterRender: -> diff --git a/app/views/courses/CourseDetailsView.coffee b/app/views/courses/CourseDetailsView.coffee index 7f1ccda63..1159bba5f 100644 --- a/app/views/courses/CourseDetailsView.coffee +++ b/app/views/courses/CourseDetailsView.coffee @@ -2,6 +2,7 @@ Campaign = require 'models/Campaign' CocoCollection = require 'collections/CocoCollection' Course = require 'models/Course' CourseInstance = require 'models/CourseInstance' +Classroom = require 'models/Classroom' LevelSession = require 'models/LevelSession' RootView = require 'views/core/RootView' template = require 'templates/courses/course-details' @@ -25,12 +26,12 @@ module.exports = class CourseDetailsView extends RootView 'click .progress-level-cell': 'onClickProgressLevelCell' 'mouseenter .progress-level-cell': 'onMouseEnterPoint' 'mouseleave .progress-level-cell': 'onMouseLeavePoint' - 'click #invite-btn': 'onClickInviteButton' constructor: (options, @courseID, @courseInstanceID) -> super options @courseID ?= options.courseID @courseInstanceID ?= options.courseInstanceID + @classroom = new Classroom() @adminMode = me.isAdmin() @memberSort = 'nameAsc' @course = @supermodel.getModel(Course, @courseID) or new Course _id: @courseID @@ -119,6 +120,9 @@ module.exports = class CourseDetailsView extends RootView onCourseInstanceSync: -> # console.log 'onCourseInstanceSync' + if @courseInstance.get('classroomID') + @classroom = new Classroom({_id: @courseInstance.get('classroomID')}) + @supermodel.loadModel @classroom, 'classroom' @adminMode = true if @courseInstance.get('ownerID') is me.id and @courseInstance.get('name') isnt 'Single Player' @levelSessions = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/level_sessions", model: LevelSession, comparator:'_id' }) @listenToOnce @levelSessions, 'sync', @onLevelSessionsSync @@ -261,26 +265,6 @@ module.exports = class CourseDetailsView extends RootView viewArgs: [{}, levelSlug] } - onClickInviteButton: (e) -> - emails = @$('#invite-emails-textarea').val() - emails = emails.split('\n') - emails = _.filter((_.string.trim(email) for email in emails)) - if not emails.length - return - url = @courseInstance.url() + '/invite_students' - @$('#invite-btn, #invite-emails-textarea').addClass('hide') - @$('#invite-emails-sending-alert').removeClass('hide') - - $.ajax({ - url: url - data: {emails: emails} - method: 'POST' - context: @ - success: -> - @$('#invite-emails-sending-alert').addClass('hide') - @$('#invite-emails-success-alert').removeClass('hide') - }) - onMouseEnterPoint: (e) -> $('.progress-popup-container').hide() container = $(e.target).find('.progress-popup-container').show() diff --git a/app/views/courses/CoursesView.coffee b/app/views/courses/CoursesView.coffee index 7d2701eb8..0a93576e1 100644 --- a/app/views/courses/CoursesView.coffee +++ b/app/views/courses/CoursesView.coffee @@ -1,238 +1,8 @@ app = require 'core/application' AuthModal = require 'views/core/AuthModal' -CocoCollection = require 'collections/CocoCollection' -Course = require 'models/Course' -CourseInstance = require 'models/CourseInstance' RootView = require 'views/core/RootView' -template = require 'templates/courses/courses' -utils = require 'core/utils' - -# TODO: Hour of Code (HoC) integration is a mess +template = require 'templates/courses/courses-view' module.exports = class CoursesView extends RootView id: 'courses-view' template: template - - events: - 'click .btn-buy': 'onClickBuy' - 'click .btn-enroll': 'onClickEnroll' - 'click .btn-enter': 'onClickEnter' - 'click .btn-hoc-student-continue': 'onClickHOCStudentContinue' - 'click .btn-student': 'onClickStudent' - 'click .btn-teacher': 'onClickTeacher' - - constructor: (options) -> - super(options) - @setUpHourOfCode() - @praise = utils.getCoursePraise() - @studentMode = Backbone.history.getFragment()?.indexOf('courses/students') >= 0 - @courses = new CocoCollection([], { url: "/db/course", model: Course}) - @supermodel.loadCollection(@courses, 'courses') - @courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance}) - @listenToOnce @courseInstances, 'sync', @onCourseInstancesLoaded - @supermodel.loadCollection(@courseInstances, 'course_instances') - if prepaidCode = utils.getQueryVariable('_ppc', false) - if me.isAnonymous() - @state = 'ppc_logged_out' - else - @studentMode = true - @courseEnrollByURL(prepaidCode) - - setUpHourOfCode: -> - # If we are coming in at /hoc, then we show the landing page. - # If we have ?hoc=true (for the step after the landing page), then we show any HoC-specific instructions. - # If we haven't tracked this player as an hourOfCode player yet, and it's a new account, we do that now. - @hocLandingPage = Backbone.history.getFragment()?.indexOf('hoc') >= 0 - @hocMode = utils.getQueryVariable('hoc', false) - elapsed = new Date() - new Date(me.get('dateCreated')) - if not me.get('hourOfCode') and (@hocLandingPage or @hocMode) and elapsed < 5 * 60 * 1000 - me.set('hourOfCode', true) - me.patch() - $('body').append($('')) - application.tracker?.trackEvent 'Hour of Code Begin' - if me.get('hourOfCode') and elapsed < 24 * 60 * 60 * 1000 - @hocMode = true # If they really just arrived, make sure we're still in hocMode even if they lost ?hoc=true. - - getRenderData: -> - context = super() - context.courses = @courses.models ? [] - context.enrolledCourses = @enrolledCourses ? {} - context.hocLandingPage = @hocLandingPage - context.hocMode = @hocMode - context.instances = @courseInstances.models ? [] - context.praise = @praise - context.state = @state - context.stateMessage = @stateMessage - context.studentMode = @studentMode - context - - afterRender: -> - super() - @setupCoursesFAQPopover() - - onCourseInstancesLoaded: -> - @enrolledCourses = {} - @enrolledCourses[courseInstance.get('courseID')] = true for courseInstance in @courseInstances.models - - setupCoursesFAQPopover: -> - popoverTitle = "

" + $.i18n.t('courses.faq') + "

" - popoverContent = "

" + $.i18n.t('courses.question') + " " + $.i18n.t('courses.question1') + "

" - popoverContent += "

" + $.i18n.t('courses.answer') + " " + $.i18n.t('courses.answer1') + "

" - popoverContent += "

" + $.i18n.t('courses.answer2') + "

" - @$el.find('.courses-faq').popover( - animation: true - html: true - placement: 'top' - trigger: 'click' - title: popoverTitle - content: popoverContent - container: @$el - ).on 'shown.bs.popover', => - application.tracker?.trackEvent 'Subscription payment methods hover' - - onClickBuy: (e) -> - $('.continue-dialog').modal('hide') - courseID = $(e.target).data('course-id') - route = "/courses/enroll/#{courseID}" - viewClass = require 'views/courses/CourseEnrollView' - viewArgs = [{}, courseID] - navigationEvent = route: route, viewClass: viewClass, viewArgs: viewArgs - Backbone.Mediator.publish 'router:navigate', navigationEvent - - onClickEnroll: (e) -> - return @openModalView new AuthModal() if me.isAnonymous() - courseID = $(e.target).data('course-id') - prepaidCode = ($(".code-input[data-course-id=#{courseID}]").val() ? '').trim() - @courseEnrollByModal(prepaidCode) - - onClickEnter: (e) -> - $('.continue-dialog').modal('hide') - courseID = $(e.target).data('course-id') - courseInstanceID = $(".select-session[data-course-id=#{courseID}]").val() - route = "/courses/#{courseID}/#{courseInstanceID}" - viewClass = require 'views/courses/CourseDetailsView' - viewArgs = [{}, courseID, courseInstanceID] - navigationEvent = route: route, viewClass: viewClass, viewArgs: viewArgs - Backbone.Mediator.publish 'router:navigate', navigationEvent - - onClickHOCStudentContinue: (e) -> - $('.continue-dialog').modal('hide') - if e - courseID = $(e.target).data('course-id') - else - courseID = '560f1a9f22961295f9427742' - - @state = 'enrolling' - @stateMessage = undefined - @render?() - - # TODO: Copied from CourseEnrollView - - data = - name: 'Single Player' - seats: 9999 - courseID: courseID - hourOfCode: true - jqxhr = $.post('/db/course_instance/-/create', data) - jqxhr.done (data, textStatus, jqXHR) => - application.tracker?.trackEvent 'Finished HoC student course creation', {courseID: courseID} - # TODO: handle fetch errors - me.fetch(cache: false).always => - courseID = courseID - route = "/courses/#{courseID}" - viewArgs = [{}, courseID] - if data?.length > 0 - courseInstanceID = data[0]._id - route += "/#{courseInstanceID}" - viewArgs[0].courseInstanceID = courseInstanceID - Backbone.Mediator.publish 'router:navigate', - route: route - viewClass: 'views/courses/CourseDetailsView' - viewArgs: viewArgs - jqxhr.fail (xhr, textStatus, errorThrown) => - console.error 'Got an error purchasing a course:', textStatus, errorThrown - application.tracker?.trackEvent 'Failed HoC student course creation', status: textStatus - if xhr.status is 402 - @state = 'declined' - @stateMessage = arguments[2] - else - @state = 'unknown_error' - @stateMessage = "#{xhr.status}: #{xhr.responseText}" - @render?() - - onClickStudent: (e) -> - if @supermodel.finished() and @hocLandingPage - # Automatically enroll in first course - @onClickHOCStudentContinue() - return - route = "/courses/students" - route += "?hoc=true" if @hocLandingPage or @hocMode - viewClass = require 'views/courses/CoursesView' - navigationEvent = route: route, viewClass: viewClass, viewArgs: [] - Backbone.Mediator.publish 'router:navigate', navigationEvent - - onClickTeacher: (e) -> - route = "/courses/teachers" - route += "?hoc=true" if @hocLandingPage or @hocMode - viewClass = require 'views/courses/CoursesView' - navigationEvent = route: route, viewClass: viewClass, viewArgs: [] - Backbone.Mediator.publish 'router:navigate', navigationEvent - - courseEnrollByURL: (prepaidCode) -> - @state = 'enrolling' - @render?() - $.ajax({ - method: 'POST' - url: '/db/course_instance/-/redeem_prepaid' - data: prepaidCode: prepaidCode - context: @ - success: @onRedeemPrepaidSuccess - error: (xhr, textStatus, errorThrown) -> - console.error 'Got an error redeeming a course prepaid code:', textStatus, errorThrown - application.tracker?.trackEvent 'Failed to redeem course prepaid code by url', status: textStatus - @state = 'unknown_error' - @stateMessage = "Failed to redeem code: #{xhr.responseText}" - @render?() - }) - - courseEnrollByModal: (prepaidCode) -> - @state = 'enrolling-by-modal' - @renderSelectors '.student-dialog-state-row' - $.ajax({ - method: 'POST' - url: '/db/course_instance/-/redeem_prepaid' - data: prepaidCode: prepaidCode - context: @ - success: -> - $('.continue-dialog').modal('hide') - @onRedeemPrepaidSuccess(arguments...) - error: (jqxhr, textStatus, errorThrown) -> - application.tracker?.trackEvent 'Failed to redeem course prepaid code by modal', status: textStatus - @state = 'unknown_error' - if jqxhr.status is 422 - @stateMessage = 'Please enter a code.' - else if jqxhr.status is 404 - @stateMessage = 'Code not found.' - else - @stateMessage = "#{jqxhr.responseText}" - @renderSelectors '.student-dialog-state-row' - }) - - onRedeemPrepaidSuccess: (data, textStatus, jqxhr) -> - prepaidID = data[0]?.prepaidID - application.tracker?.trackEvent 'Redeemed course prepaid code', {prepaidCode: prepaidID} - me.fetch(cache: false).always => - if data?.length > 0 && data[0].courseID && data[0]._id - courseID = data[0].courseID - courseInstanceID = data[0]._id - route = "/courses/#{courseID}/#{courseInstanceID}" - viewArgs = [{}, courseID, courseInstanceID] - Backbone.Mediator.publish 'router:navigate', - route: route - viewClass: 'views/courses/CourseDetailsView' - viewArgs: viewArgs - else - @state = 'unknown_error' - @stateMessage = "Database error." - @render?() - diff --git a/app/views/courses/HourOfCodeView.coffee b/app/views/courses/HourOfCodeView.coffee new file mode 100644 index 000000000..21873d8d5 --- /dev/null +++ b/app/views/courses/HourOfCodeView.coffee @@ -0,0 +1,49 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +Course = require 'models/Course' +CourseInstance = require 'models/CourseInstance' +RootView = require 'views/core/RootView' +template = require 'templates/courses/hour-of-code-view' +utils = require 'core/utils' + + +module.exports = class HourOfCodeView extends RootView + id: 'hour-of-code-view' + template: template + + events: + 'click #student-btn': 'onClickStudentButton' + + constructor: (options) -> + super(options) + @setUpHourOfCode() + + setUpHourOfCode: -> + # If we are coming in at /hoc, then we show the landing page. + # If we have ?hoc=true (for the step after the landing page), then we show any HoC-specific instructions. + # If we haven't tracked this player as an hourOfCode player yet, and it's a new account, we do that now. + @hocLandingPage = true + @hocMode = true + elapsed = new Date() - new Date(me.get('dateCreated')) + if not me.get('hourOfCode') and (@hocLandingPage or @hocMode) and elapsed < 5 * 60 * 1000 + me.set('hourOfCode', true) + me.patch() + $('body').append($('')) + application.tracker?.trackEvent 'Hour of Code Begin' + + onClickStudentButton: -> + @state = 'enrolling' + @stateMessage = undefined + @render?() + + $.ajax({ + method: 'POST' + url: '/db/course_instance/-/create-for-hoc' + context: @ + success: (data) -> + application.tracker?.trackEvent 'Finished HoC student course creation', {courseID: data.courseID} + app.router.navigate("/courses/#{data.courseID}/#{data._id}", { + trigger: true + }) + }) \ No newline at end of file diff --git a/app/views/courses/InviteToClassroomModal.coffee b/app/views/courses/InviteToClassroomModal.coffee new file mode 100644 index 000000000..fae1a2556 --- /dev/null +++ b/app/views/courses/InviteToClassroomModal.coffee @@ -0,0 +1,32 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/courses/invite-to-classroom-modal' + +module.exports = class InviteToClassroomModal extends ModalView + id: 'invite-to-classroom-modal' + template: template + + events: + 'click #send-invites-btn': 'onClickSendInvitesButton' + + initialize: (options) -> + @classroom = options.classroom + + onClickSendInvitesButton: -> + emails = @$('#invite-emails-textarea').val() + emails = emails.split('\n') + emails = _.filter((_.string.trim(email) for email in emails)) + if not emails.length + return + url = @classroom.url() + '/invite-members' + @$('#send-invites-btn, #invite-emails-textarea').addClass('hide') + @$('#invite-emails-sending-alert').removeClass('hide') + + $.ajax({ + url: url + data: {emails: emails} + method: 'POST' + context: @ + success: -> + @$('#invite-emails-sending-alert').addClass('hide') + @$('#invite-emails-success-alert').removeClass('hide') + }) diff --git a/app/views/courses/PurchaseCoursesView.coffee b/app/views/courses/PurchaseCoursesView.coffee new file mode 100644 index 000000000..210a56287 --- /dev/null +++ b/app/views/courses/PurchaseCoursesView.coffee @@ -0,0 +1,85 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +Course = require 'models/Course' +RootView = require 'views/core/RootView' +stripeHandler = require 'core/services/stripe' +template = require 'templates/courses/purchase-courses-view' +utils = require 'core/utils' + +module.exports = class PurchaseCoursesView extends RootView + id: 'purchase-courses-view' + template: template + numberOfStudents: 30 + pricePerStudent: 4 + + initialize: (options) -> + @listenTo stripeHandler, 'received-token', @onStripeReceivedToken + super(options) + + events: + 'input #students-input': 'onInputStudentsInput' + 'click #purchase-btn': 'onClickPurchaseButton' + + getPriceString: -> '$' + (@getPrice()).toFixed(2) + getPrice: -> @pricePerStudent * @numberOfStudents + + onInputStudentsInput: -> + @numberOfStudents = parseInt(@$('#students-input').val()) or 0 + @updatePrice() + + updatePrice: -> + @renderSelectors '#price-form-group' + + onClickPurchaseButton: -> + return @openModalView new AuthModal() if me.isAnonymous() + if @numberOfStudents < 1 or not _.isFinite(@numberOfStudents) + 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: @pricePerStudent} + stripeHandler.open + amount: @price + 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?() + console.log 'e', e + + 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/StudentCoursesView.coffee b/app/views/courses/StudentCoursesView.coffee new file mode 100644 index 000000000..bd9c1f928 --- /dev/null +++ b/app/views/courses/StudentCoursesView.coffee @@ -0,0 +1,93 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +Course = require 'models/Course' +Classroom = require 'models/Classroom' +User = require 'models/User' +CourseInstance = require 'models/CourseInstance' +RootView = require 'views/core/RootView' +template = require 'templates/courses/student-courses-view' +utils = require 'core/utils' + +# TODO: Implement join class +# TODO: Implement course instance links + +module.exports = class StudentCoursesView extends RootView + id: 'student-courses-view' + template: template + + events: + 'click #join-class-btn': 'onClickJoinClassButton' + + constructor: (options) -> + super(options) + @courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance}) + @courseInstances.comparator = (ci) -> return ci.get('classroomID') + ci.get('courseID') + @supermodel.loadCollection(@courseInstances, 'course_instances') + @classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom }) + @supermodel.loadCollection(@classrooms, 'classrooms', { data: {memberID: me.id} }) + @courses = new CocoCollection([], { url: "/db/course", model: Course}) + @supermodel.loadCollection(@courses, 'courses') + + onLoaded: -> + if (@classCode = utils.getQueryVariable('_cc', false)) and not me.isAnonymous() + @joinClass() + super() + + onClickJoinClassButton: (e) -> + return @openModalView new AuthModal() if me.isAnonymous() + @classCode = @$('#classroom-code-input').val() + @joinClass() + + joinClass: () -> + @state = 'enrolling' + @renderSelectors '#join-classroom-form' + $.ajax({ + method: 'POST' + url: '/db/classroom/-/members' + data: code: @classCode + context: @ + success: @onJoinClassroomSuccess + error: (jqxhr, textStatus, errorThrown) -> + application.tracker?.trackEvent 'Failed to join classroom with code', status: textStatus + @state = 'unknown_error' + if jqxhr.status is 422 + @stateMessage = 'Please enter a code.' + else if jqxhr.status is 404 + @stateMessage = 'Code not found.' + else + @stateMessage = "#{jqxhr.responseText}" + @renderSelectors '#join-classroom-form' + }) + + onJoinClassroomSuccess: (data, textStatus, jqxhr) -> + classroom = new Classroom(data) + application.tracker?.trackEvent 'Joined classroom', { + classroomID: classroom.id, + classroomName: classroom.get('name') + ownerID: classroom.get('ownerID') + } + @classrooms.add(classroom) + @render() + + classroomCourseInstances = new CocoCollection([], { url: "/db/course_instance", model: CourseInstance }) + classroomCourseInstances.fetch({ data: {classroomID: classroom.id} }) + @listenToOnce classroomCourseInstances, 'sync', -> + + # join any course instances in the classroom which are free to join + jqxhrs = [] + for courseInstance in classroomCourseInstances.models + course = @courses.get(courseInstance.get('courseID')) + if course.get('free') + jqxhrs.push $.ajax({ + method: 'POST' + url: _.result(courseInstance, 'url') + '/members' + data: { userID: me.id } + context: @ + success: (data) -> + @courseInstances.add(data) + @courseInstances.get(data._id).justJoined = true + }) + $.when(jqxhrs...).done => + @state = '' + @render() diff --git a/app/views/courses/TeacherCoursesView.coffee b/app/views/courses/TeacherCoursesView.coffee new file mode 100644 index 000000000..26cf74ad4 --- /dev/null +++ b/app/views/courses/TeacherCoursesView.coffee @@ -0,0 +1,204 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +CocoCollection = require 'collections/CocoCollection' +CocoModel = require 'models/CocoModel' +Course = require 'models/Course' +Classroom = require 'models/Classroom' +User = require 'models/User' +Prepaid = require 'models/Prepaid' +CourseInstance = require 'models/CourseInstance' +RootView = require 'views/core/RootView' +template = require 'templates/courses/teacher-courses-view' +utils = require 'core/utils' +InviteToClassroomModal = require 'views/courses/InviteToClassroomModal' + +module.exports = class TeacherCoursesView extends RootView + id: 'teacher-courses-view' + template: template + + events: + 'click #create-new-class-btn': 'onClickCreateNewclassButton' + 'click .add-students-btn': 'onClickAddStudentsButton' + 'click .course-instance-membership-checkbox': 'onClickCourseInstanceMembershipCheckbox' + 'click #save-changes-btn': 'onClickSaveChangesButton' + 'click #manage-tab-link': 'onClickManageTabLink' + + constructor: (options) -> + super(options) + @courses = new CocoCollection([], { url: "/db/course", model: Course}) + @supermodel.loadCollection(@courses, 'courses') + @classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom }) + @classrooms.comparator = '_id' + @listenToOnce @classrooms, 'sync', @onceClassroomsSync + @supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}}) + @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}}) + @members = new CocoCollection([], { model: User }) + @prepaids = new CocoCollection([], { url: "/db/prepaid", model: Prepaid }) + sum = (numbers) -> _.reduce(numbers, (a, b) -> a + b) + @prepaids.totalMaxRedeemers = -> sum((prepaid.get('maxRedeemers') for prepaid in @models)) or 0 + @prepaids.totalRedeemers = -> sum((_.size(prepaid.get('redeemers')) for prepaid in @models)) or 0 + @prepaids.comparator = '_id' + @supermodel.loadCollection(@prepaids, 'prepaids', {data: {creator: me.id}}) + @listenTo @members, 'sync', @renderManageTab + @usersToRedeem = new CocoCollection([], { model: User }) + @hoc = utils.getQueryVariable('hoc') + @ + + onceClassroomsSync: -> + for classroom in @classrooms.models + @members.fetch({ + remove: false + url: "/db/classroom/#{classroom.id}/members" + }) + + onClickCreateNewclassButton: -> + name = @$('#new-classroom-name-input').val() + return unless name + classroom = new Classroom({ name: name }) + classroom.save() + @classrooms.add(classroom) + classroom.saving = true + @renderManageTab() + @listenTo classroom, 'sync', -> + classroom.saving = false + @fillMissingCourseInstances() + + renderManageTab: -> + isActive = @$('#manage-tab-pane').hasClass('active') + @renderSelectors('#manage-tab-pane') + @$('#manage-tab-pane').toggleClass('active', isActive) + + onClickAddStudentsButton: (e) -> + classroomID = $(e.target).data('classroom-id') + classroom = @classrooms.get(classroomID) + modal = new InviteToClassroomModal({classroom: classroom}) + @openModalView(modal) + + onLoaded: -> + super() + @linkCourseIntancesToCourses() + @fillMissingCourseInstances() + + linkCourseIntancesToCourses: -> + for courseInstance in @courseInstances.models + courseInstance.course = @courses.get(courseInstance.get('courseID')) + + fillMissingCourseInstances: -> + # TODO: Give teachers control over which courses are enabled for a given class. + # Add/remove course instances and columns in the view to match. + for classroom in @classrooms.models + classroom.filling = false + for course in @courses.models + courseInstance = @courseInstances.findWhere({classroomID: classroom.id, courseID: course.id}) + if not courseInstance + classroom.filling = true + courseInstance = new CourseInstance({ + classroomID: classroom.id + courseID: course.id + }) + # TODO: figure out a better way to get around triggering validation errors for properties + # that the server will end up filling in, like an empty members array, ownerID + courseInstance.save(null, {validate: false}) + courseInstance.course = course + @courseInstances.add(courseInstance) + @listenToOnce courseInstance, 'sync', @fillMissingCourseInstances + @renderManageTab() + return + @renderManageTab() + + onClickCourseInstanceMembershipCheckbox: -> + usersToRedeem = {} + checkedBoxes = @$('.course-instance-membership-checkbox:checked') + _.each checkedBoxes, (el) => + $el = $(el) + userID = $el.data('user-id') + return if usersToRedeem[userID] + user = @members.get(userID) + return if user.get('coursePrepaidID') + courseInstanceID = $el.data('course-instance-id') + courseInstance = @courseInstances.get(courseInstanceID) + return if courseInstance.course.get('free') + usersToRedeem[userID] = user + + @usersToRedeem = new CocoCollection(_.values(usersToRedeem), {model: User}) + @numCourseInstancesToAddTo = checkedBoxes.length + @renderSelectors '#fixed-area' + + onClickSaveChangesButton: -> + @$('.course-instance-membership-checkbox').attr('disabled', true) + checkedBoxes = @$('.course-instance-membership-checkbox:checked') + raw = _.map checkedBoxes, (el) => + $el = $(el) + userID = $el.data('user-id') + courseInstanceID = $el.data('course-instance-id') + courseInstance = @courseInstances.get(courseInstanceID) + return { + courseInstance: courseInstance + userID: userID + } + @membershipAdditions = new CocoCollection(raw, { model: User }) # TODO: Allow collections not to have models defined? + @membershipAdditions.originalSize = @membershipAdditions.size() + @usersToRedeem.originalSize = @usersToRedeem.size() + @state = 'saving-changes' + @renderSelectors '#fixed-area' + @redeemUsers() + + redeemUsers: -> + if not @usersToRedeem.size() + @addMemberships() + return + + user = @usersToRedeem.first() + prepaid = @prepaids.find (prepaid) -> prepaid.openSpots() + $.ajax({ + method: 'POST' + url: _.result(prepaid, 'url') + '/redeemers' + data: { userID: user.id } + context: @ + success: -> + @usersToRedeem.remove(user) + @renderSelectors '#fixed-area' + @redeemUsers() + error: (jqxhr, textStatus, errorThrown) -> + if jqxhr.status is 402 + @state = 'error' + @stateMessage = arguments[2] + else + @state = 'error' + @stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}" + @renderSelectors '#fixed-area' + }) + + addMemberships: -> + if not @membershipAdditions.size() + @renderSelectors '#fixed-area' + document.location.reload() + return + + membershipAddition = @membershipAdditions.first() + courseInstance = membershipAddition.get('courseInstance') + userID = membershipAddition.get('userID') + $.ajax({ + method: 'POST' + url: _.result(courseInstance, 'url') + '/members' + data: { userID: userID } + context: @ + success: -> + @membershipAdditions.remove(membershipAddition) + @renderSelectors '#fixed-area' + @addMemberships() + error: (jqxhr, textStatus, errorThrown) -> + if jqxhr.status is 402 + @state = 'error' + @stateMessage = arguments[2] + else + @state = 'error' + @stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}" + @renderSelectors '#fixed-area' + }) + + onClickManageTabLink: -> + @$('.nav-tabs a[href="#manage-tab-pane"]').tab('show') \ No newline at end of file diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee index 3a7211d9a..fde29cd63 100644 --- a/app/views/play/level/LevelLoadingView.coffee +++ b/app/views/play/level/LevelLoadingView.coffee @@ -14,6 +14,7 @@ module.exports = class LevelLoadingView extends CocoView subscriptions: 'level:loaded': 'onLevelLoaded' # If Level loads after level loading view. + 'level:session-loaded': 'onSessionLoaded' 'level:subscription-required': 'onSubscriptionRequired' # If they'd need a subscription to start playing. 'level:course-membership-required': 'onCourseMembershipRequired' # If they'd need a subscription to start playing. 'subscribe-modal:subscribed': 'onSubscribed' @@ -44,6 +45,14 @@ module.exports = class LevelLoadingView extends CocoView onLevelLoaded: (e) -> @level = e.level + @prepareGoals() + @prepareTip() + @prepareIntro() + + onSessionLoaded: (e) -> + @session = e.session if e.session.get('creator') is me.id + + prepareGoals: -> goalContainer = @$el.find('.level-loading-goals') goalList = goalContainer.find('ul') goalCount = 0 @@ -55,57 +64,121 @@ module.exports = class LevelLoadingView extends CocoView goalContainer.removeClass('secret') if goalCount is 1 goalContainer.find('.panel-heading').text $.i18n.t 'play_level.goal' # Not plural + + prepareTip: -> tip = @$el.find('.tip') if @level.get('loadingTip') loadingTip = utils.i18n @level.attributes, 'loadingTip' tip.text(loadingTip) tip.removeClass('secret') + prepareIntro: -> + @docs = @level.get('documentation') ? {} + specific = @docs.specificArticles or [] + @intro = _.find specific, name: 'Intro' + showReady: -> return if @shownReady @shownReady = true - _.delay @finishShowingReady, 1500 # Let any blocking JS hog the main thread before we show that we're done. + _.delay @finishShowingReady, 100 # Let any blocking JS hog the main thread before we show that we're done. finishShowingReady: => return if @destroyed - if @options.autoUnveil + showIntro = @getQueryVariable('intro') + autoUnveil = not showIntro and (@options.autoUnveil or @session?.get('state').complete) + if autoUnveil @startUnveiling() - @unveil() + @unveil true else @playSound 'level_loaded', 0.75 # old: loading_ready @$el.find('.progress').hide() @$el.find('.start-level-button').show() + @unveil false startUnveiling: (e) -> @playSound 'menu-button-click' + @unveiling = true Backbone.Mediator.publish 'level:loading-view-unveiling', {} _.delay @onClickStartLevel, 1000 # If they never mouse-up for the click (or a modal shows up and interrupts the click), do it anyway. onClickStartLevel: (e) => return if @destroyed - @unveil() + @unveil true onEnterPressed: (e) -> - return unless @shownReady and not @$el.hasClass 'unveiled' + return unless @shownReady and not @unveiled @startUnveiling() @onClickStartLevel() - unveil: -> - return if @$el.hasClass 'unveiled' - @$el.addClass 'unveiled' - loadingDetails = @$el.find('.loading-details') - duration = parseFloat loadingDetails.css 'transition-duration' - loadingDetails.css 'top', -loadingDetails.outerHeight(true) + unveil: (full) -> + return if @destroyed or @unveiled + @unveiled = full + @$loadingDetails = @$el.find('#loading-details') + duration = parseFloat(@$loadingDetails.css 'transition-duration') * 1000 + unless @$el.hasClass 'unveiled' + @$el.addClass 'unveiled' + @unveilWings duration + if full + @unveilLoadingFull() + _.delay @onUnveilEnded, duration + else + @unveilLoadingPreview duration + + unveilLoadingFull: -> + # Get rid of the loading details screen entirely--the level is totally ready. + unless @unveiling + Backbone.Mediator.publish 'level:loading-view-unveiling', {} + @unveiling = true + if @$el.hasClass 'preview-screen' + @$loadingDetails.css 'right', -@$loadingDetails.outerWidth(true) + else + @$loadingDetails.css 'top', -@$loadingDetails.outerHeight(true) + @$el.removeClass 'preview-screen' + $('#canvas-wrapper').removeClass 'preview-overlay' + + unveilLoadingPreview: (duration) -> + # Move the loading details screen over the code editor to preview the level. + return if @$el.hasClass 'preview-screen' + $('#canvas-wrapper').addClass 'preview-overlay' + @$el.addClass('preview-screen') + @$loadingDetails.addClass('preview') + @resize() + @onWindowResize = _.debounce @onWindowResize, 700 # Wait a bit for other views to resize before we resize + $(window).on 'resize', @onWindowResize + if @intro + @$el.find('.progress-or-start-container').addClass('intro-footer') + @$el.find('#tip-wrapper').remove() + _.delay @unveilIntro, duration + + resize: -> + maxHeight = $('#page-container').outerHeight(true) + minHeight = $('#code-area').outerHeight(true) + @$el.css height: maxHeight + @$loadingDetails.css minHeight: minHeight, maxHeight: maxHeight + $intro = @$el.find('.intro-doc') + $intro.css maxHeight: minHeight - $intro.offset().top - @$el.find('.progress-or-start-container').outerHeight() - 30 - 20 + + unveilWings: (duration) -> + @playSound 'loading-view-unveil', 0.5 @$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0' @$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0' - @playSound 'loading-view-unveil', 0.5 - _.delay @onUnveilEnded, duration * 1000 - $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration * 1000) + $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration) + + unveilIntro: => + return if @destroyed or not @intro or @unveiled + html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body')) + @$el.find('.intro-doc').html html + @resize() onUnveilEnded: => return if @destroyed Backbone.Mediator.publish 'level:loading-view-unveiled', view: @ + onWindowResize: (e) => + return if @destroyed + @$loadingDetails.css transition: 'none' + @resize() + onSubscriptionRequired: (e) -> @$el.find('.level-loading-goals, .tip, .load-progress').hide() @$el.find('.subscription-required').show() @@ -120,3 +193,7 @@ module.exports = class LevelLoadingView extends CocoView onSubscribed: -> document.location.reload() + + destroy: -> + $(window).off 'resize', @onWindowResize + super() diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee index 627245cb3..78828d08d 100644 --- a/app/views/play/level/PlayLevelView.coffee +++ b/app/views/play/level/PlayLevelView.coffee @@ -155,7 +155,7 @@ module.exports = class PlayLevelView extends RootView afterRender: -> super() window.onPlayLevelViewLoaded? @ # still a hack - @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level # May not have @level loaded yet + @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level, session: @levelLoader?.session ? @session # May not have @level loaded yet @$el.find('#level-done-button').hide() $('body').addClass('is-playing') $('body').bind('touchmove', false) if @isIPadApp() @@ -177,7 +177,6 @@ module.exports = class PlayLevelView extends RootView @initVolume() @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged) - @originalSessionState = $.extend(true, {}, @session.get('state')) @register() @controlBar.setBus(@bus) @initScriptManager() @@ -341,14 +340,16 @@ module.exports = class PlayLevelView extends RootView if window.currentModal and not window.currentModal.destroyed and window.currentModal.constructor isnt VictoryModal return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @ @surface.showLevel() - if @isEditorPreview or @observing + Backbone.Mediator.publish 'level:set-time', time: 0 + if (@isEditorPreview or @observing) and not @getQueryVariable('intro') @loadingView.startUnveiling() - @loadingView.unveil() + @loadingView.unveil true onLoadingViewUnveiling: (e) -> - @restoreSessionState() + @selectHero() onLoadingViewUnveiled: (e) -> + Backbone.Mediator.publish 'level:set-playing', playing: true @loadingView.$el.remove() @removeSubView @loadingView @loadingView = null @@ -372,21 +373,11 @@ module.exports = class PlayLevelView extends RootView @ambientSound = createjs.Sound.play src, loop: -1, volume: 0.1 createjs.Tween.get(@ambientSound).to({volume: 1.0}, 10000) - restoreSessionState: -> - return if @alreadyLoadedState - @alreadyLoadedState = true - state = @originalSessionState - if not @level or @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] - Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true - Backbone.Mediator.publish 'tome:select-primary-sprite', {} - Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false - @surface.focusOnHero() - Backbone.Mediator.publish 'level:set-time', time: 0 - Backbone.Mediator.publish 'level:set-playing', playing: true - else - if state.selected - # TODO: Should also restore selected spell here by saving spellName - Backbone.Mediator.publish 'level:select-sprite', thangID: state.selected, spellName: null + selectHero: -> + Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true + Backbone.Mediator.publish 'tome:select-primary-sprite', {} + Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false + @surface.focusOnHero() # callbacks diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee index 27c505502..9cdc15491 100644 --- a/app/views/play/level/tome/SpellPaletteEntryView.coffee +++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee @@ -3,7 +3,7 @@ template = require 'templates/play/level/tome/spell_palette_entry' {me} = require 'core/auth' filters = require 'lib/image_filter' DocFormatter = require './DocFormatter' -SpellView = require 'views/play/level/tome/SpellView' +utils = require 'core/utils' module.exports = class SpellPaletteEntryView extends CocoView tagName: 'div' # Could also try instead of
, but would need to adjust colors @@ -59,26 +59,8 @@ module.exports = class SpellPaletteEntryView extends CocoView @aceEditors = [] aceEditors = @aceEditors popover?.$tip?.find('.docs-ace').each -> - contents = $(@).text() - editor = ace.edit @ - editor.setOptions maxLines: Infinity - editor.setReadOnly true - editor.setTheme 'ace/theme/textmate' - editor.setShowPrintMargin false - editor.setShowFoldWidgets false - editor.setHighlightActiveLine false - editor.setHighlightActiveLine false - editor.setBehavioursEnabled false - editor.renderer.setShowGutter false - editor.setValue contents - editor.clearSelection() - session = editor.getSession() - session.setUseWorker false - session.setMode SpellView.editModes[codeLanguage] - session.setWrapLimitRange null - session.setUseWrapMode true - session.setNewLineMode 'unix' - aceEditors.push editor + aceEditor = utils.initializeACE @, codeLanguage + aceEditors.push aceEditor onMouseEnter: (e) -> # Make sure the doc has the updated Thang so it can regenerate its prop value diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee index 1e7214182..95be903e7 100644 --- a/app/views/play/level/tome/SpellView.coffee +++ b/app/views/play/level/tome/SpellView.coffee @@ -9,6 +9,7 @@ SpellDebugView = require './SpellDebugView' SpellToolbarView = require './SpellToolbarView' LevelComponent = require 'models/LevelComponent' UserCodeProblem = require 'models/UserCodeProblem' +utils = require 'core/utils' module.exports = class SpellView extends CocoView id: 'spell-view' @@ -18,14 +19,6 @@ module.exports = class SpellView extends CocoView eventsSuppressed: true writable: true - @editModes: - 'javascript': 'ace/mode/javascript' - 'coffeescript': 'ace/mode/coffee' - 'python': 'ace/mode/python' - 'clojure': 'ace/mode/clojure' - 'lua': 'ace/mode/lua' - 'io': 'ace/mode/text' - keyBindings: 'default': null 'vim': 'ace/keyboard/vim' @@ -93,7 +86,7 @@ module.exports = class SpellView extends CocoView @aceSession = @ace.getSession() @aceDoc = @aceSession.getDocument() @aceSession.setUseWorker false - @aceSession.setMode SpellView.editModes[@spell.language] + @aceSession.setMode utils.aceEditModes[@spell.language] @aceSession.setWrapLimitRange null @aceSession.setUseWrapMode true @aceSession.setNewLineMode 'unix' @@ -479,7 +472,7 @@ module.exports = class SpellView extends CocoView # window.zatannaInstance = @zatanna # For debugging. Make sure to not leave active when committing. # window.snippetEntries = snippetEntries - lang = SpellView.editModes[e.language].substr 'ace/mode/'.length + lang = utils.aceEditModes[e.language].substr 'ace/mode/'.length @zatanna.addSnippets snippetEntries, lang @editorLang = lang @@ -1138,8 +1131,8 @@ module.exports = class SpellView extends CocoView onChangeLanguage: (e) -> return unless @spell.canWrite() - @aceSession.setMode SpellView.editModes[e.language] - @zatanna?.set 'language', SpellView.editModes[e.language].substr('ace/mode/') + @aceSession.setMode utils.aceEditModes[e.language] + @zatanna?.set 'language', utils.aceEditModes[e.language].substr('ace/mode/') wasDefault = @getSource() is @spell.originalSource @spell.setLanguage e.language @reloadCode true if wasDefault diff --git a/app/views/play/menu/GuideView.coffee b/app/views/play/menu/GuideView.coffee index 606c586b4..b1d727081 100644 --- a/app/views/play/menu/GuideView.coffee +++ b/app/views/play/menu/GuideView.coffee @@ -4,8 +4,6 @@ Article = require 'models/Article' SubscribeModal = require 'views/core/SubscribeModal' utils = require 'core/utils' -# let's implement this once we have the docs database schema set up - module.exports = class LevelGuideView extends CocoView template: template id: 'guide-view' @@ -41,10 +39,10 @@ module.exports = class LevelGuideView extends CocoView @docs = specific.concat(general) @docs = $.extend(true, [], @docs) @docs = [@docs[0]] if @firstOnly and @docs[0] - doc.html = marked(@filterCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs + doc.html = marked(utils.filterMarkdownCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs doc.name = (utils.i18n doc, 'name') for doc in @docs doc.slug = _.string.slugify(doc.name) for doc in @docs - super() + super options destroy: -> if @vimeoListenerAttached @@ -52,6 +50,7 @@ module.exports = class LevelGuideView extends CocoView window.removeEventListener('message', @onMessageReceived, false) else window.detachEvent('onmessage', @onMessageReceived, false) + oldEditor.destroy() for oldEditor in @aceEditors ? [] super() getRenderData: -> @@ -70,13 +69,17 @@ module.exports = class LevelGuideView extends CocoView @$el.find('.nav-tabs li:first').addClass('active') @$el.find('.tab-content .tab-pane:first').addClass('active') @$el.find('.nav-tabs a').click(@clickTab) + @configureACEEditors() @playSound 'guide-open' - filterCodeLanguages: (text) -> - currentLanguage = me.get('aceConfig')?.language or 'python' - excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'io'], currentLanguage - exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm' - text.replace exclusionRegex, '' + configureACEEditors: -> + oldEditor.destroy() for oldEditor in @aceEditors ? [] + @aceEditors = [] + aceEditors = @aceEditors + codeLanguage = me.get('aceConfig')?.language or 'python' + @$el.find('pre').each -> + aceEditor = utils.initializeACE @, codeLanguage + aceEditors.push aceEditor clickSubscribe: (e) -> level = @levelSlug # Save ref to level slug diff --git a/package.json b/package.json index afa519a5d..5dfaa11ae 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "brunch": "brunch", "bower": "bower", "dev": "brunch watch --server", + "nodemon": "nodemon", + "jasmine-node": "jasmine-node", "multicore": "coffee multicore.coffee", "nodemon": "nodemon" }, diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js index 913709a17..f154e4805 100644 --- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js +++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js @@ -642,7 +642,13 @@ function getRecurringRevenueCounts(startDay) { var cursor = db.payments.find({_id: {$gte: startObj}}); while (cursor.hasNext()) { var doc = cursor.next(); - var day = doc._id.getTimestamp().toISOString().substring(0, 10); + var day; + if (doc.created) { + day = doc.created.substring(0, 10); + } + else { + day = doc._id.getTimestamp().toISOString().substring(0, 10); + } if (doc.service === 'ios' || doc.service === 'bitcoin') continue; diff --git a/scripts/mongodb/migrations/2015-11-10-course-correction.js b/scripts/mongodb/migrations/2015-11-10-course-correction.js new file mode 100644 index 000000000..4b6851033 --- /dev/null +++ b/scripts/mongodb/migrations/2015-11-10-course-correction.js @@ -0,0 +1,34 @@ +var counts = { + hasClassroom: 0, + isOwn: 0, + migrated: 0 +}; + +// script for generating codes +// JSON.stringify(_.unique(_.map(_.range(1000), function() { return _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('') }))) +var codes = +db.course.instances.find().forEach(function(courseInstance) { + if(courseInstance.classroomID) { + counts.hasClassroom += 1; + return; + } + if(courseInstance.ownerID && courseInstance.members && courseInstance.ownerID.equals(courseInstance.members[0]) && courseInstance.members.length === 1) { + counts.isOwn += 1; + return; + } + + var id = ObjectId(); + + var newClassroom = { + members: courseInstance.members, + ownerID: courseInstance.ownerID, + description: courseInstance.description, + name: courseInstance.name, + code: codes.pop(), + _id: id + }; + print('migrating', JSON.stringify(newClassroom, null, '\t')); + db.classrooms.save(newClassroom); + courseInstance.classroomID = id; + db.course.instances.save(courseInstance); +}); diff --git a/server/classrooms/Classroom.coffee b/server/classrooms/Classroom.coffee new file mode 100644 index 000000000..38a2e3018 --- /dev/null +++ b/server/classrooms/Classroom.coffee @@ -0,0 +1,42 @@ +mongoose = require 'mongoose' +log = require 'winston' +config = require '../../server_config' +plugins = require '../plugins/plugins' +User = require '../users/User' +jsonSchema = require '../../app/schemas/models/classroom.schema' + +ClassroomSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:config.mongo.readpref} + +ClassroomSchema.statics.privateProperties = [] +ClassroomSchema.statics.editableProperties = [ + 'description' + 'name' +] + +ClassroomSchema.statics.generateNewCode = (done) -> + tryCode = -> + code = _.sample("abcdefghijklmnopqrstuvwxyz0123456789", 8).join('') + Classroom.findOne code: code, (err, classroom) -> + return done() if err + return done(code) unless classroom + tryCode() + tryCode() + +#ClassroomSchema.plugin plugins.NamedPlugin + +ClassroomSchema.pre('save', (next) -> + return next() if @get('code') + Classroom.generateNewCode (code) => + @set 'code', code + next() +) + +ClassroomSchema.methods.isOwner = (userID) -> + return userID.equals(@get('ownerID')) + +ClassroomSchema.methods.isMember = (userID) -> + return _.any @get('members') or [], (memberID) -> userID.equals(memberID) + +ClassroomSchema.statics.jsonSchema = jsonSchema + +module.exports = Classroom = mongoose.model 'classroom', ClassroomSchema, 'classrooms' diff --git a/server/classrooms/classroom_handler.coffee b/server/classrooms/classroom_handler.coffee new file mode 100644 index 000000000..5ef0d1a98 --- /dev/null +++ b/server/classrooms/classroom_handler.coffee @@ -0,0 +1,110 @@ +async = require 'async' +mongoose = require 'mongoose' +Handler = require '../commons/Handler' +Classroom = require './Classroom' +User = require '../users/User' +sendwithus = require '../sendwithus' +utils = require '../lib/utils' +UserHandler = require '../users/user_handler' + +ClassroomHandler = class ClassroomHandler extends Handler + modelClass: Classroom + jsonSchema: require '../../app/schemas/models/classroom.schema' + allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'] + + hasAccess: (req) -> + return false unless req.user + return true if req.method is 'GET' + req.method in @allowedMethods or req.user?.isAdmin() + + hasAccessToDocument: (req, document, method=null) -> + return false unless document? + return true if req.user?.isAdmin() + return true if document.get('ownerID')?.equals req.user?._id + isGet = (method or req.method).toLowerCase() is 'get' + isMember = _.any(document.get('members') or [], (memberID) -> memberID.equals(req.user.get('_id'))) + return true if isGet and isMember + false + + makeNewInstance: (req) -> + instance = super(req) + instance.set 'ownerID', req.user._id + instance.set 'members', [] + instance + + getByRelationship: (req, res, args...) -> + method = req.method.toLowerCase() + return @inviteStudents(req, res, args[0]) if args[1] is 'invite-members' + return @joinClassroomAPI(req, res, args[0]) if method is 'post' and args[1] is 'members' + return @getMembersAPI(req, res, args[0]) if args[1] is 'members' + super(arguments...) + + getMembersAPI: (req, res, classroomID) -> + Classroom.findById classroomID, (err, classroom) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res) unless classroom + memberIDs = classroom.get('members') ? [] + User.find {_id: {$in: memberIDs}}, (err, users) => + return @sendDatabaseError(res, err) if err + cleandocs = (UserHandler.formatEntity(req, doc) for doc in users) + @sendSuccess(res, cleandocs) + + joinClassroomAPI: (req, res, classroomID) -> + return @sendBadInputError(res, 'Need an object with a code') unless req.body?.code + Classroom.findOne {code: req.body.code}, (err, classroom) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res) if not classroom + members = _.clone(classroom.get('members')) + if _.any(members, (memberID) -> memberID.equals(req.user.get('_id'))) + return @sendSuccess(res, @formatEntity(req, classroom)) + update = { $push: { members : req.user.get('_id')}} + classroom.update update, (err) => + return @sendDatabaseError(res, err) if err + members.push req.user.get('_id') + classroom.set('members', members) + return @sendSuccess(res, @formatEntity(req, classroom)) + + formatEntity: (req, doc) -> + if req.user?.isAdmin() or req.user?.get('_id').equals(doc.get('ownerID')) + return doc.toObject() + return _.omit(doc.toObject(), 'code') + + inviteStudents: (req, res, classroomID) -> + if not req.body.emails + return @sendBadInputError(res, 'Emails not included') + + Classroom.findById classroomID, (err, classroom) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res) unless classroom + return @sendForbiddenError(res) unless classroom.get('ownerID').equals(req.user.get('_id')) + + for email in req.body.emails + context = + email_id: sendwithus.templates.course_invite_email + recipient: + address: email + email_data: + class_name: classroom.get('name') + # TODO: join_link + join_link: "https://codecombat.com/courses/students?_cc=" + classroom.get('code') + sendwithus.api.send context, _.noop + return @sendSuccess(res, {}) + + get: (req, res) -> + if ownerID = req.query.ownerID + return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or ownerID is req.user.id) + return @sendBadInputError(res, 'Bad ownerID') unless utils.isID ownerID + Classroom.find {ownerID: mongoose.Types.ObjectId(ownerID)}, (err, classrooms) => + return @sendDatabaseError(res, err) if err + return @sendSuccess(res, (@formatEntity(req, classroom) for classroom in classrooms)) + else if memberID = req.query.memberID + return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or memberID is req.user.id) + return @sendBadInputError(res, 'Bad memberID') unless utils.isID memberID + Classroom.find {members: mongoose.Types.ObjectId(memberID)}, (err, classrooms) => + return @sendDatabaseError(res, err) if err + return @sendSuccess(res, (@formatEntity(req, classroom) for classroom in classrooms)) + else + super(arguments...) + + +module.exports = new ClassroomHandler() diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 31226e504..9dd3b4235 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -8,6 +8,7 @@ module.exports.handlers = 'article': 'articles/article_handler' 'campaign': 'campaigns/campaign_handler' 'clan': 'clans/clan_handler' + 'classroom': 'classrooms/classroom_handler' 'course': 'courses/course_handler' 'course_instance': 'courses/course_instance_handler' 'level': 'levels/level_handler' diff --git a/server/courses/CourseInstance.coffee b/server/courses/CourseInstance.coffee index 34122f879..6d1a49a78 100644 --- a/server/courses/CourseInstance.coffee +++ b/server/courses/CourseInstance.coffee @@ -3,15 +3,24 @@ config = require '../../server_config' plugins = require '../plugins/plugins' jsonSchema = require '../../app/schemas/models/course_instance.schema' -CourseInstanceSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:config.mongo.readpref} +CourseInstanceSchema = new mongoose.Schema { + ownerID: mongoose.Schema.Types.ObjectId + courseID: mongoose.Schema.Types.ObjectId + classroomID: mongoose.Schema.Types.ObjectId + prepaidID: mongoose.Schema.Types.ObjectId + members: [mongoose.Schema.Types.ObjectId] +}, {strict: false, minimize: false, read:config.mongo.readpref} CourseInstanceSchema.statics.privateProperties = [] CourseInstanceSchema.statics.editableProperties = [ 'description' - 'members' 'name' 'aceConfig' ] +CourseInstanceSchema.statics.postEditableProperties = [ + 'courseID' + 'classroomID' +] CourseInstanceSchema.statics.jsonSchema = jsonSchema diff --git a/server/courses/course_instance_handler.coffee b/server/courses/course_instance_handler.coffee index 82cf58722..24a0e28d1 100644 --- a/server/courses/course_instance_handler.coffee +++ b/server/courses/course_instance_handler.coffee @@ -1,6 +1,7 @@ async = require 'async' Handler = require '../commons/Handler' Campaign = require '../campaigns/Campaign' +Classroom = require '../classrooms/Classroom' Course = require './Course' CourseInstance = require './CourseInstance' LevelSession = require '../levels/sessions/LevelSession' @@ -11,6 +12,7 @@ User = require '../users/User' UserHandler = require '../users/user_handler' utils = require '../../app/core/utils' sendwithus = require '../sendwithus' +mongoose = require 'mongoose' CourseInstanceHandler = class CourseInstanceHandler extends Handler modelClass: CourseInstance @@ -30,63 +32,83 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler getByRelationship: (req, res, args...) -> relationship = args[1] - return @createAPI(req, res) if relationship is 'create' + return @createHOCAPI(req, res) if relationship is 'create-for-hoc' return @getLevelSessionsAPI(req, res, args[0]) if args[1] is 'level_sessions' + return @addMember(req, res, args[0]) if req.method is 'POST' 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 @redeemPrepaidCodeAPI(req, res) if args[1] is 'redeem_prepaid' super arguments... - createAPI: (req, res) -> + createHOCAPI: (req, res) -> return @sendUnauthorizedError(res) if not req.user? - return @sendUnauthorizedError(res) if req.user.isAnonymous() and not (req.body.hourOfCode and req.body.courseID is '560f1a9f22961295f9427742') - - # Required Input - seats = req.body.seats - unless seats > 0 - @logError(req.user, 'Course create API missing required seats count') - return @sendBadInputError(res, 'Missing required seats count') - # Optional - unspecified means create instances for all courses - courseID = req.body.courseID - # Optional - name = req.body.name - aceConfig = req.body.aceConfig or {} - # Optional - as long as course(s) are all free - stripeToken = req.body.stripe?.token - - query = if courseID? then {_id: courseID} else {} - Course.find query, (err, courses) => - if err - @logError(user, "Find courses error: #{JSON.stringify(err)}") - return done(err) - - PrepaidHandler.purchasePrepaidCourse req.user, courses, seats, new Date().getTime(), stripeToken, (err, prepaid) => - if err - @logError(req.user, err) - return @sendBadInputError(res, err) if err is 'Missing required Stripe token' - return @sendDatabaseError(res, err) - - courseInstances = [] - makeCreateInstanceFn = (course, name, prepaid, aceConfig) => - (done) => - @createInstance req, course, name, prepaid, aceConfig, (err, newInstance)=> - courseInstances.push newInstance unless err - done(err) - tasks = (makeCreateInstanceFn(course, name, prepaid, aceConfig) for course in courses) - async.parallel tasks, (err, results) => + courseID = mongoose.Types.ObjectId('560f1a9f22961295f9427742') + CourseInstance.findOne { courseID: courseID, ownerID: req.user.get('_id'), hourOfCode: true }, (err, courseInstance) => + return @sendDatabaseError(res, err) if err + if courseInstance + console.log 'already made a course instance' + return @sendSuccess(res, courseInstance) if courseInstance + console.log 'making a new course instance' + courseInstance = new CourseInstance({ + courseID: courseID + members: [req.user.get('_id')] + name: 'Single Player' + ownerID: req.user.get('_id') + aceConfig: { language: 'python' } + hourOfCode: true + }) + courseInstance.save (err, courseInstance) => + return @sendDatabaseError(res, err) if err + @sendCreated(res, courseInstance) + + addMember: (req, res, courseInstanceID) -> + userID = req.body.userID + return @sendBadInputError(res, 'Input must be a MongoDB ID') unless utils.isID(userID) + CourseInstance.findById courseInstanceID, (err, courseInstance) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Course instance not found') unless courseInstance + Classroom.findById courseInstance.get('classroomID'), (err, classroom) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Classroom referenced by course instance not found') unless classroom + return @sendForbiddenError(res) unless _.any(classroom.get('members'), (memberID) -> memberID.toString() is userID) + ownsCourseInstance = courseInstance.get('ownerID').equals(req.user.get('_id')) + addingSelf = userID is req.user.id + return @sendForbiddenError(res) unless ownsCourseInstance or addingSelf + alreadyInCourseInstance = _.any courseInstance.get('members') or [], (memberID) -> memberID.toString() is userID + return @sendSuccess(res, @formatEntity(req, courseInstance)) if alreadyInCourseInstance + Prepaid.find({ 'redeemers.userID': mongoose.Types.ObjectId(userID) }).count (err, userIsPrepaid) => return @sendDatabaseError(res, err) if err - @sendCreated(res, courseInstances) - - createInstance: (req, course, name, prepaid, aceConfig, done) => - courseInstance = new CourseInstance - courseID: course.get('_id') - members: [req.user.get('_id')] - name: name + Course.findById courseInstance.get('courseID'), (err, course) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Course referenced by course instance not found') unless course + if not (course.get('free') or userIsPrepaid) + return @sendPaymentRequiredError(res, 'Cannot add this user to a course instance until they are added to a prepaid') + members = courseInstance.get('members') + members.push(userID) + courseInstance.set('members', members) + courseInstance.save (err, courseInstance) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, @formatEntity(req, courseInstance)) + + post: (req, res) -> + return @sendBadInputError(res, 'No classroomID') unless req.body.classroomID + return @sendBadInputError(res, 'No courseID') unless req.body.courseID + Classroom.findById req.body.classroomID, (err, classroom) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Classroom not found') unless classroom + return @sendForbiddenError(res) unless classroom.get('ownerID').equals(req.user.get('_id')) + Course.findById req.body.courseID, (err, course) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res, 'Course not found') unless course + super(req, res) + + makeNewInstance: (req) -> + doc = new CourseInstance({ + members: [] ownerID: req.user.get('_id') - prepaidID: prepaid.get('_id') - aceConfig: aceConfig - courseInstance.save (err, newInstance) => - done(err, newInstance) + }) + doc.set('aceConfig', {}) # constructor will ignore empty objects + return doc getLevelSessionsAPI: (req, res, courseInstanceID) -> CourseInstance.findById courseInstanceID, (err, courseInstance) => @@ -182,4 +204,28 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler return @sendDatabaseError(res, err) if err @sendSuccess(res, courseInstances) + get: (req, res) -> + if ownerID = req.query.ownerID + return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or ownerID is req.user.id) + return @sendBadInputError(res, 'Bad ownerID') unless utils.isID ownerID + CourseInstance.find {ownerID: mongoose.Types.ObjectId(ownerID)}, (err, courseInstances) => + return @sendDatabaseError(res, err) if err + return @sendSuccess(res, (@formatEntity(req, courseInstance) for courseInstance in courseInstances)) + else if memberID = req.query.memberID + return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or memberID is req.user.id) + return @sendBadInputError(res, 'Bad memberID') unless utils.isID memberID + CourseInstance.find {members: mongoose.Types.ObjectId(memberID)}, (err, courseInstances) => + return @sendDatabaseError(res, err) if err + return @sendSuccess(res, (@formatEntity(req, courseInstance) for courseInstance in courseInstances)) + else if classroomID = req.query.classroomID + return @sendForbiddenError(res) unless req.user + return @sendBadInputError(res, 'Bad memberID') unless utils.isID classroomID + Classroom.findById classroomID, (err, classroom) => + return @sendForbiddenError(res) unless classroom.isMember(req.user._id) or classroom.isOwner(req.user._id) + CourseInstance.find {classroomID: mongoose.Types.ObjectId(classroomID)}, (err, courseInstances) => + return @sendDatabaseError(res, err) if err + return @sendSuccess(res, (@formatEntity(req, courseInstance) for courseInstance in courseInstances)) + else + super(arguments...) + module.exports = new CourseInstanceHandler() diff --git a/server/prepaids/Prepaid.coffee b/server/prepaids/Prepaid.coffee index c56a60839..5fd2ea0b0 100644 --- a/server/prepaids/Prepaid.coffee +++ b/server/prepaids/Prepaid.coffee @@ -1,8 +1,11 @@ mongoose = require 'mongoose' config = require '../../server_config' -PrepaidSchema = new mongoose.Schema {}, {strict: false, minimize: false,read:config.mongo.readpref} +PrepaidSchema = new mongoose.Schema { + creator: mongoose.Schema.Types.ObjectId +}, {strict: false, minimize: false,read:config.mongo.readpref} PrepaidSchema.index({code: 1}, { unique: true }) +PrepaidSchema.index({'redeemers.userID': 1}) PrepaidSchema.statics.generateNewCode = (done) -> tryCode = -> @@ -12,5 +15,22 @@ PrepaidSchema.statics.generateNewCode = (done) -> return done(code) unless prepaid tryCode() tryCode() + +PrepaidSchema.pre('save', (next) -> + @set('exhausted', @get('maxRedeemers') <= _.size(@get('redeemers'))) + if not @get('code') + Prepaid.generateNewCode (code) => + @set('code', code) + next() + else + next() +) + +PrepaidSchema.post 'init', (doc) -> + doc.set('maxRedeemers', parseInt(doc.get('maxRedeemers'))) + +PrepaidSchema.statics.postEditableProperties = [ + 'creator', 'maxRedeemers', 'type' +] module.exports = Prepaid = mongoose.model('prepaid', PrepaidSchema) diff --git a/server/prepaids/prepaid_handler.coffee b/server/prepaids/prepaid_handler.coffee index 5ade97186..06b0c1671 100644 --- a/server/prepaids/prepaid_handler.coffee +++ b/server/prepaids/prepaid_handler.coffee @@ -2,12 +2,16 @@ Course = require '../courses/Course' Handler = require '../commons/Handler' hipchat = require '../hipchat' Prepaid = require './Prepaid' +User = require '../users/User' StripeUtils = require '../lib/stripe_utils' utils = require '../../app/core/utils' +mongoose = require 'mongoose' # TODO: Should this happen on a save() call instead of a prepaid/-/create post? # TODO: Probably a better way to create a unique 8 charactor string property using db voodoo +cutoffID = mongoose.Types.ObjectId('5642877accc6494a01cc6bfe') + PrepaidHandler = class PrepaidHandler extends Handler modelClass: Prepaid jsonSchema: require '../../app/schemas/models/prepaid.schema' @@ -26,6 +30,7 @@ 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... getPrepaidAPI: (req, res, code) -> @@ -44,7 +49,7 @@ PrepaidHandler = class PrepaidHandler extends Handler createPrepaidAPI: (req, res) -> return @sendForbiddenError(res) unless @hasAccess(req) return @sendForbiddenError(res) unless req.body.type in ['course', 'subscription','terminal_subscription'] - return @sendForbiddenError(res) unless req.body.maxRedeemers > 0 + return @sendForbiddenError(res) unless parseInt(req.body.maxRedeemers) > 0 properties = {} type = req.body.type @@ -61,6 +66,44 @@ 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 _.size(prepaid.get('redeemers')) >= prepaid.get('maxRedeemers') + return @sendForbiddenError(res) unless prepaid.get('type') is 'course' + 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 + userID = user.get('_id') +# Prepaid.count {'redeemers.userID': userID}, (err, count) => +# return @sendDatabaseError(res, err) if err +# return @sendSuccess(res, @formatEntity(req, prepaid)) if count + + query = + _id: prepaid.get('_id') + 'redeemers.userID': { $ne: req.user.get('_id') } + $where: "this.redeemers.length < #{prepaid.get('maxRedeemers')}" + update = { $push: { redeemers : { date: new Date(), userID: userID } }} + Prepaid.update query, update, (err, nMatched) => + return @sendDatabaseError(res, err) if err + if nMatched is 0 + @logError(req.user, "POST prepaid redeemer lost race on maxRedeemers") + return @sendForbiddenError(res) + + user.set('coursePrepaidID', prepaid.get('_id')) + 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 @@ -68,7 +111,7 @@ PrepaidHandler = class PrepaidHandler extends Handler creator: user._id type: type code: code - maxRedeemers: maxRedeemers + maxRedeemers: parseInt(maxRedeemers) properties: properties redeemers: [] @@ -97,40 +140,30 @@ PrepaidHandler = class PrepaidHandler extends Handler @sendSuccess(res, prepaid.toObject()) else if req.body.type is 'course' - courseID = req.body.courseID - maxRedeemers = parseInt(req.body.maxRedeemers) timestamp = req.body.stripe?.timestamp token = req.body.stripe?.token return @sendBadInputError(res) unless isNaN(maxRedeemers) is false and maxRedeemers > 0 - query = if courseID? then {_id: courseID} else {} - Course.find query, (err, courses) => - if err - @logError(user, "Find courses error: #{JSON.stringify(err)}") - return done(err) - - @purchasePrepaidCourse req.user, courses, maxRedeemers, timestamp, token, (err, prepaid) => - # TODO: this badinput detection is fragile, in course instance handler as well - return @sendBadInputError(res, err) if err is 'Missing required Stripe token' - return @sendDatabaseError(res, err) if err - @sendSuccess(res, prepaid.toObject()) + @purchasePrepaidCourse req.user, maxRedeemers, timestamp, token, (err, prepaid) => + # TODO: this badinput detection is fragile, in course instance handler as well + return @sendBadInputError(res, err) if err is 'Missing required Stripe token' + return @sendDatabaseError(res, err) if err + @sendSuccess(res, prepaid.toObject()) else @sendForbiddenError(res) - purchasePrepaidCourse: (user, courses, maxRedeemers, timestamp, token, done) -> + purchasePrepaidCourse: (user, maxRedeemers, timestamp, token, done) -> type = 'course' - courseIDs = (c.get('_id') for c in courses) - coursePrices = (c.get('pricePerSeat') for c in courses) - amount = utils.getCourseBundlePrice(coursePrices, maxRedeemers) + amount = maxRedeemers * 400 if amount > 0 and not (token or user.isAdmin()) @logError(user, "Purchase prepaid courses missing required Stripe token #{amount}") return done('Missing required Stripe token') if amount is 0 or user.isAdmin() - @createPrepaid(user, type, maxRedeemers, courseIDs: courseIDs, done) + @createPrepaid(user, type, maxRedeemers, {}, done) else StripeUtils.getCustomer user, token, (err, customer) => @@ -142,10 +175,8 @@ PrepaidHandler = class PrepaidHandler extends Handler type: type userID: user.id timestamp: parseInt(timestamp) - description: if courses.length is 1 then courses[0].get('name') else 'All Courses' maxRedeemers: maxRedeemers productID: "prepaid #{type}" - courseIDs: courseIDs StripeUtils.createCharge user, amount, metadata, (err, charge) => if err @@ -156,9 +187,9 @@ PrepaidHandler = class PrepaidHandler extends Handler if err @logError(user, "createPayment error: #{JSON.stringify(err)}") return done(err) - msg = "Prepaid code purchased: #{type} seats=#{maxRedeemers} courseIDs=#{courseIDs} #{user.get('email')}" + msg = "Prepaid code purchased: #{type} seats=#{maxRedeemers} #{user.get('email')}" hipchat.sendHipChatMessage msg, ['tower'] - @createPrepaid(user, type, maxRedeemers, courseIDs: courseIDs, done) + @createPrepaid(user, type, maxRedeemers, {}, done) purchasePrepaidTerminalSubscription: (user, description, maxRedeemers, months, timestamp, token, done) -> type = 'terminal_subscription' @@ -195,7 +226,7 @@ PrepaidHandler = class PrepaidHandler extends Handler creator: user._id type: type code: code - maxRedeemers: maxRedeemers + maxRedeemers: parseInt(maxRedeemers) redeemers: [] properties: months: months @@ -205,4 +236,25 @@ PrepaidHandler = class PrepaidHandler extends Handler hipchat.sendHipChatMessage msg, ['tower'] return 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 + return @sendSuccess(res, (@formatEntity(req, prepaid) for prepaid in prepaids)) + else + super(arguments...) + + makeNewInstance: (req) -> + prepaid = super(req) + prepaid.set('redeemers', []) + return prepaid + module.exports = new PrepaidHandler() diff --git a/test/server/common.coffee b/test/server/common.coffee index 40b815cd4..0635cbc45 100644 --- a/test/server/common.coffee +++ b/test/server/common.coffee @@ -32,6 +32,7 @@ models_path = [ '../../server/articles/Article' '../../server/campaigns/Campaign' '../../server/clans/Clan' + '../../server/classrooms/Classroom' '../../server/courses/Course' '../../server/courses/CourseInstance' '../../server/levels/Level' @@ -163,8 +164,6 @@ GLOBAL.purchasePrepaid = (type, properties, maxRedeemers, token, done) -> options.json.stripe.token = token if token? if type is 'terminal_subscription' options.json.months = properties.months - else if type is 'course' - options.json.courseID = properties.courseID if properties?.courseID request.post options, done GLOBAL.subscribeWithPrepaid = (ppc, done) => diff --git a/test/server/functional/classrooms.spec.coffee b/test/server/functional/classrooms.spec.coffee new file mode 100644 index 000000000..4bd176b1c --- /dev/null +++ b/test/server/functional/classrooms.spec.coffee @@ -0,0 +1,149 @@ +config = require '../../../server_config' +require '../common' +utils = require '../../../app/core/utils' # Must come after require /common +mongoose = require 'mongoose' + +classroomsURL = getURL('/db/classroom') + +describe 'GET /db/classroom?ownerID=:id', -> + it 'clears database users and classrooms', (done) -> + clearModels [User, Classroom], (err) -> + throw err if err + done() + + it 'returns an array of classrooms with the given owner', (done) -> + loginNewUser (user1) -> + new Classroom({name: 'Classroom 1', ownerID: user1.get('_id') }).save (err, classroom) -> + expect(err).toBeNull() + loginNewUser (user2) -> + new Classroom({name: 'Classroom 2', ownerID: user2.get('_id') }).save (err, classroom) -> + expect(err).toBeNull() + url = getURL('/db/classroom?ownerID='+user2.id) + request.get { uri: url, json: true }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.length).toBe(1) + expect(body[0].name).toBe('Classroom 2') + done() + + it 'returns 403 when a non-admin tries to get classrooms for another user', (done) -> + loginNewUser (user1) -> + loginNewUser (user2) -> + url = getURL('/db/classroom?ownerID='+user1.id) + request.get { uri: url }, (err, res, body) -> + expect(res.statusCode).toBe(403) + done() + + +describe 'GET /db/classroom/:id', -> + it 'clears database users and classrooms', (done) -> + clearModels [User, Classroom], (err) -> + throw err if err + done() + + it 'returns the classroom for the given id', (done) -> + loginNewUser (user1) -> + data = { name: 'Classroom 1' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + classroomID = body._id + request.get {uri: classroomsURL + '/' + body._id }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body._id).toBe(classroomID = body._id) + done() + +describe 'POST /db/classroom', -> + + it 'clears database users and classrooms', (done) -> + clearModels [User, Classroom], (err) -> + throw err if err + done() + + it 'creates a new classroom for the given user', (done) -> + loginNewUser (user1) -> + data = { name: 'Classroom 1' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.name).toBe('Classroom 1') + expect(body.members.length).toBe(0) + expect(body.ownerID).toBe(user1.id) + done() + + it 'does not work for anonymous users', (done) -> + logoutUser -> + data = { name: 'Classroom 2' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(401) + done() + + +describe 'PUT /db/classroom', -> + + it 'clears database users and classrooms', (done) -> + clearModels [User, Classroom], (err) -> + throw err if err + done() + + it 'edits name and description', (done) -> + loginNewUser (user1) -> + data = { name: 'Classroom 2' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + data = { name: 'Classroom 3', description: 'New Description' } + url = classroomsURL + '/' + body._id + request.put { uri: url, json: data }, (err, res, body) -> + expect(body.name).toBe('Classroom 3') + expect(body.description).toBe('New Description') + done() + + it 'is not allowed if you are just a member', (done) -> + loginNewUser (user1) -> + data = { name: 'Classroom 4' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + classroomCode = body.code + loginNewUser (user2) -> + url = getURL("/db/classroom/~/members") + data = { code: classroomCode } + request.post { uri: url, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + url = classroomsURL + '/' + body._id + request.put { uri: url, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(403) + done() + +describe 'POST /db/classroom/:id/members', -> + + it 'clears database users and classrooms', (done) -> + clearModels [User, Classroom], (err) -> + throw err if err + done() + + it 'adds the signed in user to the list of members in the classroom', (done) -> + loginNewUser (user1) -> + data = { name: 'Classroom 5' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + classroomCode = body.code + classroomID = body._id + expect(res.statusCode).toBe(200) + loginNewUser (user2) -> + url = getURL("/db/classroom/~/members") + data = { code: classroomCode } + request.post { uri: url, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + Classroom.findById classroomID, (err, classroom) -> + expect(classroom.get('members').length).toBe(1) + done() + + +describe 'POST /db/classroom/:id/invite-members', -> + + it 'takes a list of emails and sends invites', (done) -> + loginNewUser (user1) -> + data = { name: 'Classroom 6' } + request.post {uri: classroomsURL, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + url = classroomsURL + '/' + body._id + '/invite-members' + data = { emails: ['test@test.com'] } + request.post { uri: url, json: data }, (err, res, body) -> + expect(res.statusCode).toBe(200) + done() diff --git a/test/server/functional/course_instance.spec.coffee b/test/server/functional/course_instance.spec.coffee index ff6506a01..fe7d154ae 100644 --- a/test/server/functional/course_instance.spec.coffee +++ b/test/server/functional/course_instance.spec.coffee @@ -2,353 +2,158 @@ async = require 'async' config = require '../../../server_config' require '../common' stripe = require('stripe')(config.stripe.secretKey) +init = require '../init' -# TODO: add permissiosn tests +describe 'POST /db/course_instance', -> -describe 'CourseInstance', -> - courseInstanceCreateURL = getURL('/db/course_instance/-/create') - courseInstanceRedeemURL = getURL('/db/course_instance/-/redeem_prepaid') - userURL = getURL('/db/user') - - createCourseInstances = (user, courseID, seats, token, done) -> - name = createName 'course instance ' - requestBody = - courseID: courseID - name: name - seats: seats - stripe: - token: token - request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(201) - CourseInstance.find {name: name}, (err, courseInstances) -> - expect(err).toBeNull() - - makeCourseInstanceVerifyFn = (courseInstance) -> - (done) -> - expect(courseInstance.get('name')).toEqual(name) - expect(courseInstance.get('ownerID')).toEqual(user.get('_id')) - expect(courseInstance.get('members')).toContain(user.get('_id')) - query = {$and: [{creator: user.get('_id')}]} - query.$and.push {'properties.courseIDs': {$in: [courseID]}} if courseID - Prepaid.find query, (err, prepaids) -> - expect(err).toBeNull() - return done(err) if err - expect(prepaids?.length).toEqual(1) - return done() unless prepaids?.length > 0 - expect(prepaids[0].get('type')).toEqual('course') - expect(prepaids[0].get('maxRedeemers')).toEqual(seats) if seats - - # TODO: verify Payment - - done(err) - - tasks = [] - for courseInstance in courseInstances - tasks.push makeCourseInstanceVerifyFn(courseInstance) - async.parallel tasks, (err) => - return done(err) if err - done(err, courseInstances) - - it 'Clear database', (done) -> - clearModels [User, Course, CourseInstance, Prepaid], (err) -> - throw err if err + beforeEach (done) -> clearModels([CourseInstance, Course, User, Classroom], done) + beforeEach (done) -> loginJoe (@joe) => done() + beforeEach init.course() + beforeEach init.classroom() + + it 'creates a CourseInstance', (done) -> + test = @ + url = getURL('/db/course_instance') + data = { + name: 'Some Name' + courseID: test.course.id + classroomID: test.classroom.id + } + request.post {uri: url, json: data}, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.classroomID).toBeDefined() + done() + + it 'returns 404 if the Course does not exist', (done) -> + test = @ + url = getURL('/db/course_instance') + data = { + name: 'Some Name' + courseID: '123456789012345678901234' + classroomID: test.classroom.id + } + request.post {uri: url, json: data}, (err, res, body) -> + expect(res.statusCode).toBe(404) done() - describe 'Single courses', -> - it 'Create for free course 1 seat', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - done() + it 'returns 404 if the Classroom does not exist', (done) -> + test = @ + url = getURL('/db/course_instance') + data = { + name: 'Some Name' + courseID: test.course.id + classroomID: '123456789012345678901234' + } + request.post {uri: url, json: data}, (err, res, body) -> + expect(res.statusCode).toBe(404) + done() + + it 'return 403 if the logged in user does not own the Classroom', (done) -> + test = @ + loginSam -> + url = getURL('/db/course_instance') + data = { + name: 'Some Name' + courseID: test.course.id + classroomID: test.classroom.id + } + request.post {uri: url, json: data}, (err, res, body) -> + expect(res.statusCode).toBe(403) + done() + + +describe 'POST /db/course_instance/:id/members', -> + + beforeEach (done) -> clearModels([CourseInstance, Course, User, Classroom, Prepaid], done) + beforeEach (done) -> loginJoe (@joe) => done() + beforeEach init.course({free: true}) + beforeEach init.classroom() + beforeEach init.courseInstance() + beforeEach init.user() + beforeEach init.prepaid() + + it 'adds a member to the given CourseInstance', (done) -> + async.eachSeries([ + + addTestUserToClassroom, + (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.post {uri: url, json: {userID: test.user.id}}, (err, res, body) -> + expect(res.statusCode).toBe(200) + expect(body.members.length).toBe(1) + expect(body.members[0]).toBe(test.user.id) + cb() + + ], makeTestIterator(@), done) + + + it 'return 403 if the member is not in the classroom', (done) -> + async.eachSeries([ + + (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.post {uri: url, json: {userID: test.user.id}}, (err, res) -> + expect(res.statusCode).toBe(403) + cb() + + ], makeTestIterator(@), done) + + + it 'returns 403 if the user does not own the course instance and is not adding self', (done) -> + async.eachSeries([ - it 'Create for free course no seats', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - name = createName 'course instance ' - requestBody = - courseID: course.get('_id') - name: createName('course instance ') - request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(422) - done() + addTestUserToClassroom, + (test, cb) -> + loginSam -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.post {uri: url, json: {userID: test.user.id}}, (err, res, body) -> + expect(res.statusCode).toBe(403) + cb() - it 'Create for free course no token', (done) -> - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 2, null, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - done() + ], makeTestIterator(@), done) + + it 'returns 200 if the user is a member of the classroom and is adding self', -> - it 'Create for paid course 1 seat', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 7000, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - expect(prepaid.get('maxRedeemers')).toEqual(1) - expect(prepaid.get('properties')?.courseIDs).toEqual([course.get('_id')]) - done() + it 'return 402 if the course is not free and the user is not in a prepaid', (done) -> + async.eachSeries([ + + addTestUserToClassroom, + makeTestCourseNotFree, + (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.post {uri: url, json: {userID: test.user.id}}, (err, res) -> + expect(res.statusCode).toBe(402) + cb() + + ], makeTestIterator(@), done) + + + it 'works if the course is not free and the user is in a prepaid', (done) -> + async.eachSeries([ + + addTestUserToClassroom, + makeTestCourseNotFree, + addTestUserToPrepaid, + (test, cb) -> + url = getURL("/db/course_instance/#{test.courseInstance.id}/members") + request.post {uri: url, json: {userID: test.user.id}}, (err, res) -> + expect(res.statusCode).toBe(200) + cb() + + ], makeTestIterator(@), done) + + + makeTestCourseNotFree = (test, cb) -> + test.course.set('free', false) + test.course.save cb + + addTestUserToClassroom = (test, cb) -> + test.classroom.set('members', [test.user.get('_id')]) + test.classroom.save cb - it 'Create for paid course 50 seats', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 7000, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 50, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - expect(prepaid.get('maxRedeemers')).toEqual(50) - expect(prepaid.get('properties')?.courseIDs).toEqual([course.get('_id')]) - done() + addTestUserToPrepaid = (test, cb) -> + test.prepaid.set('redeemers', [{userID: test.user.get('_id')}]) + test.prepaid.save cb - it 'Create for paid course no token', (done) -> - loginNewUser (user1) -> - createCourse 7000, (err, course) -> - expect(err).toBeNull() - return done(err) if err - name = createName 'course instance ' - requestBody = - courseID: course.get('_id') - name: createName('course instance ') - seats: 1 - request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(422) - done() - - it 'Create for paid course -1 seats', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 7000, (err, course) -> - expect(err).toBeNull() - return done(err) if err - name = createName 'course instance ' - requestBody = - courseID: course.get('_id') - name: createName('course instance ') - seats: -1 - request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(422) - done() - - describe 'All Courses', -> - it 'Create for 50 seats', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 7000, (err, course1) -> - expect(err).toBeNull() - return done(err) if err - createCourse 7000, (err, course2) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, null, 50, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - Course.find {}, (err, courses) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(courses.length) - Prepaid.find creator: user1.get('_id'), (err, prepaids) -> - expect(err).toBeNull() - return done(err) if err - expect(prepaids.length).toEqual(1) - return done('no prepaids found') unless prepaids?.length > 0 - prepaid = prepaids[0] - expect(prepaid.get('maxRedeemers')).toEqual(50) - expect(prepaid.get('properties')?.courseIDs?.length).toEqual(courses.length) - done() - - describe 'Invite to course', -> - it 'takes a list of emails and sends invites', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - inviteStudentsURL = getURL("/db/course_instance/#{courseInstances[0]._id}/invite_students") - requestBody = { - emails: ['test@test.com'] - } - request.post { uri: inviteStudentsURL, json: requestBody }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - done() - - describe 'Redeem prepaid code', -> - - it 'Redeem prepaid code for an instance of max 2', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 2, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - loginNewUser (user2) -> - request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - - # Check prepaid - Prepaid.findById prepaid.id, (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - expect(prepaid.get('redeemers')?.length).toEqual(1) - expect(prepaid.get('redeemers')[0].date).toBeLessThan(new Date()) - expect(prepaid.get('redeemers')[0].userID).toEqual(user2.get('_id')) - - # Check course instance - CourseInstance.findById courseInstances[0].id, (err, courseInstance) -> - expect(err).toBeNull() - return done(err) if err - members = courseInstance.get('members') - expect(members?.length).toEqual(2) - # TODO: must be a better way to check membership - usersFound = 0 - for memberID in members - usersFound++ if memberID.equals(user1.get('_id')) - usersFound++ if memberID.equals(user2.get('_id')) - expect(usersFound).toEqual(2) - done() - - it 'Redeem full prepaid code for on instance of max 1', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - loginNewUser (user2) -> - request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - loginNewUser (user3) -> - request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(403) - done() - - it 'Redeem 50 count course prepaid codes 51 times, in parallel', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - seatCount = 50 - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), seatCount, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - - forbiddenResults = 0 - makeRedeemCall = -> - (callback) -> - loginNewUser (user2) -> - request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) -> - expect(err).toBeNull() - if res.statusCode is 403 - forbiddenResults++ - else - expect(res.statusCode).toBe(200) - callback err - tasks = (makeRedeemCall() for i in [1..seatCount+1]) - async.parallel tasks, (err, results) -> - expect(err?).toEqual(false) - expect(forbiddenResults).toEqual(1) - Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - expect(prepaid.get('redeemers')?.length).toEqual(prepaid.get('maxRedeemers')) - done() - - it 'Redeem prepaid code twice', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 0, (err, course) -> - expect(err).toBeNull() - return done(err) if err - createCourseInstances user1, course.get('_id'), 2, token.id, (err, courseInstances) -> - expect(err).toBeNull() - return done(err) if err - expect(courseInstances.length).toEqual(1) - Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) -> - expect(err).toBeNull() - return done(err) if err - loginNewUser (user2) -> - # Redeem once - request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - # Redeem twice - request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - done() +makeTestIterator = (testObject) -> (func, callback) -> func(testObject, callback) + \ No newline at end of file diff --git a/test/server/functional/prepaid.spec.coffee b/test/server/functional/prepaid.spec.coffee index ece5867e3..f299e5dda 100644 --- a/test/server/functional/prepaid.spec.coffee +++ b/test/server/functional/prepaid.spec.coffee @@ -19,7 +19,6 @@ describe '/db/prepaid', -> expect(prepaid.type).toEqual('course') expect(prepaid.maxRedeemers).toBeGreaterThan(0) expect(prepaid.code).toMatch(/^\w{8}$/) - expect(prepaid.properties?.courseIDs?.length).toBeGreaterThan(0) done() verifySubscriptionPrepaid = (user, prepaid, done) -> @@ -30,6 +29,94 @@ describe '/db/prepaid', -> expect(prepaid.properties?.couponID).toEqual('free') done() + it 'Clear database', (done) -> + clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) -> + throw err if err + done() + + describe 'POST /db/prepaid//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 + }) + 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) + done() + + it 'does not allow more redeemers than maxRedeemers', (done) -> + loginNewUser (user1) -> + prepaid = new Prepaid({ + maxRedeemers: 0, + redeemers: [], + creator: user1.get('_id') + code: 1 + }) + 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 + }) + 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() + otherUser.save (err, otherUser) -> + prepaid1 = new Prepaid({ + redeemers: [{userID: otherUser.get('_id')}], + code: 3 + }) + prepaid1.save (err, prepaid1) -> + prepaid2 = new Prepaid({ + maxRedeemers: 10, + redeemers: [], + creator: user1.get('_id') + code: 4 + }) + 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 'Clear database', (done) -> clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) -> throw err if err @@ -141,68 +228,36 @@ describe '/db/prepaid', -> done() unless found describe 'Purchase course', -> - it 'Standard user purchases a prepaid for one course, 0 seats', (done) -> + it 'Standard user purchases a prepaid for 0 seats', (done) -> stripe.tokens.create { card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } }, (err, token) -> loginNewUser (user1) -> - createCourse 700, (err, course) -> + purchasePrepaid 'course', {}, 0, token.id, (err, res, prepaid) -> expect(err).toBeNull() - purchasePrepaid 'course', courseID: course.id, 0, token.id, (err, res, prepaid) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(422) - done() - it 'Standard user purchases a prepaid for one course, 1 seat', (done) -> + expect(res.statusCode).toBe(422) + done() + + it 'Standard user purchases a prepaid for 1 seat', (done) -> stripe.tokens.create { card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } }, (err, token) -> loginNewUser (user1) -> - createCourse 700, (err, course) -> + purchasePrepaid 'course', {}, 1, token.id, (err, res, prepaid) -> expect(err).toBeNull() - purchasePrepaid 'course', courseID: course.id, 1, token.id, (err, res, prepaid) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - verifyCoursePrepaid(user1, prepaid, done) - it 'Standard user purchases a prepaid for one course, 3 seats', (done) -> - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 700, (err, course) -> - expect(err).toBeNull() - purchasePrepaid 'course', courseID: course.id, 3, token.id, (err, res, prepaid) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - verifyCoursePrepaid(user1, prepaid, done) - it 'Standard user purchases a prepaid for all courses, 10 seats', (done) -> - clearModels [Course, CourseInstance, Payment, Prepaid, User], (err) -> - throw err if err - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user1) -> - createCourse 700, (err, course) -> - expect(err).toBeNull() - createCourse 700, (err, course) -> - expect(err).toBeNull() - purchasePrepaid 'course', null, 10, token.id, (err, res, prepaid) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - expect(prepaid.properties?.courseIDs?.length).toEqual(2) - verifyCoursePrepaid(user1, prepaid, done) + expect(res.statusCode).toBe(200) + verifyCoursePrepaid(user1, prepaid, done) - it 'Standard user purchases a prepaid course for 3', (done) -> + it 'Standard user purchases a prepaid for 3 seats', (done) -> stripe.tokens.create { card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } }, (err, token) -> loginNewUser (user1) -> - createCourse 700, (err, course) -> + purchasePrepaid 'course', {}, 3, token.id, (err, res, prepaid) -> expect(err).toBeNull() - purchasePrepaid 'course', courseID: course.id, 3, token.id, (err, res, prepaid) -> - expect(err).toBeNull() - expect(res.statusCode).toBe(200) - done() - + expect(res.statusCode).toBe(200) + verifyCoursePrepaid(user1, prepaid, done) + describe 'Purchase terminal_subscription', -> it 'Anonymous submits a prepaid purchase', (done) -> stripe.tokens.create { @@ -306,6 +361,7 @@ describe '/db/prepaid', -> joeCode = prepaid.code expect(prepaid.creator).toBeDefined() expect(prepaid.maxRedeemers).toEqual(3) + expect(prepaid.exhausted).toBe(false) expect(prepaid.properties).toBeDefined() expect(prepaid.properties.months).toEqual(3) done() @@ -464,47 +520,47 @@ describe '/db/prepaid', -> expect(res.statusCode).not.toEqual(200) done() - it 'Test a bunch of people trying to redeem at once', (done) -> - doRedeem = (userX, code, testnum, retry, fnDone) => - loginUser userX, () => - endDate = new moment().add(3, 'months').toISOString().substring(0, 10) - subscribeWithPrepaid code, (err, res, result) -> - if err - return fnDone(err) - - expect(err).toBeNull() - expect(result).toBeDefined() - if result.stripe - expect(result.stripe).toBeDefined() - expect(result.stripe.free).toEqual(endDate) - expect(result?.purchased?.gems).toEqual(10500) - return fnDone(null, {status: "ok", msg: "Redeemed " + retry}) - else - return fnDone(null, {status: 'error', msg: "Redeem attempt Error #{result} (#{userX.id})" + retry }) - - redeemPrepaidFn = (code, testnum) => - (fnDone) => - loginNewUser (user1) => - doRedeem(user1, code, testnum, 0, fnDone) - - stripe.tokens.create { - card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } - }, (err, token) -> - loginNewUser (user) => - codeRedeemers = 50 - codeMonths = 3 - redeemers = 51 - purchasePrepaid 'terminal_subscription', months: codeMonths, codeRedeemers, token.id, (err, res, prepaid) -> - expect(err).toBeNull() - expect(prepaid).toBeDefined() - expect(prepaid.code).toBeDefined() - tasks = (redeemPrepaidFn(prepaid.code, i) for i in [0...redeemers]) - async.parallel tasks, (err, results) => - redeemed = 0 - error = 0 - for result in results - redeemed += 1 if result.status is 'ok' - error += 1 if result.status is 'error' - expect(redeemed).toEqual(codeRedeemers) - expect(error).toEqual(redeemers - codeRedeemers) - done() +# it 'Test a bunch of people trying to redeem at once', (done) -> +# doRedeem = (userX, code, testnum, retry, fnDone) => +# loginUser userX, () => +# endDate = new moment().add(3, 'months').toISOString().substring(0, 10) +# subscribeWithPrepaid code, (err, res, result) -> +# if err +# return fnDone(err) +# +# expect(err).toBeNull() +# expect(result).toBeDefined() +# if result.stripe +# expect(result.stripe).toBeDefined() +# expect(result.stripe.free).toEqual(endDate) +# expect(result?.purchased?.gems).toEqual(10500) +# return fnDone(null, {status: "ok", msg: "Redeemed " + retry}) +# else +# return fnDone(null, {status: 'error', msg: "Redeem attempt Error #{result} (#{userX.id})" + retry }) +# +# redeemPrepaidFn = (code, testnum) => +# (fnDone) => +# loginNewUser (user1) => +# doRedeem(user1, code, testnum, 0, fnDone) +# +# stripe.tokens.create { +# card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' } +# }, (err, token) -> +# loginNewUser (user) => +# codeRedeemers = 50 +# codeMonths = 3 +# redeemers = 51 +# purchasePrepaid 'terminal_subscription', months: codeMonths, codeRedeemers, token.id, (err, res, prepaid) -> +# expect(err).toBeNull() +# expect(prepaid).toBeDefined() +# expect(prepaid.code).toBeDefined() +# tasks = (redeemPrepaidFn(prepaid.code, i) for i in [0...redeemers]) +# async.parallel tasks, (err, results) => +# redeemed = 0 +# error = 0 +# for result in results +# redeemed += 1 if result.status is 'ok' +# error += 1 if result.status is 'error' +# expect(redeemed).toEqual(codeRedeemers) +# expect(error).toEqual(redeemers - codeRedeemers) +# done() diff --git a/test/server/init.coffee b/test/server/init.coffee new file mode 100644 index 000000000..912ff6b1a --- /dev/null +++ b/test/server/init.coffee @@ -0,0 +1,86 @@ + +module.exports.course = (properties) -> + properties ?= {} + _.defaults(properties, { + name: 'Unnamed course' + campaignID: ObjectId("55b29efd1cd6abe8ce07db0d") + concepts: ['basic_syntax', 'arguments', 'while_loops', 'strings', 'variables'] + description: "Learn basic syntax, while loops, and the CodeCombat environment." + screenshot: "/images/pages/courses/101_info.png" + }) + + return (done) -> + test = @ + course = new Course(properties) + course.save (err, course) -> + expect(err).toBeNull() + test.course = course + done() + + +module.exports.classroom = (givenProperties) -> + return (done) -> + properties = _.defaults({}, givenProperties, { + name: 'Unnamed classroom' + }) + test = @ + url = getURL('/db/classroom') + request.post {uri: url, json: properties}, (err, res, body) -> + expect(res.statusCode).toBe(200) + Classroom.findById body._id, (err, classroom) -> + expect(err).toBeNull() + expect(classroom).toBeTruthy() + test.classroom = classroom + done() + + +module.exports.courseInstance = (givenProperties) -> + return (done) -> + properties = _.defaults({}, givenProperties, { + name: 'Unnamed course instance' + }) + test = @ + url = getURL('/db/course_instance') + properties.courseID ?= test.course.id + properties.classroomID ?= test.classroom.id + request.post {uri: url, json: properties}, (err, res, body) -> + expect(res.statusCode).toBe(200) + CourseInstance.findById body._id, (err, courseInstance) -> + expect(err).toBeNull() + expect(courseInstance).toBeTruthy() + test.courseInstance = courseInstance + done() + + +module.exports.user = (givenOptions) -> + return (done) -> + options = _.defaults({}, givenOptions, { + setTo: 'user', + properties: { + name: 'User'+_.uniqueId() + } + }) + test = @ + user = new User(options.properties) + user.save (err, user) -> + expect(err).toBeNull() + test[options.setTo] = user + done() + + +module.exports.prepaid = (givenOptions) -> + return (done) -> + options = _.defaults({}, givenOptions, { + setTo: 'prepaid', + properties: { + type: 'course' + maxRedeemers: 10 + redeemers: [] + } + }) + test = @ + prepaid = new Prepaid(options.properties) + prepaid.save (err, prepaid) -> + expect(err).toBeNull() + test[options.setTo] = prepaid + done() \ No newline at end of file