Merge branch 'master' into production

This commit is contained in:
Nick Winter 2015-11-11 05:53:17 -08:00
commit 6bf61dab39
68 changed files with 2624 additions and 1367 deletions

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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')

128
app/core/d3_utils.coffee Normal file
View file

@ -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++

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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 могутніх <strong>нових героїв</strong> з унікальними здібностями!" # {change}
feature3: "Більше 30 бонусних рівнів" # {change}
feature1: "Більше 110 основних рівней на просторах 4 світів"
feature2: "10 могутніх <strong>нових героїв</strong> з унікальними здібностями!"
feature3: "Більше 80-ти бонусних рівнів"
feature4: "<strong>3500 бонусних самоцвітів</strong> кожного місяця!"
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: "Назва посилання"

View file

@ -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'

View file

@ -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)

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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:

View file

@ -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')

View file

@ -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'

View file

@ -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

View file

@ -0,0 +1,3 @@
#courses-view
.row
margin-top: 40px

View file

@ -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

View file

@ -0,0 +1 @@
//#hour-of-code-view

View file

@ -0,0 +1 @@
//#student-courses-view

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -185,7 +185,7 @@ block content
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

View file

@ -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

View file

@ -2,6 +2,8 @@ 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

View file

@ -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

View file

@ -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

View file

@ -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 &times;
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 &times;
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')

View file

@ -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")

View file

@ -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")

View file

@ -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='<number of seats>'
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

View file

@ -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='<enter unlock code here>',
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')

View file

@ -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

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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'}

View file

@ -132,6 +132,7 @@ module.exports = class CocoView extends Backbone.View
context.translate = $.i18n.t
context.view = @
context._ = _
context.document = document
context
afterRender: ->

View file

@ -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()

View file

@ -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($('<img src="https://code.org/api/hour/begin_codecombat.png" style="visibility: hidden;">'))
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 = "<h3>" + $.i18n.t('courses.faq') + "<button type='button' class='close' onclick='$(&#39;.courses-faq&#39;).popover(&#39;hide&#39;);'>&times;</button></h3>"
popoverContent = "<p><strong>" + $.i18n.t('courses.question') + "</strong> " + $.i18n.t('courses.question1') + "</p>"
popoverContent += "<p><strong>" + $.i18n.t('courses.answer') + "</strong> " + $.i18n.t('courses.answer1') + "</p>"
popoverContent += "<p>" + $.i18n.t('courses.answer2') + "</p>"
@$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?()

View file

@ -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($('<img src="https://code.org/api/hour/begin_codecombat.png" style="visibility: hidden;">'))
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
})
})

View file

@ -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')
})

View file

@ -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?()
})

View file

@ -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()

View file

@ -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')

View file

@ -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()

View file

@ -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

View file

@ -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 <code> instead of <div>, 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

View file

@ -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

View file

@ -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

View file

@ -37,6 +37,8 @@
"brunch": "brunch",
"bower": "bower",
"dev": "brunch watch --server",
"nodemon": "nodemon",
"jasmine-node": "jasmine-node",
"multicore": "coffee multicore.coffee",
"nodemon": "nodemon"
},

View file

@ -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;

View file

@ -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);
});

View file

@ -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'

View file

@ -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()

View file

@ -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'

View file

@ -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

View file

@ -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')
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)
# 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) =>
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)
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))
createInstance: (req, course, name, prepaid, aceConfig, done) =>
courseInstance = new CourseInstance
courseID: course.get('_id')
members: [req.user.get('_id')]
name: name
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()

View file

@ -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 = ->
@ -13,4 +16,21 @@ PrepaidSchema.statics.generateNewCode = (done) ->
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)

View file

@ -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()

View file

@ -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) =>

View file

@ -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()

View file

@ -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')
beforeEach (done) -> clearModels([CourseInstance, Course, User, Classroom], done)
beforeEach (done) -> loginJoe (@joe) => done()
beforeEach init.course()
beforeEach init.classroom()
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
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()
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 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()
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()
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 '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()
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()
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 '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()
describe 'POST /db/course_instance/:id/members', ->
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()
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 '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()
it 'adds a member to the given CourseInstance', (done) ->
async.eachSeries([
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()
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()
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()
], makeTestIterator(@), 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)
it 'return 403 if the member is not in the classroom', (done) ->
async.eachSeries([
# 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'))
(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()
# 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()
], makeTestIterator(@), 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
it 'returns 403 if the user does not own the course instance and is not adding self', (done) ->
async.eachSeries([
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()
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()
], makeTestIterator(@), done)
it 'returns 200 if the user is a member of the classroom and is adding self', ->
it 'return 402 if the course is not free and the user is not in a prepaid', (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
addTestUserToPrepaid = (test, cb) ->
test.prepaid.set('redeemers', [{userID: test.user.get('_id')}])
test.prepaid.save cb
makeTestIterator = (testObject) -> (func, callback) -> func(testObject, callback)
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()

View file

@ -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) ->
@ -35,6 +34,94 @@ describe '/db/prepaid', ->
throw err if err
done()
describe 'POST /db/prepaid/<id>/redeemers', ->
it 'adds a given user to the redeemers property', (done) ->
loginNewUser (user1) ->
prepaid = new Prepaid({
maxRedeemers: 1,
redeemers: [],
creator: user1.get('_id')
code: 0
})
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
done()
it 'Anonymous creates prepaid code', (done) ->
createPrepaid 'subscription', 1, 0, (err, res, body) ->
expect(err).toBeNull()
@ -141,67 +228,35 @@ 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) ->
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, 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(422)
done()
it 'Standard user purchases a prepaid course for 3', (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, 3, token.id, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
done()
expect(res.statusCode).toBe(200)
verifyCoursePrepaid(user1, prepaid, 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) ->
purchasePrepaid 'course', {}, 3, token.id, (err, res, prepaid) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
verifyCoursePrepaid(user1, prepaid, done)
describe 'Purchase terminal_subscription', ->
it 'Anonymous submits a prepaid purchase', (done) ->
@ -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()

86
test/server/init.coffee Normal file
View file

@ -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()