mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-27 17:45:40 -05:00
Merge branch 'master' into production
This commit is contained in:
commit
6bf61dab39
68 changed files with 2624 additions and 1367 deletions
|
@ -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)
|
||||
|
|
BIN
app/assets/images/pages/about/josh_c_small.png
Normal file
BIN
app/assets/images/pages/about/josh_c_small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
|
@ -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
128
app/core/d3_utils.coffee
Normal 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++
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "Назва посилання"
|
||||
|
|
7
app/models/Classroom.coffee
Normal file
7
app/models/Classroom.coffee
Normal 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'
|
|
@ -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)
|
||||
|
|
14
app/schemas/models/classroom.schema.coffee
Normal file
14
app/schemas/models/classroom.schema.coffee
Normal 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
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
3
app/styles/courses/courses-view.sass
Normal file
3
app/styles/courses/courses-view.sass
Normal file
|
@ -0,0 +1,3 @@
|
|||
#courses-view
|
||||
.row
|
||||
margin-top: 40px
|
|
@ -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
|
1
app/styles/courses/hour-of-code-view.sass
Normal file
1
app/styles/courses/hour-of-code-view.sass
Normal file
|
@ -0,0 +1 @@
|
|||
//#hour-of-code-view
|
1
app/styles/courses/student-courses-view.sass
Normal file
1
app/styles/courses/student-courses-view.sass
Normal file
|
@ -0,0 +1 @@
|
|||
//#student-courses-view
|
21
app/styles/courses/teacher-courses-view.sass
Normal file
21
app/styles/courses/teacher-courses-view.sass
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
12
app/templates/courses/courses-view.jade
Normal file
12
app/templates/courses/courses-view.jade
Normal 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
|
|
@ -1,236 +0,0 @@
|
|||
extends /templates/base
|
||||
|
||||
block content
|
||||
|
||||
div(style='border-bottom: 1px solid black')
|
||||
span *UNDER CONSTRUCTION, please send feedback to
|
||||
a.spl(href='mailto:team@codecombat.com') team@codecombat.com
|
||||
|
||||
br
|
||||
|
||||
.hidden-md.hidden-lg
|
||||
.alert.alert-danger Courses not supported on mobile devices.
|
||||
|
||||
.hidden-xs.hidden-sm
|
||||
if state === 'enrolling'
|
||||
.alert.alert-info Enrolling in course..
|
||||
else if state === 'ppc_logged_out'
|
||||
.alert.alert-danger.logged_out Create account or log in to join this course.
|
||||
button.btn.btn-sm.btn-primary.header-font.signup-button(data-i18n="login.sign_up")
|
||||
button.btn.btn-sm.btn-default.header-font.login-button(data-i18n="login.log_in")
|
||||
else
|
||||
if state === 'unknown_error'
|
||||
.alert.alert-danger.alert-dismissible= stateMessage
|
||||
|
||||
if hocLandingPage
|
||||
+hoc-landing
|
||||
else
|
||||
if view.courseInstances.size()
|
||||
+course-instance-list
|
||||
|
||||
if studentMode
|
||||
+student-main
|
||||
else
|
||||
if hocMode
|
||||
+teacher-hoc
|
||||
else
|
||||
+teacher-main
|
||||
.container-fluid
|
||||
- var i = 0
|
||||
while i < courses.length
|
||||
.row
|
||||
+course-block(courses[i], instances)
|
||||
- i++
|
||||
if i < courses.length
|
||||
+course-block(courses[i], instances)
|
||||
- i++
|
||||
|
||||
|
||||
mixin hoc-landing
|
||||
h1.center Welcome to CodeCombat's Hour of Code!
|
||||
br
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-6.center
|
||||
button.btn.btn-lg.btn-success.btn-student(data-i18n="courses.students_click")
|
||||
.col-md-6.center
|
||||
button.btn.btn-lg.btn-default.btn-teacher(data-i18n="courses.teachers_click")
|
||||
|
||||
mixin course-instance-list
|
||||
h1.center Courses You Are In
|
||||
.row
|
||||
.col-md-10.col-md-offset-1
|
||||
.list-group
|
||||
for courseInstance in view.courseInstances.models
|
||||
- var course = view.courses.get(courseInstance.get('courseID'));
|
||||
.list-group-item
|
||||
.list-group-item-heading
|
||||
h3
|
||||
a(href="/courses/#{course.id}/#{courseInstance.id}")
|
||||
span.spr #{courseInstance.get('name')}
|
||||
small (#{course.get('name')})
|
||||
p= courseInstance.get('description')
|
||||
|
||||
mixin student-main
|
||||
button.btn.btn-warning.btn-teacher(data-i18n="courses.teachers_click")
|
||||
h1.center(data-i18n="courses.courses_on_coco")
|
||||
|
||||
mixin teacher-hoc
|
||||
button.btn.btn-warning.btn-student(data-i18n="courses.students_click")
|
||||
h1.center Welcome to CodeCombat's Hour of Code!
|
||||
p
|
||||
strong How to use CodeCombat with your students:
|
||||
ol
|
||||
li Click the green 'Get FREE course' button below
|
||||
li Follow the enrollment instructions
|
||||
li Add students via the 'Add Students' tab
|
||||
p
|
||||
span.spr If you have any problems, please email
|
||||
a(href='mailto:team@codecombat.com') team@codecombat.com
|
||||
br
|
||||
|
||||
mixin teacher-main
|
||||
button.btn.btn-warning.btn-student(data-i18n="courses.students_click")
|
||||
h1.center(data-i18n="courses.courses_on_coco")
|
||||
.info-container
|
||||
p(data-i18n="courses.designed_to")
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-6
|
||||
ul
|
||||
li(data-i18n="courses.more_in_less")
|
||||
li(data-i18n="courses.no_experience")
|
||||
li(data-i18n="courses.easy_monitor")
|
||||
|
||||
p(data-i18n="courses.purchase_for_class")
|
||||
p.faq-blurb
|
||||
span.spr(data-i18n="courses.see_the")
|
||||
a.courses-faq(data-i18n="courses.faq")
|
||||
span.spl(data-i18n="courses.more_info")
|
||||
.col-md-6
|
||||
img.img-quote(src="/images/pages/courses/coco_complab.png")
|
||||
p
|
||||
.well.well-sm
|
||||
div.praise-quote "#{praise.quote}"
|
||||
div.praise-caption - #{praise.source}
|
||||
|
||||
//- h1.center(data-i18n="courses.free_trial")
|
||||
//- .info-container
|
||||
//- p
|
||||
//- span.spr(data-i18n="teachers.teacher_subs_1")
|
||||
//- a(href='/teachers/freetrial', data-i18n="teachers.teacher_subs_2")
|
||||
//- span.spl(data-i18n="courses.get_access")
|
||||
|
||||
h2.center(data-i18n="courses.choose_course")
|
||||
|
||||
mixin student-dialog(course)
|
||||
.modal.continue-dialog(id="continueModal#{course.id}")
|
||||
.modal-dialog
|
||||
.modal-header
|
||||
button.close(data-dismiss='modal')
|
||||
span ×
|
||||
h3.modal-title= course.get('name')
|
||||
.modal-body
|
||||
.container-fluid
|
||||
.row.button-row
|
||||
.col-md-12
|
||||
.well.well-sm
|
||||
p
|
||||
div.instruction-label(data-i18n="courses.enter_code")
|
||||
.container-fluid
|
||||
.row.student-dialog-state-row
|
||||
.col-md-12
|
||||
if view.state === 'enrolling-by-modal'
|
||||
.progress.progress-striped.active
|
||||
.progress-bar(style="width: 100%")
|
||||
else if view.state === 'unknown_error'
|
||||
.alert.alert-danger= view.stateMessage
|
||||
.row
|
||||
.col-md-8
|
||||
input.code-input(type='text', data-course-id="#{course.id}", data-i18n="[placeholder]courses.enter_code1", placeholder="Enter unlock code")
|
||||
.col-md-4
|
||||
button.btn.btn-success.btn-enroll(data-course-id="#{course.id}", data-i18n="courses.enroll")
|
||||
if hocMode && course.get('pricePerSeat') === 0 || me.isAdmin()
|
||||
.row.button-row.center.row-pick-class
|
||||
.col-md-12
|
||||
br
|
||||
div.or(data-i18n="courses.or")
|
||||
.row.button-row.center
|
||||
.col-md-12
|
||||
button.btn.btn-success.btn-lg.btn-hoc-student-continue(data-course-id="#{course.id}") Continue by yourself
|
||||
|
||||
mixin teacher-dialog(course)
|
||||
.modal.continue-dialog(id="continueModal#{course.id}")
|
||||
.modal-dialog
|
||||
.modal-header
|
||||
button.close(data-dismiss='modal')
|
||||
span ×
|
||||
h3.modal-title= course.get('name')
|
||||
.modal-body
|
||||
.container-fluid
|
||||
if enrolledCourses[course.id]
|
||||
.row.button-row.row-pick-class
|
||||
.col-md-12
|
||||
.well.well-sm
|
||||
p
|
||||
div.instruction-label(data-i18n="courses.pick_from_classes")
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-8
|
||||
select.form-control.select-session(data-course-id="#{course.id}")
|
||||
each inst in instances
|
||||
if inst.get('courseID') == course.id
|
||||
if inst.get('name')
|
||||
option(value="#{inst.id}")= inst.get('name')
|
||||
else
|
||||
option(value="#{inst.id}", data-i18n="courses.unnamed")
|
||||
.col-md-4
|
||||
button.btn.btn-success.btn-enter(data-course-id="#{course.id}", data-i18n="courses.enter")
|
||||
.row.button-row.center.row-pick-class
|
||||
.col-md-12
|
||||
div.or(data-i18n="courses.or")
|
||||
.row.button-row.center
|
||||
.col-md-12
|
||||
if course.get('pricePerSeat') === 0 || me.isAdmin()
|
||||
button.btn.btn-success.btn-lg.btn-buy(data-course-id="#{course.id}") Start new class
|
||||
else
|
||||
button.btn.btn-success.btn-lg.btn-buy(data-course-id="#{course.id}", data-i18n="courses.buy_course1")
|
||||
|
||||
mixin course-block(course)
|
||||
if studentMode
|
||||
+student-dialog(course)
|
||||
else
|
||||
+teacher-dialog(course)
|
||||
.col-md-6
|
||||
.well.panel.course-panel(class=enrolledCourses[course.id] ? 'panel-success' : 'panel-info')
|
||||
.panel-heading
|
||||
.panel-title
|
||||
span.spr #{course.get('name')}
|
||||
strong #{enrolledCourses[course.id] ? '[ enrolled ]' : ''}
|
||||
.panel-body
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-12
|
||||
p
|
||||
img.course-image(src="#{course.get('screenshot')}")
|
||||
.row.button-row
|
||||
.col-md-6
|
||||
strong(data-i18n="courses.topics")
|
||||
ul
|
||||
each concept in course.get('concepts')
|
||||
li(data-i18n="concepts." + concept)
|
||||
strong
|
||||
span.spr(data-i18n="courses.hours_content")
|
||||
span #{course.get('duration')}
|
||||
.col-md-6.center(style='margin-top: 40px;')
|
||||
if studentMode
|
||||
if enrolledCourses[course.id]
|
||||
a.btn.btn-lg.btn-success.btn-continue(href="/courses/#{course.id}?student=true", data-i18n="common.continue")
|
||||
else
|
||||
button.btn.btn-lg.btn-success.btn-continue(data-toggle='modal', data-target="#continueModal#{course.id}", data-i18n="courses.enter") Enter
|
||||
else if enrolledCourses[course.id]
|
||||
button.btn.btn-lg.btn-success.btn-continue(data-toggle='modal', data-target="#continueModal#{course.id}", data-i18n="common.continue")
|
||||
else if course.get('pricePerSeat') === 0 || me.isAdmin()
|
||||
button.btn.btn-lg.btn-success.btn-buy(data-course-id="#{course.id}", data-i18n='courses.get_free')
|
||||
else
|
||||
button.btn.btn-lg.btn-success.btn-buy(data-course-id="#{course.id}", data-i18n='courses.buy_course')
|
11
app/templates/courses/hour-of-code-view.jade
Normal file
11
app/templates/courses/hour-of-code-view.jade
Normal 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")
|
22
app/templates/courses/invite-to-classroom-modal.jade
Normal file
22
app/templates/courses/invite-to-classroom-modal.jade
Normal 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")
|
41
app/templates/courses/purchase-courses-view.jade
Normal file
41
app/templates/courses/purchase-courses-view.jade
Normal 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
|
70
app/templates/courses/student-courses-view.jade
Normal file
70
app/templates/courses/student-courses-view.jade
Normal 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')
|
178
app/templates/courses/teacher-courses-view.jade
Normal file
178
app/templates/courses/teacher-courses-view.jade
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -132,6 +132,7 @@ module.exports = class CocoView extends Backbone.View
|
|||
context.translate = $.i18n.t
|
||||
context.view = @
|
||||
context._ = _
|
||||
context.document = document
|
||||
context
|
||||
|
||||
afterRender: ->
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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='$('.courses-faq').popover('hide');'>×</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?()
|
||||
|
||||
|
|
49
app/views/courses/HourOfCodeView.coffee
Normal file
49
app/views/courses/HourOfCodeView.coffee
Normal 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
|
||||
})
|
||||
})
|
32
app/views/courses/InviteToClassroomModal.coffee
Normal file
32
app/views/courses/InviteToClassroomModal.coffee
Normal 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')
|
||||
})
|
85
app/views/courses/PurchaseCoursesView.coffee
Normal file
85
app/views/courses/PurchaseCoursesView.coffee
Normal 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?()
|
||||
})
|
||||
|
93
app/views/courses/StudentCoursesView.coffee
Normal file
93
app/views/courses/StudentCoursesView.coffee
Normal 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()
|
204
app/views/courses/TeacherCoursesView.coffee
Normal file
204
app/views/courses/TeacherCoursesView.coffee
Normal 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')
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -37,6 +37,8 @@
|
|||
"brunch": "brunch",
|
||||
"bower": "bower",
|
||||
"dev": "brunch watch --server",
|
||||
"nodemon": "nodemon",
|
||||
"jasmine-node": "jasmine-node",
|
||||
"multicore": "coffee multicore.coffee",
|
||||
"nodemon": "nodemon"
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
34
scripts/mongodb/migrations/2015-11-10-course-correction.js
Normal file
34
scripts/mongodb/migrations/2015-11-10-course-correction.js
Normal 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);
|
||||
});
|
42
server/classrooms/Classroom.coffee
Normal file
42
server/classrooms/Classroom.coffee
Normal 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'
|
110
server/classrooms/classroom_handler.coffee
Normal file
110
server/classrooms/classroom_handler.coffee
Normal 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()
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) =>
|
||||
|
|
149
test/server/functional/classrooms.spec.coffee
Normal file
149
test/server/functional/classrooms.spec.coffee
Normal 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()
|
|
@ -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()
|
||||
|
|
|
@ -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
86
test/server/init.coffee
Normal 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()
|
Loading…
Reference in a new issue