mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-23 15:48:11 -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/verifier': 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'
|
||||
|
||||
|
@ -122,6 +124,7 @@ module.exports = class CocoRouter extends Backbone.Router
|
|||
'i18n/achievement/:handle': go('i18n/I18NEditAchievementView')
|
||||
'i18n/campaign/:handle': go('i18n/I18NEditCampaignView')
|
||||
'i18n/poll/:handle': go('i18n/I18NEditPollView')
|
||||
'i18n/course/:handle': go('i18n/I18NEditCourseView')
|
||||
|
||||
'identify': go('user/IdentifyView')
|
||||
|
||||
|
|
|
@ -1564,6 +1564,7 @@
|
|||
article_title: "Article Editor"
|
||||
thang_title: "Thang Editor"
|
||||
level_title: "Level Editor"
|
||||
course_title: "Course Editor"
|
||||
achievement_title: "Achievement Editor"
|
||||
poll_title: "Poll Editor"
|
||||
back: "Back"
|
||||
|
|
|
@ -4,16 +4,19 @@ CourseSchema = c.object {title: 'Course', required: ['name']}
|
|||
c.extendNamedProperties CourseSchema # name first
|
||||
|
||||
_.extend CourseSchema.properties,
|
||||
i18n: {type: 'object', title: 'i18n', format: 'i18n', props: ['name', 'description' ]}
|
||||
campaignID: c.objectId()
|
||||
concepts: c.array {title: 'Programming Concepts', uniqueItems: true}, c.concept
|
||||
description: {type: 'string'}
|
||||
duration: {type: 'number', description: 'Approximate hours of content'}
|
||||
pricePerSeat: {type: 'number', description: 'Price per seat in USD cents.'} # deprecated
|
||||
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.' }
|
||||
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.extendTranslationCoverageProperties CourseSchema
|
||||
c.extendPatchableProperties CourseSchema
|
||||
|
||||
module.exports = CourseSchema
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
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']}, {
|
||||
delta: {title: 'Delta', type: ['array', 'object']}
|
||||
|
@ -24,6 +34,7 @@ PatchSchema = c.object({title: 'Patch', required: ['target', 'delta', 'commitMes
|
|||
|
||||
wasPending: type: 'boolean'
|
||||
newlyAccepted: type: 'boolean'
|
||||
reasonNotAutoAccepted: { type: 'string' }
|
||||
})
|
||||
|
||||
c.extendBasicProperties(PatchSchema, 'patch')
|
||||
|
|
|
@ -9,6 +9,7 @@ combine = (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\-\.\?\,\'\/\\\+&%\$#_=]*)?$'
|
||||
pathPattern = '^\/([a-zA-Z0-9\-\.\?\,\'\/\\\+&%\$#_=]*)?$'
|
||||
|
||||
# Common schema properties
|
||||
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
|
||||
me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext) # old
|
||||
me.stringDate = (ext) -> combine({type: ['string'], format: 'date-time'}, ext) # new
|
||||
me.objectId = (ext) -> schema = combine({type: ['object', 'string']}, ext) # old
|
||||
me.stringID = (ext) -> schema = combine({type: 'string', minLength: 24, maxLength: 24}, ext) # use for anything new
|
||||
me.objectId = (ext) -> combine({type: ['object', 'string']}, ext) # old
|
||||
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.path = (ext) -> combine({type: 'string', pattern: pathPattern}, ext)
|
||||
me.int = (ext) -> combine {type: 'integer'}, 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.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
|
||||
else
|
||||
else if view.canMakeNew
|
||||
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
|
||||
div.results
|
||||
table
|
||||
|
@ -22,4 +22,3 @@ block content
|
|||
else
|
||||
.alert.alert-danger
|
||||
span Admin only. Turn around.
|
||||
// TODO Ruben prettify
|
||||
|
|
|
@ -90,7 +90,7 @@ block content
|
|||
- continue;
|
||||
if inCourse
|
||||
.row
|
||||
.col-sm-3.text-right= course.get('name')
|
||||
.col-sm-3.text-right= i18n(course.attributes, 'name')
|
||||
.col-sm-9
|
||||
if inCourse
|
||||
- var levels = view.classroom.getLevels({courseID: course.id});
|
||||
|
@ -119,7 +119,7 @@ block content
|
|||
.text-center
|
||||
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= course.get('name')
|
||||
span= i18n(course.attributes, 'name')
|
||||
|
||||
|
||||
if view.teacherMode && !paidFor
|
||||
|
|
|
@ -23,7 +23,7 @@ block content
|
|||
strong= view.getOwnerName()
|
||||
|
||||
h1
|
||||
| #{view.course.get('name')}
|
||||
| #{i18n(view.course.attributes, 'name')}
|
||||
if view.courseComplete
|
||||
span.spl -
|
||||
span.spl(data-i18n='courses.complete')
|
||||
|
|
|
@ -78,7 +78,7 @@ block content
|
|||
.course-instance-entry
|
||||
- var course = view.courses.get(courseInstance.get('courseID'));
|
||||
h6
|
||||
span.spr= course.get('name')
|
||||
span.spr= i18n(course.attributes, 'name')
|
||||
small
|
||||
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)
|
||||
|
|
|
@ -267,7 +267,7 @@ mixin courseProgressTab
|
|||
each trimCourse in view.classroom.get('courses')
|
||||
- var course = view.courses.get(trimCourse._id);
|
||||
option(value=course.id selected=(course===state.get('selectedCourse')))
|
||||
= course.get('name')
|
||||
= i18n(course.attributes, 'name')
|
||||
if state.get('progressData')
|
||||
.render-on-course-sync
|
||||
+courseOverview
|
||||
|
@ -313,7 +313,7 @@ mixin courseOverview
|
|||
- var levels = view.classroom.getLevels({courseID: course.id}).models
|
||||
.course-overview-row
|
||||
.course-title.student-name
|
||||
span= course.get('name')
|
||||
span= i18n(course.attributes, 'name')
|
||||
span= ': '
|
||||
span(data-i18n='teacher.course_overview')
|
||||
.course-overview-progress
|
||||
|
@ -391,7 +391,7 @@ mixin bulkAssignControls
|
|||
each trimCourse in _.rest(view.classroom.get('courses'))
|
||||
- var course = view.courses.get(trimCourse._id)
|
||||
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
|
||||
span(data-i18n='teacher.assign_to_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'));
|
||||
.course-info
|
||||
.text-h4.semibold
|
||||
= course.get('name')
|
||||
p= course.get('description')
|
||||
= i18n(course.attributes, 'name')
|
||||
p= i18n(course.attributes, 'description')
|
||||
p.concepts.semibold
|
||||
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
|
||||
.free-course
|
||||
h6(data-i18n="new_home.ffa")
|
||||
.media-body(title=course.get('description'))
|
||||
h6.course-name= course.get('name') + ':'
|
||||
.media-body(title=i18n(course.attributes, 'description'))
|
||||
h6.course-name= i18n(course.attributes, 'name') + ':'
|
||||
p.small
|
||||
- var total = 0;
|
||||
each concept in course.get('concepts')
|
||||
|
|
|
@ -13,7 +13,7 @@ block content
|
|||
|
||||
.text-center
|
||||
span#course-name
|
||||
span= view.course.get('name')
|
||||
span= i18n(view.course.attributes, 'name')
|
||||
span.spl - Arena
|
||||
|
||||
div#level-column
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
.level-name-area-container
|
||||
.level-name-area
|
||||
if view.course
|
||||
.level-label= view.course.get('name')
|
||||
.level-label= i18n(view.course.attributes, 'name')
|
||||
.level-name(title=difficultyTitle || "")
|
||||
span #{view.levelNumber ? view.levelNumber + '. ' : ''}#{worldName.replace('Course: ', '')}
|
||||
if levelDifficulty
|
||||
|
|
|
@ -8,17 +8,17 @@ block content
|
|||
.text-center
|
||||
img(src="http://direct.codecombat.com/images/pages/base/logo.png")
|
||||
if view.course.loaded
|
||||
h1 #{view.course.get('name')}
|
||||
h1 #{i18n(view.course.attributes, 'name')}
|
||||
h3 #{view.prettyLanguage}
|
||||
i= view.course.get('description')
|
||||
i= i18n(view.course.attributes, 'description')
|
||||
br
|
||||
br
|
||||
|
||||
if view.levels
|
||||
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")
|
||||
i #{level.get('description')}
|
||||
i #{i18n(level.attributes, 'description')}
|
||||
div
|
||||
h4.text-center(data-i18n="common.intro")
|
||||
if level.get('intro')
|
||||
|
|
|
@ -30,6 +30,7 @@ module.exports = class SearchView extends RootView
|
|||
modelURL: null # '/db/article'
|
||||
tableTemplate: null # require 'templates/editor/article/table'
|
||||
projected: null # ['name', 'description', 'version'] or null for default
|
||||
canMakeNew: true
|
||||
|
||||
events:
|
||||
'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'
|
||||
template = require 'templates/i18n/i18n-home-view'
|
||||
CocoCollection = require 'collections/CocoCollection'
|
||||
Courses = require 'collections/Courses'
|
||||
|
||||
LevelComponent = require 'models/LevelComponent'
|
||||
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 })
|
||||
@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 })
|
||||
@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.fetch({data: {skip: 0, limit: PAGE_SIZE}, cache:false})
|
||||
@supermodel.loadCollection(c, 'documents')
|
||||
|
@ -55,6 +57,7 @@ module.exports = class I18NHomeView extends RootView
|
|||
when 'Level' then '/i18n/level/'
|
||||
when 'Campaign' then '/i18n/campaign/'
|
||||
when 'Poll' then '/i18n/poll/'
|
||||
when 'Course' then '/i18n/course/'
|
||||
getMore = collection.models.length is PAGE_SIZE
|
||||
@aggregateModels.add(collection.models)
|
||||
@render()
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
// NOTE: uses name as unique identifier, so changing the name will insert a new course
|
||||
// NOTE: pricePerSeat in USD cents
|
||||
load('bower_components/lodash/dist/lodash.js');
|
||||
|
||||
var courses =
|
||||
[
|
||||
|
@ -95,6 +96,33 @@ var courses =
|
|||
screenshot: "/images/pages/courses/105_info.png",
|
||||
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",
|
||||
slug: "js-primer",
|
||||
|
@ -106,10 +134,10 @@ var courses =
|
|||
}
|
||||
];
|
||||
|
||||
print("Finding course concepts..");
|
||||
for (var i = 0; i < courses.length; i++) {
|
||||
_.forEach(courses, function(course) {
|
||||
// Find course 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()) {
|
||||
var doc = cursor.next();
|
||||
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..");
|
||||
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.");
|
||||
|
|
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'
|
||||
Level = require '../models/Level'
|
||||
parse = require '../commons/parse'
|
||||
Patch = require '../models/Patch'
|
||||
tv4 = require('tv4').tv4
|
||||
slack = require '../slack'
|
||||
{ isJustFillingTranslations } = require '../commons/deltas'
|
||||
|
||||
module.exports =
|
||||
|
||||
|
@ -30,7 +34,7 @@ module.exports =
|
|||
|
||||
levelOriginals = (mongoose.Types.ObjectId(levelID) for levelID in sortedLevelIDs)
|
||||
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.sort((a, b) -> sortedLevelIDs.indexOf(a.original + '') - sortedLevelIDs.indexOf(b.original + ''))
|
||||
res.status(200).send(levels)
|
||||
|
@ -82,3 +86,60 @@ module.exports =
|
|||
results = yield database.viewSearch(dbq, req)
|
||||
results = Course.sortCourses 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.SearchablePlugin, {searchable: ['name', 'description']}
|
||||
CourseSchema.plugin(plugins.TranslationCoveragePlugin)
|
||||
|
||||
CourseSchema.statics.privateProperties = []
|
||||
CourseSchema.statics.editableProperties = []
|
||||
CourseSchema.statics.editableProperties = [
|
||||
'i18n',
|
||||
'i18nCoverage'
|
||||
]
|
||||
|
||||
CourseSchema.statics.jsonSchema = jsonSchema
|
||||
|
||||
|
@ -41,4 +45,8 @@ CourseSchema.statics.sortCourses = (courses) ->
|
|||
index = 9001 if index is -1
|
||||
index
|
||||
|
||||
CourseSchema.post 'init', (doc) ->
|
||||
if !doc.get('i18nCoverage')
|
||||
doc.set('i18nCoverage', [])
|
||||
|
||||
module.exports = Course = mongoose.model 'course', CourseSchema, 'courses'
|
||||
|
|
|
@ -5,10 +5,12 @@ log = require 'winston'
|
|||
config = require '../../server_config'
|
||||
|
||||
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) ->
|
||||
return next() unless @isNew # patch can't be altered after creation, so only need to check data once
|
||||
target = @get('target')
|
||||
return next() if target.collection is 'course' # Migrating this logic out of the Patch model, into middleware
|
||||
targetID = target.id
|
||||
Handler = require '../commons/Handler'
|
||||
if not Handler.isID(targetID)
|
||||
|
@ -83,4 +85,7 @@ PatchSchema.pre 'save', (next) ->
|
|||
|
||||
next()
|
||||
|
||||
jsonSchema = require '../../app/schemas/models/patch'
|
||||
PatchSchema.statics.jsonSchema = jsonSchema
|
||||
|
||||
module.exports = mongoose.model('patch', PatchSchema)
|
||||
|
|
|
@ -226,6 +226,7 @@ UserSchema.statics.statsMapping =
|
|||
'Achievement': 'stats.achievementEdits'
|
||||
'campaign': 'stats.campaignEdits'
|
||||
'poll': 'stats.pollEdits'
|
||||
'course': 'stats.courseEdits'
|
||||
translations:
|
||||
article: 'stats.articleTranslationPatches'
|
||||
level: 'stats.levelTranslationPatches'
|
||||
|
@ -235,6 +236,7 @@ UserSchema.statics.statsMapping =
|
|||
'Achievement': 'stats.achievementTranslationPatches'
|
||||
'campaign': 'stats.campaignTranslationPatches'
|
||||
'poll': 'stats.pollTranslationPatches'
|
||||
'course': 'stats.courseTranslationPatches'
|
||||
misc:
|
||||
article: 'stats.articleMiscPatches'
|
||||
level: 'stats.levelMiscPatches'
|
||||
|
@ -244,6 +246,7 @@ UserSchema.statics.statsMapping =
|
|||
'Achievement': 'stats.achievementMiscPatches'
|
||||
'campaign': 'stats.campaignMiscPatches'
|
||||
'poll': 'stats.pollMiscPatches'
|
||||
'course': 'stats.courseMiscPatches'
|
||||
|
||||
UserSchema.statics.incrementStat = (id, statName, done, inc=1) ->
|
||||
id = mongoose.Types.ObjectId id if _.isString id
|
||||
|
|
|
@ -81,9 +81,12 @@ module.exports.setup = (app) ->
|
|||
|
||||
Course = require '../models/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/level-solutions', mw.courses.fetchLevelSolutions)
|
||||
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.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'
|
||||
Campaign = require '../../../server/models/Campaign'
|
||||
Level = require '../../../server/models/Level'
|
||||
Patch = require '../../../server/models/Patch'
|
||||
|
||||
courseFixture = {
|
||||
name: 'Unnamed course'
|
||||
|
@ -60,6 +61,42 @@ describe 'GET /db/course/:handle', ->
|
|||
expect(res.statusCode).toBe(404)
|
||||
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', ->
|
||||
|
||||
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 }
|
||||
expect(res.statusCode).toBe(403)
|
||||
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
|
||||
data.campaignID = sources.campaign._id
|
||||
|
||||
data.releasePhase ||= 'released'
|
||||
data = _.extend({}, {
|
||||
name: _.uniqueId('Course ')
|
||||
releasePhase: 'released'
|
||||
i18nCoverage: []
|
||||
i18n: {'-':{'-':'-'}}
|
||||
}, data)
|
||||
|
||||
course = new Course(data)
|
||||
return course.save()
|
||||
|
|
Loading…
Reference in a new issue