v reálnych triedach/strong> a to dokonca aj učiteľmi, ktorí nemali predchádzajúce skúsenosti v programovaní."
+ our_courses: "Naše kurzy boli špeciálne testované v reálnych triedach a to dokonca aj učiteľmi, ktorí nemali predchádzajúce skúsenosti v programovaní."
top_screenshots_hint: "Zmeny v správaní programu vidia študenti v reálnom čase."
designed_with: "Navrhnuté s ohľadom na potreby učiteľov"
real_code: "Skutočný kód"
from_the_first_level: "od úplného začiatku"
- getting_students: "Umožniť študentom písať kód, čo najrýchlejšie, je kritické pri výuke syntaxe a správnej štruktúry programu."
+ getting_students: "Umožniť študentom písať kód, čo najrýchlejšie,čo je kritické pri výuke syntaxe a správnej štruktúry programu."
educator_resources: "Zdroje pre učiteľov"
course_guides: "a príručky ku kurzu"
teaching_computer_science: "Výučbu programovania zvládne s nami každý učiteľ"
@@ -316,8 +316,8 @@ module.exports = nativeDescription: "slovenčina", englishDescription: "Slovak",
course: "Kurz:"
done: "Hotovo"
next_level: "Ďalšia úroveň:"
-# next_game: "Next game"
-# show_menu: "Show game menu"
+ next_game: "Ďalšia hra"
+ show_menu: "Ukáž menu hry"
home: "Domov" # Not used any more, will be removed soon.
level: "Úroveň" # Like "Level: Dungeons of Kithgard"
skip: "Preskočiť"
@@ -347,13 +347,13 @@ module.exports = nativeDescription: "slovenčina", englishDescription: "Slovak",
victory_saving_progress: "Stav ukladania"
victory_go_home: "Návrat Domov"
victory_review: "Povedz nám viac!"
-# victory_review_placeholder: "How was the level?"
+ victory_review_placeholder: "Ako sa ti páčilo?"
victory_hour_of_code_done: "Skončil si?"
victory_hour_of_code_done_yes: "Áno, pre dnešok som skončil™!"
victory_experience_gained: "Získaných XP"
victory_gems_gained: "Získaných kryštálov"
-# victory_new_item: "New Item"
-# victory_viking_code_school: "Holy smokes, that was a hard level you just beat! If you aren't already a software developer, you should be. You just got fast-tracked for acceptance with Viking Code School, where you can take your skills to the next level and become a professional web developer in 14 weeks."
+ victory_new_item: "Nový predmet"
+ victory_viking_code_school: "No teda, podarilo sa ti prejsť veľmi ťažkú úroveň! Ak nie si vývojar softvéru, tak je najvyšší čas. Si prijatý do Vikingskej školy programovania,kde môžeš ďalej rozvinúť svoje programovacie schopnosti a stať sa profesionálnym webovým vývojarom za 14 týždňov."
victory_become_a_viking: "Staň sa vikingom!"
guide_title: "Návod"
tome_cast_button_run: "Spustiť"
@@ -376,11 +376,11 @@ module.exports = nativeDescription: "slovenčina", englishDescription: "Slovak",
time_current: "Teraz:"
time_total: "Max:"
time_goto: "Choď na:"
-# non_user_code_problem_title: "Unable to Load Level"
-# infinite_loop_title: "Infinite Loop Detected"
-# infinite_loop_description: "The initial code to build the world never finished running. It's probably either really slow or has an infinite loop. Or there might be a bug. You can either try running this code again or reset the code to the default state. If that doesn't fix it, please let us know."
-# check_dev_console: "You can also open the developer console to see what might be going wrong."
-# check_dev_console_link: "(instructions)"
+ non_user_code_problem_title: "Nie je možné nahrať úroveň."
+ infinite_loop_title: "Odhalená nekonečná slučka"
+ infinite_loop_description: "Úvodný kód vytvorenia sveta neskončil. Je buď neskutočne pomalý alebo obsahuje nekonečnú slučku. Možná je aj chyba. Skús spustiť program znovu alebo obnov kód do pôvodného stavu. Ak nič nepomôže, oznám nám to, prosím."
+ check_dev_console: "Môžeš otvoriť aj Nástroje pre vývojárov a pozri sa, v čom by mohla byť chyba."
+ check_dev_console_link: "(inštrukcie)"
infinite_loop_try_again: "Skús znova"
infinite_loop_reset_level: "Reštartuj level"
infinite_loop_comment_out: "Zakomentovať môj kód"
@@ -442,13 +442,13 @@ module.exports = nativeDescription: "slovenčina", englishDescription: "Slovak",
tip_sharpening_swords: "Naostri si meče."
tip_ratatouille: "Nenechaj nikomu definovať svoje hranice len z dôvodu tvojho pôvodu. Tvojím jediným obmedzením si iba ty sám. - Gusteau, Ratatouille"
tip_nemo: "Ak ťa život zráža dolu, chceš vedieť čo ti pomôže? Plávaj, len stále plávaj. - Dory, Finding Nemo"
-# tip_internet_weather: "Just move to the internet, it's great here. We get to live inside where the weather is always awesome. - John Green"
-# tip_nerds: "Nerds are allowed to love stuff, like jump-up-and-down-in-the-chair-can't-control-yourself love it. - John Green"
-# tip_self_taught: "I taught myself 90% of what I've learned. And that's normal! - Hank Green"
-# tip_luna_lovegood: "Don't worry, you're just as sane as I am. - Luna Lovegood"
-# tip_good_idea: "The best way to have a good idea is to have a lot of ideas. - Linus Pauling"
-# tip_programming_not_about_computers: "Computer Science is no more about computers than astronomy is about telescopes. - Edsger Dijkstra"
-# tip_mulan: "Believe you can, then you will. - Mulan"
+ tip_internet_weather: "Choď na internet, je tam skvele. Ži doma, kde je vždy nádherné počasie. - John Green"
+ tip_nerds: "Nerdi milujú skákanie na stoličku a zo stoličky. Strácajú pritom kontrolu. - John Green"
+ tip_self_taught: "90% toho, čo potrebujem, som sa naučil sám. A je to normálne! - Hank Green"
+ tip_luna_lovegood: "Don't worry, you're just as sane as I am. - Luna Lovegood"
+ tip_good_idea: "Najlepším spôsobom ako mať dobrý nápad, je mať veľa dobrých nápadov. - Linus Pauling"
+ tip_programming_not_about_computers: "Veda o počítačoch je o počítačoch v tej miere ako je astronómia o teleskopoch. - Edsger Dijkstra"
+ tip_mulan: "Ver, že môžeš a potom budeš aj chcieť. - Mulan"
game_menu:
inventory_tab: "Inventár"
@@ -513,49 +513,49 @@ module.exports = nativeDescription: "slovenčina", englishDescription: "Slovak",
feature4: "{{gems}} bonusových diamantov každý mesiac !"
feature5: "Video tutoriály"
feature6: "Prémiová emailová podpora"
-# feature7: "Private Clans"
-# feature8: "No ads!"
+ feature7: "Súkromnéklany"
+ feature8: "Žiadne reklamy!"
free: "Zdarma"
month: "mesiac"
-# must_be_logged: "You must be logged in first. Please create an account or log in from the menu above."
+ must_be_logged: "Najskôr sa musíš prihlásiť. Vytvor si účet alebo sa prihlás."
subscribe_title: "Predplatné"
unsubscribe: "Zrušiť predplatné"
confirm_unsubscribe: "Potvrdiť zrušenie predplatného"
never_mind: "Nevadí, stále ťa máme radi"
thank_you_months_prefix: "Ďakujeme za tvoju podporu v posledných"
thank_you_months_suffix: "mesiacoch."
-# thank_you: "Thank you for supporting CodeCombat."
-# sorry_to_see_you_go: "Sorry to see you go! Please let us know what we could have done better."
-# unsubscribe_feedback_placeholder: "O, what have we done?"
-# parent_button: "Ask your parent"
-# parent_email_description: "We'll email them so they can buy you a CodeCombat subscription."
-# parent_email_input_invalid: "Email address invalid."
-# parent_email_input_label: "Parent email address"
-# parent_email_input_placeholder: "Enter parent email"
-# parent_email_send: "Send Email"
-# parent_email_sent: "Email sent!"
-# parent_email_title: "What's your parent's email?"
-# parents: "For Parents"
-# parents_title: "Dear Parent: Your child is learning to code. Will you help them continue?"
-# parents_blurb1: "Your child has played __nLevels__ levels and learned programming basics. Help cultivate their interest and buy them a subscription so they can keep playing."
-# parents_blurb1a: "Computer programming is an essential skill that your child will undoubtedly use as an adult. By 2020, basic software skills will be needed by 77% of jobs, and software engineers are in high demand across the world. Did you know that Computer Science is the highest-paid university degree?"
-# parents_blurb2: "For ${{price}} USD/mo, your child will get new challenges every week and personal email support from professional programmers."
-# parents_blurb3: "No Risk: 100% money back guarantee, easy 1-click unsubscribe."
-# payment_methods: "Payment Methods"
-# payment_methods_title: "Accepted Payment Methods"
-# payment_methods_blurb1: "We currently accept credit cards and Alipay. You can also PayPal {{three_month_price}} USD to nick@codecombat.com with your account email in the memo to purchase three months' subscription and gems, or ${{year_price}} for a year."
-# payment_methods_blurb2: "If you require an alternate form of payment, please contact"
-# sale_button: "Sale!"
-# sale_button_title: "Save $21 when you purchase a 1 year subscription"
-# stripe_description: "Monthly Subscription"
-# stripe_description_year_sale: "1 Year Subscription (${{discount}} discount)"
-# subscription_required_to_play: "You'll need a subscription to play this level."
-# unlock_help_videos: "Subscribe to unlock all video tutorials."
-# personal_sub: "Personal Subscription" # Accounts Subscription View below
-# loading_info: "Loading subscription information..."
-# managed_by: "Managed by"
-# will_be_cancelled: "Will be cancelled on"
-# currently_free: "You currently have a free subscription"
+ thank_you: "Ďakujeme za podporu CodeCombatu."
+ sorry_to_see_you_go: "Je nám ľúto, že odchádzaš. Čo sme mali urobiť lepšie?"
+ unsubscribe_feedback_placeholder: "Ó, čo sme ti urobili?"
+ parent_button: "Spýtaj sa rodičov"
+ parent_email_description: "Pošleme im email, aby ti mohli predplatiť CodeCombat."
+ parent_email_input_invalid: "Neplatný email."
+ parent_email_input_label: "Email rodiča"
+ parent_email_input_placeholder: "Zadaj email rodiča"
+ parent_email_send: "Pošli email"
+ parent_email_sent: "Email odoslaný!"
+ parent_email_title: "Aký je email jedného z tvojích rodičov?"
+ parents: "Pre rodičov"
+ parents_title: "Drahý rodič: Vaše dieťa sa učí programovať. Chcete, aby v tom pokračovalo?"
+ parents_blurb1: "Vaše dieťa už prešlo __nLevels__ úrovňami a naučilo sa základy programovania. Pomôžte mu v rozvíjaní jeho záujmov a zaplaťte mu predplatné."
+ parents_blurb1a: "Programovanie je základná zručnosť, ktorú Vaše dieťa určite využije v dospelosti. V roku 2020 budú základné softvérové zručnosti potrebné v 77% povolaní. Po programátoroch je veľký dopyt.Je to tiež najlepšie platené miesto pre ľudí s vysokoškolským vzdelaním."
+ parents_blurb2: "Za ${{price}} USD/mesiac získa Vaše dieťa nové výzvy každý mesiac a osobnú podporu cez email od profesionálnych programátorov."
+ parents_blurb3: "Žiadne riziko: 100% garancia vrátenia peňazí,ľahké odhlásenie predplatného."
+ payment_methods: "Metódy platby"
+ payment_methods_title: "Akceptované metódy platby"
+ payment_methods_blurb1: "V súčasmosti akceptujeme kreditné karty a Alipay. Môžete tiež použiť PayPal a poslať {{three_month_price}} USD na email nick@codecombat.com. Uveďte v poznámke ku platbe registračný email a predplaťťe si 3 mesiace alebo za cenu ${{year_price}} si zakúpte ročné predplatné."
+ payment_methods_blurb2: "Ak požadujete iný spôsob platby, spojte sa s nami"
+ sale_button: "Kúp!"
+ sale_button_title: "Objednaj si ročné predplatné a ušetri 21$"
+ stripe_description: "Mesačné predplatné"
+ stripe_description_year_sale: "Ročné predplatné (zľava ${{discount}})"
+ subscription_required_to_play: "Potrebuješ predplatné, ak chceš hrať túto úroveň."
+ unlock_help_videos: "Predplať si Codecombat a získaj prístup ku videonávodom."
+ personal_sub: "Predplatné" # Accounts Subscription View below
+ loading_info: "Nahrávam informácie o predplatnom..."
+ managed_by: "Riadené"
+ will_be_cancelled: "Končí"
+ currently_free: "Nemáš platené predplatné"
# currently_free_until: "You currently have a subscription until"
# was_free_until: "You had a free subscription until"
# managed_subs: "Managed Subscriptions"
diff --git a/app/locale/sl.coffee b/app/locale/sl.coffee
index abcfdecbf..8f5ed155f 100644
--- a/app/locale/sl.coffee
+++ b/app/locale/sl.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "slovenščina", englishDescription: "Sloven
# completed_level: "Completed Level:"
# course: "Course:"
# done: "Done"
-# next_level: "Next Level:"
+# next_level: "Next Level"
# next_game: "Next game"
# show_menu: "Show game menu"
# home: "Home" # Not used any more, will be removed soon.
diff --git a/app/locale/sr.coffee b/app/locale/sr.coffee
index 1f078273f..75a0c6a15 100644
--- a/app/locale/sr.coffee
+++ b/app/locale/sr.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian
completed_level: "Завршен ниво:"
course: "Курс:"
done: "Урађено"
- next_level: "Следећи ниво:"
+ next_level: "Следећи ниво"
next_game: "Следећа игра"
show_menu: "Види мени игре"
home: "Почетна" # Not used any more, will be removed soon.
diff --git a/app/locale/sv.coffee b/app/locale/sv.coffee
index 340ca5a19..bdf35d30b 100644
--- a/app/locale/sv.coffee
+++ b/app/locale/sv.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "Svenska", englishDescription: "Swedish", tr
completed_level: "Avklarad nivå:"
course: "Lektion:"
done: "Klar"
- next_level: "Nästa nivå:"
+ next_level: "Nästa nivå"
next_game: "Nästa spel"
show_menu: "Visa spelmeny"
home: "Hem" # Not used any more, will be removed soon.
diff --git a/app/locale/th.coffee b/app/locale/th.coffee
index 035a4cfb0..04e4a5955 100644
--- a/app/locale/th.coffee
+++ b/app/locale/th.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "ไทย", englishDescription: "Thai", tra
# completed_level: "Completed Level:"
# course: "Course:"
done: "เสร็จสิ้น"
-# next_level: "Next Level:"
+# next_level: "Next Level"
# next_game: "Next game"
# show_menu: "Show game menu"
home: "หน้าแรก" # Not used any more, will be removed soon.
diff --git a/app/locale/uk.coffee b/app/locale/uk.coffee
index 5f0ca7e8e..7f0b05083 100644
--- a/app/locale/uk.coffee
+++ b/app/locale/uk.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "Українська", englishDescription:
# completed_level: "Completed Level:"
# course: "Course:"
done: "Готово"
-# next_level: "Next Level:"
+# next_level: "Next Level"
next_game: "Наступна гра"
show_menu: "Показати меню гри"
home: "На головну" # Not used any more, will be removed soon.
diff --git a/app/locale/ur.coffee b/app/locale/ur.coffee
index 95e4330ef..2ef99581d 100644
--- a/app/locale/ur.coffee
+++ b/app/locale/ur.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "اُردُو", englishDescription: "Urdu",
# completed_level: "Completed Level:"
# course: "Course:"
# done: "Done"
-# next_level: "Next Level:"
+# next_level: "Next Level"
# next_game: "Next game"
# show_menu: "Show game menu"
# home: "Home" # Not used any more, will be removed soon.
diff --git a/app/locale/uz.coffee b/app/locale/uz.coffee
index 25dd90e23..a891b3a75 100644
--- a/app/locale/uz.coffee
+++ b/app/locale/uz.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "O'zbekcha", englishDescription: "Uzbek", tr
# completed_level: "Completed Level:"
# course: "Course:"
# done: "Done"
-# next_level: "Next Level:"
+# next_level: "Next Level"
# next_game: "Next game"
# show_menu: "Show game menu"
# home: "Home" # Not used any more, will be removed soon.
diff --git a/app/locale/vi.coffee b/app/locale/vi.coffee
index fa6f7e2cd..4b3b06950 100644
--- a/app/locale/vi.coffee
+++ b/app/locale/vi.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "Tiếng Việt", englishDescription: "Vietn
completed_level: "Hoàn thành Level:"
course: "Khoá học:"
done: "Hoàn thành"
- next_level: "Level tiếp theo:"
+ next_level: "Level tiếp theo"
next_game: "Game kế tiếp"
show_menu: "Hiện game menu"
# home: "Home" # Not used any more, will be removed soon.
diff --git a/app/locale/zh-HANS.coffee b/app/locale/zh-HANS.coffee
index 1d458150d..9dec32aad 100644
--- a/app/locale/zh-HANS.coffee
+++ b/app/locale/zh-HANS.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "简体中文", englishDescription: "Chinese
completed_level: "完成关卡:"
course: "课程:"
done: "完成"
- next_level: "下一个关卡:"
+ next_level: "下一个关卡"
next_game: "下一场游戏"
show_menu: "显示游戏菜单"
home: "主页" # Not used any more, will be removed soon.
diff --git a/app/locale/zh-WUU-HANS.coffee b/app/locale/zh-WUU-HANS.coffee
index dcc26bb87..413084b38 100644
--- a/app/locale/zh-WUU-HANS.coffee
+++ b/app/locale/zh-WUU-HANS.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "吴语", englishDescription: "Wuu (Simplifi
# completed_level: "Completed Level:"
# course: "Course:"
# done: "Done"
-# next_level: "Next Level:"
+# next_level: "Next Level"
# next_game: "Next game"
# show_menu: "Show game menu"
# home: "Home" # Not used any more, will be removed soon.
diff --git a/app/locale/zh-WUU-HANT.coffee b/app/locale/zh-WUU-HANT.coffee
index 469a14dcf..3ad12f378 100644
--- a/app/locale/zh-WUU-HANT.coffee
+++ b/app/locale/zh-WUU-HANT.coffee
@@ -315,7 +315,7 @@ module.exports = nativeDescription: "吳語", englishDescription: "Wuu (Traditio
# completed_level: "Completed Level:"
# course: "Course:"
done: "妝下落"
-# next_level: "Next Level:"
+# next_level: "Next Level"
# next_game: "Next game"
# show_menu: "Show game menu"
home: "主頁" # Not used any more, will be removed soon.
diff --git a/app/models/Level.coffee b/app/models/Level.coffee
index 914b9f54f..ff0783cab 100644
--- a/app/models/Level.coffee
+++ b/app/models/Level.coffee
@@ -248,6 +248,6 @@ module.exports = class Level extends CocoModel
width = c.width if c.width? and c.width > width
height = c.height if c.height? and c.height > height
return {width: width, height: height}
-
+
isLadder: ->
return @get('type')?.indexOf('ladder') > -1
diff --git a/app/models/SuperModel.coffee b/app/models/SuperModel.coffee
index f0b6d5c76..3a2c28767 100644
--- a/app/models/SuperModel.coffee
+++ b/app/models/SuperModel.coffee
@@ -23,7 +23,7 @@ module.exports = class SuperModel extends Backbone.Model
console.info "#{_.values(@resources).length} resources."
unfinished = []
for resource in _.values(@resources) when resource
- console.info "\t", resource.name, 'loaded', resource.isLoaded
+ console.info "\t", resource.name, 'loaded', resource.isLoaded, resource.model
unfinished.push resource unless resource.isLoaded
unfinished
@@ -158,7 +158,7 @@ module.exports = class SuperModel extends Backbone.Model
# Tracking resources being loaded for this supermodel
finished: ->
- return (@progress is 1.0) or (not @denom) or @failed
+ return (@progress is 1.0) or (not @denom) or @failed
addModelResource: (modelOrCollection, name, fetchOptions, value=1) ->
# Deprecating name. Handle if name is not included
@@ -192,8 +192,8 @@ module.exports = class SuperModel extends Backbone.Model
return res
checkName: (name) ->
- if _.isString(name)
- console.warn("SuperModel name property deprecated. Remove '#{name}' from code.")
+ #if _.isString(name)
+ # console.warn("SuperModel name property deprecated. Remove '#{name}' from code.")
storeResource: (resource, value) ->
@rid++
diff --git a/app/schemas/subscriptions/god.coffee b/app/schemas/subscriptions/god.coffee
index de6f0c606..2a6ccdced 100644
--- a/app/schemas/subscriptions/god.coffee
+++ b/app/schemas/subscriptions/god.coffee
@@ -49,6 +49,8 @@ module.exports =
goalStates: goalStatesSchema
preload: {type: 'boolean'}
overallStatus: {type: ['string', 'null'], enum: ['success', 'failure', 'incomplete', null]}
+ totalFrames: {type: ['integer', 'undefined']}
+ lastFrameHash: {type: ['number', 'undefined']}
'god:world-load-progress-changed': c.object {required: ['progress', 'god']},
god: {type: 'object'}
diff --git a/app/schemas/subscriptions/misc.coffee b/app/schemas/subscriptions/misc.coffee
index 9fb7ace35..50b94822e 100644
--- a/app/schemas/subscriptions/misc.coffee
+++ b/app/schemas/subscriptions/misc.coffee
@@ -70,3 +70,6 @@ module.exports =
'application:service-loaded': c.object {required: ['service']},
service: {type: 'string'} # 'segment'
+
+ 'test:update': c.object {},
+ state: {type: 'string'}
diff --git a/app/schemas/subscriptions/tome.coffee b/app/schemas/subscriptions/tome.coffee
index d2e2d1acc..983b746a3 100644
--- a/app/schemas/subscriptions/tome.coffee
+++ b/app/schemas/subscriptions/tome.coffee
@@ -12,6 +12,7 @@ module.exports =
preload: {type: 'boolean'}
realTime: {type: 'boolean'}
submissionCount: {type: 'integer'}
+ fixedSeed: {type: ['integer', 'undefined']}
flagHistory: {type: 'array'}
difficulty: {type: 'integer'}
god: {type: 'object'}
diff --git a/app/styles/play/level/modal/progress-view.sass b/app/styles/play/level/modal/progress-view.sass
index 700933bc8..61d9f89c3 100644
--- a/app/styles/play/level/modal/progress-view.sass
+++ b/app/styles/play/level/modal/progress-view.sass
@@ -5,4 +5,9 @@
margin-bottom: 5px
p
- margin-top: 30px
\ No newline at end of file
+ margin-top: 30px
+
+ .course-title
+ white-space: nowrap
+ text-overflow: ellipsis
+ overflow: hidden
diff --git a/app/templates/courses/teacher-courses-view.jade b/app/templates/courses/teacher-courses-view.jade
index 3a08fbb8c..7d33eb6bd 100644
--- a/app/templates/courses/teacher-courses-view.jade
+++ b/app/templates/courses/teacher-courses-view.jade
@@ -20,20 +20,20 @@ block content
.courses.container
- var courses = view.courses.models;
- - var i = 0;
- while i < courses.length
- - var course = courses[i];
- - i++;
+ - var courseIndex = 0;
+ while courseIndex < courses.length
+ - var course = courses[courseIndex];
+ - courseIndex++;
.course.row
.col-sm-9
+course-info(course)
- .col-sm-3.hidden
- .play-level-form
+ .col-sm-3
+ .play-level-form(data-course-id=course.id)
.form-group
label.control-label
span(data-i18n="courses.select_language")
| :
- select.form-control
+ select.language-select.form-control
// TODO: Automate this list @scott
option(value="python")
| Python
@@ -51,11 +51,17 @@ block content
label.control-label
span(data-i18n="courses.select_level")
| :
- select.form-control
- // TODO: Automate this list @scott
- option(value='TODO')
- | 1. Dungeons of Kithgard
- a.btn.btn-lg.btn-primary
+ select.level-select.form-control
+ if view.campaigns.loaded
+ each level, levelIndex in view.campaigns.get(course.get('campaignID')).getLevels().models
+ option(value=level.get('slug'))
+ span
+ = levelIndex + 1
+ span
+ | .
+ span
+ = level.get('name')
+ a.play-level-button.btn.btn-lg.btn-primary
span(data-i18n="courses.play_level")
.clearfix
diff --git a/app/templates/editor/verifier/verifier-view.jade b/app/templates/editor/verifier/verifier-view.jade
new file mode 100644
index 000000000..c3d82403b
--- /dev/null
+++ b/app/templates/editor/verifier/verifier-view.jade
@@ -0,0 +1,51 @@
+extends /templates/base-flat
+
+block content
+ .container
+ each test, id in view.tests
+ if test.level
+
+ if !test.goals
+ h2(style='color: orange')= test.level.get('name')
+ small= ' in ' + test.language + ''
+ else if test.isSucessful()
+ h2(style='color: green')= test.level.get('name')
+ small= ' in ' + test.language + ''
+ else
+ h2(style='color: red')= test.level.get('name')
+ small= ' in ' + test.language + ''
+
+ div.row(class=(test.isSucessful() && id > 1 ? 'collapse' : 'collapse in'))
+ div.col-xs-8
+ if test.solution
+ pre #{test.solution.source}
+ else
+ h4 Solution not found...
+ div.col-xs-4.well
+ if test.goals
+ if test.frames == test.solution.frameCount
+ div(style='color: green') ✓ Frames: #{test.frames}
+ else
+ div(style='color: red') ✘ Frames: #{test.frames} vs #{test.solution.frameCount}
+
+ each v,k in test.goals || []
+ if !test.solution.goals
+ div(style='color: orange') ? #{k} (#{v.status})
+ else if v.status == test.solution.goals[k]
+ div(style='color: green') ✓ #{k} (#{v.status})
+ else
+ div(style='color: red') ✘ #{k} (#{v.status} vs #{test.solution.goals[k]})
+ else
+ h3 Running....
+
+ else
+ h1 Loading Level...
+
+ div#tome-view
+ div#goals-veiw
+
+ br
+
+ // TODO: show errors
+ // TODO: frame length
+ // TODO: show last frame hash
diff --git a/app/templates/play/level/control_bar.jade b/app/templates/play/level/control_bar.jade
index f60bf73ef..bc8f5a5d7 100644
--- a/app/templates/play/level/control_bar.jade
+++ b/app/templates/play/level/control_bar.jade
@@ -7,7 +7,7 @@
.levels-link-area
a.levels-link(href=homeLink || "/")
.glyphicon.glyphicon-play
- span(data-i18n=ladderGame ? "general.ladder" : "nav.play").home-text Levels
+ span(data-i18n=me.isTeacher() ? "nav.courses" : (ladderGame ? "general.ladder" : "nav.play")).home-text Levels
if isMultiplayerLevel && !observing
.multiplayer-area-container
diff --git a/app/templates/play/level/modal/progress-view.jade b/app/templates/play/level/modal/progress-view.jade
index a1a787018..02fdd0c7f 100644
--- a/app/templates/play/level/modal/progress-view.jade
+++ b/app/templates/play/level/modal/progress-view.jade
@@ -2,7 +2,7 @@
#close-modal.well.well-sm.well-parchment(data-dismiss="modal")
span.glyphicon.glyphicon-remove
.well.well-sm.well-parchment
- h1 Level Complete
+ h1(data-i18n='play_level.level_complete')
.modal-body
.container-fluid
@@ -10,23 +10,32 @@
- var colClass = view.nextLevel ? 'col-sm-7' : 'col-sm-12'
div(class=colClass)
.well.well-sm.well-parchment
- h3.text-uppercase Completed Level:
+ h3.text-uppercase(data-i18n='play_level.completed_level')
h2.text-uppercase.text-center= i18n(view.level.attributes, 'name')
.well.well-sm.well-parchment
- h3.text-uppercase Course:
- .row
- .col-sm-8
- h3.text-uppercase.text-center= i18n(view.course.attributes, 'name')
- .col-sm-4
- - var stats = view.campaign.statsForSessions(view.levelSessions)
- h1
- span #{stats.levels.numDone}/#{stats.levels.size}
-
+ if me.isTeacher()
+ h3.course-title
+ span.text-uppercase.spr(data-i18n='play_level.course')
+ span.text-uppercase.text-center= i18n(view.course.attributes, 'name')
+ span(data-i18n='play_level.victory_no_progress_for_teachers')
+
+ else
+ h3.text-uppercase(data-i18n='play_level.course')
+ .row
+ .col-sm-8
+ h3.text-uppercase.text-center= i18n(view.course.attributes, 'name')
+ .col-sm-4
+ - var stats = view.campaign.statsForSessions(view.levelSessions)
+ h1
+ span #{stats.levels.numDone}/#{stats.levels.size}
+
if view.nextLevel
.col-sm-5
.well.well-sm.well-parchment
- h3.text-uppercase Next Level:
+ h3.text-uppercase
+ span(data-i18n='play_level.next_level')
+ span :
h2.text-uppercase= i18n(view.nextLevel.attributes, 'name')
p= i18n(view.nextLevel.attributes, 'description')
@@ -37,6 +46,6 @@
// button#continue-btn.btn.btn-illustrated.btn-default.btn-block.btn-lg.text-uppercase View Leaderboards
.col-sm-5
if view.nextLevel
- button#next-level-btn.btn.btn-illustrated.btn-primary.btn-block.btn-lg.text-uppercase Next Level
+ button#next-level-btn.btn.btn-illustrated.btn-primary.btn-block.btn-lg.text-uppercase(data-i18n='play_level.next_level')
else
- button#done-btn.btn.btn-illustrated.btn-primary.btn-block.btn-lg.text-uppercase Done
\ No newline at end of file
+ button#done-btn.btn.btn-illustrated.btn-primary.btn-block.btn-lg.text-uppercase(data-i18n='play_level.done')
diff --git a/app/views/core/ContactModal.coffee b/app/views/core/ContactModal.coffee
index 8c9acf951..5d304a564 100644
--- a/app/views/core/ContactModal.coffee
+++ b/app/views/core/ContactModal.coffee
@@ -48,5 +48,5 @@ module.exports = class ContactModal extends ModalView
updateScreenshot: ->
return unless @screenshotURL
screenshotEl = @$el.find('#contact-screenshot').removeClass('secret')
- screenshotEl.find('a').prop('href', @screenshotURL)
- screenshotEl.find('img').prop('src', @screenshotURL)
+ screenshotEl.find('a').prop('href', @screenshotURL.replace("http://codecombat.com/", "/"))
+ screenshotEl.find('img').prop('src', @screenshotURL.replace("http://codecombat.com/", "/"))
diff --git a/app/views/courses/TeacherCoursesView.coffee b/app/views/courses/TeacherCoursesView.coffee
index ef1a38e87..4e0517687 100644
--- a/app/views/courses/TeacherCoursesView.coffee
+++ b/app/views/courses/TeacherCoursesView.coffee
@@ -3,6 +3,7 @@ app = require 'core/application'
CocoCollection = require 'collections/CocoCollection'
CocoModel = require 'models/CocoModel'
Course = require 'models/Course'
+Campaigns = require 'collections/Campaigns'
Classroom = require 'models/Classroom'
Classrooms = require 'collections/Classrooms'
InviteToClassroomModal = require 'views/courses/InviteToClassroomModal'
@@ -22,6 +23,7 @@ module.exports = class TeacherCoursesView extends RootView
'click .btn-add-students': 'onClickAddStudents'
'click .create-new-class': 'onClickCreateNewClassButton'
'click .edit-classroom-small': 'onClickEditClassroomSmall'
+ 'click .play-level-button': 'onClickPlayLevel'
guideLinks:
{
@@ -43,6 +45,9 @@ module.exports = class TeacherCoursesView extends RootView
@classrooms.comparator = '_id'
@listenToOnce @classrooms, 'sync', @onceClassroomsSync
@supermodel.loadCollection(@classrooms, 'classrooms', {data: {ownerID: me.id}})
+ @campaigns = new Campaigns()
+ @campaigns.fetch()
+ @supermodel.trackCollection(@campaigns)
@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')
@@ -99,6 +104,14 @@ module.exports = class TeacherCoursesView extends RootView
modal = new ClassroomSettingsModal({classroom: classroom})
@openModalView(modal)
@listenToOnce modal, 'hide', @render
+
+ onClickPlayLevel: (e) ->
+ form = $(e.currentTarget).closest('.play-level-form')
+ levelSlug = form.find('.level-select').val()
+ courseID = form.data('course-id')
+ language = form.find('.language-select').val()
+ url = "/play/level/#{levelSlug}?course=#{courseID}&codeLanguage=#{language}"
+ application.router.navigate(url, { trigger: true })
onLoaded: ->
super()
diff --git a/app/views/editor/verifier/VerifierTest.coffee b/app/views/editor/verifier/VerifierTest.coffee
new file mode 100644
index 000000000..b71301242
--- /dev/null
+++ b/app/views/editor/verifier/VerifierTest.coffee
@@ -0,0 +1,111 @@
+CocoClass = require 'core/CocoClass'
+SuperModel = require 'models/SuperModel'
+{createAetherOptions} = require 'lib/aether_utils'
+God = require 'lib/God'
+GoalManager = require 'lib/world/GoalManager'
+LevelLoader = require 'lib/LevelLoader'
+
+module.exports = class VerifierTest extends CocoClass
+ constructor: (@levelID, @updateCallback, @supermodel, @language) ->
+ super()
+ # TODO: turn this into a Subview
+ # TODO: listen to Backbone.Mediator.publish 'god:non-user-code-problem', problem: event.data.problem, god: @shared.god from Angel to detect when we can't load the thing
+ # TODO: listen to the progress report from Angel to show a simulation progress bar (maybe even out of the number of frames we actually know it'll take)
+ @supermodel ?= new SuperModel()
+ @language ?= 'python'
+ @load()
+
+ load: ->
+ @loadStartTime = new Date()
+ @god = new God maxAngels: 1, headless: true
+ @levelLoader = new LevelLoader supermodel: @supermodel, levelID: @levelID, headless: true, fakeSessionConfig: {codeLanguage: @language, callback: @configureSession}
+ @listenToOnce @levelLoader, 'world-necessities-loaded', @onWorldNecessitiesLoaded
+
+ onWorldNecessitiesLoaded: ->
+ # Called when we have enough to build the world, but not everything is loaded
+ @grabLevelLoaderData()
+
+ unless @solution
+ @updateCallback? state: 'error'
+ @error = 'No solution present...'
+ @state = 'error'
+ return
+ me.team = @team = 'humans'
+ @setupGod()
+ @initGoalManager()
+ @register()
+
+ configureSession: (session, level) =>
+ # TODO: reach into and find hero and get the config from the solution
+ try
+ hero = _.find level.get("thangs"), id: "Hero Placeholder"
+ programmable = _.find(hero.components, (x) -> x.config?.programmableMethods?.plan).config.programmableMethods.plan
+ session.solution = _.find (programmable.solutions ? []), language: session.get('codeLanguage')
+ session.set 'heroConfig', session.solution.heroConfig
+ session.set 'code', {'hero-placeholder': plan: session.solution.source}
+ state = session.get 'state'
+ state.flagHistory = session.solution.flagHistory
+ state.difficulty = session.solution.difficulty or 0
+ session.solution.seed = undefined unless _.isNumber session.solution.seed # TODO: migrate away from submissionCount/sessionID seed objects
+ catch e
+ @state = 'error'
+ @error = "Could not load the session solution for #{level.get('name')}: " + e.toString()
+
+ grabLevelLoaderData: ->
+ @world = @levelLoader.world
+ @level = @levelLoader.level
+ @session = @levelLoader.session
+ @solution = @levelLoader.session.solution
+
+ setupGod: ->
+ @god.setLevel @level.serialize @supermodel, @session
+ @god.setLevelSessionIDs [@session.id]
+ @god.setWorldClassMap @world.classMap
+ @god.lastFlagHistory = @session.get('state').flagHistory
+ @god.lastDifficulty = @session.get('state').difficulty
+ @god.lastFixedSeed = @session.solution.seed
+ @god.lastSubmissionCount = 0
+
+ initGoalManager: ->
+ @goalManager = new GoalManager(@world, @level.get('goals'), @team)
+ @god.setGoalManager @goalManager
+
+ register: ->
+ @listenToOnce @god, 'infinite-loop', @fail # TODO: have one of these
+
+ @listenToOnce @god, 'goals-calculated', @processSingleGameResults
+ @god.createWorld @generateSpellsObject()
+ @updateCallback? state: 'running'
+
+ processSingleGameResults: (e) ->
+ console.log(e)
+ @goals = e.goalStates
+ @frames = e.totalFrames
+ @lastFrameHash = e.lastFrameHash
+ @state = 'complete'
+ @updateCallback? state: @state
+
+ isSucessful: () ->
+ return false unless @frames == @solution.frameCount
+ if @goals and @solution.goals
+ for k of @goals
+ continue if not @solution.goals[k]
+ return false if @solution.goals[k] != @goals[k].status
+ return true
+
+ fail: (e) ->
+ @error = 'Failed due to infinate loop.'
+ @state = 'error'
+ @updateCallback? state: @state
+
+ generateSpellsObject: ->
+ aetherOptions = createAetherOptions functionName: 'plan', codeLanguage: @session.get('codeLanguage')
+ spellThang = aether: new Aether aetherOptions
+ spells = "hero-placeholder/plan": thangs: {'Hero Placeholder': spellThang}, name: 'plan'
+ source = @session.get('code')['hero-placeholder'].plan
+ try
+ spellThang.aether.transpile source
+ catch e
+ console.log "Couldn't transpile!\n#{source}\n", e
+ spellThang.aether.transpile ''
+ spells
diff --git a/app/views/editor/verifier/VerifierView.coffee b/app/views/editor/verifier/VerifierView.coffee
new file mode 100644
index 000000000..1fc6a6b3f
--- /dev/null
+++ b/app/views/editor/verifier/VerifierView.coffee
@@ -0,0 +1,35 @@
+RootView = require 'views/core/RootView'
+template = require 'templates/editor/verifier/verifier-view'
+VerifierTest = require './VerifierTest'
+
+module.exports = class VerifierView extends RootView
+ className: 'style-flat'
+ template: template
+ id: 'verifier-view'
+ events:
+ 'input input': 'searchUpdate'
+ 'change input': 'searchUpdate'
+
+ constructor: (options, @levelID) ->
+ super options
+ # TODO: rework to handle N at a time instead of all at once
+ # TODO: sort tests by unexpected result first
+ testLevels = ["dungeons-of-kithgard", "gems-in-the-deep", "shadow-guard", "kounter-kithwise", "crawlways-of-kithgard", "enemy-mine", "illusory-interruption", "forgetful-gemsmith", "signs-and-portents", "favorable-odds", "true-names", "the-prisoner", "banefire", "the-raised-sword", "kithgard-librarian", "fire-dancing", "loop-da-loop", "haunted-kithmaze", "riddling-kithmaze", "descending-further", "the-second-kithmaze", "dread-door", "cupboards-of-kithgard", "hack-and-dash", "known-enemy", "master-of-names", "lowly-kithmen", "closing-the-distance", "tactical-strike", "the-skeleton", "a-mayhem-of-munchkins", "the-final-kithmaze", "the-gauntlet", "radiant-aura", "kithgard-gates", "destroying-angel", "deadly-dungeon-rescue", "kithgard-brawl", "cavern-survival", "breakout", "attack-wisely", "kithgard-mastery", "kithgard-apprentice", "robot-ragnarok", "defense-of-plainswood", "peasant-protection", "forest-fire-dancing"]
+ #testLevels = testLevels.slice 0, 15
+ levelIDs = if @levelID then [@levelID] else testLevels
+ #supermodel = if @levelID then @supermodel else undefined
+ @tests = []
+ async.eachSeries levelIDs, (levelID, lnext) =>
+ async.eachSeries ['python','javascript'], (lang, next) =>
+ @tests.unshift new VerifierTest levelID, (e) =>
+ @update(e)
+ next() if e.state in ['complete', 'error']
+ , @supermodel, lang
+ , -> lnext()
+
+ update: (event) =>
+ # TODO: show unworkable tests instead of hiding them
+ # TODO: destroy them Tests after or something
+ console.log 'got event', event, 'on some test'
+ @tests = _.filter @tests, (test) -> test.state isnt 'error'
+ @render()
diff --git a/app/views/play/level/ControlBarView.coffee b/app/views/play/level/ControlBarView.coffee
index 4a5b61c4d..be90a0d79 100644
--- a/app/views/play/level/ControlBarView.coffee
+++ b/app/views/play/level/ControlBarView.coffee
@@ -75,7 +75,10 @@ module.exports = class ControlBarView extends CocoView
c.spectateGame = @spectateGame
c.observing = @observing
@homeViewArgs = [{supermodel: if @hasReceivedMemoryWarning then null else @supermodel}]
- if @level.get('type', true) in ['ladder', 'ladder-tutorial', 'hero-ladder', 'course-ladder']
+ if me.isTeacher()
+ @homeLink = "/teachers/courses"
+ @homeViewClass = "views/courses/TeacherCoursesView"
+ else if @level.get('type', true) in ['ladder', 'ladder-tutorial', 'hero-ladder', 'course-ladder']
levelID = @level.get('slug')?.replace(/\-tutorial$/, '') or @level.id
@homeLink = '/play/ladder/' + levelID
@homeViewClass = 'views/ladder/LadderView'
diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee
index 71c134ca8..e47bd9c56 100644
--- a/app/views/play/level/PlayLevelView.coffee
+++ b/app/views/play/level/PlayLevelView.coffee
@@ -135,8 +135,11 @@ module.exports = class PlayLevelView extends RootView
load: ->
@loadStartTime = new Date()
- @god = new God debugWorker: true
- @levelLoader = new LevelLoader supermodel: @supermodel, levelID: @levelID, sessionID: @sessionID, opponentSessionID: @opponentSessionID, team: @getQueryVariable('team'), observing: @observing, courseID: @courseID
+ @god = new God()
+ levelLoaderOptions = supermodel: @supermodel, levelID: @levelID, sessionID: @sessionID, opponentSessionID: @opponentSessionID, team: @getQueryVariable('team'), observing: @observing, courseID: @courseID
+ if me.isTeacher()
+ levelLoaderOptions.fakeSessionConfig = {}
+ @levelLoader = new LevelLoader levelLoaderOptions
@listenToOnce @levelLoader, 'world-necessities-loaded', @onWorldNecessitiesLoaded
trackLevelLoadEnd: ->
@@ -512,7 +515,7 @@ module.exports = class PlayLevelView extends RootView
break
Backbone.Mediator.publish 'tome:cast-spell', {}
- onWindowResize: (e) =>
+ onWindowResize: (e) =>
@endHighlight()
onDisableControls: (e) ->
@@ -549,7 +552,7 @@ module.exports = class PlayLevelView extends RootView
@endHighlight()
options = {level: @level, supermodel: @supermodel, session: @session, hasReceivedMemoryWarning: @hasReceivedMemoryWarning, courseID: @courseID, courseInstanceID: @courseInstanceID, world: @world}
ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder'] then HeroVictoryModal else VictoryModal
- ModalClass = CourseVictoryModal if @isCourseMode()
+ ModalClass = CourseVictoryModal if @isCourseMode() or me.isTeacher()
ModalClass = PicoCTFVictoryModal if window.serverConfig.picoCTF
victoryModal = new ModalClass(options)
@openModalView(victoryModal)
diff --git a/app/views/play/level/modal/CourseVictoryModal.coffee b/app/views/play/level/modal/CourseVictoryModal.coffee
index 4a8ef83fb..cbbd0270d 100644
--- a/app/views/play/level/modal/CourseVictoryModal.coffee
+++ b/app/views/play/level/modal/CourseVictoryModal.coffee
@@ -11,6 +11,7 @@ EarnedAchievement = require 'models/EarnedAchievement'
LocalMongo = require 'lib/LocalMongo'
ProgressView = require './ProgressView'
NewItemView = require './NewItemView'
+utils = require 'core/utils'
module.exports = class CourseVictoryModal extends ModalView
id: 'course-victory-modal'
@@ -100,20 +101,23 @@ module.exports = class CourseVictoryModal extends ModalView
triggeredBy: @session.id
achievement: achievement.id
})
- ea.save()
- # Can't just add models to supermodel because each ea has the same url
- ea.sr = @supermodel.addSomethingResource(ea.cid)
- @newEarnedAchievements.push ea
- @listenToOnce ea, 'sync', (model) ->
- model.sr.markLoaded()
- if _.all((ea.id for ea in @newEarnedAchievements))
- unless me.loading
- @supermodel.loadModel(me, {cache: false})
- @newEarnedAchievementsResource.markLoaded()
+ if me.isTeacher()
+ @newEarnedAchievements.push ea
+ else
+ ea.save()
+ # Can't just add models to supermodel because each ea has the same url
+ ea.sr = @supermodel.addSomethingResource(ea.cid)
+ @newEarnedAchievements.push ea
+ @listenToOnce ea, 'sync', (model) ->
+ model.sr.markLoaded()
+ if _.all((ea.id for ea in @newEarnedAchievements))
+ unless me.loading
+ @supermodel.loadModel(me, {cache: false})
+ @newEarnedAchievementsResource.markLoaded()
-
- # have to use a something resource because addModelResource doesn't handle models being upserted/fetched via POST like we're doing here
- @newEarnedAchievementsResource = @supermodel.addSomethingResource('earned achievements') if @newEarnedAchievements.length
+ unless me.isTeacher()
+ # have to use a something resource because addModelResource doesn't handle models being upserted/fetched via POST like we're doing here
+ @newEarnedAchievementsResource = @supermodel.addSomethingResource('earned achievements') if @newEarnedAchievements.length
onLoaded: ->
@@ -160,9 +164,15 @@ module.exports = class CourseVictoryModal extends ModalView
@showView(@views[index+1])
onNextLevel: ->
- link = "/play/level/#{@nextLevel.get('slug')}?course=#{@courseID}&course-instance=#{@courseInstanceID}"
+ if me.isTeacher()
+ link = "/play/level/#{@nextLevel.get('slug')}?course=#{@courseID}&codeLanguage=#{utils.getQueryVariable('codeLanguage', 'python')}"
+ else
+ link = "/play/level/#{@nextLevel.get('slug')}?course=#{@courseID}&course-instance=#{@courseInstanceID}"
application.router.navigate(link, {trigger: true})
onDone: ->
- link = "/courses/#{@courseID}/#{@courseInstanceID}"
+ if me.isTeacher()
+ link = "/teachers/courses"
+ else
+ link = "/courses/#{@courseID}/#{@courseInstanceID}"
application.router.navigate(link, {trigger: true})
diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee
index a8ad0cef8..5582f4ede 100644
--- a/app/views/play/level/tome/SpellView.coffee
+++ b/app/views/play/level/tome/SpellView.coffee
@@ -517,6 +517,17 @@ module.exports = class SpellView extends CocoView
when 'python' then 'while True'
when 'coffeescript' then 'loop'
else 'while true'
+ # For now, update autocomplete to use hero instead of self/this, if hero is already used in the source.
+ # Later, we should make this happen all the time - or better yet update the snippets.
+ source = @getSource()
+ if /hero/.test(source)
+ thisToken =
+ 'python': /self/,
+ 'javascript': /this/,
+ 'lua': /self/
+ if thisToken[e.language] and thisToken[e.language].test(content)
+ content = content.replace thisToken[e.language], 'hero'
+
entry =
content: content
meta: $.i18n.t('keyboard_shortcuts.press_enter', defaultValue: 'press enter')
@@ -1193,7 +1204,7 @@ module.exports = class SpellView extends CocoView
onSpellBeautify: (e) ->
return unless @spellThang and (@ace.isFocused() or e.spell is @spell)
ugly = @getSource()
- pretty = @spellThang.aether.beautify ugly
+ pretty = @spellThang.aether.beautify(ugly.replace /\bloop\b/g, 'while (__COCO_LOOP_CONSTRUCT__)').replace /while \(__COCO_LOOP_CONSTRUCT__\)/g, 'loop'
@ace.setValue pretty
onMaximizeToggled: (e) ->
diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee
index 3e8bf1fb3..27766e578 100644
--- a/app/views/play/level/tome/TomeView.coffee
+++ b/app/views/play/level/tome/TomeView.coffee
@@ -169,7 +169,7 @@ module.exports = class TomeView extends CocoView
difficulty = sessionState.difficulty ? 0
if @options.observing
difficulty = Math.max 0, difficulty - 1 # Show the difficulty they won, not the next one.
- Backbone.Mediator.publish 'tome:cast-spells', spells: @spells, preload: preload, realTime: realTime, submissionCount: sessionState.submissionCount ? 0, flagHistory: sessionState.flagHistory ? [], difficulty: difficulty, god: @options.god
+ Backbone.Mediator.publish 'tome:cast-spells', spells: @spells, preload: preload, realTime: realTime, submissionCount: sessionState.submissionCount ? 0, flagHistory: sessionState.flagHistory ? [], difficulty: difficulty, god: @options.god, fixedSeed: @options.fixedSeed
onToggleSpellList: (e) ->
@spellList?.rerenderEntries()
diff --git a/headless_client.coffee b/headless_client.coffee
index c0e545a09..24ebd26a6 100644
--- a/headless_client.coffee
+++ b/headless_client.coffee
@@ -29,7 +29,7 @@ options =
simulateOnlyOneGame: simulateOneGame
options.heapdump = require('heapdump') if options.heapdump
-server = if options.testing then 'http://127.0.0.1:3000' else 'https://codecombat.com'
+server = if options.testing then 'http://127.0.0.1:3000' else 'http://direct.codecombat.com'
# Use direct instead of live site because jQlone's requests proxy doesn't do caching properly and CloudFlare gets too aggressive.
# Disabled modules
@@ -43,22 +43,25 @@ disable = [
# Global emulated stuff
GLOBAL.window = GLOBAL
-GLOBAL.document = location: pathname: 'headless_client'
+GLOBAL.document =
+ location:
+ pathname: 'headless_client'
+ search: 'esper=1'
GLOBAL.console.debug = console.log
+GLOBAL.serverConfig =
+ picoCTF: false
+ production: false
try
GLOBAL.Worker = require('webworker-threads').Worker
+ Worker::removeEventListener = (what) ->
+ if what is 'message'
+ @onmessage = -> #This webworker api has only one event listener at a time.
catch
- console.log ""
- console.log "Headless client needs the webworker-threads package from NPM to function."
- console.log "Try installing it with the command:"
- console.log ""
- console.log " npm install webworker-threads"
- console.log ""
- process.exit(1)
+ # Fall back to IE compatibility mode where it runs synchronously with no web worker.
+ # (Which we will be doing now always because webworker-threads doesn't run in newer node versions.)
+ eval require('fs').readFileSync('./vendor/scripts/Box2dWeb-2.1.a.3.js', 'utf8')
+ GLOBAL.Box2D = Box2D
-Worker::removeEventListener = (what) ->
- if what is 'message'
- @onmessage = -> #This webworker api has only one event listener at a time.
GLOBAL.tv4 = require('tv4').tv4
GLOBAL.TreemaUtils = require bowerComponentsPath + 'treema/treema-utils'
GLOBAL.marked = setOptions: ->
diff --git a/headless_client/cluster.coffee b/headless_client/cluster.coffee
new file mode 100644
index 000000000..3a985cce8
--- /dev/null
+++ b/headless_client/cluster.coffee
@@ -0,0 +1,63 @@
+child_process = require 'child_process'
+chalk = require 'chalk'
+_ = require 'lodash'
+Promise = require 'bluebird'
+path = require 'path'
+
+cores = 4
+
+list = [
+ "dungeons-of-kithgard", "gems-in-the-deep", "shadow-guard", "kounter-kithwise", "crawlways-of-kithgard",
+ "enemy-mine", "illusory-interruption", "forgetful-gemsmith", "signs-and-portents", "favorable-odds",
+ "true-names", "the-prisoner", "banefire", "the-raised-sword", "kithgard-librarian", "fire-dancing",
+ "loop-da-loop", "haunted-kithmaze", "riddling-kithmaze", "descending-further", "the-second-kithmaze",
+ "dread-door", "cupboards-of-kithgard", "hack-and-dash", "known-enemy", "master-of-names",
+ "lowly-kithmen", "closing-the-distance", "tactical-strike", "the-skeleton", "a-mayhem-of-munchkins",
+ "the-final-kithmaze", "the-gauntlet", "radiant-aura", "kithgard-gates", "destroying-angel", "deadly-dungeon-rescue",
+ "kithgard-brawl", "cavern-survival", "breakout", "attack-wisely", "kithgard-mastery", "kithgard-apprentice",
+ "robot-ragnarok", "defense-of-plainswood", "peasant-protection", "forest-fire-dancing"
+]
+
+c1 = ["dungeons-of-kithgard", "gems-in-the-deep", "shadow-guard", "enemy-mine", "true-names", "fire-dancing", "loop-da-loop", "haunted-kithmaze", "the-second-kithmaze", "dread-door", "cupboards-of-kithgard", "breakout", "known-enemy", "master-of-names", "a-mayhem-of-munchkins", "the-gauntlet", "the-final-kithmaze", "kithgard-gates", "wakka-maul"]
+c2 = ["defense-of-plainswood", "course-winding-trail", "patrol-buster", "endangered-burl", "thumb-biter", "gems-or-death", "village-guard", "thornbush-farm", "back-to-back", "ogre-encampment", "woodland-cleaver", "shield-rush", "range-finder", "peasant-protection", "munchkin-swarm", "forest-fire-dancing", "stillness-in-motion", "the-agrippa-defense", "backwoods-bombardier", "coinucopia", "copper-meadows", "drop-the-flag", "mind-the-trap", "signal-corpse", "rich-forager", "cross-bones"]
+
+list = [].concat(c1, c2)
+list = c1
+
+list = _.shuffle(list);
+
+lpad = (s, l, color = 'white') ->
+ return chalk[color](s.substring(0, l)) if s.length >= l
+ return chalk[color](s + new Array(l - s.length).join(' '))
+
+
+chunks = _.groupBy list, (v,i) -> i%cores
+_.forEach chunks, (list, cid) ->
+ console.log(list)
+ cp = child_process.fork path.join(__dirname, './verifier.js'), list, silent: true
+ cp.on 'message', (m) ->
+ return if m.state is 'running'
+ okay = true
+ goals = _.map m.observed.goals, (v,k) ->
+ return lpad('No Goals Set', 15, 'yellow') unless m.solution.goals
+ lpad(k, 15, if v == m.solution.goals[k] then 'green' else 'red')
+
+
+ extra = []
+ if m.observed.frameCount == m.solution.frameCount
+ extra.push lpad('F:' + m.observed.frameCount, 15, 'green')
+ else
+ extra.push lpad('F:' + m.observed.frameCount + ' vs ' + m.solution.frameCount , 15, 'red')
+ okay = false
+
+ if m.observed.lastHash == m.solution.lastHash
+ extra.push lpad('Hash', 5, 'green')
+ else
+ extra.push lpad('Hash' , 5, 'red')
+ okay = false
+
+ col = if okay then 'green' else 'red'
+ if m.state is 'error' or m.error
+ console.log lpad(m.level, 30, 'red') + lpad(m.language, 15, 'cyan') + chalk.red(m.error)
+ else
+ console.log lpad(m.level, 30, col) + lpad(m.language, 15, 'cyan') + ' ' + extra.join(' ') + ' ' + goals.join(' ')
diff --git a/headless_client/jQlone.coffee b/headless_client/jQlone.coffee
index 0d914f094..2e950ebd7 100644
--- a/headless_client/jQlone.coffee
+++ b/headless_client/jQlone.coffee
@@ -8,8 +8,6 @@ module.exports = $ = (input) ->
append: (input)-> exports: ()->
# Non-standard jQuery stuff. Don't use outside of server.
-$._debug = false
-$._server = 'https://codecombat.com'
$._cookies = request.jar()
$.when = Deferred.when
diff --git a/headless_client/verifier.js b/headless_client/verifier.js
new file mode 100644
index 000000000..67fbed286
--- /dev/null
+++ b/headless_client/verifier.js
@@ -0,0 +1,3 @@
+require('coffee-script');
+require('coffee-script/register');
+var server = require('../verifier.coffee');
diff --git a/package.json b/package.json
index 0f28fce31..6b4856eff 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"aws-sdk": "~2.0.0",
"bayesian-battle": "0.0.7",
"bluebird": "^3.2.1",
+ "chalk": "^1.1.3",
"co-express": "^1.2.1",
"coffee-script": "1.9.x",
"connect": "2.7.x",
diff --git a/scripts/mongodb/migrations/2016-03-18-init-school-roles.js b/scripts/mongodb/migrations/2016-03-18-init-school-roles.js
index c57c54545..a08395f20 100644
--- a/scripts/mongodb/migrations/2016-03-18-init-school-roles.js
+++ b/scripts/mongodb/migrations/2016-03-18-init-school-roles.js
@@ -3,30 +3,57 @@
// Set all users with trial requests to a teacher or teacher-like role, depending on trial request.
-var hasTrialRequest = {};
+var project = {role:1, name:1, email:1, permissions: 1};
db.trial.requests.find().forEach(function(trialRequest) {
+ print('Inspecting trial request', trialRequest._id);
var role = trialRequest.properties.role || 'teacher';
- var user = db.users.findOne({_id: trialRequest.applicant}, {role:1, name:1, email:1});
- print(JSON.stringify(user), JSON.stringify(trialRequest.properties), role);
- if (!user.role) {
- print(db.users.update({_id: trialRequest.applicant}, {$set: {role: role}}));
+ var user = null;
+ if(!trialRequest.applicant) {
+ print('\tNO APPLICANT INCLUDED', JSON.stringify(trialRequest));
+ if(!trialRequest.properties.email) {
+ print('\tNO EMAIL EITHER');
+ return;
+ }
+ user = db.users.findOne({emailLower: trialRequest.properties.email.toLowerCase()}, project);
+ if(!user) {
+ print('\tUSER WITH EMAIL NOT FOUND, CONTINUE');
+ return;
+ }
+ else {
+ print("\tOKAY GOT USER, UPDATE TRIAL REQUEST", JSON.stringify(user));
+ db.trial.requests.update({_id: trialRequest._id}, {$set: {applicant: user._id}});
+ }
+ }
+ else {
+ user = db.users.findOne({_id: trialRequest.applicant}, project);
+ }
+ if (!user.role && (user.permissions||[]).indexOf('admin') === -1) {
+ print('\tUpdating', JSON.stringify(user), 'to', role);
+ print(db.users.update({_id: user._id}, {$set: {role: role}}));
}
- hasTrialRequest[user._id.str] = true;
});
-var teacherRoles = ['teacher', 'technology coordinator', 'advisor', 'principal', 'superintendent'];
-
// Unset all teacher-like roles for users without a trial request.
// AND removes all remaining users with a teacher-like role from classroom membership (after conversion period)
+var hasTrialRequest = {};
+var teacherRoles = ['teacher', 'technology coordinator', 'advisor', 'principal', 'superintendent'];
+
+db.trial.requests.find().forEach(function(trialRequest) {
+ if(!trialRequest.applicant) { return; }
+ hasTrialRequest[trialRequest.applicant.str] = true;
+});
+print(Object.keys(hasTrialRequest).length);
+
db.users.find({'role': {$in: teacherRoles}}, {_id: 1, name: 1, email: 1, role: 1}).forEach(function(user) {
- print('Updating user', JSON.stringify(user));
- if (!hasTrialRequest.user._id.str) {
- print('\tunset role');
- //db.users.update({_id: user._id}, {$unset: {role: ''}});
+ print('Got user with teacher role', user._id);
+ if (!hasTrialRequest[user._id.str]) {
+ print('\tUnset role', JSON.stringify(user));
+ db.users.update({_id: user._id}, {$unset: {role: ''}});
}
else {
+ return; // TODO: Run when we've moved completely to separate user roles
var count = db.classrooms.count({members: user._id}, {name: 1});
if (count) {
print('\tWill remove from classrooms');
@@ -44,9 +71,10 @@ db.classrooms.find({}, {members: 1}).forEach(function(classroom) {
if(!classroom.members) {
return;
}
+ print('Updating for classroom', classroom._id, 'with members', classroom.members.length);
for (var i in classroom.members) {
var memberID = classroom.members[i];
- print('updating member', memberID);
+ print('\tupdating member', memberID);
print(db.users.update({_id: memberID, role: {$exists: false}}, {$set: {role: 'student'}}));
- }
-});
\ No newline at end of file
+ }
+});
diff --git a/server/commons/auth.coffee b/server/commons/auth.coffee
new file mode 100644
index 000000000..323b71d30
--- /dev/null
+++ b/server/commons/auth.coffee
@@ -0,0 +1,40 @@
+authentication = require 'passport'
+LocalStrategy = require('passport-local').Strategy
+User = require '../models/User'
+config = require '../../server_config'
+errors = require '../commons/errors'
+
+module.exports.setup = ->
+ authentication.serializeUser((user, done) -> done(null, user._id))
+ authentication.deserializeUser((id, done) ->
+ User.findById(id, (err, user) -> done(err, user)))
+
+ if config.picoCTF
+ pico = require('../lib/picoctf');
+ authentication.use new pico.PicoStrategy()
+ return
+
+ authentication.use(new LocalStrategy(
+ (username, password, done) ->
+
+ # kind of a hacky way to make it possible for iPads to 'log in' with their unique device id
+ if username.length is 36 and '@' not in username # must be an identifier for vendor
+ q = { iosIdentifierForVendor: username }
+ else
+ q = { emailLower: username.toLowerCase() }
+
+ User.findOne(q).exec((err, user) ->
+ return done(err) if err
+ if not user
+ return done(new errors.Unauthorized('not found', { property: 'email' }))
+ passwordReset = (user.get('passwordReset') or '').toLowerCase()
+ if passwordReset and password.toLowerCase() is passwordReset
+ User.update {_id: user.get('_id')}, {$unset: {passwordReset: ''}}, {}, ->
+ return done(null, user)
+
+ hash = User.hashPassword(password)
+ unless user.get('passwordHash') is hash
+ return done(new errors.Unauthorized('is wrong', { property: 'password' }))
+ return done(null, user)
+ )
+ ))
diff --git a/server/commons/errors.coffee b/server/commons/errors.coffee
index 58f45babb..a82eb7b0f 100644
--- a/server/commons/errors.coffee
+++ b/server/commons/errors.coffee
@@ -88,6 +88,10 @@ errorResponseSchema = {
type: 'string'
description: 'Property which is related to the error (conflict, validation).'
}
+ name: {
+ type: 'string'
+ description: 'Provided for /auth/name.' # TODO: refactor out
+ }
}
}
errorProps = _.keys(errorResponseSchema.properties)
diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee
index d72b9ddce..1c35e6676 100644
--- a/server/commons/mapping.coffee
+++ b/server/commons/mapping.coffee
@@ -47,7 +47,6 @@ module.exports.handlerUrlOverrides =
module.exports.routes =
[
'routes/admin'
- 'routes/auth'
'routes/contact'
'routes/db'
'routes/file'
diff --git a/server/handlers/campaign_handler.coffee b/server/handlers/campaign_handler.coffee
index e3cb82a44..24e292b93 100644
--- a/server/handlers/campaign_handler.coffee
+++ b/server/handlers/campaign_handler.coffee
@@ -7,24 +7,10 @@ mongoose = require 'mongoose'
CampaignHandler = class CampaignHandler extends Handler
modelClass: Campaign
- editableProperties: [
- 'name'
- 'fullName'
- 'description'
- 'type'
- 'i18n'
- 'i18nCoverage'
- 'ambientSound'
- 'backgroundImage'
- 'backgroundColor'
- 'backgroundColorTransparent'
- 'adjacentCampaigns'
- 'levels'
- ]
jsonSchema: require '../../app/schemas/models/campaign.schema'
hasAccess: (req) ->
- req.method in ['GET', 'PUT'] or req.user?.isAdmin()
+ req.method in ['GET'] or req.user?.isAdmin()
hasAccessToDocument: (req, document, method=null) ->
return true if req.user?.isAdmin()
@@ -124,10 +110,6 @@ CampaignHandler = class CampaignHandler extends Handler
return @sendDatabaseError(res, err) if err
return @sendSuccess(res, (achievement.toObject() for achievement in achievements))
- onPutSuccess: (req, doc) ->
- docLink = "http://codecombat.com#{req.headers['x-current-path']}"
- @sendChangedSlackMessage creator: req.user, target: doc, docLink: docLink
-
getNamesByIDs: (req, res) -> @getNamesByOriginals req, res, true
module.exports = new CampaignHandler()
diff --git a/server/handlers/classroom_handler.coffee b/server/handlers/classroom_handler.coffee
index 69008e018..9fbe6d9d2 100644
--- a/server/handlers/classroom_handler.coffee
+++ b/server/handlers/classroom_handler.coffee
@@ -10,12 +10,11 @@ UserHandler = require './user_handler'
ClassroomHandler = class ClassroomHandler extends Handler
modelClass: Classroom
jsonSchema: require '../../app/schemas/models/classroom.schema'
- allowedMethods: ['GET', 'POST', 'PUT', 'DELETE']
+ allowedMethods: ['GET', 'PUT', 'DELETE']
hasAccess: (req) ->
return false unless req.user
return true if req.method is 'GET'
- return false if req.method is 'POST' and not req.user?.isTeacher()
req.method in @allowedMethods or req.user?.isAdmin()
hasAccessToDocument: (req, document, method=null) ->
@@ -27,12 +26,6 @@ ClassroomHandler = class ClassroomHandler extends Handler
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'
diff --git a/server/middleware/auth.coffee b/server/middleware/auth.coffee
index a8d087780..8ac1894ab 100644
--- a/server/middleware/auth.coffee
+++ b/server/middleware/auth.coffee
@@ -8,6 +8,9 @@ request = require 'request'
User = require '../models/User'
utils = require '../lib/utils'
mongoose = require 'mongoose'
+authentication = require 'passport'
+sendwithus = require '../sendwithus'
+LevelSession = require '../models/LevelSession'
module.exports =
checkDocumentPermissions: (req, res, next) ->
@@ -34,8 +37,26 @@ module.exports =
if not _.size(_.intersection(req.user.get('permissions'), permissions))
return next new errors.Forbidden('You do not have permissions necessary.')
next()
+
+ whoAmI: wrap (req, res) ->
+ if not req.user
+ user = User.makeNew(req)
+ yield user.save()
+ req.logInAsync = Promise.promisify(req.logIn)
+ yield req.logInAsync(user)
+
+ if req.query.callback
+ res.jsonp(req.user.toObject({req, publicOnly: true}))
+ else
+ res.send(req.user.toObject({req, publicOnly: false}))
+ res.end()
- loginByGPlus: wrap (req, res) ->
+ afterLogin: wrap (req, res, next) ->
+ activity = req.user.trackActivity 'login', 1
+ yield req.user.update {activity: activity}
+ res.status(200).send(req.user.toObject({req: req}))
+
+ loginByGPlus: wrap (req, res, next) ->
gpID = req.body.gplusID
gpAT = req.body.gplusAccessToken
throw new errors.UnprocessableEntity('gplusID and gplusAccessToken required.') unless gpID and gpAT
@@ -48,9 +69,9 @@ module.exports =
throw new errors.NotFound('No user with that G+ ID') unless user
req.logInAsync = Promise.promisify(req.logIn)
yield req.logInAsync(user)
- res.status(200).send(user.formatEntity(req))
+ next()
- loginByFacebook: wrap (req, res) ->
+ loginByFacebook: wrap (req, res, next) ->
fbID = req.body.facebookID
fbAT = req.body.facebookAccessToken
throw new errors.UnprocessableEntity('facebookID and facebookAccessToken required.') unless fbID and fbAT
@@ -63,7 +84,7 @@ module.exports =
throw new errors.NotFound('No user with that Facebook ID') unless user
req.logInAsync = Promise.promisify(req.logIn)
yield req.logInAsync(user)
- res.status(200).send(user.formatEntity(req))
+ next()
spy: wrap (req, res) ->
throw new errors.Unauthorized('You must be logged in to enter espionage mode') unless req.user
@@ -94,3 +115,84 @@ module.exports =
req.loginAsync = Promise.promisify(req.login)
yield req.loginAsync user
res.status(200).send(user.toObject({req: req}))
+
+ logout: (req, res) ->
+ req.logout()
+ res.send({})
+
+ reset: wrap (req, res) ->
+ unless req.body.email
+ throw new errors.UnprocessableEntity('Need an email specified.', {property: 'email'})
+
+ user = yield User.findOne({emailLower: req.body.email.toLowerCase()})
+ if not user
+ throw new errors.NotFound('not found', {property: 'email'})
+
+ user.set('passwordReset', utils.getCodeCamel())
+ yield user.save()
+ context =
+ email_id: sendwithus.templates.password_reset
+ recipient:
+ address: req.body.email
+ email_data:
+ tempPassword: user.get('passwordReset')
+ sendwithus.api.sendAsync = Promise.promisify(sendwithus.api.send)
+ yield sendwithus.api.sendAsync(context)
+ res.end()
+
+ unsubscribe: wrap (req, res) ->
+ email = req.query.email
+ unless email
+ throw new errors.UnprocessableEntity 'No email provided to unsubscribe.'
+ email = decodeURIComponent(email)
+
+ if req.query.session
+ # Unsubscribe from just one session's notifications instead.
+ session = yield LevelSession.findOne({_id: req.query.session})
+ if not session
+ throw new errors.NotFound "Level session not found"
+ session.set 'unsubscribed', true
+ yield session.save()
+ res.send "Unsubscribed #{email} from CodeCombat emails for #{session.get('levelName')} #{session.get('team')} ladder updates. Sorry to see you go! Ladder preferences
"
+ res.end()
+ return
+
+ user = yield User.findOne({emailLower: email.toLowerCase()})
+ if not user
+ throw new errors.NotFound "No user found with email '#{email}'"
+
+ emails = _.clone(user.get('emails')) or {}
+ msg = ''
+
+ if req.query.recruitNotes
+ emails.recruitNotes ?= {}
+ emails.recruitNotes.enabled = false
+ msg = "Unsubscribed #{email} from recruiting emails."
+ else if req.query.employerNotes
+ emails.employerNotes ?= {}
+ emails.employerNotes.enabled = false
+ msg = "Unsubscribed #{email} from employer emails."
+ else
+ msg = "Unsubscribed #{email} from all CodeCombat emails. Sorry to see you go!"
+ emailSettings.enabled = false for emailSettings in _.values(emails)
+ emails.generalNews ?= {}
+ emails.generalNews.enabled = false
+ emails.anyNotes ?= {}
+ emails.anyNotes.enabled = false
+
+ yield user.update {$set: {emails: emails}}
+ res.send msg + 'Account settings
'
+ res.end()
+
+ name: wrap (req, res) ->
+ if not req.params.name
+ throw new errors.UnprocessableEntity 'No name provided.'
+ originalName = req.params.name
+
+ User.unconflictNameAsync = Promise.promisify(User.unconflictName)
+ name = yield User.unconflictNameAsync originalName
+ response = name: name
+ if originalName is name
+ res.send 200, response
+ else
+ throw new errors.Conflict('Name is taken', response)
\ No newline at end of file
diff --git a/server/middleware/campaigns.coffee b/server/middleware/campaigns.coffee
index c5d003ca5..754966576 100644
--- a/server/middleware/campaigns.coffee
+++ b/server/middleware/campaigns.coffee
@@ -7,6 +7,7 @@ mongoose = require 'mongoose'
Campaign = require '../models/Campaign'
parse = require '../commons/parse'
LevelSession = require '../models/LevelSession'
+slack = require '../slack'
module.exports =
fetchByType: wrap (req, res, next) ->
@@ -19,3 +20,18 @@ module.exports =
campaigns = yield dbq.exec()
campaigns = (campaign.toObject({req: req}) for campaign in campaigns)
res.status(200).send(campaigns)
+
+ put: wrap (req, res) ->
+ campaign = yield database.getDocFromHandle(req, Campaign)
+ if not campaign
+ throw new errors.NotFound('Campaign not found.')
+ hasPermission = req.user.isAdmin()
+ unless hasPermission or database.isJustFillingTranslations(req, campaign)
+ throw new errors.Forbidden('Must be an admin or submitting translations to edit a campaign')
+
+ database.assignBody(req, campaign)
+ database.validateDoc(campaign)
+ campaign = yield campaign.save()
+ res.status(200).send(campaign.toObject())
+ docLink = "http://codecombat.com#{req.headers['x-current-path']}"
+ slack.sendChangedSlackMessage creator: req.user, target: campaign, docLink: docLink
diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee
index 1464eed6a..13b5e357f 100644
--- a/server/middleware/classrooms.coffee
+++ b/server/middleware/classrooms.coffee
@@ -62,3 +62,14 @@ module.exports =
memberObjects = (member.toObject({ req: req, includedPrivates: ["name", "email"] }) for member in members)
res.status(200).send(memberObjects)
+
+ post: wrap (req, res) ->
+ throw new errors.Unauthorized() unless req.user and not req.user.isAnonymous()
+ throw new errors.Forbidden() unless req.user?.isTeacher()
+ classroom = database.initDoc(req, Classroom)
+ classroom.set 'ownerID', req.user._id
+ classroom.set 'members', []
+ database.assignBody(req, classroom)
+ database.validateDoc(classroom)
+ classroom = yield classroom.save()
+ res.status(201).send(classroom.toObject({req: req}))
\ No newline at end of file
diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee
index 5b4f30d50..0744df455 100644
--- a/server/middleware/users.coffee
+++ b/server/middleware/users.coffee
@@ -20,7 +20,7 @@ module.exports =
throw new errors.UnprocessableEntity('Invalid G+ Access Token.') unless idsMatch
user = yield User.findOne({gplusID: gpID})
throw new errors.NotFound('No user with that G+ ID') unless user
- res.status(200).send(user.formatEntity(req))
+ res.status(200).send(user.toObject({req: req}))
fetchByFacebookID: wrap (req, res, next) ->
fbID = req.query.facebookID
@@ -31,10 +31,8 @@ module.exports =
dbq.select(parse.getProjectFromReq(req))
url = "https://graph.facebook.com/me?access_token=#{fbAT}"
[facebookRes, body] = yield request.getAsync(url, {json: true})
- console.log '...', body, facebookRes.statusCode
idsMatch = fbID is body.id
throw new errors.UnprocessableEntity('Invalid Facebook Access Token.') unless idsMatch
user = yield User.findOne({facebookID: fbID})
throw new errors.NotFound('No user with that Facebook ID') unless user
- console.log 'okay done'
- res.status(200).send(user.formatEntity(req))
+ res.status(200).send(user.toObject({req: req}))
diff --git a/server/models/Campaign.coffee b/server/models/Campaign.coffee
index 28c0d87b3..0fc68d2bd 100644
--- a/server/models/Campaign.coffee
+++ b/server/models/Campaign.coffee
@@ -37,5 +37,19 @@ CampaignSchema.statics.updateAdjacentCampaigns = (savedCampaign) ->
CampaignSchema.post 'save', -> @constructor.updateAdjacentCampaigns @
CampaignSchema.statics.jsonSchema = jsonSchema
+CampaignSchema.statics.editableProperties = [
+ 'name'
+ 'fullName'
+ 'description'
+ 'type'
+ 'i18n'
+ 'i18nCoverage'
+ 'ambientSound'
+ 'backgroundImage'
+ 'backgroundColor'
+ 'backgroundColorTransparent'
+ 'adjacentCampaigns'
+ 'levels'
+]
module.exports = mongoose.model('campaign', CampaignSchema)
diff --git a/server/models/Classroom.coffee b/server/models/Classroom.coffee
index 05e835d8f..c49533273 100644
--- a/server/models/Classroom.coffee
+++ b/server/models/Classroom.coffee
@@ -54,7 +54,7 @@ ClassroomSchema.set('toObject', {
transform: (doc, ret, options) ->
return ret unless options.req
user = options.req.user
- unless user?.isAdmin() or user?.get('_id').equals(doc.get('ownerID'))
+ unless user and (user.isAdmin() or user._id.equals(doc.get('ownerID')))
delete ret.code
delete ret.codeCamel
return ret
diff --git a/server/models/User.coffee b/server/models/User.coffee
index bdb734152..a4f7c34c9 100644
--- a/server/models/User.coffee
+++ b/server/models/User.coffee
@@ -6,6 +6,7 @@ mail = require '../commons/mail'
log = require 'winston'
plugins = require '../plugins/plugins'
AnalyticsUsersActive = require './AnalyticsUsersActive'
+languages = require '../routes/languages'
config = require '../../server_config'
stripe = require('stripe')(config.stripe.secretKey)
@@ -253,16 +254,6 @@ UserSchema.methods.isPremium = ->
return true if @hasSubscription()
return false
-UserSchema.methods.formatEntity = (req, publicOnly=false) ->
- obj = @toObject()
- serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP']
- delete obj[prop] for prop in serverProperties
- candidateProperties = ['jobProfile', 'jobProfileApproved', 'jobProfileNotes']
- delete obj[prop] for prop in candidateProperties
- includePrivates = not publicOnly and (req.user and (req.user.isAdmin() or req.user._id.equals(@_id)))
- delete obj[prop] for prop in User.privateProperties unless includePrivates
- return obj
-
UserSchema.methods.isOnPremiumServer = ->
@get('country') in ['china', 'brazil']
@@ -374,9 +365,27 @@ UserSchema.set('toObject', {
delete ret[prop] for prop in User.candidateProperties
return ret
})
+
+UserSchema.statics.makeNew = (req) ->
+ user = new User({anonymous: true})
+ if global.testing
+ # allows tests some control over user id creation
+ newID = _.pad((User.idCounter++).toString(16), 24, '0')
+ user.set('_id', newID)
+ user.set 'testGroupNumber', Math.floor(Math.random() * 256) # also in app/core/auth
+ lang = languages.languageCodeFromAcceptedLanguages req.acceptedLanguages
+ user.set 'preferredLanguage', lang if lang[...2] isnt 'en'
+ user.set 'preferredLanguage', 'pt-BR' if not user.get('preferredLanguage') and /br\.codecombat\.com/.test(req.get('host'))
+ user.set 'preferredLanguage', 'zh-HANS' if not user.get('preferredLanguage') and /cn\.codecombat\.com/.test(req.get('host'))
+ user.set 'lastIP', (req.headers['x-forwarded-for'] or req.connection.remoteAddress)?.split(/,? /)[0]
+ user.set 'country', req.country if req.country
+ user
+
+
UserSchema.plugin plugins.NamedPlugin
module.exports = User = mongoose.model('User', UserSchema)
+User.idCounter = 0
AchievablePlugin = require '../plugins/achievements'
UserSchema.plugin(AchievablePlugin)
diff --git a/server/routes/auth.coffee b/server/routes/auth.coffee
deleted file mode 100644
index 6e3cee149..000000000
--- a/server/routes/auth.coffee
+++ /dev/null
@@ -1,203 +0,0 @@
-authentication = require 'passport'
-LocalStrategy = require('passport-local').Strategy
-User = require '../models/User'
-UserHandler = require '../handlers/user_handler'
-LevelSession = require '../models/LevelSession'
-config = require '../../server_config'
-errors = require '../commons/errors'
-languages = require '../routes/languages'
-sendwithus = require '../sendwithus'
-log = require 'winston'
-utils = require '../lib/utils'
-
-module.exports.setup = (app) ->
- authentication.serializeUser((user, done) -> done(null, user._id))
- authentication.deserializeUser((id, done) ->
- User.findById(id, (err, user) -> done(err, user)))
-
- if config.picoCTF
- pico = require('../lib/picoctf');
- authentication.use new pico.PicoStrategy()
- return
-
- authentication.use(new LocalStrategy(
- (username, password, done) ->
-
- # kind of a hacky way to make it possible for iPads to 'log in' with their unique device id
- if username.length is 36 and '@' not in username # must be an identifier for vendor
- q = { iosIdentifierForVendor: username }
- else
- q = { emailLower: username.toLowerCase() }
-
- User.findOne(q).exec((err, user) ->
- return done(err) if err
- return done(null, false, {message: 'not found', property: 'email'}) if not user
- passwordReset = (user.get('passwordReset') or '').toLowerCase()
- if passwordReset and password.toLowerCase() is passwordReset
- User.update {_id: user.get('_id')}, {passwordReset: ''}, {}, ->
- return done(null, user)
-
- hash = User.hashPassword(password)
- unless user.get('passwordHash') is hash
- return done(null, false, {message: 'is wrong', property: 'password'})
- return done(null, user)
- )
- ))
-
- app.post('/auth/login', (req, res, next) ->
- authentication.authenticate('local', (err, user, info) ->
- return next(err) if err
- if not user
- return errors.unauthorized(res, [{message: info.message, property: info.property}])
-
- req.logIn(user, (err) ->
- return next(err) if (err)
- activity = req.user.trackActivity 'login', 1
- user.update {activity: activity}, (err) ->
- return next(err) if (err)
- res.send(UserHandler.formatEntity(req, req.user))
- return res.end()
- )
- )(req, res, next)
- )
-
- app.get('/auth/whoami', (req, res) ->
- if req.user
- sendSelf(req, res)
- else
- user = makeNewUser(req)
- makeNext = (req, res) -> -> sendSelf(req, res)
- next = makeNext(req, res)
- loginUser(req, res, user, false, next)
- )
-
- sendSelf = (req, res) ->
- res.setHeader('Content-Type', 'text/json')
- if req.query.callback
- res.jsonp UserHandler.formatEntity(req, req.user, true)
- else
- res.send UserHandler.formatEntity(req, req.user, false)
- res.end()
-
- app.post('/auth/logout', (req, res) ->
- req.logout()
- res.send({})
- )
-
- app.post('/auth/reset', (req, res) ->
- unless req.body.email
- return errors.badInput(res, [{message: 'Need an email specified.', property: 'email'}])
-
- User.findOne({emailLower: req.body.email.toLowerCase()}).exec((err, user) ->
- if not user
- return errors.notFound(res, [{message: 'not found', property: 'email'}])
-
- user.set('passwordReset', utils.getCodeCamel())
- emailContent = "Your temporary password: #{user.get('passwordReset')}
"
- emailContent += "Reset your password at http://codecombat.com/account/settings
"
- emailContent += "Your old password cannot be retrieved.
"
- user.save (err) =>
- return errors.serverError(res) if err
- context =
- email_id: sendwithus.templates.generic_email
- recipient:
- address: req.body.email
- email_data:
- subject: 'CodeCombat Recovery Password'
- title: ''
- content: emailContent
- sendwithus.api.send context, (err, result) ->
- if err
- console.error "Error sending password reset email: #{err.message or err}"
- res.end()
- )
- )
-
- app.get '/auth/unsubscribe', (req, res) ->
- req.query.email = decodeURIComponent(req.query.email)
- email = req.query.email
- unless req.query.email
- return errors.badInput res, 'No email provided to unsubscribe.'
-
- if req.query.session
- # Unsubscribe from just one session's notifications instead.
- return LevelSession.findOne({_id: req.query.session}).exec (err, session) ->
- return errors.serverError res, 'Could not unsubscribe: #{req.query.session}, #{req.query.email}: #{err}' if err
- session.set 'unsubscribed', true
- session.save (err) ->
- return errors.serverError res, 'Database failure.' if err
- res.send "Unsubscribed #{req.query.email} from CodeCombat emails for #{session.get('levelName')} #{session.get('team')} ladder updates. Sorry to see you go! Ladder preferences
"
- res.end()
-
- User.findOne({emailLower: req.query.email.toLowerCase()}).exec (err, user) ->
- if not user
- return errors.notFound res, "No user found with email '#{req.query.email}'"
-
- emails = _.clone(user.get('emails')) or {}
- msg = ''
-
- if req.query.recruitNotes
- emails.recruitNotes ?= {}
- emails.recruitNotes.enabled = false
- msg = "Unsubscribed #{req.query.email} from recruiting emails."
- else if req.query.employerNotes
- emails.employerNotes ?= {}
- emails.employerNotes.enabled = false
-
- msg = "Unsubscribed #{req.query.email} from employer emails."
- else
- msg = "Unsubscribed #{req.query.email} from all CodeCombat emails. Sorry to see you go!"
- emailSettings.enabled = false for emailSettings in _.values(emails)
- emails.generalNews ?= {}
- emails.generalNews.enabled = false
- emails.anyNotes ?= {}
- emails.anyNotes.enabled = false
-
- user.update {$set: {emails: emails}}, {}, =>
- return errors.serverError res, 'Database failure.' if err
- res.send msg + 'Account settings
'
- res.end()
-
- app.get '/auth/name*', (req, res) ->
- parts = req.path.split '/'
- originalName = decodeURI parts[3]
- return errors.badInput res, 'No name provided.' unless parts.length > 3 and originalName? and originalName isnt ''
- return errors.notFound res if parts.length isnt 4
-
- User.unconflictName originalName, (err, name) ->
- return errors.serverError res, err if err
- response = name: name
- if originalName is name
- res.send 200, response
- else
- errors.conflict res, response
-
-
-module.exports.loginUser = loginUser = (req, res, user, send=true, next=null) ->
- user.save((err) ->
- return errors.serverError res, err if err?
-
- req.logIn(user, (err) ->
- return errors.serverError res, err if err?
- return res.send(user) and res.end() if send
- next() if next
- )
- )
-
-module.exports.idCounter = 0
-
-module.exports.makeNewUser = makeNewUser = (req) ->
- user = new User({anonymous: true})
- if global.testing
- # allows tests some control over user id creation
- newID = _.pad((module.exports.idCounter++).toString(16), 24, '0')
- user.set('_id', newID)
- user.set 'testGroupNumber', Math.floor(Math.random() * 256) # also in app/core/auth
- lang = languages.languageCodeFromAcceptedLanguages req.acceptedLanguages
- user.set 'preferredLanguage', lang if lang[...2] isnt 'en'
- user.set 'preferredLanguage', 'pt-BR' if not user.get('preferredLanguage') and /br\.codecombat\.com/.test(req.get('host'))
- user.set 'preferredLanguage', 'zh-HANS' if not user.get('preferredLanguage') and /cn\.codecombat\.com/.test(req.get('host'))
- user.set 'lastIP', (req.headers['x-forwarded-for'] or req.connection.remoteAddress)?.split(/,? /)[0]
- user.set 'country', req.country if req.country
- #log.info "making new user #{user.get('_id')} with language #{user.get('preferredLanguage')} of #{req.acceptedLanguages} and country #{req.country} on #{if config.tokyo then 'Tokyo' else (if config.saoPaulo then 'Brazil' else 'US')} server and lastIP #{user.get('lastIP')}."
- user
diff --git a/server/routes/index.coffee b/server/routes/index.coffee
index 1e7945e10..abea5d999 100644
--- a/server/routes/index.coffee
+++ b/server/routes/index.coffee
@@ -2,10 +2,17 @@ mw = require '../middleware'
module.exports.setup = (app) ->
- app.post('/auth/login-facebook', mw.auth.loginByFacebook)
- app.post('/auth/login-gplus', mw.auth.loginByGPlus)
+ passport = require('passport')
+ app.post('/auth/login', passport.authenticate('local'), mw.auth.afterLogin)
+ app.post('/auth/login-facebook', mw.auth.loginByFacebook, mw.auth.afterLogin)
+ app.post('/auth/login-gplus', mw.auth.loginByGPlus, mw.auth.afterLogin)
+ app.post('/auth/logout', mw.auth.logout)
+ app.get('/auth/name/?(:name)?', mw.auth.name)
+ app.post('/auth/reset', mw.auth.reset)
app.post('/auth/spy', mw.auth.spy)
app.post('/auth/stop-spying', mw.auth.stopSpying)
+ app.get('/auth/unsubscribe', mw.auth.unsubscribe)
+ app.get('/auth/whoami', mw.auth.whoAmI)
Achievement = require '../models/Achievement'
app.get('/db/achievement', mw.achievements.fetchByRelated, mw.rest.get(Achievement))
@@ -33,7 +40,11 @@ module.exports.setup = (app) ->
app.get('/db/article/:handle/patches', mw.patchable.patches(Article))
app.post('/db/article/:handle/watchers', mw.patchable.joinWatchers(Article))
app.delete('/db/article/:handle/watchers', mw.patchable.leaveWatchers(Article))
+
+ app.get('/db/campaign', mw.campaigns.fetchByType)
+ app.put('/db/campaign/:handle', mw.campaigns.put)
+ app.post('/db/classroom', mw.classrooms.post)
app.get('/db/classroom', mw.classrooms.getByOwner)
app.get('/db/classroom/:handle/member-sessions', mw.classrooms.fetchMemberSessions)
app.get('/db/classroom/:handle/members', mw.classrooms.fetchMembers) # TODO: Use mw.auth?
@@ -43,8 +54,6 @@ module.exports.setup = (app) ->
app.get('/db/course', mw.rest.get(Course))
app.get('/db/course/:handle', mw.rest.getByHandle(Course))
- app.get('/db/campaign', mw.campaigns.fetchByType) #TODO
-
app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers)
app.get('/db/user', mw.users.fetchByGPlusID, mw.users.fetchByFacebookID)
diff --git a/server/sendwithus.coffee b/server/sendwithus.coffee
index 75a5ead1a..1cf6fea98 100644
--- a/server/sendwithus.coffee
+++ b/server/sendwithus.coffee
@@ -1,14 +1,20 @@
config = require '../server_config'
sendwithusAPI = require 'sendwithus'
swuAPIKey = config.mail.sendwithusAPIKey
+log = require 'winston'
module.exports.setupRoutes = (app) ->
return
debug = not config.isProduction
-module.exports.api = new sendwithusAPI swuAPIKey, debug
-if config.unittest
- module.exports.api.send = ->
+module.exports.api =
+ send: (context, cb) ->
+ log.debug('Tried to send email with context: ', JSON.stringify(context, null, '\t'))
+ setTimeout(cb, 10)
+
+if swuAPIKey
+ module.exports.api = new sendwithusAPI swuAPIKey, debug
+
module.exports.templates =
parent_subscribe_email: 'tem_2APERafogvwKhmcnouigud'
share_progress_email: 'tem_VHE3ihhGmVa3727qds9zY8'
@@ -25,3 +31,4 @@ module.exports.templates =
teacher_free_trial: 'tem_R7d9Hpoba9SceQNiYSXBak'
teacher_free_trial_hoc: 'tem_4ZSY9wsA9Qwn4wBFmZgPdc'
teacher_request_demo: 'tem_cwG3HZjEyb6QE493hZuUra'
+ password_reset: 'tem_wbQUMRtLY9xhec8BSCykLA'
diff --git a/server/slack.coffee b/server/slack.coffee
index 990356451..2f069d31e 100644
--- a/server/slack.coffee
+++ b/server/slack.coffee
@@ -5,8 +5,13 @@ log = require 'winston'
roomChannelMap =
main: '#general'
artisans: '#artisan'
+
+module.exports.sendChangedSlackMessage = (options) ->
+ message = "#{options.creator.get('name')} saved a change to #{options.target.get('name')}: #{options.target.get('commitMessage') or '(no commit message)'} #{options.docLink}"
+ rooms = if /Diplomat submission/.test(message) then ['dev-feed'] else ['dev-feed', 'artisans']
+ @sendSlackMessage message, rooms
-module.exports.sendSlackMessage = sendSlackMessage = (message, rooms=['tower'], options={}) ->
+module.exports.sendSlackMessage = (message, rooms=['tower'], options={}) ->
unless config.isProduction
log.info "Slack msg: #{message}"
return
diff --git a/server_config.coffee b/server_config.coffee
index daa93949e..68a752e68 100644
--- a/server_config.coffee
+++ b/server_config.coffee
@@ -3,10 +3,8 @@ config = {}
config.unittest = global.testing
config.proxy = process.env.COCO_PROXY
-config.tokyo = process.env.TOKYO or false
-config.saoPaulo = process.env.SAOPAULO or false
-config.chinaDomain = "http://cn.codecombat.com"
-config.brazilDomain = "http://br.codecombat.com"
+config.chinaDomain = "cn.codecombat.com"
+config.brazilDomain = "br.codecombat.com"
config.port = process.env.COCO_PORT or process.env.COCO_NODE_PORT or process.env.PORT or 3000
config.ssl_port = process.env.COCO_SSL_PORT or process.env.COCO_SSL_NODE_PORT or 3443
config.cloudflare =
diff --git a/server_setup.coffee b/server_setup.coffee
index 675321c3f..deff5bb84 100644
--- a/server_setup.coffee
+++ b/server_setup.coffee
@@ -13,7 +13,7 @@ baseRoute = require './server/routes/base'
user = require './server/handlers/user_handler'
logging = require './server/commons/logging'
config = require './server_config'
-auth = require './server/routes/auth'
+auth = require './server/commons/auth'
routes = require './server/routes'
UserHandler = require './server/handlers/user_handler'
slack = require './server/slack'
@@ -108,11 +108,17 @@ setupPassportMiddleware = (app) ->
require('./server/lib/picoctf').init app
else
app.use(authentication.session())
+ auth.setup()
-setupCountryRedirectMiddleware = (app, country="china", countryCode="CN", languageCode="zh", serverID="tokyo") ->
+setupCountryRedirectMiddleware = (app, country="china", countryCode="CN", languageCode="zh", host="cn.codecombat.com") ->
shouldRedirectToCountryServer = (req) ->
speaksLanguage = _.any req.acceptedLanguages, (language) -> language.indexOf languageCode isnt -1
- unless config[serverID]
+
+ #Work around express 3.0
+ reqHost = req.hostname
+ reqHost ?= req.host
+
+ unless reqHost.toLowerCase() is host
ip = req.headers['x-forwarded-for'] or req.connection.remoteAddress
ip = ip?.split(/,? /)[0] # If there are two IP addresses, say because of CloudFlare, we just take the first.
geo = geoip.lookup(ip)
@@ -126,7 +132,7 @@ setupCountryRedirectMiddleware = (app, country="china", countryCode="CN", langua
app.use (req, res, next) ->
if shouldRedirectToCountryServer req
- res.writeHead 302, "Location": config[country + 'Domain'] + req.url
+ res.writeHead 302, "Location": 'http://' + host + req.url
res.end()
else
next()
@@ -163,8 +169,8 @@ setupPerfMonMiddleware = (app) ->
exports.setupMiddleware = (app) ->
setupPerfMonMiddleware app
- setupCountryRedirectMiddleware app, "china", "CN", "zh", "tokyo"
- setupCountryRedirectMiddleware app, "brazil", "BR", "pt-BR", "saoPaulo"
+ setupCountryRedirectMiddleware app, "china", "CN", "zh", config.chinaDomain
+ setupCountryRedirectMiddleware app, "brazil", "BR", "pt-BR", config.brazilDomain
setupMiddlewareToSendOldBrowserWarningWhenPlayersViewLevelDirectly app
setupExpressMiddleware app
setupPassportMiddleware app
diff --git a/spec/server/common.coffee b/spec/server/common.coffee
index 3ef743b0b..ae6fa307c 100644
--- a/spec/server/common.coffee
+++ b/spec/server/common.coffee
@@ -211,6 +211,5 @@ _drop = (done) ->
done()
GLOBAL.resetUserIDCounter = (number=0) ->
- auth = require '../../server/routes/auth'
- auth.idCounter = number
+ User.idCounter = number
diff --git a/spec/server/functional/auth.spec.coffee b/spec/server/functional/auth.spec.coffee
index 076dd1d2a..b97227676 100644
--- a/spec/server/functional/auth.spec.coffee
+++ b/spec/server/functional/auth.spec.coffee
@@ -6,6 +6,7 @@ Promise = require 'bluebird'
nock = require 'nock'
request = require '../request'
sendwithus = require '../../../server/sendwithus'
+LevelSession = require '../../../server/models/LevelSession'
urlLogin = getURL('/auth/login')
urlReset = getURL('/auth/reset')
@@ -24,7 +25,7 @@ describe 'POST /auth/login', ->
done()
it 'allows logging in by iosIdentifierForVendor', utils.wrap (done) ->
- user = yield utils.initUser({
+ yield utils.initUser({
'iosIdentifierForVendor': '012345678901234567890123456789012345'
'password': '12345'
})
@@ -44,7 +45,7 @@ describe 'POST /auth/login', ->
done()
it 'returns 200 when the user does exist', utils.wrap (done) ->
- user = yield utils.initUser({
+ yield utils.initUser({
'email': 'some@email.com'
'password': '12345'
})
@@ -56,7 +57,7 @@ describe 'POST /auth/login', ->
done()
it 'rejects wrong passwords', utils.wrap (done) ->
- user = yield utils.initUser({
+ yield utils.initUser({
'email': 'some@email.com'
'password': '12345'
})
@@ -68,7 +69,7 @@ describe 'POST /auth/login', ->
done()
it 'is completely case insensitive', utils.wrap (done) ->
- user = yield utils.initUser({
+ yield utils.initUser({
'email': 'Some@Email.com'
'password': 'AbCdE'
})
@@ -104,7 +105,9 @@ describe 'POST /auth/reset', ->
done()
it 'resets the user password', utils.wrap (done) ->
- spyOn(sendwithus.api, 'send')
+ spyOn(sendwithus.api, 'send').and.callFake (options, cb) ->
+ expect(options.recipient.address).toBe('some@email.com')
+ cb()
[res, body] = yield request.postAsync(
{uri: urlReset, json: {email: 'some@email.com'}}
)
@@ -120,9 +123,11 @@ describe 'POST /auth/reset', ->
expect(res.statusCode).toBe(200)
done()
- # TODO: Finish refactoring the rest of these old tests
it 'resetting password is not idempotent', utils.wrap (done) ->
+ spyOn(sendwithus.api, 'send').and.callFake (options, cb) ->
+ expect(options.recipient.address).toBe('some@email.com')
+ cb()
[res, body] = yield request.postAsync(
{uri: urlReset, json: {email: 'some@email.com'}}
)
@@ -145,17 +150,65 @@ describe 'GET /auth/unsubscribe', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([User])
+ @user = yield utils.initUser()
+ done()
+
+ it 'returns 422 if email is not included', utils.wrap (done) ->
+ url = getURL('/auth/unsubscribe')
+ [res, body] = yield request.getAsync(url)
+ expect(res.statusCode).toBe(422)
done()
- it 'removes just recruitment emails if you include ?recruitNotes=1', utils.wrap (done) ->
- user = yield utils.initUser()
- url = getURL('/auth/unsubscribe?recruitNotes=1&email='+user.get('email'))
+ it 'returns 404 if email is not found', utils.wrap (done) ->
+ url = getURL('/auth/unsubscribe?email=ladeeda')
[res, body] = yield request.getAsync(url)
- expect(res.statusCode).toBe(200)
- user = yield User.findOne(user._id)
- expect(user.get('emails').recruitNotes.enabled).toBe(false)
- expect(user.isEmailSubscriptionEnabled('generalNews')).toBeTruthy()
+ expect(res.statusCode).toBe(404)
done()
+
+ describe '?recruitNotes=1', ->
+
+ it 'unsubscribes the user from recruitment emails', utils.wrap (done) ->
+ url = getURL('/auth/unsubscribe?recruitNotes=1&email='+@user.get('email'))
+ [res, body] = yield request.getAsync(url)
+ expect(res.statusCode).toBe(200)
+ user = yield User.findOne(@user._id)
+ expect(user.get('emails').recruitNotes.enabled).toBe(false)
+ expect(user.isEmailSubscriptionEnabled('generalNews')).toBeTruthy()
+ done()
+
+ describe '?employerNotes=1', ->
+
+ it 'unsubscribes the user from employer emails', utils.wrap (done) ->
+ url = getURL('/auth/unsubscribe?employerNotes=1&email='+@user.get('email'))
+ [res, body] = yield request.getAsync(url)
+ expect(res.statusCode).toBe(200)
+ user = yield User.findOne(@user._id)
+ expect(user.get('emails').employerNotes.enabled).toBe(false)
+ expect(user.isEmailSubscriptionEnabled('generalNews')).toBeTruthy()
+ done()
+
+ describe '?session=:id', ->
+
+ it 'sets the given LevelSession\'s unsubscribed property to true', utils.wrap (done) ->
+ session = new LevelSession({permissions:[target: @user._id, access: 'owner']})
+ yield session.save()
+ url = getURL("/auth/unsubscribe?session=#{session.id}&email=#{@user.get('email')}")
+ [res, body] = yield request.getAsync(url)
+ expect(res.statusCode).toBe(200)
+ session = yield LevelSession.findById(session.id)
+ expect(session.get('unsubscribed')).toBe(true)
+ done()
+
+ describe 'no GET query params', ->
+
+ it 'unsubscribes the user from all emails', utils.wrap (done) ->
+ url = getURL("/auth/unsubscribe?email=#{@user.get('email')}")
+ [res, body] = yield request.getAsync(url)
+ expect(res.statusCode).toBe(200)
+ user = yield User.findOne(@user._id)
+ expect(user.get('emails').generalNews.enabled).toBe(false)
+ expect(user.get('emails').anyNotes.enabled).toBe(false)
+ done()
describe 'GET /auth/name', ->
url = '/auth/name'
diff --git a/spec/server/functional/campaign_handler.spec.coffee b/spec/server/functional/campaign_handler.spec.coffee
index 8949eec07..940cd5f86 100644
--- a/spec/server/functional/campaign_handler.spec.coffee
+++ b/spec/server/functional/campaign_handler.spec.coffee
@@ -22,6 +22,7 @@ achievement = {
campaign = {
name: 'Campaign'
levels: {}
+ i18n: {}
}
levelURL = getURL('/db/level')
@@ -34,6 +35,46 @@ Campaign = require '../../../server/models/Campaign'
Level = require '../../../server/models/Level'
User = require '../../../server/models/User'
request = require '../request'
+utils = require '../utils'
+slack = require '../../../server/slack'
+
+describe 'PUT /db/campaign', ->
+ beforeEach utils.wrap (done) ->
+ yield utils.clearModels [Achievement, Campaign, Level, User]
+ admin = yield utils.initAdmin()
+ yield utils.loginUser(admin)
+ [res, body] = yield request.postAsync { uri: campaignURL, json: campaign }
+ @campaign = yield Campaign.findById(body._id)
+ done()
+
+ it 'saves changes to campaigns', utils.wrap (done) ->
+ [res, body] = yield request.putAsync { uri: campaignURL+'/'+@campaign.id, json: { name: 'A new name' } }
+ expect(body.name).toBe('A new name')
+ c = yield Campaign.findById(body._id)
+ expect(c.get('name')).toBe('A new name')
+ done()
+
+ it 'does not allow normal users to make changes', utils.wrap (done) ->
+ user = yield utils.initUser()
+ yield utils.loginUser(user)
+ [res, body] = yield request.putAsync { uri: campaignURL+'/'+@campaign.id, json: { name: 'A new name' } }
+ expect(res.statusCode).toBe(403)
+ done()
+
+ it 'allows normal users to put translation changes', utils.wrap (done) ->
+ user = yield utils.initUser()
+ yield utils.loginUser(user)
+ json = _.clone @campaign.toObject()
+ json.i18n = { de: { name: 'A new name' } }
+ [res, body] = yield request.putAsync { uri: campaignURL+'/'+@campaign.id, json: json }
+ expect(res.statusCode).toBe(200)
+ done()
+
+ it 'sends a slack message', utils.wrap (done) ->
+ spyOn(slack, 'sendSlackMessage')
+ [res, body] = yield request.putAsync { uri: campaignURL+'/'+@campaign.id, json: { name: 'A new name' } }
+ expect(slack.sendSlackMessage).toHaveBeenCalled()
+ done()
describe '/db/campaign', ->
it 'prepares the db first', (done) ->
diff --git a/spec/server/functional/classrooms.spec.coffee b/spec/server/functional/classrooms.spec.coffee
index 117b83c78..e8f10dc31 100644
--- a/spec/server/functional/classrooms.spec.coffee
+++ b/spec/server/functional/classrooms.spec.coffee
@@ -50,7 +50,7 @@ describe 'GET /db/classroom/:id', ->
user1.save (err) ->
data = { name: 'Classroom 1' }
request.post {uri: classroomsURL, json: data }, (err, res, body) ->
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
classroomID = body._id
request.get {uri: classroomsURL + '/' + body._id }, (err, res, body) ->
expect(res.statusCode).toBe(200)
@@ -59,36 +59,34 @@ describe 'GET /db/classroom/:id', ->
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) ->
- user1.set('role', 'teacher')
- user1.save (err) ->
- 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()
+ beforeEach utils.wrap (done) ->
+ yield utils.clearModels [User, Classroom]
+ done()
+
+ it 'creates a new classroom for the given user with teacher role', utils.wrap (done) ->
+ teacher = yield utils.initUser({role: 'teacher'})
+ yield utils.loginUser(teacher)
+ data = { name: 'Classroom 1' }
+ [res, body] = yield request.postAsync {uri: classroomsURL, json: data }
+ expect(res.statusCode).toBe(201)
+ expect(res.body.name).toBe('Classroom 1')
+ expect(res.body.members.length).toBe(0)
+ expect(res.body.ownerID).toBe(teacher.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()
+ it 'returns 401 for anonymous users', utils.wrap (done) ->
+ data = { name: 'Classroom 2' }
+ [res, body] = yield request.postAsync {uri: classroomsURL, json: data }
+ expect(res.statusCode).toBe(401)
+ done()
- it 'does not work for non-teacher users', (done) ->
- loginNewUser (user1) ->
- data = { name: 'Classroom 1' }
- request.post {uri: classroomsURL, json: data }, (err, res, body) ->
- expect(res.statusCode).toBe(403)
- done()
+ it 'does not work for non-teacher users', utils.wrap (done) ->
+ user = yield utils.initUser()
+ yield utils.loginUser(user)
+ data = { name: 'Classroom 1' }
+ [res, body] = yield request.postAsync {uri: classroomsURL, json: data }
+ expect(res.statusCode).toBe(403)
+ done()
describe 'PUT /db/classroom', ->
@@ -104,7 +102,7 @@ describe 'PUT /db/classroom', ->
user1.save (err) ->
data = { name: 'Classroom 2' }
request.post {uri: classroomsURL, json: data }, (err, res, body) ->
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
data = { name: 'Classroom 3', description: 'New Description' }
url = classroomsURL + '/' + body._id
request.put { uri: url, json: data }, (err, res, body) ->
@@ -118,7 +116,7 @@ describe 'PUT /db/classroom', ->
user1.save (err) ->
data = { name: 'Classroom 4' }
request.post {uri: classroomsURL, json: data }, (err, res, body) ->
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
classroomCode = body.code
loginNewUser (user2) ->
url = getURL("/db/classroom/~/members")
@@ -145,7 +143,7 @@ describe 'POST /db/classroom/~/members', ->
request.post {uri: classroomsURL, json: data }, (err, res, body) ->
classroomCode = body.code
classroomID = body._id
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
loginNewUser (user2) ->
url = getURL("/db/classroom/~/members")
data = { code: classroomCode }
@@ -166,7 +164,7 @@ describe 'POST /db/classroom/~/members', ->
request.post {uri: classroomsURL, json: data }, (err, res, body) ->
classroomCode = body.code
classroomID = body._id
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
loginNewUser (user2) ->
user2.set('role', 'teacher')
user2.save (err, user2) ->
@@ -183,7 +181,7 @@ describe 'POST /db/classroom/~/members', ->
teacher = yield utils.initUser({role: 'teacher'})
yield utils.loginUser(teacher)
[res, body] = yield request.postAsync {uri: classroomsURL, json: { name: 'Classroom' } }
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
classroomCode = body.code
yield utils.becomeAnonymous()
[res, body] = yield request.postAsync { uri: getURL("/db/classroom/~/members"), json: { code: classroomCode } }
@@ -206,7 +204,7 @@ describe 'DELETE /db/classroom/:id/members', ->
request.post {uri: classroomsURL, json: data }, (err, res, body) ->
classroomCode = body.code
classroomID = body._id
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
loginNewUser (user2) ->
url = getURL("/db/classroom/~/members")
data = { code: classroomCode }
@@ -231,7 +229,7 @@ describe 'POST /db/classroom/:id/invite-members', ->
user1.save (err) ->
data = { name: 'Classroom 6' }
request.post {uri: classroomsURL, json: data }, (err, res, body) ->
- expect(res.statusCode).toBe(200)
+ expect(res.statusCode).toBe(201)
url = classroomsURL + '/' + body._id + '/invite-members'
data = { emails: ['test@test.com'] }
request.post { uri: url, json: data }, (err, res, body) ->
diff --git a/verifier.coffee b/verifier.coffee
new file mode 100644
index 000000000..72e963794
--- /dev/null
+++ b/verifier.coffee
@@ -0,0 +1,147 @@
+useEsper = true
+bowerComponentsPath = './bower_components/'
+headlessClientPath = './headless_client/'
+require 'aether'
+# SETTINGS
+options =
+ workerCode: require headlessClientPath + 'worker_world'
+ debug: false # Enable logging of ajax calls mainly
+ testing: false # Instead of simulating 'real' games, use the same one over and over again. Good for leak hunting.
+ testFile: require headlessClientPath + 'test.js'
+ leakTest: false # Install callback that tries to find leaks automatically
+ exitOnLeak: false # Exit if leak is found. Only useful if leaktest is set to true, obviously.
+ heapdump: false # Dumps the whole heap after every pass. The heap dumps can then be viewed in Chrome browser.
+ headlessClient: true
+
+options.heapdump = require('heapdump') if options.heapdump
+server = if options.testing then 'http://127.0.0.1:3000' else 'http://direct.codecombat.com'
+# Use direct instead of live site because jQlone's requests proxy doesn't do caching properly and CloudFlare gets too aggressive.
+
+# Disabled modules
+disable = [
+ 'lib/AudioPlayer'
+ 'locale/locale'
+ '../locale/locale'
+]
+
+# Start of the actual code. Setting up the enivronment to match the environment of the browser
+
+# Global emulated stuff
+GLOBAL.window = GLOBAL
+GLOBAL.document =
+ location:
+ pathname: 'headless_client'
+ search: ''
+
+GLOBAL.console.debug = console.log
+GLOBAL.serverConfig =
+ picoCTF: false
+ production: false
+
+#try
+# GLOBAL.Worker = require('webworker-threads').Worker
+#catch e
+# GLOBAL.Worker = require('./headless_client/fork_web_worker').Worker
+# options.workerCode = './worker_world.coffee'
+#
+#Worker::removeEventListener = (what) ->
+# if what is 'message'
+# @onmessage = -> #This webworker api has only one event listener at a time.
+GLOBAL.tv4 = require('tv4').tv4
+GLOBAL.TreemaUtils = require bowerComponentsPath + 'treema/treema-utils'
+GLOBAL.marked = setOptions: ->
+store = {}
+GLOBAL.localStorage =
+ getItem: (key) => store[key]
+ setItem: (key, s) => store[key] = s
+ removeItem: (key) => delete store[key]
+GLOBAL.lscache = require bowerComponentsPath + 'lscache/lscache'
+GLOBAL.esper = require bowerComponentsPath + 'esper.js/esper'
+
+# Hook node.js require. See https://github.com/mfncooper/mockery/blob/master/mockery.js
+# The signature of this function *must* match that of Node's Module._load,
+# since it will replace that.
+# (Why is there no easier way?)
+# the path used for the loader. __dirname is module dependent.
+path = __dirname
+m = require 'module'
+originalLoader = m._load
+hookedLoader = (request, parent, isMain) ->
+ if request in disable or ~request.indexOf('templates')
+ console.log 'Ignored ' + request if options.debug
+ return class fake
+ else if /node_modules[\\\/]aether[\\\/]/.test parent.id
+ null # Let it through
+ else if '/' in request and not (request[0] is '.') or request is 'application'
+ #console.log 'making path', path + '/app/' + request, 'from', path, request, 'with parent', parent
+ request = path + '/app/' + request
+ else if request is 'underscore'
+ request = 'lodash'
+ console.log 'loading ' + request if options.debug
+ originalLoader request, parent, isMain
+
+unhook = () ->
+ m._load = originalLoader
+hook = () ->
+ m._load = hookedLoader
+
+GLOBAL.$ = GLOBAL.jQuery = require headlessClientPath + 'jQlone'
+$._debug = options.debug
+$._server = server
+
+do (setupLodash = this) ->
+ GLOBAL._ = require 'lodash'
+ _.str = require 'underscore.string'
+ _.string = _.str
+ _.mixin _.str.exports()
+
+# load Backbone. Needs hooked loader to reroute underscore to lodash.
+hook()
+GLOBAL.Backbone = require bowerComponentsPath + 'backbone/backbone'
+# Use original loader for theese
+unhook()
+Backbone.$ = $
+require bowerComponentsPath + 'validated-backbone-mediator/backbone-mediator'
+Backbone.Mediator.setValidationEnabled false
+GLOBAL.Aether = require 'aether'
+eval require('fs').readFileSync('./vendor/scripts/Box2dWeb-2.1.a.3.js', 'utf8')
+GLOBAL.Box2D = Box2D
+# Set up new loader. Again.
+hook()
+
+
+SuperModel = require 'models/SuperModel'
+VerifierTest = require('views/editor/verifier/VerifierTest')
+
+supermodel = new SuperModel()
+
+oldGetQueryVariable = require('core/utils').getQueryVariable
+require('core/utils').getQueryVariable = (args...) ->
+ return useEsper if args[0] is 'esper'
+ oldGetQueryVariable args...
+
+list = process.argv.slice(2);
+async = require 'async'
+
+
+
+async.eachSeries list, (item, next) ->
+ async.eachSeries ['python','javascript'], (lang, lnext) ->
+ test = new VerifierTest item, (e) ->
+ return if e.state is 'running'
+ obj =
+ error: test.error
+ state: e.state
+ level: item,
+ language: lang
+ observed:
+ goals: _.mapValues(test.goals, 'status')
+ frameCount: test.frames
+ lastHash: test.lastFrameHash
+ solution:
+ test.solution
+ process.send?(obj)
+ console.log(obj)
+ lnext() if e.state in ['error','complete']
+ , supermodel, lang
+ , () -> next()