codecombat/app/models/CocoModel.coffee
Scott Erickson ef0547f72a Simplify applying licenses
In TeacherClassView, when a teacher assigns a paid course to any unenrolled
student, the view automatically enrolls those students, rather than requiring
the teacher to enroll those students manually first. Update copy throughout.

Also add back (smaller) padding to progress dots in TeacherClassView.
2016-08-23 10:43:31 -07:00

465 lines
16 KiB
CoffeeScript

storage = require 'core/storage'
deltasLib = require 'core/deltas'
locale = require 'locale/locale'
class CocoModel extends Backbone.Model
idAttribute: '_id'
loaded: false
loading: false
saveBackups: false
notyErrors: true
@schema: null
initialize: (attributes, options) ->
super(arguments...)
options ?= {}
@setProjection options.project
if not @constructor.className
console.error("#{@} needs a className set.")
@on 'sync', @onLoaded, @
@on 'error', @onError, @
@on 'add', @onLoaded, @
@saveBackup = _.debounce(@saveBackup, 500)
@usesVersions = @schema()?.properties?.version?
if window.application?.testing
@fakeRequests = []
@on 'request', -> @fakeRequests.push jasmine.Ajax.requests.mostRecent()
created: -> new Date(parseInt(@id.substring(0, 8), 16) * 1000)
backupKey: ->
if @usesVersions then @id else @id # + ':' + @attributes.__v # TODO: doesn't work because __v doesn't actually increment. #2061
# if fixed, RevertModal will also need the fix
setProjection: (project) ->
# TODO: ends up getting done twice, since the URL is modified and the @project is modified. So don't do this, just set project directly... (?)
return if project is @project
url = @getURL()
url += '&project=' unless /project=/.test url
url = url.replace '&', '?' unless /\?/.test url
url = url.replace /project=[^&]*/, "project=#{project?.join(',') or ''}"
url = url.replace /[&?]project=&/, '&' unless project?.length
url = url.replace /[&?]project=$/, '' unless project?.length
@setURL url
@project = project
type: ->
@constructor.className
clone: (withChanges=true) ->
# Backbone does not support nested documents
clone = super()
clone.set($.extend(true, {}, if withChanges then @attributes else @_revertAttributes))
clone
onError: (level, jqxhr) ->
@loading = false
@jqxhr = null
if jqxhr.status is 402
if _.contains(jqxhr.responseText, 'be in a course')
Backbone.Mediator.publish 'level:course-membership-required', {}
else
Backbone.Mediator.publish 'level:subscription-required', {}
onLoaded: ->
@loaded = true
@loading = false
@jqxhr = null
@loadFromBackup()
getCreationDate: -> new Date(parseInt(@id.slice(0,8), 16)*1000)
getNormalizedURL: -> "#{@urlRoot}/#{@id}"
attributesWithDefaults: undefined
get: (attribute, withDefault=false) ->
if withDefault
if @attributesWithDefaults is undefined then @buildAttributesWithDefaults()
return @attributesWithDefaults[attribute]
else
super(attribute)
set: (attributes, options) ->
delete @attributesWithDefaults unless attributes is 'thangs' # unless attributes is 'thangs': performance optimization for Levels keeping their cache.
inFlux = @loading or not @loaded
@markToRevert() unless inFlux or @_revertAttributes or @project or options?.fromMerge
res = super attributes, options
@saveBackup() if @saveBackups and (not inFlux)
res
buildAttributesWithDefaults: ->
t0 = new Date()
clone = $.extend true, {}, @attributes
thisTV4 = tv4.freshApi()
thisTV4.addSchema('#', @schema())
thisTV4.addSchema('metaschema', require('schemas/metaschema'))
TreemaUtils.populateDefaults(clone, @schema(), thisTV4)
@attributesWithDefaults = clone
duration = new Date() - t0
console.debug "Populated defaults for #{@type()}#{if @attributes.name then ' ' + @attributes.name else ''} in #{duration}ms" if duration > 10
loadFromBackup: ->
return unless @saveBackups
existing = storage.load @backupKey()
if existing
@set(existing, {silent: true})
CocoModel.backedUp[@backupKey()] = @
saveBackup: -> @saveBackupNow()
saveBackupNow: ->
storage.save(@backupKey(), @attributes)
CocoModel.backedUp[@backupKey()] = @
@backedUp = {}
schema: -> return @constructor.schema
getValidationErrors: ->
# Since Backbone unset only sets things to undefined instead of deleting them, we ignore undefined properties.
definedAttributes = _.pick @attributes, (v) -> v isnt undefined
errors = tv4.validateMultiple(definedAttributes, @constructor.schema or {}).errors
return errors if errors?.length
validate: ->
errors = @getValidationErrors()
if errors?.length
unless application.testing
console.debug "Validation failed for #{@constructor.className}: '#{@get('name') or @}'."
for error in errors
console.debug "\t", error.dataPath, ':', error.message
console.trace?()
return errors
save: (attrs, options) ->
options ?= {}
originalOptions = _.cloneDeep(options)
options.headers ?= {}
options.headers['X-Current-Path'] = document.location?.pathname ? 'unknown'
success = options.success
error = options.error
options.success = (model, res) =>
@retries = 0
@trigger 'save:success', @
success(@, res) if success
@markToRevert() if @_revertAttributes
@clearBackup()
CocoModel.pollAchievements()
options.success = options.error = null # So the callbacks can be garbage-collected.
options.error = (model, res) =>
if res.status is 0
@retries ?= 0
@retries += 1
if @retries > 20
msg = 'Your computer or our servers appear to be offline. Please try refreshing.'
noty text: msg, layout: 'center', type: 'error', killer: true
return
else
msg = $.i18n.t 'loading_error.connection_failure', defaultValue: 'Connection failed.'
try
noty text: msg, layout: 'center', type: 'error', killer: true, timeout: 3000
catch notyError
console.warn "Couldn't even show noty error for", error, "because", notyError
return _.delay((f = => @save(attrs, originalOptions)), 3000)
error(@, res) if error
return unless @notyErrors
errorMessage = "Error saving #{@get('name') ? @type()}"
console.log 'going to log an error message'
console.warn errorMessage, res.responseJSON
unless webkit?.messageHandlers # Don't show these notys on iPad
try
noty text: "#{errorMessage}: #{res.status} #{res.statusText}\n#{res.responseText}", layout: 'topCenter', type: 'error', killer: false, timeout: 10000
catch notyError
console.warn "Couldn't even show noty error for", error, "because", notyError
options.success = options.error = null # So the callbacks can be garbage-collected.
@trigger 'save', @
return super attrs, options
patch: (options) ->
return false unless @_revertAttributes
options ?= {}
options.patch = true
options.type = 'PUT'
attrs = {_id: @id}
keys = []
for key in _.keys @attributes
unless _.isEqual @attributes[key], @_revertAttributes[key]
attrs[key] = @attributes[key]
keys.push key
return unless keys.length
@save(attrs, options)
fetch: (options) ->
options ?= {}
options.data ?= {}
options.data.project = @project.join(',') if @project
#console.error @constructor.className, @, "fetching with cache?", options.cache, "options", options # Useful for debugging cached IE fetches
@jqxhr = super(options)
@loading = true
@jqxhr
markToRevert: ->
if @type() is 'ThangType'
# Don't deep clone the raw vector data, but do deep clone everything else.
@_revertAttributes = _.clone @attributes
for smallProp, value of @attributes when value and smallProp isnt 'raw'
@_revertAttributes[smallProp] = _.cloneDeep value
else
@_revertAttributes = $.extend(true, {}, @attributes)
revert: ->
@clear({silent: true})
@set(@_revertAttributes, {silent: true}) if @_revertAttributes
@clearBackup()
clearBackup: ->
storage.remove @backupKey()
hasLocalChanges: ->
@_revertAttributes and not _.isEqual @attributes, @_revertAttributes
cloneNewMinorVersion: ->
newData = _.clone @attributes
clone = new @constructor(newData)
clone
cloneNewMajorVersion: ->
clone = @cloneNewMinorVersion()
clone.unset('version')
clone
isPublished: ->
for permission in (@get('permissions', true) ? [])
return true if permission.target is 'public' and permission.access is 'read'
false
publish: ->
if @isPublished() then throw new Error('Can\'t publish what\'s already-published. Can\'t kill what\'s already dead.')
@set 'permissions', @get('permissions', true).concat({access: 'read', target: 'public'})
@isObjectID: (s) ->
s.length is 24 and s.match(/[a-f0-9]/gi)?.length is 24
hasReadAccess: (actor) ->
# actor is a User object
actor ?= me
return true if actor.isAdmin()
return true if actor.isArtisan() and @editableByArtisans
for permission in (@get('permissions', true) ? [])
if permission.target is 'public' or actor.get('_id') is permission.target
return true if permission.access in ['owner', 'read']
return false
hasWriteAccess: (actor) ->
# actor is a User object
actor ?= me
return true if actor.isAdmin()
return true if actor.isArtisan() and @editableByArtisans
for permission in (@get('permissions', true) ? [])
if permission.target is 'public' or actor.get('_id') is permission.target
return true if permission.access in ['owner', 'write']
return false
getOwner: ->
ownerPermission = _.find @get('permissions', true), access: 'owner'
ownerPermission?.target
getDelta: ->
differ = deltasLib.makeJSONDiffer()
differ.diff(_.omit(@_revertAttributes, deltasLib.DOC_SKIP_PATHS), _.omit(@attributes, deltasLib.DOC_SKIP_PATHS))
getDeltaWith: (otherModel) ->
differ = deltasLib.makeJSONDiffer()
differ.diff @attributes, otherModel.attributes
applyDelta: (delta) ->
newAttributes = $.extend(true, {}, @attributes)
try
jsondiffpatch.patch newAttributes, delta
catch error
console.error 'Error applying delta\n', JSON.stringify(delta, null, '\t'), '\n\nto attributes\n\n', newAttributes
return false
for key, value of newAttributes
delete newAttributes[key] if _.isEqual value, @attributes[key]
@set newAttributes
return true
getExpandedDelta: ->
delta = @getDelta()
deltasLib.expandDelta(delta, @_revertAttributes, @schema())
getExpandedDeltaWith: (otherModel) ->
delta = @getDeltaWith(otherModel)
deltasLib.expandDelta(delta, @attributes, @schema())
watch: (doWatch=true) ->
$.ajax("#{@urlRoot}/#{@id}/watch", {type: 'PUT', data: {on: doWatch}})
@watching = -> doWatch
watching: ->
return me.id in (@get('watchers') or [])
populateI18N: (data, schema, path='') ->
# TODO: Better schema/json walking
sum = 0
data ?= $.extend true, {}, @attributes
schema ?= @schema() or {}
if schema.oneOf # get populating the Programmable component config to work
schema = _.find(schema.oneOf, {type: 'object'})
addedI18N = false
if schema.properties?.i18n and _.isPlainObject(data) and not data.i18n?
data.i18n = {'-':{'-':'-'}} # mongoose doesn't work with empty objects
sum += 1
addedI18N = true
if _.isPlainObject data
for key, value of data
numChanged = 0
childSchema = schema.properties?[key]
if not childSchema and _.isObject(schema.additionalProperties)
childSchema = schema.additionalProperties
if childSchema
numChanged = @populateI18N(value, childSchema, path+'/'+key)
if numChanged and not path # should only do this for the root object
@set key, value
sum += numChanged
if schema.items and _.isArray data
sum += @populateI18N(value, schema.items, path+'/'+index) for value, index in data
@set('i18n', data.i18n) if addedI18N and not path # need special case for root i18n
@updateI18NCoverage()
sum
@getReferencedModel: (data, schema) ->
return null unless schema.links?
linkObject = _.find schema.links, rel: 'db'
return null unless linkObject
return null if linkObject.href.match('thang.type') and not @isObjectID(data) # Skip loading hardcoded Thang Types for now (TODO)
# not fully extensible, but we can worry about that later
link = linkObject.href
link = link.replace('{(original)}', data.original)
link = link.replace('{(majorVersion)}', '' + (data.majorVersion ? 0))
link = link.replace('{($)}', data)
@getOrMakeModelFromLink(link)
@getOrMakeModelFromLink: (link) ->
makeUrlFunc = (url) -> -> url
modelUrl = link.split('/')[2]
modelModule = _.string.classify(modelUrl)
modulePath = "models/#{modelModule}"
try
Model = require modulePath
catch e
console.error 'could not load model from link path', link, 'using path', modulePath
return
model = new Model()
model.url = makeUrlFunc(link)
return model
setURL: (url) ->
makeURLFunc = (u) -> -> u
@url = makeURLFunc(url)
@
getURL: ->
return if _.isString @url then @url else @url()
@pollAchievements: ->
return if application.testing
CocoCollection = require 'collections/CocoCollection'
EarnedAchievement = require 'models/EarnedAchievement'
class NewAchievementCollection extends CocoCollection
model: EarnedAchievement
initialize: (me = require('core/auth').me) ->
@url = "/db/user/#{me.id}/achievements?notified=false"
achievements = new NewAchievementCollection
achievements.fetch
success: (collection) ->
me.fetch (cache: false, success: -> Backbone.Mediator.publish('achievements:new', earnedAchievements: collection)) unless _.isEmpty(collection.models)
error: ->
console.error 'Miserably failed to fetch unnotified achievements', arguments
cache: false
CocoModel.pollAchievements = _.debounce CocoModel.pollAchievements, 500
#- Internationalization
updateI18NCoverage: ->
langCodeArrays = []
pathToData = {}
TreemaUtils.walk(@attributes, @schema(), null, (path, data, workingSchema) ->
# Store parent data for the next block...
if data?.i18n
pathToData[path] = data
if _.string.endsWith path, 'i18n'
i18n = data
# grab the parent data
parentPath = path[0...-5]
parentData = pathToData[parentPath]
# use it to determine what properties actually need to be translated
props = workingSchema.props or []
props = (prop for prop in props when parentData[prop])
#unless props.length
# console.log 'props is', props, 'path is', path, 'data is', data, 'parentData is', parentData, 'workingSchema is', workingSchema
# langCodeArrays.push _.without _.keys(locale), 'update' # Every language has covered a path with no properties to be translated.
# return
return if 'additionalProperties' of i18n # Workaround for #2630: Programmable is weird
# get a list of lang codes where its object has keys for every prop to be translated
coverage = _.filter(_.keys(i18n), (langCode) ->
translations = i18n[langCode]
_.all((translations[prop] for prop in props))
)
#console.log 'got coverage', coverage, 'for', path, props, workingSchema, parentData
langCodeArrays.push coverage
)
return unless langCodeArrays.length
# language codes that are covered for every i18n object are fully covered
overallCoverage = _.intersection(langCodeArrays...)
@set('i18nCoverage', overallCoverage)
saveNewMinorVersion: (attrs, options={}) ->
options.url = @url() + '/new-version'
options.type = 'POST'
return @save(attrs, options)
saveNewMajorVersion: (attrs, options={}) ->
attrs = attrs or _.omit(@attributes, 'version')
options.url = @url() + '/new-version'
options.type = 'POST'
options.patch = true # do not let version get sent along
return @save(attrs, options)
fetchPatchesWithStatus: (status='pending', options={}) ->
Patches = require '../collections/Patches'
patches = new Patches()
options.data ?= {}
options.data.status = status
options.url = @urlRoot + '/' + (@get('original') or @id) + '/patches'
patches.fetch(options)
return patches
stringify: -> return JSON.stringify(@toJSON())
wait: (event) -> new Promise((resolve) => @once(event, resolve))
module.exports = CocoModel