mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-27 17:45:40 -05:00
Add course (name, description) translations
This commit is contained in:
parent
f61f14571f
commit
d4af931e05
35 changed files with 541 additions and 37 deletions
|
@ -105,6 +105,8 @@ module.exports = class CocoRouter extends Backbone.Router
|
||||||
'editor/thang-tasks': go('editor/ThangTasksView')
|
'editor/thang-tasks': go('editor/ThangTasksView')
|
||||||
'editor/verifier': go('editor/verifier/VerifierView')
|
'editor/verifier': go('editor/verifier/VerifierView')
|
||||||
'editor/verifier/:levelID': go('editor/verifier/VerifierView')
|
'editor/verifier/:levelID': go('editor/verifier/VerifierView')
|
||||||
|
'editor/course': go('editor/course/CourseSearchView')
|
||||||
|
'editor/course/:courseID': go('editor/course/CourseEditView')
|
||||||
|
|
||||||
'file/*path': 'routeToServer'
|
'file/*path': 'routeToServer'
|
||||||
|
|
||||||
|
@ -122,6 +124,7 @@ module.exports = class CocoRouter extends Backbone.Router
|
||||||
'i18n/achievement/:handle': go('i18n/I18NEditAchievementView')
|
'i18n/achievement/:handle': go('i18n/I18NEditAchievementView')
|
||||||
'i18n/campaign/:handle': go('i18n/I18NEditCampaignView')
|
'i18n/campaign/:handle': go('i18n/I18NEditCampaignView')
|
||||||
'i18n/poll/:handle': go('i18n/I18NEditPollView')
|
'i18n/poll/:handle': go('i18n/I18NEditPollView')
|
||||||
|
'i18n/course/:handle': go('i18n/I18NEditCourseView')
|
||||||
|
|
||||||
'identify': go('user/IdentifyView')
|
'identify': go('user/IdentifyView')
|
||||||
|
|
||||||
|
|
|
@ -1564,6 +1564,7 @@
|
||||||
article_title: "Article Editor"
|
article_title: "Article Editor"
|
||||||
thang_title: "Thang Editor"
|
thang_title: "Thang Editor"
|
||||||
level_title: "Level Editor"
|
level_title: "Level Editor"
|
||||||
|
course_title: "Course Editor"
|
||||||
achievement_title: "Achievement Editor"
|
achievement_title: "Achievement Editor"
|
||||||
poll_title: "Poll Editor"
|
poll_title: "Poll Editor"
|
||||||
back: "Back"
|
back: "Back"
|
||||||
|
|
|
@ -4,16 +4,19 @@ CourseSchema = c.object {title: 'Course', required: ['name']}
|
||||||
c.extendNamedProperties CourseSchema # name first
|
c.extendNamedProperties CourseSchema # name first
|
||||||
|
|
||||||
_.extend CourseSchema.properties,
|
_.extend CourseSchema.properties,
|
||||||
|
i18n: {type: 'object', title: 'i18n', format: 'i18n', props: ['name', 'description' ]}
|
||||||
campaignID: c.objectId()
|
campaignID: c.objectId()
|
||||||
concepts: c.array {title: 'Programming Concepts', uniqueItems: true}, c.concept
|
concepts: c.array {title: 'Programming Concepts', uniqueItems: true}, c.concept
|
||||||
description: {type: 'string'}
|
description: {type: 'string'}
|
||||||
duration: {type: 'number', description: 'Approximate hours of content'}
|
duration: {type: 'number', description: 'Approximate hours of content'}
|
||||||
pricePerSeat: {type: 'number', description: 'Price per seat in USD cents.'} # deprecated
|
pricePerSeat: {type: 'number', description: 'Price per seat in USD cents.'} # deprecated
|
||||||
free: { type: 'boolean' }
|
free: { type: 'boolean' }
|
||||||
screenshot: c.url {title: 'URL', description: 'Link to course screenshot.'}
|
screenshot: c.path { title: 'URL', description: 'Link to course screenshot.'}
|
||||||
adminOnly: { type: 'boolean', description: 'Deprecated in favor of releasePhase.' }
|
adminOnly: { type: 'boolean', description: 'Deprecated in favor of releasePhase.' }
|
||||||
releasePhase: { type: {enum: ['beta', 'released'] }, description: "How far along the course's development is, determining who sees it." }
|
releasePhase: { enum: ['beta', 'released'], description: "How far along the course's development is, determining who sees it." }
|
||||||
|
|
||||||
c.extendBasicProperties CourseSchema, 'Course'
|
c.extendBasicProperties CourseSchema, 'Course'
|
||||||
|
c.extendTranslationCoverageProperties CourseSchema
|
||||||
|
c.extendPatchableProperties CourseSchema
|
||||||
|
|
||||||
module.exports = CourseSchema
|
module.exports = CourseSchema
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
c = require './../schemas'
|
c = require './../schemas'
|
||||||
|
|
||||||
patchables = ['level', 'thang_type', 'level_system', 'level_component', 'article', 'achievement', 'campaign', 'poll']
|
patchables = [
|
||||||
|
'achievement'
|
||||||
|
'article'
|
||||||
|
'campaign'
|
||||||
|
'course'
|
||||||
|
'level'
|
||||||
|
'level_component'
|
||||||
|
'level_system'
|
||||||
|
'poll'
|
||||||
|
'thang_type'
|
||||||
|
]
|
||||||
|
|
||||||
PatchSchema = c.object({title: 'Patch', required: ['target', 'delta', 'commitMessage']}, {
|
PatchSchema = c.object({title: 'Patch', required: ['target', 'delta', 'commitMessage']}, {
|
||||||
delta: {title: 'Delta', type: ['array', 'object']}
|
delta: {title: 'Delta', type: ['array', 'object']}
|
||||||
|
@ -24,6 +34,7 @@ PatchSchema = c.object({title: 'Patch', required: ['target', 'delta', 'commitMes
|
||||||
|
|
||||||
wasPending: type: 'boolean'
|
wasPending: type: 'boolean'
|
||||||
newlyAccepted: type: 'boolean'
|
newlyAccepted: type: 'boolean'
|
||||||
|
reasonNotAutoAccepted: { type: 'string' }
|
||||||
})
|
})
|
||||||
|
|
||||||
c.extendBasicProperties(PatchSchema, 'patch')
|
c.extendBasicProperties(PatchSchema, 'patch')
|
||||||
|
|
|
@ -9,6 +9,7 @@ combine = (base, ext) ->
|
||||||
return _.extend(base, ext)
|
return _.extend(base, ext)
|
||||||
|
|
||||||
urlPattern = '^(ht|f)tp(s?)\:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:(0-9)*)*(\/?)([a-zA-Z0-9\-\.\?\,\'\/\\\+&%\$#_=]*)?$'
|
urlPattern = '^(ht|f)tp(s?)\:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:(0-9)*)*(\/?)([a-zA-Z0-9\-\.\?\,\'\/\\\+&%\$#_=]*)?$'
|
||||||
|
pathPattern = '^\/([a-zA-Z0-9\-\.\?\,\'\/\\\+&%\$#_=]*)?$'
|
||||||
|
|
||||||
# Common schema properties
|
# Common schema properties
|
||||||
me.object = (ext, props) -> combine({type: 'object', additionalProperties: false, properties: props or {}}, ext)
|
me.object = (ext, props) -> combine({type: 'object', additionalProperties: false, properties: props or {}}, ext)
|
||||||
|
@ -20,10 +21,11 @@ me.passwordString = {type: 'string', maxLength: 256, minLength: 2, title: 'Passw
|
||||||
# Dates should usually be strings, ObjectIds should be strings: https://github.com/codecombat/codecombat/issues/1384
|
# Dates should usually be strings, ObjectIds should be strings: https://github.com/codecombat/codecombat/issues/1384
|
||||||
me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext) # old
|
me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext) # old
|
||||||
me.stringDate = (ext) -> combine({type: ['string'], format: 'date-time'}, ext) # new
|
me.stringDate = (ext) -> combine({type: ['string'], format: 'date-time'}, ext) # new
|
||||||
me.objectId = (ext) -> schema = combine({type: ['object', 'string']}, ext) # old
|
me.objectId = (ext) -> combine({type: ['object', 'string']}, ext) # old
|
||||||
me.stringID = (ext) -> schema = combine({type: 'string', minLength: 24, maxLength: 24}, ext) # use for anything new
|
me.stringID = (ext) -> combine({type: 'string', minLength: 24, maxLength: 24}, ext) # use for anything new
|
||||||
|
|
||||||
me.url = (ext) -> combine({type: 'string', format: 'url', pattern: urlPattern}, ext)
|
me.url = (ext) -> combine({type: 'string', format: 'url', pattern: urlPattern}, ext)
|
||||||
|
me.path = (ext) -> combine({type: 'string', pattern: pathPattern}, ext)
|
||||||
me.int = (ext) -> combine {type: 'integer'}, ext
|
me.int = (ext) -> combine {type: 'integer'}, ext
|
||||||
me.float = (ext) -> combine {type: 'number'}, ext
|
me.float = (ext) -> combine {type: 'number'}, ext
|
||||||
|
|
||||||
|
|
14
app/styles/editor/course/edit.sass
Normal file
14
app/styles/editor/course/edit.sass
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
#editor-course-edit-view
|
||||||
|
.treema-root
|
||||||
|
margin-bottom: 20px
|
||||||
|
|
||||||
|
.course-tool-button
|
||||||
|
float: right
|
||||||
|
margin-bottom: 15px
|
||||||
|
margin-left: 10px
|
||||||
|
|
||||||
|
textarea
|
||||||
|
width: 92%
|
||||||
|
height: 300px
|
||||||
|
|
||||||
|
|
|
@ -12,9 +12,9 @@ block content
|
||||||
if me.isAdmin() || !newModelsAdminOnly
|
if me.isAdmin() || !newModelsAdminOnly
|
||||||
if me.get('anonymous')
|
if me.get('anonymous')
|
||||||
a.btn.btn-primary.open-modal-button(data-toggle="coco-modal", data-target="core/CreateAccountModal", role="button", data-i18n="#{currentNewSignup}") Log in to Create a New Something
|
a.btn.btn-primary.open-modal-button(data-toggle="coco-modal", data-target="core/CreateAccountModal", role="button", data-i18n="#{currentNewSignup}") Log in to Create a New Something
|
||||||
else
|
else if view.canMakeNew
|
||||||
a.btn.btn-primary.open-modal-button#new-model-button(data-i18n="#{currentNew}") Create a New Something
|
a.btn.btn-primary.open-modal-button#new-model-button(data-i18n="#{currentNew}") Create a New Something
|
||||||
input#search(data-i18n="[placeholder]#{currentSearch}")
|
input#search(data-i18n="[placeholder]#{currentSearch}" placeholder="Search")
|
||||||
hr
|
hr
|
||||||
div.results
|
div.results
|
||||||
table
|
table
|
||||||
|
@ -22,4 +22,3 @@ block content
|
||||||
else
|
else
|
||||||
.alert.alert-danger
|
.alert.alert-danger
|
||||||
span Admin only. Turn around.
|
span Admin only. Turn around.
|
||||||
// TODO Ruben prettify
|
|
||||||
|
|
|
@ -90,7 +90,7 @@ block content
|
||||||
- continue;
|
- continue;
|
||||||
if inCourse
|
if inCourse
|
||||||
.row
|
.row
|
||||||
.col-sm-3.text-right= course.get('name')
|
.col-sm-3.text-right= i18n(course.attributes, 'name')
|
||||||
.col-sm-9
|
.col-sm-9
|
||||||
if inCourse
|
if inCourse
|
||||||
- var levels = view.classroom.getLevels({courseID: course.id});
|
- var levels = view.classroom.getLevels({courseID: course.id});
|
||||||
|
@ -119,7 +119,7 @@ block content
|
||||||
.text-center
|
.text-center
|
||||||
button.enable-btn.btn.btn-info.btn-sm.text-uppercase(data-user-id=user.id, data-course-instance-cid=courseInstance.cid)
|
button.enable-btn.btn.btn-info.btn-sm.text-uppercase(data-user-id=user.id, data-course-instance-cid=courseInstance.cid)
|
||||||
span.spr(data-i18n="courses.assign")
|
span.spr(data-i18n="courses.assign")
|
||||||
span= course.get('name')
|
span= i18n(course.attributes, 'name')
|
||||||
|
|
||||||
|
|
||||||
if view.teacherMode && !paidFor
|
if view.teacherMode && !paidFor
|
||||||
|
|
|
@ -23,7 +23,7 @@ block content
|
||||||
strong= view.getOwnerName()
|
strong= view.getOwnerName()
|
||||||
|
|
||||||
h1
|
h1
|
||||||
| #{view.course.get('name')}
|
| #{i18n(view.course.attributes, 'name')}
|
||||||
if view.courseComplete
|
if view.courseComplete
|
||||||
span.spl -
|
span.spl -
|
||||||
span.spl(data-i18n='courses.complete')
|
span.spl(data-i18n='courses.complete')
|
||||||
|
|
|
@ -78,7 +78,7 @@ block content
|
||||||
.course-instance-entry
|
.course-instance-entry
|
||||||
- var course = view.courses.get(courseInstance.get('courseID'));
|
- var course = view.courses.get(courseInstance.get('courseID'));
|
||||||
h6
|
h6
|
||||||
span.spr= course.get('name')
|
span.spr= i18n(course.attributes, 'name')
|
||||||
small
|
small
|
||||||
a.view-levels-btn(data-course-id=courseInstance.get('courseID'), data-courseinstance-id=courseInstance.id, data-i18n="courses.view_levels")
|
a.view-levels-btn(data-course-id=courseInstance.get('courseID'), data-courseinstance-id=courseInstance.id, data-i18n="courses.view_levels")
|
||||||
+course-instance-body(courseInstance, classroom)
|
+course-instance-body(courseInstance, classroom)
|
||||||
|
|
|
@ -267,7 +267,7 @@ mixin courseProgressTab
|
||||||
each trimCourse in view.classroom.get('courses')
|
each trimCourse in view.classroom.get('courses')
|
||||||
- var course = view.courses.get(trimCourse._id);
|
- var course = view.courses.get(trimCourse._id);
|
||||||
option(value=course.id selected=(course===state.get('selectedCourse')))
|
option(value=course.id selected=(course===state.get('selectedCourse')))
|
||||||
= course.get('name')
|
= i18n(course.attributes, 'name')
|
||||||
if state.get('progressData')
|
if state.get('progressData')
|
||||||
.render-on-course-sync
|
.render-on-course-sync
|
||||||
+courseOverview
|
+courseOverview
|
||||||
|
@ -313,7 +313,7 @@ mixin courseOverview
|
||||||
- var levels = view.classroom.getLevels({courseID: course.id}).models
|
- var levels = view.classroom.getLevels({courseID: course.id}).models
|
||||||
.course-overview-row
|
.course-overview-row
|
||||||
.course-title.student-name
|
.course-title.student-name
|
||||||
span= course.get('name')
|
span= i18n(course.attributes, 'name')
|
||||||
span= ': '
|
span= ': '
|
||||||
span(data-i18n='teacher.course_overview')
|
span(data-i18n='teacher.course_overview')
|
||||||
.course-overview-progress
|
.course-overview-progress
|
||||||
|
@ -391,7 +391,7 @@ mixin bulkAssignControls
|
||||||
each trimCourse in _.rest(view.classroom.get('courses'))
|
each trimCourse in _.rest(view.classroom.get('courses'))
|
||||||
- var course = view.courses.get(trimCourse._id)
|
- var course = view.courses.get(trimCourse._id)
|
||||||
option(value=course.id selected=(course===state.get('selectedCourse')))
|
option(value=course.id selected=(course===state.get('selectedCourse')))
|
||||||
= course.get('name')
|
= i18n(course.attributes, 'name')
|
||||||
button.btn.btn-primary-alt.assign-to-selected-students
|
button.btn.btn-primary-alt.assign-to-selected-students
|
||||||
span(data-i18n='teacher.assign_to_selected_students')
|
span(data-i18n='teacher.assign_to_selected_students')
|
||||||
button.btn.btn-primary-alt.enroll-selected-students
|
button.btn.btn-primary-alt.enroll-selected-students
|
||||||
|
|
|
@ -84,8 +84,8 @@ mixin course-info(course)
|
||||||
- var campaign = view.campaigns.get(course.get('campaignID'));
|
- var campaign = view.campaigns.get(course.get('campaignID'));
|
||||||
.course-info
|
.course-info
|
||||||
.text-h4.semibold
|
.text-h4.semibold
|
||||||
= course.get('name')
|
= i18n(course.attributes, 'name')
|
||||||
p= course.get('description')
|
p= i18n(course.attributes, 'description')
|
||||||
p.concepts.semibold
|
p.concepts.semibold
|
||||||
span(data-i18n="courses.concepts_covered")
|
span(data-i18n="courses.concepts_covered")
|
||||||
| :
|
| :
|
||||||
|
|
27
app/templates/editor/course/edit.jade
Normal file
27
app/templates/editor/course/edit.jade
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
extends /templates/base
|
||||||
|
|
||||||
|
block content
|
||||||
|
div
|
||||||
|
ol.breadcrumb
|
||||||
|
li
|
||||||
|
a(href="/editor", data-i18n="editor.main_title") CodeCombat Editors
|
||||||
|
li
|
||||||
|
a(href="/editor/course", data-i18n="editor.course_title") Course Editor
|
||||||
|
li.active
|
||||||
|
| #{view.course.get('name')}
|
||||||
|
|
||||||
|
- var authorized = !me.get('anonymous');
|
||||||
|
button.course-tool-button(data-i18n="common.save", disabled=authorized === true ? undefined : "true").btn.btn-primary#save-button Save
|
||||||
|
|
||||||
|
h3(data-i18n="course.edit_course_title") Edit Course
|
||||||
|
span
|
||||||
|
|: "#{view.course.attributes.name}"
|
||||||
|
|
||||||
|
.alert.alert-warning Can only edit translations currently
|
||||||
|
|
||||||
|
#course-treema
|
||||||
|
|
||||||
|
h3(data-i18n="resources.patches") Patches
|
||||||
|
.patches-view
|
||||||
|
|
||||||
|
hr
|
21
app/templates/editor/course/table.jade
Executable file
21
app/templates/editor/course/table.jade
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
extends /templates/common/table
|
||||||
|
|
||||||
|
block tableResultsHeader
|
||||||
|
tr
|
||||||
|
th(colspan=4)
|
||||||
|
span(data-i18n="general.results")
|
||||||
|
| Results
|
||||||
|
span
|
||||||
|
|: #{documents.length}
|
||||||
|
|
||||||
|
block tableHeader
|
||||||
|
tr
|
||||||
|
th(data-i18n="general.name") Name
|
||||||
|
th(data-i18n="general.description") Description
|
||||||
|
|
||||||
|
block tableBody
|
||||||
|
for course in documents
|
||||||
|
tr(class=course.get('creator') == me.id ? 'mine' : '')
|
||||||
|
td(title=course.get('name')).name-row
|
||||||
|
a(href="/editor/#{page}/#{course.get('slug') || course.id}")= course.get('name')
|
||||||
|
td(title=course.get('description')).description-row= course.get('description')
|
|
@ -238,8 +238,8 @@ block content
|
||||||
if courseIndex === 0
|
if courseIndex === 0
|
||||||
.free-course
|
.free-course
|
||||||
h6(data-i18n="new_home.ffa")
|
h6(data-i18n="new_home.ffa")
|
||||||
.media-body(title=course.get('description'))
|
.media-body(title=i18n(course.attributes, 'description'))
|
||||||
h6.course-name= course.get('name') + ':'
|
h6.course-name= i18n(course.attributes, 'name') + ':'
|
||||||
p.small
|
p.small
|
||||||
- var total = 0;
|
- var total = 0;
|
||||||
each concept in course.get('concepts')
|
each concept in course.get('concepts')
|
||||||
|
|
|
@ -13,7 +13,7 @@ block content
|
||||||
|
|
||||||
.text-center
|
.text-center
|
||||||
span#course-name
|
span#course-name
|
||||||
span= view.course.get('name')
|
span= i18n(view.course.attributes, 'name')
|
||||||
span.spl - Arena
|
span.spl - Arena
|
||||||
|
|
||||||
div#level-column
|
div#level-column
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
.level-name-area-container
|
.level-name-area-container
|
||||||
.level-name-area
|
.level-name-area
|
||||||
if view.course
|
if view.course
|
||||||
.level-label= view.course.get('name')
|
.level-label= i18n(view.course.attributes, 'name')
|
||||||
.level-name(title=difficultyTitle || "")
|
.level-name(title=difficultyTitle || "")
|
||||||
span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')}
|
span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')}
|
||||||
if levelDifficulty
|
if levelDifficulty
|
||||||
|
|
|
@ -8,17 +8,17 @@ block content
|
||||||
.text-center
|
.text-center
|
||||||
img(src="http://direct.codecombat.com/images/pages/base/logo.png")
|
img(src="http://direct.codecombat.com/images/pages/base/logo.png")
|
||||||
if view.course.loaded
|
if view.course.loaded
|
||||||
h1 #{view.course.get('name')}
|
h1 #{i18n(view.course.attributes, 'name')}
|
||||||
h3 #{view.prettyLanguage}
|
h3 #{view.prettyLanguage}
|
||||||
i= view.course.get('description')
|
i= i18n(view.course.attributes, 'description')
|
||||||
br
|
br
|
||||||
br
|
br
|
||||||
|
|
||||||
if view.levels
|
if view.levels
|
||||||
each level, index in view.levels.models
|
each level, index in view.levels.models
|
||||||
h2.page-break-before ##{index + 1}. #{level.get('name')}
|
h2.page-break-before ##{index + 1}. #{i18n(level.attributes, 'name')}
|
||||||
h3(data-i18n="teacher.level_overview_solutions")
|
h3(data-i18n="teacher.level_overview_solutions")
|
||||||
i #{level.get('description')}
|
i #{i18n(level.attributes, 'description')}
|
||||||
div
|
div
|
||||||
h4.text-center(data-i18n="common.intro")
|
h4.text-center(data-i18n="common.intro")
|
||||||
if level.get('intro')
|
if level.get('intro')
|
||||||
|
|
|
@ -30,6 +30,7 @@ module.exports = class SearchView extends RootView
|
||||||
modelURL: null # '/db/article'
|
modelURL: null # '/db/article'
|
||||||
tableTemplate: null # require 'templates/editor/article/table'
|
tableTemplate: null # require 'templates/editor/article/table'
|
||||||
projected: null # ['name', 'description', 'version'] or null for default
|
projected: null # ['name', 'description', 'version'] or null for default
|
||||||
|
canMakeNew: true
|
||||||
|
|
||||||
events:
|
events:
|
||||||
'change input#search': 'runSearch'
|
'change input#search': 'runSearch'
|
||||||
|
|
62
app/views/editor/course/CourseEditView.coffee
Normal file
62
app/views/editor/course/CourseEditView.coffee
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
RootView = require 'views/core/RootView'
|
||||||
|
template = require 'templates/editor/course/edit'
|
||||||
|
Course = require 'models/Course'
|
||||||
|
ConfirmModal = require 'views/editor/modal/ConfirmModal'
|
||||||
|
PatchesView = require 'views/editor/PatchesView'
|
||||||
|
errors = require 'core/errors'
|
||||||
|
app = require 'core/application'
|
||||||
|
|
||||||
|
module.exports = class CourseEditView extends RootView
|
||||||
|
id: 'editor-course-edit-view'
|
||||||
|
template: template
|
||||||
|
|
||||||
|
events:
|
||||||
|
'click #save-button': 'onClickSaveButton'
|
||||||
|
|
||||||
|
constructor: (options, @courseID) ->
|
||||||
|
super options
|
||||||
|
@course = new Course(_id: @courseID)
|
||||||
|
@course.saveBackups = true
|
||||||
|
@supermodel.loadModel @course
|
||||||
|
|
||||||
|
onLoaded: ->
|
||||||
|
super()
|
||||||
|
@buildTreema()
|
||||||
|
@listenTo @course, 'change', =>
|
||||||
|
@course.updateI18NCoverage()
|
||||||
|
@treema.set('/', @course.attributes)
|
||||||
|
|
||||||
|
buildTreema: ->
|
||||||
|
return if @treema? or (not @course.loaded)
|
||||||
|
data = $.extend(true, {}, @course.attributes)
|
||||||
|
options =
|
||||||
|
data: data
|
||||||
|
filePath: "db/course/#{@course.get('_id')}"
|
||||||
|
schema: Course.schema
|
||||||
|
readOnly: me.get('anonymous')
|
||||||
|
supermodel: @supermodel
|
||||||
|
@treema = @$el.find('#course-treema').treema(options)
|
||||||
|
@treema.build()
|
||||||
|
@treema.childrenTreemas.rewards?.open(3)
|
||||||
|
|
||||||
|
afterRender: ->
|
||||||
|
super()
|
||||||
|
return unless @supermodel.finished()
|
||||||
|
@showReadOnly() if me.get('anonymous')
|
||||||
|
@patchesView = @insertSubView(new PatchesView(@course), @$el.find('.patches-view'))
|
||||||
|
@patchesView.load()
|
||||||
|
|
||||||
|
onClickSaveButton: (e) ->
|
||||||
|
@treema.endExistingEdits()
|
||||||
|
for key, value of @treema.data
|
||||||
|
@course.set(key, value)
|
||||||
|
@course.updateI18NCoverage()
|
||||||
|
|
||||||
|
res = @course.save()
|
||||||
|
|
||||||
|
res.error (collection, response, options) =>
|
||||||
|
console.error response
|
||||||
|
|
||||||
|
res.success =>
|
||||||
|
url = "/editor/course/#{@course.get('slug') or @course.id}"
|
||||||
|
document.location.href = url
|
20
app/views/editor/course/CourseSearchView.coffee
Normal file
20
app/views/editor/course/CourseSearchView.coffee
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
SearchView = require 'views/common/SearchView'
|
||||||
|
|
||||||
|
module.exports = class LevelSearchView extends SearchView
|
||||||
|
id: 'editor-course-home-view'
|
||||||
|
modelLabel: 'Course'
|
||||||
|
model: require 'models/Course'
|
||||||
|
modelURL: '/db/course'
|
||||||
|
tableTemplate: require 'templates/editor/course/table'
|
||||||
|
projection: ['slug', 'name', 'description', 'watchers', 'creator']
|
||||||
|
page: 'course'
|
||||||
|
canMakeNew: false
|
||||||
|
|
||||||
|
getRenderData: ->
|
||||||
|
context = super()
|
||||||
|
context.currentEditor = 'editor.course_title'
|
||||||
|
context.currentNew = 'editor.new_course_title'
|
||||||
|
context.currentNewSignup = 'editor.new_course_title_login'
|
||||||
|
context.currentSearch = 'editor.course_search_title'
|
||||||
|
@$el.i18n()
|
||||||
|
context
|
48
app/views/i18n/I18NEditCourseView.coffee
Normal file
48
app/views/i18n/I18NEditCourseView.coffee
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
I18NEditModelView = require './I18NEditModelView'
|
||||||
|
Course = require 'models/Course'
|
||||||
|
deltasLib = require 'core/deltas'
|
||||||
|
Patch = require 'models/Patch'
|
||||||
|
|
||||||
|
module.exports = class I18NEditCourseView extends I18NEditModelView
|
||||||
|
id: "i18n-edit-course-view"
|
||||||
|
modelClass: Course
|
||||||
|
|
||||||
|
buildTranslationList: ->
|
||||||
|
lang = @selectedLanguage
|
||||||
|
|
||||||
|
# name, description
|
||||||
|
if i18n = @model.get('i18n')
|
||||||
|
if name = @model.get('name')
|
||||||
|
@wrapRow 'Course short name', ['name'], name, i18n[lang]?.name, []
|
||||||
|
if description = @model.get('description')
|
||||||
|
@wrapRow 'Course description', ['description'], description, i18n[lang]?.description, []
|
||||||
|
|
||||||
|
onSubmitPatch: (e) ->
|
||||||
|
|
||||||
|
delta = @model.getDelta()
|
||||||
|
flattened = deltasLib.flattenDelta(delta)
|
||||||
|
|
||||||
|
patch = new Patch({
|
||||||
|
delta
|
||||||
|
target: {
|
||||||
|
'collection': _.string.underscored @model.constructor.className
|
||||||
|
'id': @model.id
|
||||||
|
}
|
||||||
|
commitMessage: "Diplomat submission for lang #{@selectedLanguage}: #{flattened.length} change(s)."
|
||||||
|
})
|
||||||
|
errors = patch.validate()
|
||||||
|
button = $(e.target)
|
||||||
|
button.attr('disabled', 'disabled')
|
||||||
|
return button.text('Failed to Submit Changes') if errors
|
||||||
|
res = patch.save(null, {
|
||||||
|
url: "/db/course/#{@model.id}/patch"
|
||||||
|
})
|
||||||
|
return button.text('Failed to Submit Changes') unless res
|
||||||
|
button.text('Submitting...')
|
||||||
|
Promise.resolve(res)
|
||||||
|
.then =>
|
||||||
|
@savedBefore = true
|
||||||
|
button.text('Submit Changes')
|
||||||
|
.catch =>
|
||||||
|
button.text('Error Submitting Changes')
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
RootView = require 'views/core/RootView'
|
RootView = require 'views/core/RootView'
|
||||||
template = require 'templates/i18n/i18n-home-view'
|
template = require 'templates/i18n/i18n-home-view'
|
||||||
CocoCollection = require 'collections/CocoCollection'
|
CocoCollection = require 'collections/CocoCollection'
|
||||||
|
Courses = require 'collections/Courses'
|
||||||
|
|
||||||
LevelComponent = require 'models/LevelComponent'
|
LevelComponent = require 'models/LevelComponent'
|
||||||
ThangType = require 'models/ThangType'
|
ThangType = require 'models/ThangType'
|
||||||
|
@ -38,8 +39,9 @@ module.exports = class I18NHomeView extends RootView
|
||||||
@achievements = new CocoCollection([], { url: '/db/achievement?view=i18n-coverage', project: project, model: Achievement })
|
@achievements = new CocoCollection([], { url: '/db/achievement?view=i18n-coverage', project: project, model: Achievement })
|
||||||
@campaigns = new CocoCollection([], { url: '/db/campaign?view=i18n-coverage', project: project, model: Campaign })
|
@campaigns = new CocoCollection([], { url: '/db/campaign?view=i18n-coverage', project: project, model: Campaign })
|
||||||
@polls = new CocoCollection([], { url: '/db/poll?view=i18n-coverage', project: project, model: Poll })
|
@polls = new CocoCollection([], { url: '/db/poll?view=i18n-coverage', project: project, model: Poll })
|
||||||
|
@courses = new Courses()
|
||||||
|
|
||||||
for c in [@thangTypes, @components, @levels, @achievements, @campaigns, @polls]
|
for c in [@thangTypes, @components, @levels, @achievements, @campaigns, @polls, @courses]
|
||||||
c.skip = 0
|
c.skip = 0
|
||||||
c.fetch({data: {skip: 0, limit: PAGE_SIZE}, cache:false})
|
c.fetch({data: {skip: 0, limit: PAGE_SIZE}, cache:false})
|
||||||
@supermodel.loadCollection(c, 'documents')
|
@supermodel.loadCollection(c, 'documents')
|
||||||
|
@ -55,6 +57,7 @@ module.exports = class I18NHomeView extends RootView
|
||||||
when 'Level' then '/i18n/level/'
|
when 'Level' then '/i18n/level/'
|
||||||
when 'Campaign' then '/i18n/campaign/'
|
when 'Campaign' then '/i18n/campaign/'
|
||||||
when 'Poll' then '/i18n/poll/'
|
when 'Poll' then '/i18n/poll/'
|
||||||
|
when 'Course' then '/i18n/course/'
|
||||||
getMore = collection.models.length is PAGE_SIZE
|
getMore = collection.models.length is PAGE_SIZE
|
||||||
@aggregateModels.add(collection.models)
|
@aggregateModels.add(collection.models)
|
||||||
@render()
|
@render()
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
// NOTE: uses name as unique identifier, so changing the name will insert a new course
|
// NOTE: uses name as unique identifier, so changing the name will insert a new course
|
||||||
// NOTE: pricePerSeat in USD cents
|
// NOTE: pricePerSeat in USD cents
|
||||||
|
load('bower_components/lodash/dist/lodash.js');
|
||||||
|
|
||||||
var courses =
|
var courses =
|
||||||
[
|
[
|
||||||
|
@ -95,6 +96,33 @@ var courses =
|
||||||
screenshot: "/images/pages/courses/105_info.png",
|
screenshot: "/images/pages/courses/105_info.png",
|
||||||
releasePhase: 'released'
|
releasePhase: 'released'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "CS: Game Development 1",
|
||||||
|
slug: "game-dev-1",
|
||||||
|
campaignID: ObjectId("5789236960deed1f00ec2ab8"),
|
||||||
|
description: "Learn to create your own games which you can share with your friends.",
|
||||||
|
duration: NumberInt(1),
|
||||||
|
free: false,
|
||||||
|
releasePhase: 'released'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CS: Web Development 1",
|
||||||
|
slug: "web-dev-1",
|
||||||
|
campaignID: ObjectId("578913f2c8871ac2326fa3e4"),
|
||||||
|
description: "Learn the basics of web development in this introductory HTML & CSS course.",
|
||||||
|
duration: NumberInt(1),
|
||||||
|
free: false,
|
||||||
|
releasePhase: 'released'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CS: Web Development 2",
|
||||||
|
slug: "web-dev-2",
|
||||||
|
campaignID: ObjectId("57891570c8871ac2326fa3f8"),
|
||||||
|
description: "Learn more advanced web development, including scripting to make interactive webpages.",
|
||||||
|
duration: NumberInt(2),
|
||||||
|
free: false,
|
||||||
|
releasePhase: 'beta'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "JS Primer",
|
name: "JS Primer",
|
||||||
slug: "js-primer",
|
slug: "js-primer",
|
||||||
|
@ -106,10 +134,10 @@ var courses =
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
print("Finding course concepts..");
|
_.forEach(courses, function(course) {
|
||||||
for (var i = 0; i < courses.length; i++) {
|
// Find course concepts
|
||||||
var concepts = {};
|
var concepts = {};
|
||||||
var cursor = db.campaigns.find({_id: courses[i].campaignID}, {'levels': 1});
|
var cursor = db.campaigns.find({_id: course.campaignID}, {'levels': 1});
|
||||||
if (cursor.hasNext()) {
|
if (cursor.hasNext()) {
|
||||||
var doc = cursor.next();
|
var doc = cursor.next();
|
||||||
for (var levelID in doc.levels) {
|
for (var levelID in doc.levels) {
|
||||||
|
@ -118,12 +146,20 @@ for (var i = 0; i < courses.length; i++) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
courses[i].concepts = Object.keys(concepts);
|
course.concepts = Object.keys(concepts);
|
||||||
}
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
print("Updating courses..");
|
print("Updating courses..");
|
||||||
for (var i = 0; i < courses.length; i++) {
|
for (var i = 0; i < courses.length; i++) {
|
||||||
db.courses.update({slug: courses[i].slug}, courses[i], {upsert: true});
|
db.courses.update({slug: courses[i].slug}, {$set: courses[i]}, {upsert: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("Upserting i18n", db.courses.update(
|
||||||
|
{i18n: {$exists: false}},
|
||||||
|
{$set: {i18n: {'-':{'-':'-'}}, i18nCoverage: []}},
|
||||||
|
{multi: true}
|
||||||
|
));
|
||||||
|
|
||||||
print("Done.");
|
print("Done.");
|
||||||
|
|
13
server/commons/deltas.coffee
Normal file
13
server/commons/deltas.coffee
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
deltasLib = require '../../app/core/deltas'
|
||||||
|
|
||||||
|
exports.isJustFillingTranslations = (delta) ->
|
||||||
|
flattened = deltasLib.flattenDelta(delta)
|
||||||
|
_.all flattened, (delta) ->
|
||||||
|
# sometimes coverage gets moved around... allow other changes to happen to i18nCoverage
|
||||||
|
return false unless _.isArray(delta.o)
|
||||||
|
return true if 'i18nCoverage' in delta.dataPath
|
||||||
|
return false unless delta.o.length is 1
|
||||||
|
index = delta.deltaPath.indexOf('i18n')
|
||||||
|
return false if index is -1
|
||||||
|
return false if delta.deltaPath[index+1] in ['en', 'en-US', 'en-GB'] # English speakers are most likely just spamming, so always treat those as patches, not saves.
|
||||||
|
return true
|
17
server/handlers/course_handler.coffee
Normal file
17
server/handlers/course_handler.coffee
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
|
||||||
|
mongoose = require 'mongoose'
|
||||||
|
Handler = require '../commons//Handler'
|
||||||
|
Course = require '../models/Course'
|
||||||
|
|
||||||
|
# TODO: Refactor PatchHandler.setStatus into its own route.
|
||||||
|
# This handler has been resurrected solely so that course patches can be accepted.
|
||||||
|
|
||||||
|
CourseHandler = class CourseHandler extends Handler
|
||||||
|
modelClass: Course
|
||||||
|
jsonSchema: require '../../app/schemas/models/course.schema'
|
||||||
|
allowedMethods: []
|
||||||
|
|
||||||
|
hasAccess: (req) ->
|
||||||
|
req.method in @allowedMethods or req.user?.isAdmin()
|
||||||
|
|
||||||
|
module.exports = new CourseHandler()
|
|
@ -10,6 +10,10 @@ Course = require '../models/Course'
|
||||||
User = require '../models/User'
|
User = require '../models/User'
|
||||||
Level = require '../models/Level'
|
Level = require '../models/Level'
|
||||||
parse = require '../commons/parse'
|
parse = require '../commons/parse'
|
||||||
|
Patch = require '../models/Patch'
|
||||||
|
tv4 = require('tv4').tv4
|
||||||
|
slack = require '../slack'
|
||||||
|
{ isJustFillingTranslations } = require '../commons/deltas'
|
||||||
|
|
||||||
module.exports =
|
module.exports =
|
||||||
|
|
||||||
|
@ -30,7 +34,7 @@ module.exports =
|
||||||
|
|
||||||
levelOriginals = (mongoose.Types.ObjectId(levelID) for levelID in sortedLevelIDs)
|
levelOriginals = (mongoose.Types.ObjectId(levelID) for levelID in sortedLevelIDs)
|
||||||
query = { original: { $in: levelOriginals }, slug: { $exists: true }}
|
query = { original: { $in: levelOriginals }, slug: { $exists: true }}
|
||||||
select = {documentation: 1, intro: 1, name: 1, original: 1, slug: 1, thangs: 1}
|
select = {documentation: 1, intro: 1, name: 1, original: 1, slug: 1, thangs: 1, i18n: 1}
|
||||||
levels = yield Level.find(query).select(select).lean()
|
levels = yield Level.find(query).select(select).lean()
|
||||||
levels.sort((a, b) -> sortedLevelIDs.indexOf(a.original + '') - sortedLevelIDs.indexOf(b.original + ''))
|
levels.sort((a, b) -> sortedLevelIDs.indexOf(a.original + '') - sortedLevelIDs.indexOf(b.original + ''))
|
||||||
res.status(200).send(levels)
|
res.status(200).send(levels)
|
||||||
|
@ -82,3 +86,60 @@ module.exports =
|
||||||
results = yield database.viewSearch(dbq, req)
|
results = yield database.viewSearch(dbq, req)
|
||||||
results = Course.sortCourses results
|
results = Course.sortCourses results
|
||||||
res.send(results)
|
res.send(results)
|
||||||
|
|
||||||
|
postPatch: wrap (req, res) ->
|
||||||
|
# TODO: Generalize this and use for other models, once this has been put through its paces
|
||||||
|
course = yield database.getDocFromHandle(req, Course)
|
||||||
|
if not course
|
||||||
|
throw new errors.NotFound('Course not found.')
|
||||||
|
|
||||||
|
originalDelta = req.body.delta
|
||||||
|
originalCourse = course.toObject()
|
||||||
|
changedCourse = _.cloneDeep(course.toObject(), (value) ->
|
||||||
|
return value if value instanceof mongoose.Types.ObjectId
|
||||||
|
return value if value instanceof Date
|
||||||
|
return undefined
|
||||||
|
)
|
||||||
|
jsondiffpatch.patch(changedCourse, originalDelta)
|
||||||
|
|
||||||
|
# normalize the delta because in tests, changes to patches would sneak in and cause false positives
|
||||||
|
# TODO: Figure out a better system. Perhaps submit a series of paths? I18N Edit Views already use them for their rows.
|
||||||
|
normalizedDelta = jsondiffpatch.diff(originalCourse, changedCourse)
|
||||||
|
normalizedDelta = _.pick(normalizedDelta, _.keys(originalDelta))
|
||||||
|
reasonNotAutoAccepted = undefined
|
||||||
|
|
||||||
|
validation = tv4.validateMultiple(changedCourse, Course.jsonSchema)
|
||||||
|
if not validation.valid
|
||||||
|
reasonNotAutoAccepted = 'Did not pass json schema.'
|
||||||
|
else if not isJustFillingTranslations(normalizedDelta)
|
||||||
|
reasonNotAutoAccepted = 'Adding to existing translations.'
|
||||||
|
else
|
||||||
|
course.set(changedCourse)
|
||||||
|
yield course.save()
|
||||||
|
|
||||||
|
patch = new Patch(req.body)
|
||||||
|
patch.set({
|
||||||
|
target: {
|
||||||
|
collection: 'course'
|
||||||
|
id: course._id
|
||||||
|
original: course._id
|
||||||
|
}
|
||||||
|
creator: req.user._id
|
||||||
|
status: if reasonNotAutoAccepted then 'pending' else 'accepted'
|
||||||
|
created: new Date().toISOString()
|
||||||
|
reasonNotAutoAccepted: reasonNotAutoAccepted
|
||||||
|
})
|
||||||
|
database.validateDoc(patch)
|
||||||
|
|
||||||
|
if reasonNotAutoAccepted
|
||||||
|
yield course.update({ $addToSet: { patches: patch._id }})
|
||||||
|
patches = course.get('patches') or []
|
||||||
|
patches.push patch._id
|
||||||
|
course.set({patches})
|
||||||
|
yield patch.save()
|
||||||
|
|
||||||
|
res.status(201).send(patch.toObject({req: req}))
|
||||||
|
|
||||||
|
docLink = "https://codecombat.com/editor/course/#{course.id}"
|
||||||
|
message = "#{req.user.get('name')} submitted a patch to #{course.get('name')}: #{patch.get('commitMessage')} #{docLink}"
|
||||||
|
slack.sendSlackMessage message, ['artisans']
|
||||||
|
|
|
@ -7,9 +7,13 @@ CourseSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:con
|
||||||
|
|
||||||
CourseSchema.plugin plugins.NamedPlugin
|
CourseSchema.plugin plugins.NamedPlugin
|
||||||
CourseSchema.plugin plugins.SearchablePlugin, {searchable: ['name', 'description']}
|
CourseSchema.plugin plugins.SearchablePlugin, {searchable: ['name', 'description']}
|
||||||
|
CourseSchema.plugin(plugins.TranslationCoveragePlugin)
|
||||||
|
|
||||||
CourseSchema.statics.privateProperties = []
|
CourseSchema.statics.privateProperties = []
|
||||||
CourseSchema.statics.editableProperties = []
|
CourseSchema.statics.editableProperties = [
|
||||||
|
'i18n',
|
||||||
|
'i18nCoverage'
|
||||||
|
]
|
||||||
|
|
||||||
CourseSchema.statics.jsonSchema = jsonSchema
|
CourseSchema.statics.jsonSchema = jsonSchema
|
||||||
|
|
||||||
|
@ -41,4 +45,8 @@ CourseSchema.statics.sortCourses = (courses) ->
|
||||||
index = 9001 if index is -1
|
index = 9001 if index is -1
|
||||||
index
|
index
|
||||||
|
|
||||||
|
CourseSchema.post 'init', (doc) ->
|
||||||
|
if !doc.get('i18nCoverage')
|
||||||
|
doc.set('i18nCoverage', [])
|
||||||
|
|
||||||
module.exports = Course = mongoose.model 'course', CourseSchema, 'courses'
|
module.exports = Course = mongoose.model 'course', CourseSchema, 'courses'
|
||||||
|
|
|
@ -5,10 +5,12 @@ log = require 'winston'
|
||||||
config = require '../../server_config'
|
config = require '../../server_config'
|
||||||
|
|
||||||
PatchSchema = new mongoose.Schema({status: String}, {strict: false,read:config.mongo.readpref})
|
PatchSchema = new mongoose.Schema({status: String}, {strict: false,read:config.mongo.readpref})
|
||||||
|
PatchSchema.index({'target.original': 1, 'status': 1}, {name: 'target_status'})
|
||||||
|
|
||||||
PatchSchema.pre 'save', (next) ->
|
PatchSchema.pre 'save', (next) ->
|
||||||
return next() unless @isNew # patch can't be altered after creation, so only need to check data once
|
return next() unless @isNew # patch can't be altered after creation, so only need to check data once
|
||||||
target = @get('target')
|
target = @get('target')
|
||||||
|
return next() if target.collection is 'course' # Migrating this logic out of the Patch model, into middleware
|
||||||
targetID = target.id
|
targetID = target.id
|
||||||
Handler = require '../commons/Handler'
|
Handler = require '../commons/Handler'
|
||||||
if not Handler.isID(targetID)
|
if not Handler.isID(targetID)
|
||||||
|
@ -83,4 +85,7 @@ PatchSchema.pre 'save', (next) ->
|
||||||
|
|
||||||
next()
|
next()
|
||||||
|
|
||||||
|
jsonSchema = require '../../app/schemas/models/patch'
|
||||||
|
PatchSchema.statics.jsonSchema = jsonSchema
|
||||||
|
|
||||||
module.exports = mongoose.model('patch', PatchSchema)
|
module.exports = mongoose.model('patch', PatchSchema)
|
||||||
|
|
|
@ -226,6 +226,7 @@ UserSchema.statics.statsMapping =
|
||||||
'Achievement': 'stats.achievementEdits'
|
'Achievement': 'stats.achievementEdits'
|
||||||
'campaign': 'stats.campaignEdits'
|
'campaign': 'stats.campaignEdits'
|
||||||
'poll': 'stats.pollEdits'
|
'poll': 'stats.pollEdits'
|
||||||
|
'course': 'stats.courseEdits'
|
||||||
translations:
|
translations:
|
||||||
article: 'stats.articleTranslationPatches'
|
article: 'stats.articleTranslationPatches'
|
||||||
level: 'stats.levelTranslationPatches'
|
level: 'stats.levelTranslationPatches'
|
||||||
|
@ -235,6 +236,7 @@ UserSchema.statics.statsMapping =
|
||||||
'Achievement': 'stats.achievementTranslationPatches'
|
'Achievement': 'stats.achievementTranslationPatches'
|
||||||
'campaign': 'stats.campaignTranslationPatches'
|
'campaign': 'stats.campaignTranslationPatches'
|
||||||
'poll': 'stats.pollTranslationPatches'
|
'poll': 'stats.pollTranslationPatches'
|
||||||
|
'course': 'stats.courseTranslationPatches'
|
||||||
misc:
|
misc:
|
||||||
article: 'stats.articleMiscPatches'
|
article: 'stats.articleMiscPatches'
|
||||||
level: 'stats.levelMiscPatches'
|
level: 'stats.levelMiscPatches'
|
||||||
|
@ -244,6 +246,7 @@ UserSchema.statics.statsMapping =
|
||||||
'Achievement': 'stats.achievementMiscPatches'
|
'Achievement': 'stats.achievementMiscPatches'
|
||||||
'campaign': 'stats.campaignMiscPatches'
|
'campaign': 'stats.campaignMiscPatches'
|
||||||
'poll': 'stats.pollMiscPatches'
|
'poll': 'stats.pollMiscPatches'
|
||||||
|
'course': 'stats.courseMiscPatches'
|
||||||
|
|
||||||
UserSchema.statics.incrementStat = (id, statName, done, inc=1) ->
|
UserSchema.statics.incrementStat = (id, statName, done, inc=1) ->
|
||||||
id = mongoose.Types.ObjectId id if _.isString id
|
id = mongoose.Types.ObjectId id if _.isString id
|
||||||
|
|
|
@ -81,9 +81,12 @@ module.exports.setup = (app) ->
|
||||||
|
|
||||||
Course = require '../models/Course'
|
Course = require '../models/Course'
|
||||||
app.get('/db/course', mw.courses.get(Course))
|
app.get('/db/course', mw.courses.get(Course))
|
||||||
|
app.put('/db/course/:handle', mw.auth.checkHasPermission(['admin']), mw.rest.put(Course))
|
||||||
app.get('/db/course/:handle', mw.rest.getByHandle(Course))
|
app.get('/db/course/:handle', mw.rest.getByHandle(Course))
|
||||||
app.get('/db/course/:handle/level-solutions', mw.courses.fetchLevelSolutions)
|
app.get('/db/course/:handle/level-solutions', mw.courses.fetchLevelSolutions)
|
||||||
app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel)
|
app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel)
|
||||||
|
app.post('/db/course/:handle/patch', mw.auth.checkLoggedIn(), mw.courses.postPatch)
|
||||||
|
app.get('/db/course/:handle/patches', mw.patchable.patches(Course))
|
||||||
|
|
||||||
app.get('/db/course_instance/-/non-hoc', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchNonHoc)
|
app.get('/db/course_instance/-/non-hoc', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchNonHoc)
|
||||||
app.post('/db/course_instance/-/recent', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchRecent)
|
app.post('/db/course_instance/-/recent', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchRecent)
|
||||||
|
|
|
@ -9,6 +9,7 @@ User = require '../../../server/models/User'
|
||||||
Classroom = require '../../../server/models/Classroom'
|
Classroom = require '../../../server/models/Classroom'
|
||||||
Campaign = require '../../../server/models/Campaign'
|
Campaign = require '../../../server/models/Campaign'
|
||||||
Level = require '../../../server/models/Level'
|
Level = require '../../../server/models/Level'
|
||||||
|
Patch = require '../../../server/models/Patch'
|
||||||
|
|
||||||
courseFixture = {
|
courseFixture = {
|
||||||
name: 'Unnamed course'
|
name: 'Unnamed course'
|
||||||
|
@ -60,6 +61,42 @@ describe 'GET /db/course/:handle', ->
|
||||||
expect(res.statusCode).toBe(404)
|
expect(res.statusCode).toBe(404)
|
||||||
done()
|
done()
|
||||||
|
|
||||||
|
describe 'PUT /db/course/:handle', ->
|
||||||
|
beforeEach utils.wrap (done) ->
|
||||||
|
yield utils.clearModels([Course, User])
|
||||||
|
@course = yield new Course({ name: 'Some Name', releasePhase: 'released' }).save()
|
||||||
|
yield utils.becomeAnonymous()
|
||||||
|
@url = getURL("/db/course/#{@course.id}")
|
||||||
|
done()
|
||||||
|
|
||||||
|
it 'allows changes to i18n and i18nCoverage', utils.wrap (done) ->
|
||||||
|
admin = yield utils.initAdmin()
|
||||||
|
yield utils.loginUser(admin)
|
||||||
|
json = {
|
||||||
|
i18n: { de: { name: 'German translation' } }
|
||||||
|
i18nCoverage: ['de']
|
||||||
|
}
|
||||||
|
[res, body] = yield request.putAsync { @url, json }
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(body._id).toBe(@course.id)
|
||||||
|
course = yield Course.findById(@course.id)
|
||||||
|
expect(course.get('i18n').de.name).toBe('German translation')
|
||||||
|
expect(course.get('i18nCoverage')).toBeDefined()
|
||||||
|
done()
|
||||||
|
|
||||||
|
it 'returns 403 to non-admins', utils.wrap (done) ->
|
||||||
|
user = yield utils.initUser()
|
||||||
|
yield utils.loginUser(user)
|
||||||
|
json = { i18n: { es: { name: 'Spanish translation' } } }
|
||||||
|
[res, body] = yield request.putAsync { @url, json }
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
course = yield Course.findById(@course.id)
|
||||||
|
expect(course.get('i18n')).toBeUndefined()
|
||||||
|
expect(course.get('i18nCoverage').length).toBe(0)
|
||||||
|
done()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
describe 'GET /db/course/:handle/levels/:levelOriginal/next', ->
|
describe 'GET /db/course/:handle/levels/:levelOriginal/next', ->
|
||||||
|
|
||||||
beforeEach utils.wrap (done) ->
|
beforeEach utils.wrap (done) ->
|
||||||
|
@ -191,3 +228,104 @@ describe 'GET /db/course/:handle/level-solutions', ->
|
||||||
[res, body] = yield request.getAsync { uri: utils.getURL("/db/course/#{@courseA.id}/level-solutions"), json: true }
|
[res, body] = yield request.getAsync { uri: utils.getURL("/db/course/#{@courseA.id}/level-solutions"), json: true }
|
||||||
expect(res.statusCode).toBe(403)
|
expect(res.statusCode).toBe(403)
|
||||||
done()
|
done()
|
||||||
|
|
||||||
|
|
||||||
|
describe 'POST /db/course/:handle/patch', ->
|
||||||
|
beforeEach utils.wrap (done) ->
|
||||||
|
yield utils.clearModels [User, Course]
|
||||||
|
admin = yield utils.initAdmin()
|
||||||
|
yield utils.loginUser(admin)
|
||||||
|
|
||||||
|
@course = yield utils.makeCourse({
|
||||||
|
name: 'Test Course'
|
||||||
|
description: 'A test course'
|
||||||
|
i18n: {
|
||||||
|
de: { name: 'existing translation' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@url = utils.getURL("/db/course/#{@course.id}/patch")
|
||||||
|
@json = {
|
||||||
|
commitMessage: 'Server test commit'
|
||||||
|
target: {
|
||||||
|
collection: 'course'
|
||||||
|
id: @course.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
|
||||||
|
it 'saves the changes immediately if just adding new translations to existing langauge', utils.wrap (done) ->
|
||||||
|
originalCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse.i18n.de.description = 'German translation!'
|
||||||
|
@json.delta = jsondiffpatch.diff(originalCourse, changedCourse)
|
||||||
|
[res, body] = yield request.postAsync({ @url, @json })
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
expect(res.body.status).toBe('accepted')
|
||||||
|
course = yield Course.findById(@course.id)
|
||||||
|
expect(course.get('i18n').de.description).toBe('German translation!')
|
||||||
|
expect(course.get('patches')).toBeUndefined()
|
||||||
|
done()
|
||||||
|
|
||||||
|
it 'saves the changes immediately if translations are for a new langauge', utils.wrap (done) ->
|
||||||
|
originalCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse.i18n.fr = { description: 'French translation!' }
|
||||||
|
@json.delta = jsondiffpatch.diff(originalCourse, changedCourse)
|
||||||
|
[res, body] = yield request.postAsync({ @url, @json })
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
expect(res.body.status).toBe('accepted')
|
||||||
|
course = yield Course.findById(@course.id)
|
||||||
|
expect(course.get('i18n').fr.description).toBe('French translation!')
|
||||||
|
expect(course.get('patches')).toBeUndefined()
|
||||||
|
done()
|
||||||
|
|
||||||
|
it 'saves a patch if it has some replacement translations', utils.wrap (done) ->
|
||||||
|
originalCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse.i18n.de.name = 'replacement'
|
||||||
|
@json.delta = jsondiffpatch.diff(originalCourse, changedCourse)
|
||||||
|
[res, body] = yield request.postAsync({ @url, @json })
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
expect(res.body.status).toBe('pending')
|
||||||
|
course = yield Course.findById(@course.id)
|
||||||
|
expect(course.get('i18n').de.name).toBe('existing translation')
|
||||||
|
expect(course.get('patches').length).toBe(1)
|
||||||
|
patch = yield Patch.findById(course.get('patches')[0])
|
||||||
|
expect(_.isEqual(patch.get('delta'), @json.delta)).toBe(true)
|
||||||
|
expect(patch.get('reasonNotAutoAccepted')).toBe('Adding to existing translations.')
|
||||||
|
[res, body] = yield request.getAsync({ url: utils.getURL("/db/course/#{@course.id}/patches?status=pending"), json: true })
|
||||||
|
expect(res.body[0]._id).toBe(patch.id)
|
||||||
|
done()
|
||||||
|
|
||||||
|
it 'saves a patch if applying the patch would invalidate the course data', utils.wrap (done) ->
|
||||||
|
originalCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse.notAProperty = 'this should not get saved to the course'
|
||||||
|
@json.delta = jsondiffpatch.diff(originalCourse, changedCourse)
|
||||||
|
[res, body] = yield request.postAsync({ @url, @json })
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
expect(res.body.status).toBe('pending')
|
||||||
|
course = yield Course.findById(@course.id)
|
||||||
|
expect(course.get('notAProperty')).toBeUndefined()
|
||||||
|
expect(course.get('patches').length).toBe(1)
|
||||||
|
patch = yield Patch.findById(course.get('patches')[0])
|
||||||
|
expect(_.isEqual(patch.get('delta'), @json.delta)).toBe(true)
|
||||||
|
expect(patch.get('reasonNotAutoAccepted')).toBe('Did not pass json schema.')
|
||||||
|
done()
|
||||||
|
|
||||||
|
it 'saves a patch if submission loses race with another translator', utils.wrap (done) ->
|
||||||
|
originalCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse = _.cloneDeep(@course.toObject())
|
||||||
|
changedCourse.i18n.de.description = 'German translation!'
|
||||||
|
yield @course.update({$set: {'i18n.de.description': 'Race condition'}}) # another change got saved first
|
||||||
|
@json.delta = jsondiffpatch.diff(originalCourse, changedCourse)
|
||||||
|
[res, body] = yield request.postAsync({ @url, @json })
|
||||||
|
expect(res.body.status).toBe('pending')
|
||||||
|
expect(res.statusCode).toBe(201)
|
||||||
|
course = yield Course.findById(@course.id)
|
||||||
|
expect(course.get('i18n').de.description).toBe('Race condition')
|
||||||
|
expect(course.get('patches').length).toBe(1)
|
||||||
|
patch = yield Patch.findById(course.get('patches')[0])
|
||||||
|
expect(_.isEqual(patch.get('delta'), @json.delta)).toBe(true)
|
||||||
|
expect(patch.get('reasonNotAutoAccepted')).toBe('Adding to existing translations.')
|
||||||
|
done()
|
||||||
|
|
|
@ -140,7 +140,12 @@ module.exports = mw =
|
||||||
if sources.campaign and not data.campaignID
|
if sources.campaign and not data.campaignID
|
||||||
data.campaignID = sources.campaign._id
|
data.campaignID = sources.campaign._id
|
||||||
|
|
||||||
data.releasePhase ||= 'released'
|
data = _.extend({}, {
|
||||||
|
name: _.uniqueId('Course ')
|
||||||
|
releasePhase: 'released'
|
||||||
|
i18nCoverage: []
|
||||||
|
i18n: {'-':{'-':'-'}}
|
||||||
|
}, data)
|
||||||
|
|
||||||
course = new Course(data)
|
course = new Course(data)
|
||||||
return course.save()
|
return course.save()
|
||||||
|
|
Loading…
Reference in a new issue