Add course (name, description) translations

This commit is contained in:
Scott Erickson 2016-08-16 09:24:34 -07:00
parent f61f14571f
commit d4af931e05
35 changed files with 541 additions and 37 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

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

View file

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

View file

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

View 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

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

View file

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

View file

@ -50,4 +50,4 @@ module.exports =
watchers = doc.get('watchers')
watchers = _.filter watchers, (id) -> not id.equals(req.user._id)
doc.set('watchers', watchers)
res.status(200).send(doc)
res.status(200).send(doc)

View file

@ -46,4 +46,4 @@ module.exports =
if not doc
throw new errors.NotFound('Document not found.')
yield doc.remove()
res.status(204).end()
res.status(204).end()

View file

@ -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
@ -40,5 +44,9 @@ CourseSchema.statics.sortCourses = (courses) ->
index = ordering.indexOf(course.get?('slug') or course.slug)
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'

View file

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

View file

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

View file

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

View file

@ -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'
@ -59,6 +60,42 @@ describe 'GET /db/course/:handle', ->
[res, body] = yield request.getAsync {uri: getURL("/db/course/dne"), json: true}
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', ->
@ -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()

View file

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