mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2025-03-14 07:00:01 -04:00
Merge master branch
This commit is contained in:
commit
42f84de394
62 changed files with 3365 additions and 1566 deletions
BIN
app/assets/images/common/button-create-new-class.png
Normal file
BIN
app/assets/images/common/button-create-new-class.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
BIN
app/assets/images/common/button-finish-signing-up.png
Normal file
BIN
app/assets/images/common/button-finish-signing-up.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
app/assets/images/common/button-go-to-my-classes.png
Normal file
BIN
app/assets/images/common/button-go-to-my-classes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
|
@ -2,8 +2,9 @@
|
|||
// Hooks into the test view logic for running tests.
|
||||
|
||||
|
||||
window.userObject = {_id:'1'}
|
||||
window.StripeCheckout = {configure: function (){}}
|
||||
window.userObject = {_id:'1'};
|
||||
window.serverConfig = {picoCTF: false, production: false};
|
||||
window.StripeCheckout = {configure: function (){}};
|
||||
initialize = require('core/initialize');
|
||||
initialize.init();
|
||||
console.debug = function() {}; // Karma conf doesn't seem to work? Debug messages are still emitted when they shouldn't be.
|
||||
|
|
|
@ -75,6 +75,9 @@
|
|||
<![endif]>
|
||||
<script>
|
||||
|
||||
// IMPORTANT: If you edit here, make sure app/assets/javascripts/run-tests.js puts in placeholders for
|
||||
// running client tests on Travis.
|
||||
|
||||
// Placeholder for iPad, which inspects the user object at the bottom of an injected page.
|
||||
window.serverConfig = "serverConfigTag";
|
||||
window.userObject = "userObjectTag";
|
||||
|
|
5
app/collections/Patches.coffee
Normal file
5
app/collections/Patches.coffee
Normal file
|
@ -0,0 +1,5 @@
|
|||
PatchModel = require 'models/Patch'
|
||||
CocoCollection = require 'collections/CocoCollection'
|
||||
|
||||
module.exports = class Patches extends CocoCollection
|
||||
model: PatchModel
|
|
@ -12,7 +12,7 @@ module.exports.createContiguousDays = (timeframeDays, skipToday=true) ->
|
|||
currentDate.setUTCDate(currentDate.getUTCDate() + 1)
|
||||
days
|
||||
|
||||
module.exports.createLineChart = (containerSelector, chartLines) ->
|
||||
module.exports.createLineChart = (containerSelector, chartLines, containerWidth) ->
|
||||
# Creates a line chart within 'containerSelector' based on chartLines
|
||||
return unless chartLines?.length > 0 and containerSelector
|
||||
|
||||
|
@ -20,7 +20,7 @@ module.exports.createLineChart = (containerSelector, chartLines) ->
|
|||
keyHeight = 20
|
||||
xAxisHeight = 20
|
||||
yAxisWidth = 40
|
||||
containerWidth = $(containerSelector).width()
|
||||
containerWidth = $(containerSelector).width() unless containerWidth
|
||||
containerHeight = $(containerSelector).height()
|
||||
|
||||
yScaleCount = 0
|
||||
|
|
|
@ -19,4 +19,4 @@ module.exports = class GitHubHandler extends CocoClass
|
|||
client_id: @clientID
|
||||
redirect_uri: @redirectURI
|
||||
|
||||
location.href = "https://github.com/login/oauth/authorize?" + $.param(request)
|
||||
location.href = "https://github.com/login/oauth/authorize?" + $.param(request)
|
|
@ -252,9 +252,9 @@ module.exports.getPrepaidCodeAmount = getPrepaidCodeAmount = (price=0, users=0,
|
|||
total = price * users * months
|
||||
total
|
||||
|
||||
module.exports.filterMarkdownCodeLanguages = (text) ->
|
||||
module.exports.filterMarkdownCodeLanguages = (text, language) ->
|
||||
return '' unless text
|
||||
currentLanguage = me.get('aceConfig')?.language or 'python'
|
||||
currentLanguage = language or me.get('aceConfig')?.language or 'python'
|
||||
excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'java', 'io'], currentLanguage
|
||||
exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm'
|
||||
text.replace exclusionRegex, ''
|
||||
|
|
|
@ -32,6 +32,9 @@ module.exports = class SpriteParser
|
|||
@width = parseInt(properties?[1] ? '0', 10)
|
||||
@height = parseInt(properties?[2] ? '0', 10)
|
||||
|
||||
# Remove webfontAvailable line, not relevant
|
||||
source = source.replace /lib\.webfontAvailable = (.|\n)+?};/, ''
|
||||
|
||||
options = {loc: false, range: true}
|
||||
ast = esprima.parse source, options
|
||||
blocks = @findBlocks ast, source
|
||||
|
|
|
@ -153,7 +153,12 @@ module.exports = class SegmentedSprite extends createjs.SpriteContainer
|
|||
# console.debug 'Did not dereference args:', args
|
||||
stopped = true
|
||||
break
|
||||
tween = tween[func.n](args...)
|
||||
if tween[func.n]
|
||||
tween = tween[func.n](args...)
|
||||
else
|
||||
# If we, say, skipped a shadow get(), then the wait() may not be present
|
||||
stopped = true
|
||||
break
|
||||
continue if stopped
|
||||
anim.timeline.addTween(tween)
|
||||
|
||||
|
|
|
@ -145,10 +145,10 @@ module.exports.thangNames = thangNames =
|
|||
]
|
||||
'Raven': [
|
||||
# Animal
|
||||
'Nevermore'
|
||||
'Baltimore'
|
||||
'Columbia'
|
||||
'Dawnstar'
|
||||
'Nevermore'
|
||||
]
|
||||
'Cougar': [
|
||||
# Animal
|
||||
|
@ -160,33 +160,35 @@ module.exports.thangNames = thangNames =
|
|||
'Frog': [
|
||||
# Animal
|
||||
'Bighead'
|
||||
'Hypnotoad'
|
||||
'Freddy'
|
||||
'Frogger'
|
||||
'Froggy'
|
||||
'Slippy'
|
||||
'Wart'
|
||||
'Bufo'
|
||||
'Bunda'
|
||||
'Dan\'l Webster'
|
||||
'Freddy'
|
||||
'Frogger'
|
||||
'Froggy'
|
||||
'Hypnotoad'
|
||||
'Mr. Toad'
|
||||
'Trevor'
|
||||
'Wei Qi'
|
||||
'Slippy'
|
||||
'Toada'
|
||||
'Trevor'
|
||||
'Wart'
|
||||
'Wei Qi'
|
||||
]
|
||||
'Horse': [
|
||||
# Animal
|
||||
'Abby'
|
||||
'Wildsilver'
|
||||
'Fleetfire'
|
||||
'Ed'
|
||||
'Silver'
|
||||
'Hurricane'
|
||||
'Beauty'
|
||||
'Lovelace'
|
||||
'Mirial'
|
||||
'Miracle'
|
||||
'Cinnamon'
|
||||
'Codasus'
|
||||
'Ed'
|
||||
'Fleetfire'
|
||||
'Hurricane'
|
||||
'Lovelace'
|
||||
'Miracle'
|
||||
'Mirial'
|
||||
'Powder'
|
||||
'Silver'
|
||||
'Wildsilver'
|
||||
]
|
||||
'Ogre M': [
|
||||
# Male
|
||||
|
@ -245,12 +247,16 @@ module.exports.thangNames = thangNames =
|
|||
'Haggar'
|
||||
'Heizenburg'
|
||||
'Ironjaw'
|
||||
'Mokuhr'
|
||||
'Muul'
|
||||
'Ork\'han'
|
||||
'Roast Beefy'
|
||||
'Toharg'
|
||||
'Trod'
|
||||
'Tuguro'
|
||||
'Turrok'
|
||||
'York'
|
||||
'Zabarek'
|
||||
'Zagurk'
|
||||
'Zeredd'
|
||||
]
|
||||
|
@ -306,6 +312,7 @@ module.exports.thangNames = thangNames =
|
|||
'Bonejangles'
|
||||
'Bonesworth'
|
||||
'Bonette'
|
||||
'Boneus'
|
||||
'Doornail'
|
||||
'Drybones'
|
||||
'Grim'
|
||||
|
@ -320,8 +327,8 @@ module.exports.thangNames = thangNames =
|
|||
'Scraps'
|
||||
'Shelly'
|
||||
'Shishka-Bob'
|
||||
'Shishka-Larry'
|
||||
'Shishka-Joe'
|
||||
'Shishka-Larry'
|
||||
'Skeletor'
|
||||
'Skellington'
|
||||
'Skulldugger'
|
||||
|
@ -333,7 +340,6 @@ module.exports.thangNames = thangNames =
|
|||
'Tatava'
|
||||
'Ulna'
|
||||
'Yorick'
|
||||
'Boneus'
|
||||
]
|
||||
'Ogre Headhunter': [
|
||||
# Male
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -426,6 +426,26 @@ class CocoModel extends Backbone.Model
|
|||
# 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') + '/patches'
|
||||
patches.fetch(options)
|
||||
return patches
|
||||
|
||||
module.exports = CocoModel
|
||||
|
|
|
@ -81,6 +81,15 @@ module.exports = class SuperModel extends Backbone.Model
|
|||
res.load() if not (res.isLoading or res.isLoaded)
|
||||
return res
|
||||
|
||||
# Eventually should use only these functions. Use SuperModel just to track progress.
|
||||
trackModel: (model, value) ->
|
||||
res = @addModelResource(collection, '', {}, value)
|
||||
res.listen()
|
||||
|
||||
trackCollection: (collection, value) ->
|
||||
res = @addModelResource(collection, '', {}, value)
|
||||
res.listen()
|
||||
|
||||
# replace or overwrite
|
||||
shouldSaveBackups: (model) -> false
|
||||
|
||||
|
@ -275,6 +284,9 @@ class ModelResource extends Resource
|
|||
|
||||
fetchModel: ->
|
||||
@jqxhr = @model.fetch(@fetchOptions) unless @model.loading
|
||||
@listen()
|
||||
|
||||
listen: ->
|
||||
@listenToOnce @model, 'sync', -> @markLoaded()
|
||||
@listenToOnce @model, 'error', -> @markFailed()
|
||||
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
#admin-analytics-view
|
||||
|
||||
// Force compact top site chrome
|
||||
background-position: center -226px
|
||||
padding-top: 50px
|
||||
#site-nav
|
||||
top: -80px
|
||||
#nav-logo
|
||||
display: none
|
||||
#small-nav-logo
|
||||
display: inline-block
|
||||
height: 30px
|
||||
|
||||
#site-content-area
|
||||
width: 100%
|
||||
.big-stat
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
@import "app/styles/bootstrap/variables"
|
||||
@import "app/styles/mixins"
|
||||
|
||||
#page-container
|
||||
overflow: hidden
|
||||
|
||||
// https://github.com/twbs/bootstrap/issues/9237 -- need a version that's not !important
|
||||
.secret
|
||||
display: none
|
||||
|
|
|
@ -203,6 +203,8 @@ $forest: #20572B
|
|||
|
||||
#new-home-view
|
||||
|
||||
overflow: hidden
|
||||
|
||||
#jumbotron-container-fluid
|
||||
background-image: url("/images/pages/home/character_jumbotron.png")
|
||||
background-position: 50% 55%
|
||||
|
|
|
@ -8,208 +8,358 @@ block content
|
|||
.container-fluid
|
||||
.row
|
||||
.col-md-5.big-stat.active-classes
|
||||
div.description Monthly Active Classes
|
||||
if activeClasses.length > 0
|
||||
div.description Monthly Active Classes
|
||||
div.count= activeClasses[0].groups[activeClasses[0].groups.length - 1]
|
||||
.col-md-5.big-stat.recurring-revenue
|
||||
div.description Monthly Recurring Revenue
|
||||
if revenue.length > 0
|
||||
div.description Monthly Recurring Revenue
|
||||
div.count $#{Math.round((revenue[0].groups[revenue[0].groups.length - 1]) / 100)}
|
||||
.col-md-5.big-stat.classroom-active-users
|
||||
div.description Classroom Monthly Active Users
|
||||
if activeUsers.length > 0
|
||||
- var classroomBigMAU = 0;
|
||||
each count, event in activeUsers[0].events
|
||||
if event.indexOf('MAU classroom') >= 0
|
||||
- classroomBigMAU += count;
|
||||
div.description Classroom Monthly Active Users
|
||||
div.count= classroomBigMAU
|
||||
.col-md-5.big-stat.campaign-active-users
|
||||
div.description Campaign Monthly Active Users
|
||||
if activeUsers.length > 0
|
||||
- var campaignBigMAU = 0;
|
||||
each count, event in activeUsers[0].events
|
||||
if event.indexOf('MAU campaign') >= 0
|
||||
- campaignBigMAU += count;
|
||||
div.description Campaign Monthly Active Users
|
||||
div.count= campaignBigMAU
|
||||
|
||||
h3 KPI 60 days
|
||||
.kpi-recent-chart.line-chart-container
|
||||
ul.nav.nav-tabs
|
||||
li.active
|
||||
a(data-target="#tab_kpis", data-toggle="tab") KPIs
|
||||
li
|
||||
a(data-target="#tab_active_classes", data-toggle="tab") Active Classes
|
||||
li
|
||||
a(data-target="#tab_revenue", data-toggle="tab") Revenue
|
||||
li
|
||||
a(data-target="#tab_classroom", data-toggle="tab") Classroom
|
||||
li
|
||||
a(data-target="#tab_campaign", data-toggle="tab") Campaign
|
||||
li
|
||||
a(data-target="#tab_campaign_vs_classroom", data-toggle="tab") Campaign vs Classroom
|
||||
|
||||
h3 KPI 365 days
|
||||
.kpi-chart.line-chart-container
|
||||
.tab-content
|
||||
.tab-pane.active#tab_kpis
|
||||
h3 KPI 60 days
|
||||
.kpi-recent-chart.line-chart-container
|
||||
|
||||
h1 Table of Contents
|
||||
b Graphs
|
||||
div
|
||||
a(href='#active-classes-graph') Active Classes
|
||||
div
|
||||
a(href='#recurring-revenue-graph') Recurring Revenue
|
||||
div
|
||||
a(href='#classroom-daus-graph') Classroom Daily Active Users
|
||||
div
|
||||
a(href='#classroom-maus-graph') Classroom Monthly Active Users
|
||||
div
|
||||
a(href='#campaign-daus-graph') Campaign Daily Active Users
|
||||
div
|
||||
a(href='#campaign-maus-graph') Campaign Monthly Active Users
|
||||
div
|
||||
a(href='#furthest-courses-table') Campaign vs Classroom Paid Monthly Active Users
|
||||
div
|
||||
a(href='#furthest-courses-table') Enrollments Issued and Redeemed
|
||||
b Tables
|
||||
div
|
||||
a(href='#furthest-courses-table') Furthest Course
|
||||
div
|
||||
a(href='#school-counts-table') School Counts
|
||||
div
|
||||
a(href='#active-classes-table') Active Classes
|
||||
div
|
||||
a(href='#recurring-revenue-table') Recurring Revenue
|
||||
div
|
||||
a(href='#active-users-table') Active Users
|
||||
div
|
||||
a(href='#enrollments-table') Enrollments
|
||||
h3 KPI 365 days
|
||||
.kpi-chart.line-chart-container
|
||||
|
||||
h3#active-classes-graph Active Classes 90 days
|
||||
.small Active class: 12+ students in a classroom, with 6+ who played in last 30 days.
|
||||
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
|
||||
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
|
||||
.small Paid class: at least one paid student in the classroom
|
||||
.small Trial class: not paid, at least one trial student in classroom
|
||||
.small Free class: not paid, not trial
|
||||
.active-classes-chart.line-chart-container
|
||||
.tab-pane#tab_active_classes
|
||||
h3 Active Classes 90 days
|
||||
.small Active class: 12+ students in a classroom, with 6+ who played in last 30 days. Played == 'Started Level' analytics event.
|
||||
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
|
||||
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
|
||||
.small Paid class: at least one paid student in the classroom
|
||||
.small Trial class: not paid, at least one trial student in classroom
|
||||
.small Free class: not paid, not trial
|
||||
.active-classes-chart-90.line-chart-container
|
||||
|
||||
h3#recurring-revenue-graph Recurring Revenue 90 days
|
||||
.recurring-revenue-chart.line-chart-container
|
||||
h3 Active Classes 365 days
|
||||
.active-classes-chart-365.line-chart-container
|
||||
|
||||
h3#classroom-daus-graph Classroom Daily Active Users 90 days
|
||||
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
|
||||
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
|
||||
.small Free student: not paid, not trial
|
||||
.classroom-daily-active-users-chart.line-chart-container
|
||||
|
||||
h3#classroom-maus-graph Classroom Monthly Active Users 90 days
|
||||
.classroom-monthly-active-users-chart.line-chart-container
|
||||
|
||||
h3#campaign-daus-graph Campaign Daily Active Users 90 days
|
||||
.small Paid user: had monthly or yearly sub on given day
|
||||
.small Free user: not paid
|
||||
.campaign-daily-active-users-chart.line-chart-container
|
||||
|
||||
h3#campaign-maus-graph Campaign Monthly Active Users 90 days
|
||||
.campaign-monthly-active-users-chart.line-chart-container
|
||||
|
||||
h3 Campaign vs Classroom Paid Monthly Active Users 90 days
|
||||
.campaign-vs-classroom-monthly-active-users-chart.line-chart-container
|
||||
|
||||
h3 Enrollments Issued and Redeemed 90 days
|
||||
.paid-courses-chart.line-chart-container
|
||||
|
||||
h3#furthest-courses-table Furthest Course
|
||||
.small Teacher: owner of a course instance
|
||||
.small Student: member of a course instance (assigned to course)
|
||||
.small For course instances created in last #{view.furthestCourseDayRange} days, not Single Player, hourOfCode != true
|
||||
.small Counts are not summed. I.e. a student or teacher only contributes to the count of one course.
|
||||
if view.teacherCourseDistribution
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Course
|
||||
th Teachers
|
||||
th Students
|
||||
th Avg students per teacher
|
||||
each count, courseIndex in view.teacherCourseDistribution
|
||||
h1#active-classes-table Active Classes
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
td= view.courses.models[courseIndex].get('name')
|
||||
td= count
|
||||
td= view.studentCourseDistribution[courseIndex] || 0
|
||||
td= Math.round((view.studentCourseDistribution[courseIndex] || 0) / count)
|
||||
else
|
||||
div Loading ...
|
||||
th Day
|
||||
for group in activeClassGroups
|
||||
th= group.replace('Active classes', '')
|
||||
each activeClass in activeClasses
|
||||
tr
|
||||
td= activeClass.day
|
||||
each val in activeClass.groups
|
||||
td= val
|
||||
|
||||
h3#school-counts-table School Counts
|
||||
.small Only including schools with #{view.minSchoolCount}+ counts
|
||||
if view.schoolCounts
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th
|
||||
th School Name
|
||||
th User Count
|
||||
each val, i in view.schoolCounts
|
||||
|
||||
.tab-pane#tab_revenue
|
||||
h3 Daily Recurring Revenue 90 days
|
||||
.recurring-daily-revenue-chart-90.line-chart-container
|
||||
|
||||
h3 Monthly Recurring Revenue 90 days
|
||||
.recurring-monthly-revenue-chart-90.line-chart-container
|
||||
|
||||
h3 Daily Recurring Revenue 365 days
|
||||
.recurring-daily-revenue-chart-365.line-chart-container
|
||||
|
||||
h3 Monthly Recurring Revenue 365 days
|
||||
.recurring-monthly-revenue-chart-365.line-chart-container
|
||||
|
||||
h1#recurring-revenue-table Recurring Revenue
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
td= i + 1
|
||||
td= val.schoolName
|
||||
td= val.count
|
||||
else
|
||||
div Loading ...
|
||||
th(style='min-width:85px;') Day
|
||||
for group in revenueGroups
|
||||
th= group.replace('DRR ', 'Daily ').replace('MRR ', 'Monthly ')
|
||||
each entry in revenue
|
||||
tr
|
||||
td= entry.day
|
||||
each val in entry.groups
|
||||
td $#{(val / 100).toFixed(2)}
|
||||
|
||||
h1#active-classes-table Active Classes
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Day
|
||||
for group in activeClassGroups
|
||||
th= group.replace('Active classes', '')
|
||||
each activeClass in activeClasses
|
||||
tr
|
||||
td= activeClass.day
|
||||
each val in activeClass.groups
|
||||
td= val
|
||||
.tab-pane#tab_classroom
|
||||
h3#classroom-daus-graph Classroom Daily Active Users 90 days
|
||||
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
|
||||
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
|
||||
.small Free student: not paid, not trial
|
||||
.classroom-daily-active-users-chart-90.line-chart-container
|
||||
|
||||
h1#recurring-revenue-table Recurring Revenue
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Day
|
||||
for group in revenueGroups
|
||||
th= group.replace('DRR ', 'Daily ')
|
||||
each entry in revenue
|
||||
tr
|
||||
td= entry.day
|
||||
each val in entry.groups
|
||||
td $#{(val / 100).toFixed(2)}
|
||||
h3#classroom-maus-graph Classroom Monthly Active Users 90 days
|
||||
.classroom-monthly-active-users-chart-90.line-chart-container
|
||||
|
||||
h1#active-users-table Active Users
|
||||
if activeUsers.length > 0
|
||||
- var eventNames = [];
|
||||
each count, event in activeUsers[0].events
|
||||
- eventNames.push(event)
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th(style='min-width:85px;') Day
|
||||
each eventName in eventNames
|
||||
th= eventName
|
||||
th DAU Campaign Total
|
||||
th DAU Classroom Total
|
||||
each activeUser in activeUsers
|
||||
tr
|
||||
td= activeUser.day
|
||||
- var dauCampaignTotal = 0
|
||||
- var dauClassroomTotal = 0
|
||||
each eventName in eventNames
|
||||
td= activeUser.events[eventName] || 0
|
||||
if eventName.indexOf('DAU campaign') >= 0
|
||||
- dauCampaignTotal += (activeUser.events[eventName] || 0);
|
||||
else if eventName.indexOf('DAU classroom') >= 0
|
||||
- dauClassroomTotal += (activeUser.events[eventName] || 0);
|
||||
td= dauCampaignTotal
|
||||
td= dauClassroomTotal
|
||||
h3#classroom-daus-graph Classroom Daily Active Users 365 days
|
||||
.classroom-daily-active-users-chart-365.line-chart-container
|
||||
|
||||
h1#enrollments-table Enrollments
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Day
|
||||
th Paid Enrollments Issued
|
||||
th Paid Enrollments Redeemed
|
||||
th Trial Enrollments Issued
|
||||
th Trial Enrollments Redeemed
|
||||
each day in enrollmentDays
|
||||
tr
|
||||
td= day
|
||||
if dayEnrollmentsMap[day]
|
||||
td= dayEnrollmentsMap[day].paidIssued || 0
|
||||
td= dayEnrollmentsMap[day].paidRedeemed || 0
|
||||
td= dayEnrollmentsMap[day].trialIssued || 0
|
||||
td= dayEnrollmentsMap[day].trialRedeemed || 0
|
||||
h3#classroom-maus-graph Classroom Monthly Active Users 365 days
|
||||
.classroom-monthly-active-users-chart-365.line-chart-container
|
||||
|
||||
h3#enrollments-graph Enrollments Issued and Redeemed 90 days
|
||||
.paid-courses-chart.line-chart-container
|
||||
|
||||
#furthest-course
|
||||
h3 Furthest Course in last #{view.furthestCourseDayRangeRecent} days
|
||||
.small Restricted to courses instances from last #{view.furthestCourseDayRangeRecent} days
|
||||
.small Teacher: owner of a course instance
|
||||
.small Student: member of a course instance (assigned to course)
|
||||
.small For course instances != Single Player, hourOfCode != true
|
||||
.small Counts are not summed. I.e. a student or teacher only contributes to the count of one course
|
||||
.small Paid student: user.coursePrepaidID set and prepaid.properties.trialRequestID NOT set
|
||||
.small Trial student: user.coursePrepaidID set and prepaid.properties.trialRequestID set
|
||||
.small Free student: not paid, not trial
|
||||
.small Paid teacher: at least one paid student in course instance
|
||||
.small Trial teacher: at least one trial student in course instance, and no paid students
|
||||
.small Free teacher: no paid students, no trial students
|
||||
.small Paid status takes precedent over furthest course, so teacher furthest course is furthest course of highest paid status student
|
||||
if view.courseDistributionsRecent
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Course
|
||||
th Paid Teachers
|
||||
th Trial Teachers
|
||||
th Free Teachers
|
||||
th Total Teachers
|
||||
th Paid Students
|
||||
th Trial Students
|
||||
th Free Students
|
||||
th Total Students
|
||||
each row in view.courseDistributionsRecent
|
||||
tr
|
||||
td= row.courseName
|
||||
td= row.totals['Paid Teachers'] || 0
|
||||
td= row.totals['Trial Teachers'] || 0
|
||||
td= row.totals['Free Teachers'] || 0
|
||||
td= row.totals['Total Teachers'] || 0
|
||||
td= row.totals['Paid Students'] || 0
|
||||
td= row.totals['Trial Students'] || 0
|
||||
td= row.totals['Free Students'] || 0
|
||||
td= row.totals['Total Students'] || 0
|
||||
else
|
||||
td 0
|
||||
td 0
|
||||
td 0
|
||||
td 0
|
||||
div Loading ...
|
||||
|
||||
h3 Furthest Course in last #{view.furthestCourseDayRange} days
|
||||
if view.courseDistributions
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Course
|
||||
th Paid Teachers
|
||||
th Trial Teachers
|
||||
th Free Teachers
|
||||
th Total Teachers
|
||||
th Paid Students
|
||||
th Trial Students
|
||||
th Free Students
|
||||
th Total Students
|
||||
each row in view.courseDistributions
|
||||
tr
|
||||
td= row.courseName
|
||||
td= row.totals['Paid Teachers'] || 0
|
||||
td= row.totals['Trial Teachers'] || 0
|
||||
td= row.totals['Free Teachers'] || 0
|
||||
td= row.totals['Total Teachers'] || 0
|
||||
td= row.totals['Paid Students'] || 0
|
||||
td= row.totals['Trial Students'] || 0
|
||||
td= row.totals['Free Students'] || 0
|
||||
td= row.totals['Total Students'] || 0
|
||||
else
|
||||
div Loading ...
|
||||
|
||||
#school-sales
|
||||
h3 School Sales
|
||||
if view.schoolSales
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Amount
|
||||
th(style='min-width:85px;') Created
|
||||
th PaymentID
|
||||
th PrepaidID
|
||||
th Description
|
||||
th Email
|
||||
th School
|
||||
each val, i in view.schoolSales
|
||||
tr
|
||||
td $#{Math.round(val.amount / 100, 2)}
|
||||
td= new Date(val.created).toISOString().substring(0, 10)
|
||||
td= val._id
|
||||
td= val.prepaidID
|
||||
td= val.description
|
||||
if val.user
|
||||
td= val.user.emailLower
|
||||
td= val.user.schoolName
|
||||
else
|
||||
td
|
||||
td
|
||||
else
|
||||
div Loading ...
|
||||
|
||||
#school-counts
|
||||
h3 School Counts
|
||||
.small Only including schools with #{view.minSchoolCount}+ counts
|
||||
if view.schoolCounts
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th
|
||||
th School Name
|
||||
th User Count
|
||||
each val, i in view.schoolCounts
|
||||
tr
|
||||
td= i + 1
|
||||
td= val.schoolName
|
||||
td= val.count
|
||||
else
|
||||
div Loading ...
|
||||
|
||||
h1#active-users-table Active Users
|
||||
if activeUsers.length > 0
|
||||
- var eventNames = [];
|
||||
each count, event in activeUsers[0].events
|
||||
if event.indexOf('classroom') >= 0
|
||||
- eventNames.push(event)
|
||||
- eventNames.sort(function (a, b) {return a.localeCompare(b);});
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th(style='min-width:85px;') Day
|
||||
each eventName in eventNames
|
||||
th= eventName
|
||||
th DAU Classroom Total
|
||||
each activeUser in activeUsers
|
||||
tr
|
||||
td= activeUser.day
|
||||
- var dauClassroomTotal = 0
|
||||
each eventName in eventNames
|
||||
if eventName.indexOf('DAU') >= 0
|
||||
- dauClassroomTotal += (activeUser.events[eventName] || 0);
|
||||
td= activeUser.events[eventName] || 0
|
||||
td= dauClassroomTotal
|
||||
|
||||
h1#enrollments-table Enrollments
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th Day
|
||||
th Paid Enrollments Issued
|
||||
th Paid Enrollments Redeemed
|
||||
th Trial Enrollments Issued
|
||||
th Trial Enrollments Redeemed
|
||||
each day in enrollmentDays
|
||||
tr
|
||||
td= day
|
||||
if dayEnrollmentsMap[day]
|
||||
td= dayEnrollmentsMap[day].paidIssued || 0
|
||||
td= dayEnrollmentsMap[day].paidRedeemed || 0
|
||||
td= dayEnrollmentsMap[day].trialIssued || 0
|
||||
td= dayEnrollmentsMap[day].trialRedeemed || 0
|
||||
else
|
||||
td 0
|
||||
td 0
|
||||
td 0
|
||||
td 0
|
||||
|
||||
.tab-pane#tab_campaign
|
||||
h3 Campaign Daily Active Users 90 days
|
||||
.small Paid user: had monthly or yearly sub on given day
|
||||
.small Free user: not paid
|
||||
.campaign-daily-active-users-chart-90.line-chart-container
|
||||
|
||||
h3 Campaign Monthly Active Users 90 days
|
||||
.campaign-monthly-active-users-chart-90.line-chart-container
|
||||
|
||||
h3 Campaign Daily Active Users 365 days
|
||||
.campaign-daily-active-users-chart-365.line-chart-container
|
||||
|
||||
h3 Campaign Monthly Active Users 365 days
|
||||
.campaign-monthly-active-users-chart-365.line-chart-container
|
||||
|
||||
h1#active-users-table Active Users
|
||||
if activeUsers.length > 0
|
||||
- var eventNames = [];
|
||||
each count, event in activeUsers[0].events
|
||||
if event.indexOf('campaign') >= 0
|
||||
- eventNames.push(event)
|
||||
- eventNames.sort(function (a, b) {return a.localeCompare(b);});
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th(style='min-width:85px;') Day
|
||||
each eventName in eventNames
|
||||
th= eventName
|
||||
th DAU Total
|
||||
each activeUser in activeUsers
|
||||
tr
|
||||
td= activeUser.day
|
||||
- var dauCampaignTotal = 0
|
||||
each eventName in eventNames
|
||||
if eventName.indexOf('DAU') >= 0
|
||||
- dauCampaignTotal += (activeUser.events[eventName] || 0);
|
||||
td= activeUser.events[eventName] || 0
|
||||
td= dauCampaignTotal
|
||||
|
||||
|
||||
.tab-pane#tab_campaign_vs_classroom
|
||||
h3#campaign-vs-classroom-paid-maus-recent-graph Campaign vs Classroom Paid Monthly Active Users 90 days
|
||||
.campaign-vs-classroom-monthly-active-users-recent-chart.line-chart-container
|
||||
|
||||
h3#campaign-vs-classroom-paid-maus-graph Campaign vs Classroom Paid Monthly Active Users 365 days
|
||||
.campaign-vs-classroom-monthly-active-users-chart.line-chart-container
|
||||
|
||||
h1#active-users-table Active Users
|
||||
if activeUsers.length > 0
|
||||
- var eventNames = [];
|
||||
each count, event in activeUsers[0].events
|
||||
- eventNames.push(event)
|
||||
- eventNames.sort(function (a, b) {
|
||||
- if (a.indexOf('campaign') == b.indexOf('campaign') || a.indexOf('classroom') == b.indexOf('classroom')) {
|
||||
- return a.localeCompare(b);
|
||||
- }
|
||||
- else if (a.indexOf('campaign') > b.indexOf('campaign')) {
|
||||
- return 1;
|
||||
- }
|
||||
- else {
|
||||
- return -1;
|
||||
- }
|
||||
- });
|
||||
table.table.table-striped.table-condensed
|
||||
tr
|
||||
th(style='min-width:85px;') Day
|
||||
each eventName in eventNames
|
||||
th= eventName
|
||||
th DAU Campaign Total
|
||||
th DAU Classroom Total
|
||||
each activeUser in activeUsers
|
||||
tr
|
||||
td= activeUser.day
|
||||
- var dauCampaignTotal = 0
|
||||
- var dauClassroomTotal = 0
|
||||
each eventName in eventNames
|
||||
td= activeUser.events[eventName] || 0
|
||||
if eventName.indexOf('DAU campaign') >= 0
|
||||
- dauCampaignTotal += (activeUser.events[eventName] || 0);
|
||||
else if eventName.indexOf('DAU classroom') >= 0
|
||||
- dauClassroomTotal += (activeUser.events[eventName] || 0);
|
||||
td= dauCampaignTotal
|
||||
td= dauClassroomTotal
|
||||
|
|
|
@ -20,11 +20,12 @@ block content
|
|||
th Location
|
||||
th Age / Level
|
||||
th Students
|
||||
th How Found / Notes
|
||||
th Role
|
||||
th Phone
|
||||
th Status
|
||||
tbody
|
||||
- var numReviewed = 0
|
||||
- var maxReviewedShown = 100
|
||||
- var maxReviewedShown = 1000
|
||||
each trialRequest in trialRequests
|
||||
if trialRequest.get('status') !== 'submitted'
|
||||
- numReviewed++
|
||||
|
@ -42,7 +43,8 @@ block content
|
|||
td= props.location || trialRequest.locationString()
|
||||
td= props.age || trialRequest.educationLevelString()
|
||||
td= props.numStudents
|
||||
td= props.heardAbout || props.notes
|
||||
td= props.role
|
||||
td= props.phoneNumber
|
||||
td.status-cell
|
||||
if trialRequest.get('status') === 'submitted'
|
||||
button.btn.btn-xs.btn-success.btn-approve(data-trial-request-id=trialRequest.id) Approve
|
||||
|
@ -51,3 +53,10 @@ block content
|
|||
span= trialRequest.get('prepaidCode')
|
||||
else
|
||||
span= trialRequest.get('status')
|
||||
if props.heardAbout || props.notes
|
||||
tr
|
||||
td(colspan=2)
|
||||
td(colspan=7)
|
||||
strong #{trialRequest.nameString()} notes:
|
||||
div= props.heardAbout || props.notes
|
||||
td
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
h3(data-i18n="signup.creating") Creating Account...
|
||||
|
||||
.auth-network-logins
|
||||
// GitHub login too buggy to survive
|
||||
// GitHub login complete, but the button does not fit in with the design yet. Hidden for now
|
||||
//div.network-login
|
||||
// btn.btn.btn-sm.github-login-button#github-login-button
|
||||
// img(src="/images/pages/modal/auth/github_icon.png")
|
||||
|
|
|
@ -12,7 +12,7 @@ block modal-body-content
|
|||
form.form-inline
|
||||
.form-group.commit-message
|
||||
input.form-control#commit-message(name="commitMessage", type="text")
|
||||
if !view.isPatch
|
||||
if !view.isPatch && !view.options.noNewMajorVersions
|
||||
.checkbox
|
||||
label
|
||||
input#major-version(name="version-is-major", type="checkbox")
|
||||
|
|
|
@ -3,6 +3,7 @@ Course = require 'models/Course'
|
|||
CourseInstance = require 'models/CourseInstance'
|
||||
require 'vendor/d3'
|
||||
d3Utils = require 'core/d3_utils'
|
||||
Payment = require 'models/Payment'
|
||||
RootView = require 'views/core/RootView'
|
||||
template = require 'templates/admin/analytics'
|
||||
utils = require 'core/utils'
|
||||
|
@ -10,7 +11,8 @@ utils = require 'core/utils'
|
|||
module.exports = class AnalyticsView extends RootView
|
||||
id: 'admin-analytics-view'
|
||||
template: template
|
||||
furthestCourseDayRange: 30
|
||||
furthestCourseDayRangeRecent: 60
|
||||
furthestCourseDayRange: 365
|
||||
lineColors: ['red', 'blue', 'green', 'purple', 'goldenrod', 'brown', 'darkcyan']
|
||||
minSchoolCount: 20
|
||||
|
||||
|
@ -76,6 +78,7 @@ module.exports = class AnalyticsView extends RootView
|
|||
|
||||
@updateAllKPIChartData()
|
||||
@updateActiveUsersChartData()
|
||||
@updateCampaignVsClassroomActiveUsersChartData()
|
||||
@render?()
|
||||
}, 0).load()
|
||||
|
||||
|
@ -83,18 +86,20 @@ module.exports = class AnalyticsView extends RootView
|
|||
url: '/db/analytics_perday/-/recurring_revenue'
|
||||
method: 'POST'
|
||||
success: (data) =>
|
||||
|
||||
# Organize data by day, then group
|
||||
groupMap = {}
|
||||
dayGroupCountMap = {}
|
||||
for dailyRevenue in data
|
||||
dayGroupCountMap[dailyRevenue.day] ?= {}
|
||||
dayGroupCountMap[dailyRevenue.day]['Daily Total'] = 0
|
||||
dayGroupCountMap[dailyRevenue.day]['DRR Total'] = 0
|
||||
for group, val of dailyRevenue.groups
|
||||
groupMap[group] = true
|
||||
dayGroupCountMap[dailyRevenue.day][group] = val
|
||||
dayGroupCountMap[dailyRevenue.day]['Daily Total'] += val
|
||||
dayGroupCountMap[dailyRevenue.day]['DRR Total'] += val
|
||||
@revenueGroups = Object.keys(groupMap)
|
||||
@revenueGroups.push 'Daily Total'
|
||||
@revenueGroups.push 'DRR Total'
|
||||
|
||||
# Build list of recurring revenue entries, where each entry is a day of individual group values
|
||||
@revenue = []
|
||||
for day of dayGroupCountMap
|
||||
|
@ -103,23 +108,35 @@ module.exports = class AnalyticsView extends RootView
|
|||
for group in @revenueGroups
|
||||
data.groups.push(dayGroupCountMap[day][group] ? 0)
|
||||
@revenue.push data
|
||||
|
||||
# Order present to past
|
||||
@revenue.sort (a, b) -> b.day.localeCompare(a.day)
|
||||
|
||||
return unless @revenue.length > 0
|
||||
|
||||
# Add monthly recurring revenue values
|
||||
@revenueGroups.push 'Monthly'
|
||||
monthlyValues = []
|
||||
for i in [@revenue.length-1..0]
|
||||
dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 1]
|
||||
monthlyValues.push(dailyTotal)
|
||||
monthlyValues.shift() while monthlyValues.length > 30
|
||||
if monthlyValues.length is 30
|
||||
@revenue[i].groups.push(_.reduce(monthlyValues, (s, num) -> s + num))
|
||||
|
||||
# For each daily group, add up monthly values walking forward through time, and add to revenue groups
|
||||
monthlyDailyGroupMap = {}
|
||||
dailyGroupIndexMap = {}
|
||||
for group, i in @revenueGroups
|
||||
monthlyDailyGroupMap[group.replace('DRR', 'MRR')] = group
|
||||
dailyGroupIndexMap[group] = i
|
||||
for monthlyGroup, dailyGroup of monthlyDailyGroupMap
|
||||
monthlyValues = []
|
||||
for i in [@revenue.length-1..0]
|
||||
dailyTotal = @revenue[i].groups[dailyGroupIndexMap[dailyGroup]]
|
||||
monthlyValues.push(dailyTotal)
|
||||
monthlyValues.shift() while monthlyValues.length > 30
|
||||
if monthlyValues.length is 30
|
||||
@revenue[i].groups.push(_.reduce(monthlyValues, (s, num) -> s + num))
|
||||
for monthlyGroup, dailyGroup of monthlyDailyGroupMap
|
||||
@revenueGroups.push monthlyGroup
|
||||
|
||||
@updateAllKPIChartData()
|
||||
@updateRevenueChartData()
|
||||
@render?()
|
||||
|
||||
}, 0).load()
|
||||
|
||||
@supermodel.addRequestResource({
|
||||
|
@ -131,7 +148,17 @@ module.exports = class AnalyticsView extends RootView
|
|||
return -1 if a.count > b.count
|
||||
return 0 if a.count is b.count
|
||||
1
|
||||
@render?()
|
||||
@renderSelectors?('#school-counts')
|
||||
}, 0).load()
|
||||
|
||||
@supermodel.addRequestResource({
|
||||
url: '/db/payment/-/school_sales'
|
||||
success: (@schoolSales) =>
|
||||
@schoolSales?.sort (a, b) ->
|
||||
return -1 if a.created > b.created
|
||||
return 0 if a.created is b.created
|
||||
1
|
||||
@renderSelectors?('#school-sales')
|
||||
}, 0).load()
|
||||
|
||||
@supermodel.addRequestResource({
|
||||
|
@ -212,35 +239,106 @@ module.exports = class AnalyticsView extends RootView
|
|||
options.error = (models, response, options) =>
|
||||
return if @destroyed
|
||||
console.error 'Failed to get recent course instances', response
|
||||
options.success = (models) =>
|
||||
@courseInstances = models ? []
|
||||
@onCourseInstancesSync()
|
||||
@render?()
|
||||
options.success = (data) =>
|
||||
@onCourseInstancesSync(data)
|
||||
@renderSelectors?('#furthest-course')
|
||||
@supermodel.addRequestResource(options, 0).load()
|
||||
|
||||
onCourseInstancesSync: ->
|
||||
return unless @courseInstances
|
||||
onCourseInstancesSync: (data) ->
|
||||
@courseDistributionsRecent = []
|
||||
@courseDistributions = []
|
||||
return unless data.courseInstances and data.students and data.prepaids
|
||||
|
||||
# Find highest course for teachers and students
|
||||
@teacherFurthestCourseMap = {}
|
||||
@studentFurthestCourseMap = {}
|
||||
for courseInstance in @courseInstances
|
||||
courseID = courseInstance.courseID
|
||||
teacherID = courseInstance.ownerID
|
||||
if not @teacherFurthestCourseMap[teacherID] or @teacherFurthestCourseMap[teacherID] < @courseOrderMap[courseID]
|
||||
@teacherFurthestCourseMap[teacherID] = @courseOrderMap[courseID]
|
||||
for studentID in courseInstance.members
|
||||
if not @studentFurthestCourseMap[studentID] or @studentFurthestCourseMap[studentID] < @courseOrderMap[courseID]
|
||||
@studentFurthestCourseMap[studentID] = @courseOrderMap[courseID]
|
||||
createCourseDistributions = (numDays) =>
|
||||
# Find student furthest course
|
||||
startDate = new Date()
|
||||
startDate.setUTCDate(startDate.getUTCDate() - numDays)
|
||||
teacherStudentsMap = {}
|
||||
studentFurthestCourseMap = {}
|
||||
studentPaidStatusMap = {}
|
||||
for courseInstance in data.courseInstances
|
||||
continue if utils.objectIdToDate(courseInstance._id) < startDate
|
||||
courseID = courseInstance.courseID
|
||||
teacherID = courseInstance.ownerID
|
||||
for studentID in courseInstance.members
|
||||
studentPaidStatusMap[studentID] = 'free'
|
||||
if not studentFurthestCourseMap[studentID] or studentFurthestCourseMap[studentID] < @courseOrderMap[courseID]
|
||||
studentFurthestCourseMap[studentID] = @courseOrderMap[courseID]
|
||||
teacherStudentsMap[teacherID] ?= []
|
||||
teacherStudentsMap[teacherID].push(studentID)
|
||||
|
||||
@teacherCourseDistribution = {}
|
||||
for teacherID, courseIndex of @teacherFurthestCourseMap
|
||||
@teacherCourseDistribution[courseIndex] ?= 0
|
||||
@teacherCourseDistribution[courseIndex]++
|
||||
@studentCourseDistribution = {}
|
||||
for studentID, courseIndex of @studentFurthestCourseMap
|
||||
@studentCourseDistribution[courseIndex] ?= 0
|
||||
@studentCourseDistribution[courseIndex]++
|
||||
# Find paid students
|
||||
prepaidUserMap = {}
|
||||
for user in data.students
|
||||
continue unless studentPaidStatusMap[user._id]
|
||||
if prepaidID = user.coursePrepaidID
|
||||
studentPaidStatusMap[user._id] = 'paid'
|
||||
prepaidUserMap[prepaidID] ?= []
|
||||
prepaidUserMap[prepaidID].push(user._id)
|
||||
|
||||
# Find trial students
|
||||
for prepaid in data.prepaids
|
||||
continue unless prepaidUserMap[prepaid._id]
|
||||
if prepaid.properties?.trialRequestID
|
||||
for userID in prepaidUserMap[prepaid._id]
|
||||
studentPaidStatusMap[userID] = 'trial'
|
||||
|
||||
# Find teacher furthest course and paid status based on their students
|
||||
# Paid teacher: at least one paid student
|
||||
# Trial teacher: at least one trial student in course instance, and no paid students
|
||||
# Free teacher: no paid students, no trial students
|
||||
# Teacher furthest course is furthest course of highest paid status student
|
||||
teacherFurthestCourseMap = {}
|
||||
teacherPaidStatusMap = {}
|
||||
for teacher, students of teacherStudentsMap
|
||||
for student in students
|
||||
if not teacherPaidStatusMap[teacher]
|
||||
teacherPaidStatusMap[teacher] = studentPaidStatusMap[student]
|
||||
teacherFurthestCourseMap[teacher] = studentFurthestCourseMap[student]
|
||||
else if teacherPaidStatusMap[teacher] is 'trial' and studentPaidStatusMap[student] is 'paid'
|
||||
teacherPaidStatusMap[teacher] = studentPaidStatusMap[student]
|
||||
teacherFurthestCourseMap[teacher] = studentFurthestCourseMap[student]
|
||||
else if teacherPaidStatusMap[teacher] is 'free' and studentPaidStatusMap[student] in ['paid', 'trial']
|
||||
teacherPaidStatusMap[teacher] = studentPaidStatusMap[student]
|
||||
teacherFurthestCourseMap[teacher] = studentFurthestCourseMap[student]
|
||||
else if teacherFurthestCourseMap[teacher] < studentFurthestCourseMap[student]
|
||||
teacherFurthestCourseMap[teacher] = studentFurthestCourseMap[student]
|
||||
|
||||
# Build table of student/teacher paid/trial/free totals
|
||||
updateCourseTotalsMap = (courseTotalsMap, furthestCourseMap, paidStatusMap, columnSuffix) =>
|
||||
for user, courseIndex of furthestCourseMap
|
||||
courseName = @courses.models[courseIndex].get('name')
|
||||
courseTotalsMap[courseName] ?= {}
|
||||
columnName = switch paidStatusMap[user]
|
||||
when 'paid' then 'Paid ' + columnSuffix
|
||||
when 'trial' then 'Trial ' + columnSuffix
|
||||
when 'free' then 'Free ' + columnSuffix
|
||||
courseTotalsMap[courseName][columnName] ?= 0
|
||||
courseTotalsMap[courseName][columnName]++
|
||||
courseTotalsMap[courseName]['Total ' + columnSuffix] ?= 0
|
||||
courseTotalsMap[courseName]['Total ' + columnSuffix]++
|
||||
courseTotalsMap['All Courses']['Total ' + columnSuffix] ?= 0
|
||||
courseTotalsMap['All Courses']['Total ' + columnSuffix]++
|
||||
courseTotalsMap['All Courses'][columnName] ?= 0
|
||||
courseTotalsMap['All Courses'][columnName]++
|
||||
courseTotalsMap = {'All Courses': {}}
|
||||
updateCourseTotalsMap(courseTotalsMap, teacherFurthestCourseMap, teacherPaidStatusMap, 'Teachers')
|
||||
updateCourseTotalsMap(courseTotalsMap, studentFurthestCourseMap, studentPaidStatusMap, 'Students')
|
||||
|
||||
courseDistributions = []
|
||||
for courseName, totals of courseTotalsMap
|
||||
courseDistributions.push({courseName: courseName, totals: totals})
|
||||
courseDistributions.sort (a, b) ->
|
||||
if a.courseName.indexOf('Introduction') >= 0 and b.courseName.indexOf('Introduction') < 0 then return -1
|
||||
else if b.courseName.indexOf('Introduction') >= 0 and a.courseName.indexOf('Introduction') < 0 then return 1
|
||||
else if a.courseName.indexOf('All Courses') >= 0 and b.courseName.indexOf('All Courses') < 0 then return 1
|
||||
else if b.courseName.indexOf('All Courses') >= 0 and a.courseName.indexOf('All Courses') < 0 then return -1
|
||||
a.courseName.localeCompare(b.courseName)
|
||||
|
||||
courseDistributions
|
||||
|
||||
@courseDistributionsRecent = createCourseDistributions(@furthestCourseDayRangeRecent)
|
||||
@courseDistributions = createCourseDistributions(@furthestCourseDayRange)
|
||||
|
||||
createLineChartPoints: (days, data) ->
|
||||
points = []
|
||||
|
@ -269,16 +367,26 @@ module.exports = class AnalyticsView extends RootView
|
|||
points
|
||||
|
||||
createLineCharts: ->
|
||||
d3Utils.createLineChart('.kpi-recent-chart', @kpiRecentChartLines)
|
||||
d3Utils.createLineChart('.kpi-chart', @kpiChartLines)
|
||||
d3Utils.createLineChart('.active-classes-chart', @activeClassesChartLines)
|
||||
d3Utils.createLineChart('.classroom-daily-active-users-chart', @classroomDailyActiveUsersChartLines)
|
||||
d3Utils.createLineChart('.classroom-monthly-active-users-chart', @classroomMonthlyActiveUsersChartLines)
|
||||
d3Utils.createLineChart('.campaign-daily-active-users-chart', @campaignDailyActiveUsersChartLines)
|
||||
d3Utils.createLineChart('.campaign-monthly-active-users-chart', @campaignMonthlyActiveUsersChartLines)
|
||||
d3Utils.createLineChart('.campaign-vs-classroom-monthly-active-users-chart.line-chart-container', @campaignVsClassroomMonthlyActiveUsersChartLines)
|
||||
d3Utils.createLineChart('.paid-courses-chart', @enrollmentsChartLines)
|
||||
d3Utils.createLineChart('.recurring-revenue-chart', @revenueChartLines)
|
||||
visibleWidth = $('.kpi-recent-chart').width()
|
||||
d3Utils.createLineChart('.kpi-recent-chart', @kpiRecentChartLines, visibleWidth)
|
||||
d3Utils.createLineChart('.kpi-chart', @kpiChartLines, visibleWidth)
|
||||
d3Utils.createLineChart('.active-classes-chart-90', @activeClassesChartLines90, visibleWidth)
|
||||
d3Utils.createLineChart('.active-classes-chart-365', @activeClassesChartLines365, visibleWidth)
|
||||
d3Utils.createLineChart('.classroom-daily-active-users-chart-90', @classroomDailyActiveUsersChartLines90, visibleWidth)
|
||||
d3Utils.createLineChart('.classroom-monthly-active-users-chart-90', @classroomMonthlyActiveUsersChartLines90, visibleWidth)
|
||||
d3Utils.createLineChart('.classroom-daily-active-users-chart-365', @classroomDailyActiveUsersChartLines365, visibleWidth)
|
||||
d3Utils.createLineChart('.classroom-monthly-active-users-chart-365', @classroomMonthlyActiveUsersChartLines365, visibleWidth)
|
||||
d3Utils.createLineChart('.campaign-daily-active-users-chart-90', @campaignDailyActiveUsersChartLines90, visibleWidth)
|
||||
d3Utils.createLineChart('.campaign-monthly-active-users-chart-90', @campaignMonthlyActiveUsersChartLines90, visibleWidth)
|
||||
d3Utils.createLineChart('.campaign-daily-active-users-chart-365', @campaignDailyActiveUsersChartLines365, visibleWidth)
|
||||
d3Utils.createLineChart('.campaign-monthly-active-users-chart-365', @campaignMonthlyActiveUsersChartLines365, visibleWidth)
|
||||
d3Utils.createLineChart('.campaign-vs-classroom-monthly-active-users-recent-chart.line-chart-container', @campaignVsClassroomMonthlyActiveUsersRecentChartLines, visibleWidth)
|
||||
d3Utils.createLineChart('.campaign-vs-classroom-monthly-active-users-chart.line-chart-container', @campaignVsClassroomMonthlyActiveUsersChartLines, visibleWidth)
|
||||
d3Utils.createLineChart('.paid-courses-chart', @enrollmentsChartLines, visibleWidth)
|
||||
d3Utils.createLineChart('.recurring-daily-revenue-chart-90', @revenueDailyChartLines90Days, visibleWidth)
|
||||
d3Utils.createLineChart('.recurring-monthly-revenue-chart-90', @revenueMonthlyChartLines90Days, visibleWidth)
|
||||
d3Utils.createLineChart('.recurring-daily-revenue-chart-365', @revenueDailyChartLines365Days, visibleWidth)
|
||||
d3Utils.createLineChart('.recurring-monthly-revenue-chart-365', @revenueMonthlyChartLines365Days, visibleWidth)
|
||||
|
||||
updateAllKPIChartData: ->
|
||||
@kpiRecentChartLines = []
|
||||
|
@ -373,9 +481,9 @@ module.exports = class AnalyticsView extends RootView
|
|||
showYScale: true
|
||||
|
||||
updateActiveClassesChartData: ->
|
||||
@activeClassesChartLines = []
|
||||
@activeClassesChartLines90 = []
|
||||
@activeClassesChartLines365 = []
|
||||
return unless @activeClasses?.length
|
||||
days = d3Utils.createContiguousDays(90)
|
||||
|
||||
groupDayMap = {}
|
||||
for entry in @activeClasses
|
||||
|
@ -384,36 +492,42 @@ module.exports = class AnalyticsView extends RootView
|
|||
groupDayMap[@activeClassGroups[i]][entry.day] ?= 0
|
||||
groupDayMap[@activeClassGroups[i]][entry.day] += count
|
||||
|
||||
lines = []
|
||||
colorIndex = 0
|
||||
totalMax = 0
|
||||
for group, entries of groupDayMap
|
||||
data = []
|
||||
for day, count of entries
|
||||
data.push
|
||||
day: day
|
||||
value: count
|
||||
data.reverse()
|
||||
points = @createLineChartPoints(days, data)
|
||||
@activeClassesChartLines.push
|
||||
points: points
|
||||
description: group.replace('Active classes ', '')
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: group is 'Total'
|
||||
totalMax = _.max(points, 'y').y if group is 'Total'
|
||||
line.max = totalMax for line in @activeClassesChartLines
|
||||
createActiveClassesChartLines = (lines, numDays) =>
|
||||
days = d3Utils.createContiguousDays(numDays)
|
||||
colorIndex = 0
|
||||
totalMax = 0
|
||||
for group, entries of groupDayMap
|
||||
data = []
|
||||
for day, count of entries
|
||||
data.push
|
||||
day: day
|
||||
value: count
|
||||
data.reverse()
|
||||
points = @createLineChartPoints(days, data)
|
||||
lines.push
|
||||
points: points
|
||||
description: group.replace('Active classes ', '')
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: group is 'Total'
|
||||
totalMax = _.max(points, 'y').y if group is 'Total'
|
||||
line.max = totalMax for line in lines
|
||||
|
||||
createActiveClassesChartLines(@activeClassesChartLines90, 90)
|
||||
createActiveClassesChartLines(@activeClassesChartLines365, 365)
|
||||
|
||||
updateActiveUsersChartData: ->
|
||||
# Create chart lines for the active user events returned by active_users in analytics_perday_handler
|
||||
@campaignDailyActiveUsersChartLines = []
|
||||
@campaignMonthlyActiveUsersChartLines = []
|
||||
@classroomDailyActiveUsersChartLines = []
|
||||
@classroomMonthlyActiveUsersChartLines = []
|
||||
@campaignVsClassroomMonthlyActiveUsersChartLines = []
|
||||
@campaignDailyActiveUsersChartLines90 = []
|
||||
@campaignMonthlyActiveUsersChartLines90 = []
|
||||
@campaignDailyActiveUsersChartLines365 = []
|
||||
@campaignMonthlyActiveUsersChartLines365 = []
|
||||
@classroomDailyActiveUsersChartLines90 = []
|
||||
@classroomMonthlyActiveUsersChartLines90 = []
|
||||
@classroomDailyActiveUsersChartLines365 = []
|
||||
@classroomMonthlyActiveUsersChartLines365 = []
|
||||
return unless @activeUsers?.length
|
||||
days = d3Utils.createContiguousDays(90)
|
||||
|
||||
# Separate day/value arrays by event
|
||||
eventDataMap = {}
|
||||
|
@ -425,76 +539,104 @@ module.exports = class AnalyticsView extends RootView
|
|||
day: entry.day
|
||||
value: count
|
||||
|
||||
# Build chart lines for each event
|
||||
eventLineMap =
|
||||
'DAU campaign': {max: 0, colorIndex: 0}
|
||||
'MAU campaign': {max: 0, colorIndex: 0}
|
||||
'DAU classroom': {max: 0, colorIndex: 0}
|
||||
'MAU classroom': {max: 0, colorIndex: 0}
|
||||
for event, data of eventDataMap
|
||||
data.reverse()
|
||||
points = @createLineChartPoints(days, data)
|
||||
max = _.max(points, 'y').y
|
||||
if event.indexOf('DAU campaign') >= 0
|
||||
chartLines = @campaignDailyActiveUsersChartLines
|
||||
eventLineMap['DAU campaign'].max = Math.max(eventLineMap['DAU campaign'].max, max)
|
||||
lineColor = @lineColors[eventLineMap['DAU campaign'].colorIndex++ % @lineColors.length]
|
||||
else if event.indexOf('MAU campaign') >= 0
|
||||
chartLines = @campaignMonthlyActiveUsersChartLines
|
||||
eventLineMap['MAU campaign'].max = Math.max(eventLineMap['MAU campaign'].max, max)
|
||||
lineColor = @lineColors[eventLineMap['MAU campaign'].colorIndex++ % @lineColors.length]
|
||||
else if event.indexOf('DAU classroom') >= 0
|
||||
chartLines = @classroomDailyActiveUsersChartLines
|
||||
eventLineMap['DAU classroom'].max = Math.max(eventLineMap['DAU classroom'].max, max)
|
||||
lineColor = @lineColors[eventLineMap['DAU classroom'].colorIndex++ % @lineColors.length]
|
||||
else if event.indexOf('MAU classroom') >= 0
|
||||
chartLines = @classroomMonthlyActiveUsersChartLines
|
||||
eventLineMap['MAU classroom'].max = Math.max(eventLineMap['MAU classroom'].max, max)
|
||||
lineColor = @lineColors[eventLineMap['MAU classroom'].colorIndex++ % @lineColors.length]
|
||||
chartLines.push
|
||||
points: points
|
||||
description: event
|
||||
lineColor: lineColor
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: false
|
||||
createActiveUsersChartLines = (lines, numDays, eventPrefix) =>
|
||||
days = d3Utils.createContiguousDays(numDays)
|
||||
colorIndex = 0
|
||||
lineMax = 0
|
||||
showYScale = true
|
||||
for event, data of eventDataMap
|
||||
continue unless event.indexOf(eventPrefix) >= 0
|
||||
points = @createLineChartPoints(days, _.cloneDeep(data).reverse())
|
||||
lineMax = Math.max(_.max(points, 'y').y, lineMax)
|
||||
lines.push
|
||||
points: points
|
||||
description: event
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: showYScale
|
||||
showYScale = false
|
||||
line.max = lineMax for line in lines
|
||||
|
||||
# Update line Y scales and maxes
|
||||
showYScaleSet = false
|
||||
for line in @campaignDailyActiveUsersChartLines
|
||||
line.max = eventLineMap['DAU campaign'].max
|
||||
unless showYScaleSet
|
||||
line.showYScale = true
|
||||
showYScaleSet = true
|
||||
showYScaleSet = false
|
||||
for line in @campaignMonthlyActiveUsersChartLines
|
||||
line.max = eventLineMap['MAU campaign'].max
|
||||
unless showYScaleSet
|
||||
line.showYScale = true
|
||||
showYScaleSet = true
|
||||
if line.description is 'MAU campaign paid'
|
||||
@campaignVsClassroomMonthlyActiveUsersChartLines.push(_.cloneDeep(line))
|
||||
showYScaleSet = false
|
||||
for line in @classroomDailyActiveUsersChartLines
|
||||
line.max = eventLineMap['DAU classroom'].max
|
||||
unless showYScaleSet
|
||||
line.showYScale = true
|
||||
showYScaleSet = true
|
||||
showYScaleSet = false
|
||||
for line in @classroomMonthlyActiveUsersChartLines
|
||||
line.max = eventLineMap['MAU classroom'].max
|
||||
unless showYScaleSet
|
||||
line.showYScale = true
|
||||
showYScaleSet = true
|
||||
if line.description is 'MAU classroom paid'
|
||||
@campaignVsClassroomMonthlyActiveUsersChartLines.push(_.cloneDeep(line))
|
||||
createActiveUsersChartLines(@campaignDailyActiveUsersChartLines90, 90, 'DAU campaign')
|
||||
createActiveUsersChartLines(@campaignMonthlyActiveUsersChartLines90, 90, 'MAU campaign')
|
||||
createActiveUsersChartLines(@classroomDailyActiveUsersChartLines90, 90, 'DAU classroom')
|
||||
createActiveUsersChartLines(@classroomMonthlyActiveUsersChartLines90, 90, 'MAU classroom')
|
||||
createActiveUsersChartLines(@campaignDailyActiveUsersChartLines365, 365, 'DAU campaign')
|
||||
createActiveUsersChartLines(@campaignMonthlyActiveUsersChartLines365, 365, 'MAU campaign')
|
||||
createActiveUsersChartLines(@classroomDailyActiveUsersChartLines365, 365, 'DAU classroom')
|
||||
createActiveUsersChartLines(@classroomMonthlyActiveUsersChartLines365, 365, 'MAU classroom')
|
||||
|
||||
updateCampaignVsClassroomActiveUsersChartData: ->
|
||||
@campaignVsClassroomMonthlyActiveUsersRecentChartLines = []
|
||||
@campaignVsClassroomMonthlyActiveUsersChartLines = []
|
||||
return unless @activeUsers?.length
|
||||
|
||||
# Separate day/value arrays by event
|
||||
eventDataMap = {}
|
||||
for entry in @activeUsers
|
||||
day = entry.day
|
||||
for event, count of entry.events
|
||||
eventDataMap[event] ?= []
|
||||
eventDataMap[event].push
|
||||
day: entry.day
|
||||
value: count
|
||||
|
||||
days = d3Utils.createContiguousDays(90)
|
||||
colorIndex = 0
|
||||
max = 0
|
||||
for line in @campaignVsClassroomMonthlyActiveUsersChartLines
|
||||
max = Math.max(_.max(line.points, 'y').y, max)
|
||||
for event, data of eventDataMap
|
||||
if event is 'MAU campaign paid'
|
||||
points = @createLineChartPoints(days, _.cloneDeep(data).reverse())
|
||||
max = Math.max(max, _.max(points, 'y').y)
|
||||
@campaignVsClassroomMonthlyActiveUsersRecentChartLines.push
|
||||
points: points
|
||||
description: event
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: true
|
||||
else if event is 'MAU classroom paid'
|
||||
points = @createLineChartPoints(days, _.cloneDeep(data).reverse())
|
||||
max = Math.max(max, _.max(points, 'y').y)
|
||||
@campaignVsClassroomMonthlyActiveUsersRecentChartLines.push
|
||||
points: points
|
||||
description: event
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: false
|
||||
|
||||
for line in @campaignVsClassroomMonthlyActiveUsersRecentChartLines
|
||||
line.max = max
|
||||
|
||||
days = d3Utils.createContiguousDays(365)
|
||||
colorIndex = 0
|
||||
max = 0
|
||||
for event, data of eventDataMap
|
||||
if event is 'MAU campaign paid'
|
||||
points = @createLineChartPoints(days, _.cloneDeep(data).reverse())
|
||||
max = Math.max(max, _.max(points, 'y').y)
|
||||
@campaignVsClassroomMonthlyActiveUsersChartLines.push
|
||||
points: points
|
||||
description: event
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: true
|
||||
else if event is 'MAU classroom paid'
|
||||
points = @createLineChartPoints(days, _.cloneDeep(data).reverse())
|
||||
max = Math.max(max, _.max(points, 'y').y)
|
||||
@campaignVsClassroomMonthlyActiveUsersChartLines.push
|
||||
points: points
|
||||
description: event
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
showYScale: false
|
||||
|
||||
for line in @campaignVsClassroomMonthlyActiveUsersChartLines
|
||||
line.max = max
|
||||
line.showYScale = true if line.description is 'MAU campaign paid'
|
||||
|
||||
updateEnrollmentsChartData: ->
|
||||
@enrollmentsChartLines = []
|
||||
|
@ -581,9 +723,11 @@ module.exports = class AnalyticsView extends RootView
|
|||
line.max = dailyMax for line in @enrollmentsChartLines
|
||||
|
||||
updateRevenueChartData: ->
|
||||
@revenueChartLines = []
|
||||
@revenueDailyChartLines90Days = []
|
||||
@revenueMonthlyChartLines90Days = []
|
||||
@revenueDailyChartLines365Days = []
|
||||
@revenueMonthlyChartLines365Days = []
|
||||
return unless @revenue?.length
|
||||
days = d3Utils.createContiguousDays(90)
|
||||
|
||||
groupDayMap = {}
|
||||
for entry in @revenue
|
||||
|
@ -592,24 +736,31 @@ module.exports = class AnalyticsView extends RootView
|
|||
groupDayMap[@revenueGroups[i]][entry.day] ?= 0
|
||||
groupDayMap[@revenueGroups[i]][entry.day] += count
|
||||
|
||||
colorIndex = 0
|
||||
dailyMax = 0
|
||||
for group, entries of groupDayMap
|
||||
data = []
|
||||
for day, count of entries
|
||||
data.push
|
||||
day: day
|
||||
value: count / 100
|
||||
data.reverse()
|
||||
points = @createLineChartPoints(days, data)
|
||||
@revenueChartLines.push
|
||||
points: points
|
||||
description: group.replace('DRR ', 'Daily ')
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
max: _.max(points, 'y').y
|
||||
showYScale: group in ['Daily Total', 'Monthly']
|
||||
dailyMax = _.max(points, 'y').y if group is 'Daily Total'
|
||||
for line in @revenueChartLines when line.description isnt 'Monthly'
|
||||
line.max = dailyMax
|
||||
addRevenueChartLine = (days, eventPrefix, lines) =>
|
||||
colorIndex = 0
|
||||
dailyMax = 0
|
||||
for group, entries of groupDayMap
|
||||
continue unless group.indexOf(eventPrefix) >= 0
|
||||
data = []
|
||||
for day, count of entries
|
||||
data.push
|
||||
day: day
|
||||
value: count / 100
|
||||
data.reverse()
|
||||
points = @createLineChartPoints(days, data)
|
||||
lines.push
|
||||
points: points
|
||||
description: group.replace(eventPrefix + ' ', 'Daily ')
|
||||
lineColor: @lineColors[colorIndex++ % @lineColors.length]
|
||||
strokeWidth: 1
|
||||
min: 0
|
||||
max: _.max(points, 'y').y
|
||||
showYScale: group is eventPrefix + ' Total'
|
||||
dailyMax = _.max(points, 'y').y if group is eventPrefix + ' Total'
|
||||
for line in lines
|
||||
line.max = dailyMax
|
||||
|
||||
addRevenueChartLine(d3Utils.createContiguousDays(90), 'DRR', @revenueDailyChartLines90Days)
|
||||
addRevenueChartLine(d3Utils.createContiguousDays(90), 'MRR', @revenueMonthlyChartLines90Days)
|
||||
addRevenueChartLine(d3Utils.createContiguousDays(365), 'DRR', @revenueDailyChartLines365Days)
|
||||
addRevenueChartLine(d3Utils.createContiguousDays(365), 'MRR', @revenueMonthlyChartLines365Days)
|
||||
|
|
|
@ -30,7 +30,7 @@ module.exports = class TrialRequestsView extends RootView
|
|||
-1
|
||||
else
|
||||
1
|
||||
@trialRequests = new CocoCollection([], { url: '/db/trial.request?conditions[sort]=-created&conditions[limit]=500', model: TrialRequest, comparator: sortRequests })
|
||||
@trialRequests = new CocoCollection([], { url: '/db/trial.request?conditions[sort]=-created&conditions[limit]=1000', model: TrialRequest, comparator: sortRequests })
|
||||
@supermodel.loadCollection(@trialRequests, 'trial-requests', {cache: false})
|
||||
|
||||
getRenderData: ->
|
||||
|
|
|
@ -91,8 +91,9 @@ module.exports = class CocoView extends Backbone.View
|
|||
didReappear: ->
|
||||
# the router brings back this view from the cache
|
||||
@delegateEvents()
|
||||
wasHidden = @hidden
|
||||
@hidden = false
|
||||
@listenToShortcuts()
|
||||
@listenToShortcuts() if wasHidden
|
||||
view.didReappear() for id, view of @subviews
|
||||
|
||||
# View Rendering
|
||||
|
|
|
@ -19,11 +19,12 @@ module.exports = class PatchesView extends CocoView
|
|||
|
||||
initPatches: ->
|
||||
@startedLoading = false
|
||||
@patches = new PatchesCollection([], {}, @model, @status)
|
||||
@patches = @model.fetchPatchesWithStatus()
|
||||
|
||||
load: ->
|
||||
@initPatches()
|
||||
@patches = @supermodel.loadCollection(@patches, 'patches', {cache: false}).model
|
||||
@patches = @model.fetchPatchesWithStatus(@status, {cache: false})
|
||||
@supermodel.trackCollection(@patches)
|
||||
@listenTo @patches, 'sync', @onPatchesLoaded
|
||||
|
||||
onPatchesLoaded: ->
|
||||
|
@ -40,6 +41,7 @@ module.exports = class PatchesView extends CocoView
|
|||
|
||||
afterRender: ->
|
||||
@$el.find(".#{@status}").addClass 'active'
|
||||
super()
|
||||
|
||||
onStatusButtonsChanged: (e) ->
|
||||
@status = $(e.target).val()
|
||||
|
|
|
@ -18,7 +18,7 @@ module.exports = class ArticleEditView extends RootView
|
|||
|
||||
constructor: (options, @articleID) ->
|
||||
super options
|
||||
@article = new Article(_id: @articleID)
|
||||
@article = new Article({_id: @articleID})
|
||||
@article.saveBackups = true
|
||||
@supermodel.loadModel @article
|
||||
@pushChangesToPreview = _.throttle(@pushChangesToPreview, 500)
|
||||
|
@ -73,7 +73,7 @@ module.exports = class ArticleEditView extends RootView
|
|||
return false
|
||||
|
||||
openSaveModal: ->
|
||||
modal = new SaveVersionModal({model: @article})
|
||||
modal = new SaveVersionModal({model: @article, noNewMajorVersions: true})
|
||||
@openModalView(modal)
|
||||
@listenToOnce modal, 'save-new-version', @saveNewArticle
|
||||
@listenToOnce modal, 'hidden', -> @stopListening(modal)
|
||||
|
@ -83,9 +83,8 @@ module.exports = class ArticleEditView extends RootView
|
|||
for key, value of @treema.data
|
||||
@article.set(key, value)
|
||||
|
||||
newArticle = if e.major then @article.cloneNewMajorVersion() else @article.cloneNewMinorVersion()
|
||||
newArticle.set('commitMessage', e.commitMessage)
|
||||
res = newArticle.save(null, {type: 'POST'}) # Override PUT so we can trigger postNewVersion logic
|
||||
@article.set('commitMessage', e.commitMessage)
|
||||
res = @article.saveNewMinorVersion()
|
||||
return unless res
|
||||
modal = @$el.find('#save-version-modal')
|
||||
@enableModalInProgress(modal)
|
||||
|
@ -96,7 +95,7 @@ module.exports = class ArticleEditView extends RootView
|
|||
res.success =>
|
||||
@article.clearBackup()
|
||||
modal.modal('hide')
|
||||
url = "/editor/article/#{newArticle.get('slug') or newArticle.id}"
|
||||
url = "/editor/article/#{@article.get('slug') or @article.id}"
|
||||
document.location.href = url
|
||||
|
||||
showVersionHistory: (e) ->
|
||||
|
|
|
@ -41,7 +41,7 @@ module.exports = class LevelGuideView extends CocoView
|
|||
@docs = $.extend(true, [], @docs)
|
||||
@docs = [@docs[0]] if @firstOnly and @docs[0]
|
||||
@addPicoCTFProblem() if window.serverConfig.picoCTF
|
||||
doc.html = marked(utils.filterMarkdownCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs
|
||||
doc.html = marked(utils.filterMarkdownCodeLanguages(utils.i18n(doc, 'body'), options.session.get('codeLanguage'))) for doc in @docs
|
||||
doc.slug = _.string.slugify(doc.name) for doc in @docs
|
||||
doc.name = (utils.i18n doc, 'name') for doc in @docs
|
||||
|
||||
|
@ -82,7 +82,7 @@ module.exports = class LevelGuideView extends CocoView
|
|||
oldEditor.destroy() for oldEditor in @aceEditors ? []
|
||||
@aceEditors = []
|
||||
aceEditors = @aceEditors
|
||||
codeLanguage = me.get('aceConfig')?.language or 'python'
|
||||
codeLanguage = @options.session.get('codeLanguage') or me.get('aceConfig')?.language or 'python'
|
||||
@$el.find('pre').each ->
|
||||
aceEditor = utils.initializeACE @, codeLanguage
|
||||
aceEditors.push aceEditor
|
||||
|
|
|
@ -56,6 +56,8 @@
|
|||
"async": "0.2.x",
|
||||
"aws-sdk": "~2.0.0",
|
||||
"bayesian-battle": "0.0.7",
|
||||
"bluebird": "^3.2.1",
|
||||
"co-express": "^1.2.1",
|
||||
"coffee-script": "1.9.x",
|
||||
"connect": "2.7.x",
|
||||
"express": "~3.0.6",
|
||||
|
|
|
@ -134,7 +134,6 @@ function getActiveClassCounts(startDay) {
|
|||
}
|
||||
// printjson(userPlayedMap);
|
||||
|
||||
|
||||
log("Calculate number of active members per classroom per day per event type..");
|
||||
var classDayTypeMap = {};
|
||||
for (var classroom in classroomUsersMap) {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
/* global ISODate */
|
||||
/* global Mongo */
|
||||
/* global ObjectId */
|
||||
/* global db */
|
||||
/* global printjson */
|
||||
|
@ -11,21 +13,30 @@ try {
|
|||
var scriptStartTime = new Date();
|
||||
var analyticsStringCache = {};
|
||||
|
||||
var numDays = 30;
|
||||
var numDays = 50;
|
||||
var daysInMonth = 30;
|
||||
|
||||
var startDay = new Date();
|
||||
var today = startDay.toISOString().substr(0, 10);
|
||||
startDay.setUTCDate(startDay.getUTCDate() - numDays);
|
||||
startDay = startDay.toISOString().substr(0, 10);
|
||||
var endDay = new Date();
|
||||
endDay = endDay.toISOString().substr(0, 10);
|
||||
|
||||
// startDay = '2015-03-01';
|
||||
// endDay = '2015-06-01';
|
||||
|
||||
var activeUserEvents = ['Finished Signup', 'Started Level'];
|
||||
|
||||
// Analytics logging failure resulted in lost data for 2/2/16 through 2/9/16.
|
||||
var missingDataDays = ['2016-02-02', '2016-02-03', '2016-02-04', '2016-02-05', '2016-02-06', '2016-02-07', '2016-02-08', '2016-02-09'];
|
||||
|
||||
log("Today is " + today);
|
||||
log("Start day is " + startDay);
|
||||
log("End day is " + endDay);
|
||||
|
||||
log("Getting active user counts..");
|
||||
var activeUserCounts = getActiveUserCounts(startDay, activeUserEvents);
|
||||
var activeUserCounts = getActiveUserCounts(startDay, endDay, activeUserEvents);
|
||||
// printjson(activeUserCounts);
|
||||
log("Inserting active user counts..");
|
||||
for (var day in activeUserCounts) {
|
||||
|
@ -44,7 +55,7 @@ finally {
|
|||
log("Script runtime: " + (new Date() - scriptStartTime));
|
||||
}
|
||||
|
||||
function getActiveUserCounts(startDay, activeUserEvents) {
|
||||
function getActiveUserCounts(startDay, endDay, activeUserEvents) {
|
||||
// Counts active users per day
|
||||
if (!startDay) return {};
|
||||
|
||||
|
@ -52,8 +63,10 @@ function getActiveUserCounts(startDay, activeUserEvents) {
|
|||
|
||||
log("Finding active user log events..");
|
||||
var startObj = objectIdWithTimestamp(ISODate(startDay + "T00:00:00.000Z"));
|
||||
var endObj = objectIdWithTimestamp(ISODate(endDay + "T00:00:00.000Z"));
|
||||
var queryParams = {$and: [
|
||||
{_id: {$gte: startObj}},
|
||||
{_id: {$lt: endObj}},
|
||||
{'event': {$in: activeUserEvents}}
|
||||
]};
|
||||
cursor = logDB['log'].find(queryParams);
|
||||
|
@ -70,8 +83,11 @@ function getActiveUserCounts(startDay, activeUserEvents) {
|
|||
if (!dayUserMap[day]) dayUserMap[day] = {};
|
||||
dayUserMap[day][user] = true;
|
||||
userIDs.push(ObjectId(user));
|
||||
// if (userIDs.length % 100000 === 0) {
|
||||
// log('Users so far: ' + userIDs.length);
|
||||
// }
|
||||
}
|
||||
print('User count: ', userIDs.length);
|
||||
log('User count: ' + userIDs.length);
|
||||
|
||||
log("Finding classroom members..");
|
||||
var classroomUserObjectIds = [];
|
||||
|
@ -155,47 +171,22 @@ function getActiveUserCounts(startDay, activeUserEvents) {
|
|||
}
|
||||
}
|
||||
|
||||
var updateActiveUserCounts = function (activeUsersCounts, day, user, isMAU) {
|
||||
var event = userEventMap[user];
|
||||
if (!event) {
|
||||
if (dayUserPaidMap[day] && dayUserPaidMap[day][user]) {
|
||||
event = 'DAU campaign paid';
|
||||
}
|
||||
else {
|
||||
event = 'DAU campaign free';
|
||||
}
|
||||
}
|
||||
if (isMAU) event = event.replace('DAU', 'MAU');
|
||||
if (!activeUsersCounts[day]) activeUsersCounts[day] = {};
|
||||
if (!activeUsersCounts[day][event]) activeUsersCounts[day][event] = 0;
|
||||
activeUsersCounts[day][event]++;
|
||||
};
|
||||
|
||||
log("Calculating DAUs..");
|
||||
var activeUsersCounts = {};
|
||||
var monthlyActives = [];
|
||||
var dailyEventNames = {};
|
||||
for (day in dayUserMap) {
|
||||
for (var user in dayUserMap[day]) {
|
||||
updateActiveUserCounts(activeUsersCounts, day, user, false);
|
||||
var event = userEventMap[user] || (dayUserPaidMap[day] && dayUserPaidMap[day][user] ? 'DAU campaign paid' : 'DAU campaign free');
|
||||
dailyEventNames[event] = true;
|
||||
if (!activeUsersCounts[day]) activeUsersCounts[day] = {};
|
||||
if (!activeUsersCounts[day][event]) activeUsersCounts[day][event] = 0;
|
||||
activeUsersCounts[day][event]++;
|
||||
}
|
||||
monthlyActives.push({day: day, users: dayUserMap[day]});
|
||||
}
|
||||
// printjson(dailyEventNames)
|
||||
|
||||
// Calculate monthly actives for each day, starting when we have enough data
|
||||
log("Calculating MAUs..");
|
||||
monthlyActives.sort(function (a, b) {return a.day.localeCompare(b.day);});
|
||||
for (var i = daysInMonth - 1; i < monthlyActives.length; i++) {
|
||||
for (var j = i - daysInMonth + 1; j <= i; j++) {
|
||||
day = monthlyActives[i].day;
|
||||
for (var user in monthlyActives[j].users) {
|
||||
updateActiveUserCounts(activeUsersCounts, day, user, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: analytics logging failure resulted in lost data for 2/2/16 through 2/9/16. Approximating those missing days here.
|
||||
// Correction for a given event: previous week's value + previous week's diff from start to end if > 0
|
||||
var missingDataDays = ['2016-02-02', '2016-02-03', '2016-02-04', '2016-02-05', '2016-02-06', '2016-02-07', '2016-02-08', '2016-02-09'];
|
||||
for (var day in activeUsersCounts) {
|
||||
if (missingDataDays.indexOf(day) >= 0) {
|
||||
var prevDate = new Date(day + "T00:00:00.000Z");
|
||||
|
@ -203,21 +194,48 @@ function getActiveUserCounts(startDay, activeUserEvents) {
|
|||
var prevStartDate = new Date(prevDate);
|
||||
prevStartDate.setUTCDate(prevStartDate.getUTCDate() - 7);
|
||||
var prevStartDay = prevStartDate.toISOString().substring(0, 10);
|
||||
if (activeUsersCounts[prevStartDay]) {
|
||||
var prevDay = prevDate.toISOString().substring(0, 10);
|
||||
for (var event in activeUsersCounts[day]) {
|
||||
var prevDay = prevDate.toISOString().substring(0, 10);
|
||||
for (var event in dailyEventNames) {
|
||||
if (activeUsersCounts[prevDay] && activeUsersCounts[prevStartDay]) {
|
||||
var prevValue = activeUsersCounts[prevDay][event];
|
||||
var prevStartValue = activeUsersCounts[prevStartDay][event];
|
||||
var prevWeekDiff = Math.max(prevValue - prevStartValue, 0);
|
||||
var betterValue = prevValue + prevWeekDiff;
|
||||
// var currentValue = activeUsersCounts[day][event] || 0;
|
||||
activeUsersCounts[day][event] = betterValue;
|
||||
// var currentValue = activeUsersCounts[day][event];
|
||||
// print(prevStartDay, '\t', prevDay, '\t', prevValue, '-', prevStartValue, '\t', prevWeekDiff, '\t', day, '\t', event, '\t', prevValue, '\t', currentValue, '\t', betterValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate monthly actives for each day, starting when we have enough data
|
||||
log("Calculating MAUs..");
|
||||
var days = [];
|
||||
for (var day in activeUsersCounts) {
|
||||
days.push(day);
|
||||
}
|
||||
days.sort(function (a, b) {return a.localeCompare(b);});
|
||||
// print('Num days', days.length);
|
||||
|
||||
// For each day, starting when we have daysInMonth days of prior data
|
||||
for (var i = daysInMonth - 1; i < days.length; i++) {
|
||||
// For the last daysInMonth days up to the current day
|
||||
var targetMonthlyDay = days[i];
|
||||
for (var j = i - daysInMonth + 1; j <= i; j++) {
|
||||
var targetDailyDay = days[j];
|
||||
// For each daily event
|
||||
for (var event in dailyEventNames) {
|
||||
// print(day, event, activeUsersCounts[day][event]);
|
||||
var mauEvent = event.replace('DAU', 'MAU');
|
||||
if (!activeUsersCounts[targetMonthlyDay][mauEvent]) activeUsersCounts[targetMonthlyDay][mauEvent] = 0
|
||||
if (activeUsersCounts[targetDailyDay][event]) {
|
||||
activeUsersCounts[targetMonthlyDay][mauEvent] += activeUsersCounts[targetDailyDay][event];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return activeUsersCounts;
|
||||
}
|
||||
|
||||
|
@ -285,11 +303,14 @@ function insertEventCount(event, day, count) {
|
|||
|
||||
if (doc && doc.c !== count) {
|
||||
// Update existing count, assume new one is more accurate
|
||||
// Don't overwrite missing data days
|
||||
// log("Updating count in db for " + day + " " + event + " " + doc.c + " => " + count);
|
||||
var results = db['analytics.perdays'].update(queryParams, {$set: {c: count}});
|
||||
if (results.nMatched !== 1 && results.nModified !== 1) {
|
||||
log("ERROR: update event count failed");
|
||||
printjson(results);
|
||||
if (missingDataDays.indexOf(day) < 0 || doc.c < count) {
|
||||
var results = db['analytics.perdays'].update(queryParams, {$set: {c: count}});
|
||||
if (results.nMatched !== 1 && results.nModified !== 1) {
|
||||
log("ERROR: update event count failed");
|
||||
printjson(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
|
@ -60,6 +60,7 @@ function getRecurringRevenueCounts(startDay) {
|
|||
}
|
||||
|
||||
if (doc.service === 'ios' || doc.service === 'bitcoin') continue;
|
||||
if (!doc.amount || doc.amount <= 0) continue;
|
||||
|
||||
if (doc.prepaidID) {
|
||||
if (prepaidDayAmountMap[doc.prepaidID.valueOf()]) {
|
||||
|
@ -72,28 +73,28 @@ function getRecurringRevenueCounts(startDay) {
|
|||
else if (doc.productID && doc.productID.indexOf('gems_') === 0) {
|
||||
if (!dailyRevenueCounts['DRR gems']) dailyRevenueCounts['DRR gems'] = {};
|
||||
if (!dailyRevenueCounts['DRR gems'][day]) dailyRevenueCounts['DRR gems'][day] = 0;
|
||||
dailyRevenueCounts['DRR gems'][day] += doc.amount
|
||||
dailyRevenueCounts['DRR gems'][day] += doc.amount;
|
||||
}
|
||||
else if (doc.productID === 'custom' || doc.service === 'external' || doc.service === 'invoice') {
|
||||
if (!dailyRevenueCounts['DRR school sales']) dailyRevenueCounts['DRR school sales'] = {};
|
||||
if (!dailyRevenueCounts['DRR school sales'][day]) dailyRevenueCounts['DRR school sales'][day] = 0;
|
||||
dailyRevenueCounts['DRR school sales'][day] += doc.amount
|
||||
dailyRevenueCounts['DRR school sales'][day] += doc.amount;
|
||||
}
|
||||
else if (doc.service === 'stripe' && doc.gems === 42000) {
|
||||
if (!dailyRevenueCounts['DRR yearly subs']) dailyRevenueCounts['DRR yearly subs'] = {};
|
||||
if (!dailyRevenueCounts['DRR yearly subs'][day]) dailyRevenueCounts['DRR yearly subs'][day] = 0;
|
||||
dailyRevenueCounts['DRR yearly subs'][day] += doc.amount
|
||||
dailyRevenueCounts['DRR yearly subs'][day] += doc.amount;
|
||||
}
|
||||
else if (doc.service === 'stripe') {
|
||||
// Catches prepaids, and assumes all are type terminal_subscription
|
||||
if (!dailyRevenueCounts['DRR monthly subs']) dailyRevenueCounts['DRR monthly subs'] = {};
|
||||
if (!dailyRevenueCounts['DRR monthly subs'][day]) dailyRevenueCounts['DRR monthly subs'][day] = 0;
|
||||
dailyRevenueCounts['DRR monthly subs'][day] += doc.amount
|
||||
dailyRevenueCounts['DRR monthly subs'][day] += doc.amount;
|
||||
}
|
||||
else if (doc.service === 'paypal') {
|
||||
if (!dailyRevenueCounts['DRR monthly subs']) dailyRevenueCounts['DRR monthly subs'] = {};
|
||||
if (!dailyRevenueCounts['DRR monthly subs'][day]) dailyRevenueCounts['DRR monthly subs'][day] = 0;
|
||||
dailyRevenueCounts['DRR monthly subs'][day] += doc.amount
|
||||
dailyRevenueCounts['DRR monthly subs'][day] += doc.amount;
|
||||
}
|
||||
// else {
|
||||
// // printjson(doc);
|
||||
|
|
|
@ -2,33 +2,33 @@
|
|||
// Usage:
|
||||
// mongo <address>:<port>/<database> <script file> -u <username> -p <password>
|
||||
|
||||
// Set classroomID and levelSlug first before running!
|
||||
print('Loading levels...');
|
||||
var levels = [db.levels.findOne({slug: 'wakka-maul'}), db.levels.findOne({slug: 'cross-bones'})];
|
||||
print('Loaded');
|
||||
|
||||
var classroomID = ObjectId('568ac66d648b9e5100de0cca');
|
||||
var levelSlug = 'wakka-maul';
|
||||
db.classrooms.find({'aceConfig.language': 'javascript'}).forEach(function(classroom) {
|
||||
for (var l in levels) {
|
||||
var level = levels[l];
|
||||
print('----------------------------');
|
||||
print('Classroom:', classroom.name);
|
||||
print('Members:', classroom.members.length);
|
||||
print('Level:', level.name);
|
||||
|
||||
var classroom = db.classrooms.findOne({_id: classroomID});
|
||||
var level = db.levels.findOne({slug: levelSlug});
|
||||
|
||||
if(!classroom) { throw new Error('Classroom not found (should be an id)'); }
|
||||
if(!level) { throw new Error('Level not found (should be a slug)'); }
|
||||
|
||||
print('Classroom:', classroom.name);
|
||||
print('Members:', classroom.members.length);
|
||||
print('Level:', level.name);
|
||||
|
||||
for (var i in classroom.members) {
|
||||
var member = classroom.members[i];
|
||||
var sessions = db.level.sessions.find({'level.original': level.original+'', 'creator': member+''}).toArray();
|
||||
print(' user:', member);
|
||||
for (var j in sessions) {
|
||||
var session = sessions[j];
|
||||
print(' session:', session._id, 'has language', session.codeLanguage);
|
||||
if (session.codeLanguage === classroom.aceConfig.language) {
|
||||
print(' all is well');
|
||||
continue;
|
||||
for (var i in classroom.members) {
|
||||
var member = classroom.members[i];
|
||||
var sessions = db.level.sessions.find({'level.original': level.original+'', 'creator': member+''}).toArray();
|
||||
print(' user:', member);
|
||||
for (var j in sessions) {
|
||||
var session = sessions[j];
|
||||
print(' session:', session._id, 'has language', session.codeLanguage);
|
||||
if (session.codeLanguage === classroom.aceConfig.language) {
|
||||
print(' all is well');
|
||||
continue;
|
||||
}
|
||||
print(' updating language...');
|
||||
print(' ', db.level.sessions.update({_id: session._id}, {$set: {codeLanguage: classroom.aceConfig.language}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
print(' updating language...');
|
||||
print(' ', db.level.sessions.update({_id: session._id}, {$set: {codeLanguage: classroom.aceConfig.language}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -36,4 +36,8 @@ ArticleSchema.plugin(plugins.SearchablePlugin, {searchable: ['body', 'name']})
|
|||
ArticleSchema.plugin(plugins.TranslationCoveragePlugin)
|
||||
ArticleSchema.plugin(plugins.PatchablePlugin)
|
||||
|
||||
ArticleSchema.postEditableProperties = []
|
||||
ArticleSchema.editableProperties = ['body', 'name', 'i18n', 'i18nCoverage']
|
||||
ArticleSchema.jsonSchema = require '../../app/schemas/models/article'
|
||||
|
||||
module.exports = mongoose.model('article', ArticleSchema)
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
# TODO: Remove once mapping.coffee is refactored out
|
||||
|
||||
Article = require './Article'
|
||||
Handler = require '../commons/Handler'
|
||||
|
||||
ArticleHandler = class ArticleHandler extends Handler
|
||||
modelClass: Article
|
||||
editableProperties: ['body', 'name', 'i18n']
|
||||
jsonSchema: require '../../app/schemas/models/article'
|
||||
editableProperties: Article.schema.editableProperties
|
||||
jsonSchema: Article.schema.jsonSchema
|
||||
|
||||
hasAccess: (req) ->
|
||||
req.method is 'GET' or req.user?.isAdmin() or req.user?.isArtisan()
|
||||
|
|
|
@ -214,6 +214,7 @@ module.exports = class Handler
|
|||
if req.query.project
|
||||
projection = {}
|
||||
projection[field] = 1 for field in req.query.project.split(',')
|
||||
projection.permissions = 1 # TODO: A better solution for always including properties the server needs
|
||||
@getDocumentForIdOrSlug id, projection, (err, document) =>
|
||||
return @sendDatabaseError(res, err) if err
|
||||
return @sendNotFoundError(res) unless document?
|
||||
|
|
|
@ -3,34 +3,201 @@ winston = require 'winston'
|
|||
mongoose = require 'mongoose'
|
||||
Grid = require 'gridfs-stream'
|
||||
mongooseCache = require 'mongoose-cache'
|
||||
errors = require '../commons/errors'
|
||||
Promise = require 'bluebird'
|
||||
_ = require 'lodash'
|
||||
|
||||
module.exports.connect = () ->
|
||||
address = module.exports.generateMongoConnectionString()
|
||||
winston.info "Connecting to Mongo with connection string #{address}"
|
||||
module.exports =
|
||||
isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24
|
||||
|
||||
connect: () ->
|
||||
address = module.exports.generateMongoConnectionString()
|
||||
winston.info "Connecting to Mongo with connection string #{address}"
|
||||
|
||||
mongoose.connect address
|
||||
mongoose.connection.once 'open', -> Grid.gfs = Grid(mongoose.connection.db, mongoose.mongo)
|
||||
|
||||
# Hack around Mongoose not exporting Aggregate so that we can patch its exec, too
|
||||
# https://github.com/LearnBoost/mongoose/issues/1910
|
||||
Level = require '../levels/Level'
|
||||
Aggregate = Level.aggregate().constructor
|
||||
maxAge = (Math.random() * 10 + 10) * 60 * 1000 # Randomize so that each server doesn't refresh cache from db at same times
|
||||
mongooseCache.install(mongoose, {max: 1000, maxAge: maxAge, debug: false}, Aggregate)
|
||||
|
||||
mongoose.connect address
|
||||
mongoose.connection.once 'open', -> Grid.gfs = Grid(mongoose.connection.db, mongoose.mongo)
|
||||
generateMongoConnectionString: ->
|
||||
if not global.testing and config.tokyo
|
||||
address = config.mongo.mongoose_tokyo_replica_string
|
||||
else if not global.testing and config.saoPaulo
|
||||
address = config.mongo.mongoose_saoPaulo_replica_string
|
||||
else if not global.testing and config.mongo.mongoose_replica_string
|
||||
address = config.mongo.mongoose_replica_string
|
||||
else
|
||||
dbName = config.mongo.db
|
||||
dbName += '_unittest' if global.testing
|
||||
address = config.mongo.host + ':' + config.mongo.port
|
||||
if config.mongo.username and config.mongo.password
|
||||
address = config.mongo.username + ':' + config.mongo.password + '@' + address
|
||||
address = "mongodb://#{address}/#{dbName}"
|
||||
|
||||
return address
|
||||
|
||||
# Hack around Mongoose not exporting Aggregate so that we can patch its exec, too
|
||||
# https://github.com/LearnBoost/mongoose/issues/1910
|
||||
Level = require '../levels/Level'
|
||||
Aggregate = Level.aggregate().constructor
|
||||
maxAge = (Math.random() * 10 + 10) * 60 * 1000 # Randomize so that each server doesn't refresh cache from db at same times
|
||||
mongooseCache.install(mongoose, {max: 1000, maxAge: maxAge, debug: false}, Aggregate)
|
||||
initDoc: (req, Model) ->
|
||||
# TODO: Move to model superclass or plugins?
|
||||
doc = new Model({})
|
||||
|
||||
module.exports.generateMongoConnectionString = ->
|
||||
if not global.testing and config.tokyo
|
||||
address = config.mongo.mongoose_tokyo_replica_string
|
||||
else if not global.testing and config.saoPaulo
|
||||
address = config.mongo.mongoose_saoPaulo_replica_string
|
||||
else if not global.testing and config.mongo.mongoose_replica_string
|
||||
address = config.mongo.mongoose_replica_string
|
||||
else
|
||||
dbName = config.mongo.db
|
||||
dbName += '_unittest' if global.testing
|
||||
address = config.mongo.host + ':' + config.mongo.port
|
||||
if config.mongo.username and config.mongo.password
|
||||
address = config.mongo.username + ':' + config.mongo.password + '@' + address
|
||||
address = "mongodb://#{address}/#{dbName}"
|
||||
if Model.schema.is_patchable
|
||||
watchers = [req.user.get('_id')]
|
||||
if req.user.isAdmin() # https://github.com/codecombat/codecombat/issues/1105
|
||||
nick = mongoose.Types.ObjectId('512ef4805a67a8c507000001')
|
||||
watchers.push nick unless _.find watchers, (id) -> id.equals nick
|
||||
doc.set 'watchers', watchers
|
||||
|
||||
if Model.schema.uses_coco_versions
|
||||
doc.set('original', doc._id)
|
||||
doc.set('creator', req.user._id)
|
||||
|
||||
|
||||
applyCustomSearchToDBQ: (req, dbq) ->
|
||||
specialParameters = ['term', 'project', 'conditions']
|
||||
|
||||
return unless req.user?.isAdmin()
|
||||
return unless req.query.filter or req.query.conditions
|
||||
|
||||
# admins can send any sort of query down the wire
|
||||
# Example URL: http://localhost:3000/db/user?filter[anonymous]=true
|
||||
filter = {}
|
||||
if 'filter' of req.query
|
||||
for own key, val of req.query.filter
|
||||
if key not in specialParameters
|
||||
try
|
||||
filter[key] = JSON.parse(val)
|
||||
catch SyntaxError
|
||||
throw new errors.UnprocessableEntity("Could not parse filter for key '#{key}'.")
|
||||
dbq.find(filter)
|
||||
|
||||
# Conditions are chained query functions, for example: query.find().limit(20).sort('-dateCreated')
|
||||
# Example URL: http://localhost:3000/db/user?conditions[limit]=20&conditions[sort]="-dateCreated"
|
||||
for own key, val of req.query.conditions
|
||||
if not dbq[key]
|
||||
throw new errors.UnprocessableEntity("No query condition '#{key}'.")
|
||||
try
|
||||
val = JSON.parse(val)
|
||||
dbq[key](val)
|
||||
catch SyntaxError
|
||||
throw new errors.UnprocessableEntity("Could not parse condition for key '#{key}'.")
|
||||
|
||||
|
||||
viewSearch: Promise.promisify (dbq, req, done) ->
|
||||
Model = dbq.model
|
||||
# TODO: Make this function only alter dbq or returns a find. It should not also execute the query.
|
||||
term = req.query.term
|
||||
matchedObjects = []
|
||||
filters = if Model.schema.uses_coco_versions or Model.schema.uses_coco_permissions then [filter: {index: true}] else [filter: {}]
|
||||
|
||||
if Model.schema.uses_coco_permissions and req.user
|
||||
filters.push {filter: {index: req.user.get('id')}}
|
||||
|
||||
for filter in filters
|
||||
callback = (err, results) ->
|
||||
return done(new errors.InternalServerError('Error fetching search results.', {err: err})) if err
|
||||
for r in results.results ? results
|
||||
obj = r.obj ? r
|
||||
continue if obj in matchedObjects # TODO: probably need a better equality check
|
||||
matchedObjects.push obj
|
||||
filters.pop() # doesn't matter which one
|
||||
unless filters.length
|
||||
done(null, matchedObjects)
|
||||
|
||||
if term
|
||||
filter.filter.$text = $search: term
|
||||
else if filters.length is 1 and filters[0].filter?.index is true
|
||||
# All we are doing is an empty text search, but that doesn't hit the index,
|
||||
# so we'll just look for the slug.
|
||||
filter.filter = slug: {$exists: true}
|
||||
|
||||
# This try/catch is here to handle when a custom search tries to find by slug. TODO: Fix this more gracefully.
|
||||
try
|
||||
dbq.find filter.filter
|
||||
catch
|
||||
dbq.exec callback
|
||||
|
||||
|
||||
assignBody: (req, doc, options={}) ->
|
||||
if _.isEmpty(req.body)
|
||||
throw new errors.UnprocessableEntity('No input')
|
||||
|
||||
props = doc.schema.editableProperties.slice()
|
||||
|
||||
if doc.isNew
|
||||
props = props.concat doc.schema.postEditableProperties
|
||||
|
||||
if doc.schema.uses_coco_permissions and req.user
|
||||
isOwner = doc.getAccessForUserObjectId(req.user._id) is 'owner'
|
||||
if doc.isNew or isOwner or req.user?.isAdmin()
|
||||
props.push 'permissions'
|
||||
|
||||
props.push 'commitMessage' if doc.schema.uses_coco_versions
|
||||
props.push 'allowPatches' if doc.schema.is_patchable
|
||||
|
||||
for prop in props
|
||||
if (val = req.body[prop])?
|
||||
doc.set prop, val
|
||||
else if options.unsetMissing and doc.get(prop)?
|
||||
doc.set prop, undefined
|
||||
|
||||
|
||||
validateDoc: (doc) ->
|
||||
obj = doc.toObject()
|
||||
# Hack to get saving of Users to work. Probably should replace these props with strings
|
||||
# so that validation doesn't get hung up on Date objects in the documents.
|
||||
delete obj.dateCreated
|
||||
tv4 = require('tv4').tv4
|
||||
result = tv4.validateMultiple(obj, doc.schema.jsonSchema)
|
||||
if not result.valid
|
||||
throw new errors.UnprocessableEntity('JSON-schema validation failed', { validationErrors: result.errors })
|
||||
|
||||
|
||||
getDocFromHandle: Promise.promisify (req, Model, options, done) ->
|
||||
if _.isFunction(options)
|
||||
done = options
|
||||
options = {}
|
||||
|
||||
dbq = Model.find()
|
||||
handle = req.params.handle
|
||||
if not handle
|
||||
return done(new errors.UnprocessableEntity('No handle provided.'))
|
||||
if @isID(handle)
|
||||
dbq.findOne({ _id: handle })
|
||||
else
|
||||
dbq.findOne({ slug: handle })
|
||||
|
||||
dbq.exec(done)
|
||||
|
||||
|
||||
hasAccessToDocument: (req, doc, method) ->
|
||||
method = method or req.method
|
||||
return true if req.user?.isAdmin()
|
||||
|
||||
if doc.schema.uses_coco_translation_coverage and method in ['post', 'put']
|
||||
return true if @isJustFillingTranslations(req, doc)
|
||||
|
||||
if doc.schema.uses_coco_permissions
|
||||
return doc.hasPermissionsForMethod?(req.user, method)
|
||||
return true
|
||||
|
||||
isJustFillingTranslations: (req, doc) ->
|
||||
deltasLib = require '../../app/core/deltas'
|
||||
differ = deltasLib.makeJSONDiffer()
|
||||
omissions = ['original'].concat(deltasLib.DOC_SKIP_PATHS)
|
||||
delta = differ.diff(_.omit(doc.toObject(), omissions), _.omit(req.body, omissions))
|
||||
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
|
||||
|
||||
return address
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
log = require 'winston'
|
||||
_ = require 'lodash'
|
||||
|
||||
module.exports.custom = (res, code=500, message='Internal Server Error') ->
|
||||
log.debug "#{code}: #{message}"
|
||||
|
@ -56,3 +57,85 @@ module.exports.clientTimeout = (res, message='The server did not receive the cli
|
|||
log.debug "408: #{message}"
|
||||
res.send 408, message
|
||||
res.end()
|
||||
|
||||
|
||||
# Objects
|
||||
|
||||
errorResponseSchema = {
|
||||
type: 'object'
|
||||
required: ['errorName', 'code', 'message']
|
||||
properties: {
|
||||
error: {
|
||||
description: 'Error object which the callback returned'
|
||||
}
|
||||
errorName: {
|
||||
type: 'string'
|
||||
description: 'Human readable error code name'
|
||||
}
|
||||
code: {
|
||||
type: 'integer'
|
||||
description: 'HTTP error code'
|
||||
}
|
||||
validationErrors: {
|
||||
type: 'array'
|
||||
description: 'TV4 array of validation error objects'
|
||||
}
|
||||
message: {
|
||||
type: 'string'
|
||||
description: 'Human readable descripton of the error'
|
||||
}
|
||||
property: {
|
||||
type: 'string'
|
||||
description: 'Property which is related to the error (conflict, validation).'
|
||||
}
|
||||
}
|
||||
}
|
||||
errorProps = _.keys(errorResponseSchema.properties)
|
||||
|
||||
class NetworkError
|
||||
code: 0
|
||||
|
||||
constructor: (@message, options) ->
|
||||
@stack = (new Error()).stack
|
||||
_.assign(@, options)
|
||||
|
||||
toJSON: ->
|
||||
_.pick(@, errorProps...)
|
||||
|
||||
module.exports.NetworkError = NetworkError
|
||||
|
||||
module.exports.Unauthorized = class Unauthorized extends NetworkError
|
||||
code: 401
|
||||
name: 'Unauthorized'
|
||||
|
||||
module.exports.Forbidden = class Forbidden extends NetworkError
|
||||
code: 403
|
||||
name: 'Forbidden'
|
||||
|
||||
module.exports.NotFound = class NotFound extends NetworkError
|
||||
code: 404
|
||||
name: 'Not Found'
|
||||
|
||||
module.exports.MethodNotAllowed = class MethodNotAllowed extends NetworkError
|
||||
code: 405
|
||||
name: 'Method Not Allowed'
|
||||
|
||||
module.exports.RequestTimeout = class RequestTimeout extends NetworkError
|
||||
code: 407
|
||||
name: 'Request Timeout'
|
||||
|
||||
module.exports.Conflict = class Conflict extends NetworkError
|
||||
code: 409
|
||||
name: 'Conflict'
|
||||
|
||||
module.exports.UnprocessableEntity = class UnprocessableEntity extends NetworkError
|
||||
code: 422
|
||||
name: 'Unprocessable Entity'
|
||||
|
||||
module.exports.InternalServerError = class InternalServerError extends NetworkError
|
||||
code: 500
|
||||
name: 'Internal Server Error'
|
||||
|
||||
module.exports.GatewayTimeout = class GatewayTimeout extends NetworkError
|
||||
code: 504
|
||||
name: 'Gateway Timeout'
|
||||
|
|
60
server/commons/parse.coffee
Normal file
60
server/commons/parse.coffee
Normal file
|
@ -0,0 +1,60 @@
|
|||
errors = require '../commons/errors'
|
||||
_ = require 'lodash'
|
||||
Promise = require 'bluebird'
|
||||
|
||||
module.exports =
|
||||
|
||||
getLimitFromReq: (req, options) ->
|
||||
options = _.extend({
|
||||
max: 1000
|
||||
default: 100
|
||||
}, options)
|
||||
|
||||
limit = options.default
|
||||
|
||||
if req.query.limit
|
||||
limit = parseInt(req.query.limit)
|
||||
valid = tv4.validate(limit, {
|
||||
type: 'integer'
|
||||
maximum: options.max
|
||||
minimum: 1
|
||||
})
|
||||
if not valid
|
||||
throw new errors.UnprocessableEntity('Invalid limit parameter.')
|
||||
|
||||
return limit
|
||||
|
||||
|
||||
getSkipFromReq: (req, options) ->
|
||||
options = _.extend({
|
||||
max: 1000000
|
||||
default: 0
|
||||
}, options)
|
||||
|
||||
skip = options.default
|
||||
|
||||
if req.query.skip
|
||||
skip = parseInt(req.query.skip)
|
||||
valid = tv4.validate(skip, {
|
||||
type: 'integer'
|
||||
maximum: options.max
|
||||
minimum: 0
|
||||
})
|
||||
if not valid
|
||||
throw new errors.UnprocessableEntity('Invalid sort parameter.')
|
||||
|
||||
return skip
|
||||
|
||||
|
||||
getProjectFromReq: (req, options) ->
|
||||
options = _.extend({}, options)
|
||||
return null unless req.query.project
|
||||
projection = {}
|
||||
|
||||
if req.query.project is 'true'
|
||||
projection = {original: 1, name: 1, version: 1, description: 1, slug: 1, kind: 1, created: 1, permissions: 1}
|
||||
else
|
||||
for field in req.query.project.split(',')
|
||||
projection[field] = 1
|
||||
|
||||
return projection
|
|
@ -207,9 +207,27 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
|
|||
query = {$and: [{name: {$ne: 'Single Player'}}, {hourOfCode: {$ne: true}}]}
|
||||
query["$and"].push(_id: {$gte: objectIdFromTimestamp(req.body.startDay + "T00:00:00.000Z")}) if req.body.startDay?
|
||||
query["$and"].push(_id: {$lt: objectIdFromTimestamp(req.body.endDay + "T00:00:00.000Z")}) if req.body.endDay?
|
||||
CourseInstance.find query, (err, courseInstances) =>
|
||||
CourseInstance.find query, {courseID: 1, members: 1, ownerID: 1}, (err, courseInstances) =>
|
||||
return @sendDatabaseError(res, err) if err
|
||||
@sendSuccess(res, courseInstances)
|
||||
userIDs = []
|
||||
for courseInstance in courseInstances
|
||||
if members = courseInstance.get('members')
|
||||
userIDs.push(userID) for userID in members
|
||||
|
||||
User.find {_id: {$in: userIDs}}, {coursePrepaidID: 1}, (err, users) =>
|
||||
return @sendDatabaseError(res, err) if err
|
||||
prepaidIDs = []
|
||||
for user in users
|
||||
if prepaidID = user.get('coursePrepaidID')
|
||||
prepaidIDs.push(prepaidID)
|
||||
|
||||
Prepaid.find {_id: {$in: prepaidIDs}}, {properties: 1}, (err, prepaids) =>
|
||||
return @sendDatabaseError(res, err) if err
|
||||
data =
|
||||
courseInstances: (@formatEntity(req, courseInstance) for courseInstance in courseInstances)
|
||||
students: (@formatEntity(req, user) for user in users)
|
||||
prepaids: (@formatEntity(req, prepaid) for prepaid in prepaids)
|
||||
@sendSuccess(res, data)
|
||||
|
||||
inviteStudents: (req, res, courseInstanceID) ->
|
||||
return @sendUnauthorizedError(res) if not req.user?
|
||||
|
|
|
@ -2,8 +2,10 @@ AnalyticsString = require '../analytics/AnalyticsString'
|
|||
log = require 'winston'
|
||||
mongoose = require 'mongoose'
|
||||
config = require '../../server_config'
|
||||
_ = require 'lodash'
|
||||
|
||||
module.exports =
|
||||
module.exports = utils =
|
||||
# TODO: Remove, use commons/database.isID instead
|
||||
isID: (id) -> _.isString(id) and id.length is 24 and id.match(/[a-f0-9]/gi)?.length is 24
|
||||
|
||||
getCodeCamel: (numWords=3) ->
|
||||
|
|
31
server/middleware/auth.coffee
Normal file
31
server/middleware/auth.coffee
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Middleware for both authentication and authorization
|
||||
|
||||
errors = require '../commons/errors'
|
||||
|
||||
module.exports = {
|
||||
checkDocumentPermissions: (req, res, next) ->
|
||||
return next() if req.user?.isAdmin()
|
||||
if not req.doc.hasPermissionsForMethod(req.user, req.method)
|
||||
if req.user
|
||||
return next new errors.Forbidden('You do not have permissions necessary.')
|
||||
return next new errors.Unauthorized('You must be logged in.')
|
||||
next()
|
||||
|
||||
checkLoggedIn: ->
|
||||
return (req, res, next) ->
|
||||
if not req.user
|
||||
return next new errors.Unauthorized('You must be logged in.')
|
||||
next()
|
||||
|
||||
checkHasPermission: (permissions) ->
|
||||
if _.isString(permissions)
|
||||
permissions = [permissions]
|
||||
|
||||
return (req, res, next) ->
|
||||
if not req.user
|
||||
return next new errors.Unauthorized('You must be logged in.')
|
||||
if not _.size(_.intersection(req.user.get('permissions'), permissions))
|
||||
return next new errors.Forbidden('You do not have permissions necessary.')
|
||||
next()
|
||||
|
||||
}
|
21
server/middleware/files.coffee
Normal file
21
server/middleware/files.coffee
Normal file
|
@ -0,0 +1,21 @@
|
|||
utils = require '../lib/utils'
|
||||
errors = require '../commons/errors'
|
||||
wrap = require 'co-express'
|
||||
Grid = require 'gridfs-stream'
|
||||
Promise = require 'bluebird'
|
||||
database = require '../commons/database'
|
||||
|
||||
module.exports =
|
||||
files: (Model, options={}) -> wrap (req, res) ->
|
||||
doc = yield database.getDocFromHandle(req, Model)
|
||||
if not doc
|
||||
throw new errors.NotFound('Document not found.')
|
||||
module = options.module or req.path[4..].split('/')[0]
|
||||
query = { 'metadata.path': "db/#{module}/#{doc.id}" }
|
||||
|
||||
c = Grid.gfs.collection('media')
|
||||
c.findAsync = Promise.promisify(c.find)
|
||||
cursor = yield c.findAsync(query)
|
||||
cursor.toArrayAsync = Promise.promisify(cursor.toArray)
|
||||
files = yield cursor.toArrayAsync()
|
||||
res.status(200).send(files)
|
7
server/middleware/index.coffee
Normal file
7
server/middleware/index.coffee
Normal file
|
@ -0,0 +1,7 @@
|
|||
module.exports =
|
||||
auth: require './auth'
|
||||
files: require './files'
|
||||
named: require './named'
|
||||
patchable: require './patchable'
|
||||
rest: require './rest'
|
||||
versions: require './versions'
|
32
server/middleware/named.coffee
Normal file
32
server/middleware/named.coffee
Normal file
|
@ -0,0 +1,32 @@
|
|||
utils = require '../lib/utils'
|
||||
errors = require '../commons/errors'
|
||||
wrap = require 'co-express'
|
||||
Promise = require 'bluebird'
|
||||
database = require '../commons/database'
|
||||
|
||||
module.exports =
|
||||
names: (Model, options={}) -> wrap (req, res) ->
|
||||
# TODO: migrate to /db/collection?ids=...&project=... and /db/collection?originals=...&project=...
|
||||
|
||||
ids = req.query.ids or req.body.ids
|
||||
ids = ids.split(',') if _.isString ids
|
||||
ids = _.uniq ids
|
||||
|
||||
# Hack: levels loading thang types need the components returned as well.
|
||||
# Need a way to specify a projection for a query.
|
||||
project = {name: 1, original: 1, kind: 1, components: 1, prerenderedSpriteSheetData: 1}
|
||||
sort = if Model.schema.uses_coco_versions then {'version.major': -1, 'version.minor': -1} else {}
|
||||
|
||||
for id in ids
|
||||
if not database.isID(id)
|
||||
throw new errors.UnprocessableEntity('Invalid MongoDB id given')
|
||||
|
||||
ids = (mongoose.Types.ObjectId(id) for id in ids)
|
||||
|
||||
promises = []
|
||||
for id in ids
|
||||
q = if Model.schema.uses_coco_versions then { original: id } else { _id: id }
|
||||
promises.push Model.findOne(q).select(project).sort(sort).exec()
|
||||
|
||||
documents = yield promises
|
||||
res.status(200).send(documents)
|
53
server/middleware/patchable.coffee
Normal file
53
server/middleware/patchable.coffee
Normal file
|
@ -0,0 +1,53 @@
|
|||
utils = require '../lib/utils'
|
||||
errors = require '../commons/errors'
|
||||
wrap = require 'co-express'
|
||||
Promise = require 'bluebird'
|
||||
Patch = require '../models/Patch'
|
||||
mongoose = require 'mongoose'
|
||||
database = require '../commons/database'
|
||||
parse = require '../commons/parse'
|
||||
|
||||
module.exports =
|
||||
patches: (options={}) -> wrap (req, res) ->
|
||||
dbq = Patch.find()
|
||||
dbq.limit(parse.getLimitFromReq(req))
|
||||
dbq.skip(parse.getSkipFromReq(req))
|
||||
dbq.select(parse.getProjectFromReq(req))
|
||||
|
||||
id = req.params.handle
|
||||
if not database.isID(id)
|
||||
throw new errors.UnprocessableEntity('Invalid ID')
|
||||
|
||||
query =
|
||||
$or: [
|
||||
{'target.original': id+''}
|
||||
{'target.original': mongoose.Types.ObjectId(id)}
|
||||
]
|
||||
status: req.query.status or 'pending'
|
||||
|
||||
patches = yield dbq.find(query).sort('-created')
|
||||
res.status(200).send(patches)
|
||||
|
||||
joinWatchers: (Model, options={}) -> wrap (req, res) ->
|
||||
doc = yield database.getDocFromHandle(req, Model)
|
||||
if not doc
|
||||
throw new errors.NotFound('Document not found.')
|
||||
if not database.hasAccessToDocument(req, doc, 'get')
|
||||
throw new errors.Forbidden()
|
||||
updateResult = yield doc.update({ $addToSet: { watchers: req.user.get('_id') }})
|
||||
if updateResult.nModified
|
||||
watchers = doc.get('watchers')
|
||||
watchers.push(req.user.get('_id'))
|
||||
doc.set('watchers', watchers)
|
||||
res.status(200).send(doc)
|
||||
|
||||
leaveWatchers: (Model, options={}) -> wrap (req, res) ->
|
||||
doc = yield database.getDocFromHandle(req, Model)
|
||||
if not doc
|
||||
throw new errors.NotFound('Document not found.')
|
||||
updateResult = yield doc.update({ $pull: { watchers: req.user.get('_id') }})
|
||||
if updateResult.nModified
|
||||
watchers = doc.get('watchers')
|
||||
watchers = _.filter watchers, (id) -> not id.equals(req.user._id)
|
||||
doc.set('watchers', watchers)
|
||||
res.status(200).send(doc)
|
42
server/middleware/rest.coffee
Normal file
42
server/middleware/rest.coffee
Normal file
|
@ -0,0 +1,42 @@
|
|||
utils = require '../lib/utils'
|
||||
errors = require '../commons/errors'
|
||||
wrap = require 'co-express'
|
||||
database = require '../commons/database'
|
||||
parse = require '../commons/parse'
|
||||
|
||||
module.exports =
|
||||
get: (Model, options={}) -> wrap (req, res) ->
|
||||
dbq = Model.find()
|
||||
dbq.limit(parse.getLimitFromReq(req))
|
||||
dbq.skip(parse.getSkipFromReq(req))
|
||||
dbq.select(parse.getProjectFromReq(req))
|
||||
database.applyCustomSearchToDBQ(req, dbq)
|
||||
|
||||
if Model.schema.uses_coco_translation_coverage and req.query.view is 'i18n-coverage'
|
||||
dbq.find({ slug: {$exists: true}, i18nCoverage: {$exists: true} })
|
||||
|
||||
results = yield database.viewSearch(dbq, req)
|
||||
res.send(results)
|
||||
|
||||
post: (Model, options={}) -> wrap (req, res) ->
|
||||
doc = database.initDoc(req, Model)
|
||||
database.assignBody(req, doc)
|
||||
database.validateDoc(doc)
|
||||
doc = yield doc.save()
|
||||
res.status(201).send(doc.toObject())
|
||||
|
||||
getByHandle: (Model, options={}) -> wrap (req, res) ->
|
||||
doc = yield database.getDocFromHandle(req, Model)
|
||||
if not doc
|
||||
throw new errors.NotFound('Document not found.')
|
||||
res.status(200).send(doc.toObject())
|
||||
|
||||
put: (Model, options={}) -> wrap (req, res) ->
|
||||
doc = yield database.getDocFromHandle(req, Model)
|
||||
if not doc
|
||||
throw new errors.NotFound('Document not found.')
|
||||
|
||||
database.assignBody(req, doc)
|
||||
database.validateDoc(doc)
|
||||
doc = yield doc.save()
|
||||
res.status(200).send(doc.toObject())
|
170
server/middleware/versions.coffee
Normal file
170
server/middleware/versions.coffee
Normal file
|
@ -0,0 +1,170 @@
|
|||
utils = require '../lib/utils'
|
||||
errors = require '../commons/errors'
|
||||
User = require '../users/User'
|
||||
sendwithus = require '../sendwithus'
|
||||
hipchat = require '../hipchat'
|
||||
_ = require 'lodash'
|
||||
wrap = require 'co-express'
|
||||
mongoose = require 'mongoose'
|
||||
database = require '../commons/database'
|
||||
parse = require '../commons/parse'
|
||||
|
||||
module.exports =
|
||||
postNewVersion: (Model, options={}) -> wrap (req, res) ->
|
||||
parent = yield database.getDocFromHandle(req, Model)
|
||||
if not parent
|
||||
throw new errors.NotFound('Parent not found.')
|
||||
|
||||
# TODO: Figure out a better way to do this
|
||||
if options.hasPermissionsOrTranslations
|
||||
permissions = options.hasPermissionsOrTranslations
|
||||
permissions = [permissions] if _.isString(permissions)
|
||||
permissions = ['admin'] if not _.isArray(permissions)
|
||||
hasPermission = _.any(req.user?.hasPermission(permission) for permission in permissions)
|
||||
if not (hasPermission or database.isJustFillingTranslations(req, parent))
|
||||
throw new errors.Forbidden()
|
||||
|
||||
doc = database.initDoc(req, Model)
|
||||
ATTRIBUTES_NOT_INHERITED = ['_id', 'version', 'created', 'creator']
|
||||
doc.set(_.omit(parent.toObject(), ATTRIBUTES_NOT_INHERITED))
|
||||
|
||||
database.assignBody(req, doc, { unsetMissing: true })
|
||||
|
||||
# Get latest version
|
||||
major = req.body.version?.major
|
||||
original = parent.get('original')
|
||||
if _.isNumber(major)
|
||||
q1 = Model.findOne({original: original, 'version.isLatestMinor': true, 'version.major': major})
|
||||
else
|
||||
q1 = Model.findOne({original: original, 'version.isLatestMajor': true})
|
||||
q1.select 'version'
|
||||
latest = yield q1.exec()
|
||||
|
||||
if not latest
|
||||
# handle the case where no version is marked as latest, since making new
|
||||
# versions is not atomic
|
||||
if _.isNumber(major)
|
||||
q2 = Model.findOne({original: original, 'version.major': major})
|
||||
q2.sort({'version.minor': -1})
|
||||
else
|
||||
q2 = Model.findOne()
|
||||
q2.sort({'version.major': -1, 'version.minor': -1})
|
||||
q2.select 'version'
|
||||
latest = yield q2.exec()
|
||||
if not latest
|
||||
throw new errors.NotFound('Previous version not found.')
|
||||
|
||||
# Transfer latest version
|
||||
major = req.body.version?.major
|
||||
version = _.clone(latest.get('version'))
|
||||
wasLatestMajor = version.isLatestMajor
|
||||
version.isLatestMajor = false
|
||||
if _.isNumber(major)
|
||||
version.isLatestMinor = false
|
||||
|
||||
conditions = {_id: latest._id}
|
||||
|
||||
raw = yield Model.update(conditions, {version: version, $unset: {index: 1, slug: 1}})
|
||||
if not raw.nModified
|
||||
console.error('Conditions', conditions)
|
||||
console.error('Doc', doc)
|
||||
console.error('Raw response', raw)
|
||||
throw new errors.InternalServerError('Latest version could not be modified.')
|
||||
|
||||
# update the new doc with version, index information
|
||||
# Relying heavily on Mongoose schema default behavior here. TODO: Make explicit?
|
||||
if _.isNumber(major)
|
||||
doc.set({
|
||||
'version.major': latest.version.major
|
||||
'version.minor': latest.version.minor + 1
|
||||
'version.isLatestMajor': wasLatestMajor
|
||||
})
|
||||
if wasLatestMajor
|
||||
doc.set('index', true)
|
||||
else
|
||||
doc.set({index: undefined, slug: undefined})
|
||||
else
|
||||
doc.set('version.major', latest.version.major + 1)
|
||||
doc.set('index', true)
|
||||
|
||||
doc.set('parent', latest._id)
|
||||
|
||||
doc = yield doc.save()
|
||||
|
||||
editPath = req.headers['x-current-path']
|
||||
docLink = "http://codecombat.com#{editPath}"
|
||||
|
||||
# Post a message on HipChat
|
||||
message = "#{req.user.get('name')} saved a change to <a href=\"#{docLink}\">#{doc.get('name')}</a>: #{doc.get('commitMessage') or '(no commit message)'}"
|
||||
rooms = if /Diplomat submission/.test(message) then ['main'] else ['main', 'artisans']
|
||||
hipchat.sendHipChatMessage message, rooms
|
||||
|
||||
# Send emails to watchers
|
||||
watchers = doc.get('watchers') or []
|
||||
# Don't send these emails to the person who submitted the patch, or to Nick, George, or Scott.
|
||||
watchers = (w for w in watchers when not w.equals(req.user.get('_id')) and not (w + '' in ['512ef4805a67a8c507000001', '5162fab9c92b4c751e000274', '51538fdb812dd9af02000001']))
|
||||
if watchers.length
|
||||
User.find({_id:{$in:watchers}}).select({email:1, name:1}).exec (err, watchers) ->
|
||||
for watcher in watchers
|
||||
context =
|
||||
email_id: sendwithus.templates.change_made_notify_watcher
|
||||
recipient:
|
||||
address: watcher.get('email')
|
||||
name: watcher.get('name')
|
||||
email_data:
|
||||
doc_name: doc.get('name') or '???'
|
||||
submitter_name: req.user.get('name') or '???'
|
||||
doc_link: if editPath then docLink else null
|
||||
commit_message: doc.get('commitMessage')
|
||||
sendwithus.api.send context, _.noop
|
||||
|
||||
res.status(201).send(doc.toObject())
|
||||
|
||||
|
||||
|
||||
getLatestVersion: (Model, options={}) -> wrap (req, res) ->
|
||||
# can get latest overall version, latest of a major version, or a specific version
|
||||
original = req.params.handle
|
||||
version = req.params.version
|
||||
if not database.isID(original)
|
||||
throw new errors.UnprocessableEntity('Invalid MongoDB id: '+original)
|
||||
|
||||
query = { 'original': mongoose.Types.ObjectId(original) }
|
||||
if version?
|
||||
version = version.split('.')
|
||||
majorVersion = parseInt(version[0])
|
||||
minorVersion = parseInt(version[1])
|
||||
query['version.major'] = majorVersion unless _.isNaN(majorVersion)
|
||||
query['version.minor'] = minorVersion unless _.isNaN(minorVersion)
|
||||
dbq = Model.findOne(query)
|
||||
|
||||
dbq.sort({ 'version.major': -1, 'version.minor': -1 })
|
||||
|
||||
# Make sure that permissions and version are fetched, but not sent back if they didn't ask for them.
|
||||
projection = parse.getProjectFromReq(req)
|
||||
if projection
|
||||
extraProjectionProps = []
|
||||
extraProjectionProps.push 'permissions' unless projection.permissions
|
||||
extraProjectionProps.push 'version' unless projection.version
|
||||
projection.permissions = 1
|
||||
projection.version = 1
|
||||
dbq.select(projection)
|
||||
|
||||
doc = yield dbq.exec()
|
||||
throw new errors.NotFound() if not doc
|
||||
throw new errors.Forbidden() unless database.hasAccessToDocument(req, doc)
|
||||
doc = _.omit doc, extraProjectionProps if extraProjectionProps?
|
||||
|
||||
res.status(200).send(doc.toObject())
|
||||
|
||||
|
||||
versions: (Model, options={}) -> wrap (req, res) ->
|
||||
original = req.params.handle
|
||||
dbq = Model.find({'original': mongoose.Types.ObjectId(original)})
|
||||
dbq.sort({'created': -1})
|
||||
dbq.limit(parse.getLimitFromReq(req))
|
||||
dbq.skip(parse.getSkipFromReq(req))
|
||||
dbq.select(parse.getProjectFromReq(req) or 'slug name version commitMessage created creator permissions')
|
||||
|
||||
results = yield dbq.exec()
|
||||
res.status(200).send(results)
|
3
server/models/Article.coffee
Normal file
3
server/models/Article.coffee
Normal file
|
@ -0,0 +1,3 @@
|
|||
# TODO: Migrate Article to here
|
||||
|
||||
module.exports = require '../articles/Article'
|
3
server/models/Patch.coffee
Normal file
3
server/models/Patch.coffee
Normal file
|
@ -0,0 +1,3 @@
|
|||
# TODO: Migrate Patch to here
|
||||
|
||||
module.exports = require '../patches/Patch'
|
|
@ -1,4 +1,5 @@
|
|||
Payment = require './Payment'
|
||||
Prepaid = require '../prepaids/Prepaid'
|
||||
Product = require '../models/Product'
|
||||
User = require '../users/User'
|
||||
Handler = require '../commons/Handler'
|
||||
|
@ -27,6 +28,11 @@ PaymentHandler = class PaymentHandler extends Handler
|
|||
res.send(payments)
|
||||
)
|
||||
|
||||
getByRelationship: (req, res, args...) ->
|
||||
relationship = args[1]
|
||||
return @getSchoolSalesAPI(req, res) if relationship is 'school_sales'
|
||||
super arguments...
|
||||
|
||||
logPaymentError: (req, msg) ->
|
||||
console.warn "Payment Error: #{req.user.get('slug')} (#{req.user._id}): '#{msg}'"
|
||||
|
||||
|
@ -37,6 +43,43 @@ PaymentHandler = class PaymentHandler extends Handler
|
|||
payment.set 'created', new Date().toISOString()
|
||||
payment
|
||||
|
||||
getSchoolSalesAPI: (req, res, code) ->
|
||||
return @sendUnauthorizedError(res) unless req.user?.isAdmin()
|
||||
userIDs = [];
|
||||
Payment.find({}, {amount: 1, created: 1, description: 1, prepaidID: 1, productID: 1, purchaser: 1, service: 1}).exec (err, payments) =>
|
||||
return @sendDatabaseError(res, err) if err
|
||||
schoolSales = []
|
||||
prepaidIDs = []
|
||||
prepaidPaymentMap = {}
|
||||
for payment in payments
|
||||
continue unless payment.get('amount')? and payment.get('amount') > 0
|
||||
unless created = payment.get('created')
|
||||
created = payment.get('_id').getTimestamp()
|
||||
description = payment.get('description') ? ''
|
||||
if prepaidID = payment.get('prepaidID')
|
||||
unless prepaidPaymentMap[prepaidID.valueOf()]
|
||||
prepaidPaymentMap[prepaidID.valueOf()] = {_id: payment.get('_id').valueOf(), amount: payment.get('amount'), created: created, description: description, userID: payment.get('purchaser').valueOf(), prepaidID: prepaidID.valueOf()}
|
||||
prepaidIDs.push(prepaidID)
|
||||
userIDs.push(payment.get('purchaser'))
|
||||
else if payment.get('productID') is 'custom' or payment.get('service') is 'external' or payment.get('service') is 'invoice'
|
||||
schoolSales.push({_id: payment.get('_id').valueOf(), amount: payment.get('amount'), created: created, description: description, userID: payment.get('purchaser').valueOf()})
|
||||
userIDs.push(payment.get('purchaser'))
|
||||
|
||||
Prepaid.find({$and: [{_id: {$in: prepaidIDs}}, {type: 'course'}]}, {_id: 1}).exec (err, prepaids) =>
|
||||
return @sendDatabaseError(res, err) if err
|
||||
for prepaid in prepaids
|
||||
schoolSales.push(prepaidPaymentMap[prepaid.get('_id').valueOf()])
|
||||
|
||||
User.find({_id: {$in: userIDs}}).exec (err, users) =>
|
||||
return @sendDatabaseError(res, err) if err
|
||||
userMap = {}
|
||||
for user in users
|
||||
userMap[user.get('_id').valueOf()] = user
|
||||
for schoolSale in schoolSales
|
||||
schoolSale.user = userMap[schoolSale.userID]?.toObject()
|
||||
|
||||
@sendSuccess(res, schoolSales)
|
||||
|
||||
post: (req, res, pathName) ->
|
||||
if pathName is 'check-stripe-charges'
|
||||
return @checkStripeCharges(req, res)
|
||||
|
|
|
@ -179,7 +179,7 @@ module.exports.VersionedPlugin = (schema) ->
|
|||
latest = latest[0]
|
||||
|
||||
# don't fix missing versions by default. In all likelihood, it's about to change anyway
|
||||
if options.autofix
|
||||
if options.autofix # not used
|
||||
latest.version.isLatestMajor = true
|
||||
latest.version.isLatestMinor = true
|
||||
latestObject = latest.toObject()
|
||||
|
@ -204,7 +204,7 @@ module.exports.VersionedPlugin = (schema) ->
|
|||
return done(null, null) if latest.length is 0
|
||||
latest = latest[0]
|
||||
|
||||
if options.autofix
|
||||
if options.autofix # not used
|
||||
latestObject = latest.toObject()
|
||||
latestObject.version.isLatestMajor = true
|
||||
latestObject.version.isLatestMinor = true
|
||||
|
|
|
@ -28,26 +28,35 @@ module.exports.setup = (app) ->
|
|||
return log.error err if err?
|
||||
githubUser = JSON.parse response
|
||||
log.info 'Got GitHub auth callback response', githubUser
|
||||
emailLower = githubUser.email?.toLowerCase()
|
||||
if not emailLower
|
||||
return errors.serverError res, "Problem finding GitHub user with that identity."
|
||||
|
||||
request.get {uri: 'https://api.github.com/user/emails', headers: headers}, (err, r, response) ->
|
||||
return log.error err if err?
|
||||
githubUserEmails = JSON.parse response
|
||||
log.info 'Got GitHub user emails', githubUserEmails
|
||||
|
||||
emailLower = _.find githubUserEmails, (email) -> email.primary is true
|
||||
emailLower = emailLower.email.toLowerCase()
|
||||
log.info 'Got primary Github email', emailLower
|
||||
|
||||
if not emailLower
|
||||
return errors.serverError res, "Problem finding GitHub user with that identity."
|
||||
|
||||
# GitHub users can change emails
|
||||
User.findOne {$or: [{emailLower: emailLower}, {githubID: githubUser.id}]}, (err, user) ->
|
||||
return errors.serverError res, err if err?
|
||||
wrapup = (err, user) ->
|
||||
# GitHub users can change emails
|
||||
User.findOne {$or: [{emailLower: emailLower}, {githubID: githubUser.id}]}, (err, user) ->
|
||||
return errors.serverError res, err if err?
|
||||
req.login (user), (err) ->
|
||||
wrapup = (err, user) ->
|
||||
return errors.serverError res, err if err?
|
||||
res.redirect '/'
|
||||
unless user
|
||||
req.user.set 'email', githubUser.email
|
||||
req.user.set 'githubID', githubUser.id
|
||||
req.user.save wrapup
|
||||
else if user.get('githubID') isnt githubUser.id # Add or replace githubID to/with existing user
|
||||
user.set 'githubID', githubUser.id
|
||||
user.save wrapup
|
||||
else if user.get('emailLower') isnt emailLower # Existing GitHub user with us changed email
|
||||
user.update {email: githubUser.email}, (err) -> wrapup err, user
|
||||
else # All good you've been here before
|
||||
wrapup null, user
|
||||
req.login (user), (err) ->
|
||||
return errors.serverError res, err if err?
|
||||
res.redirect '/'
|
||||
unless user
|
||||
req.user.set 'email', emailLower
|
||||
req.user.set 'githubID', githubUser.id
|
||||
req.user.save wrapup
|
||||
else if user.get('githubID') isnt githubUser.id # Add or replace githubID to/with existing user
|
||||
user.set 'githubID', githubUser.id
|
||||
user.save wrapup
|
||||
else if user.get('emailLower') isnt emailLower # Existing GitHub user with us changed email
|
||||
user.update {email: emailLower}, (err) -> wrapup err, user
|
||||
else # All good you've been here before
|
||||
wrapup null, user
|
||||
|
|
|
@ -1,4 +1,22 @@
|
|||
mw = require '../middleware'
|
||||
|
||||
module.exports.setup = (app) ->
|
||||
Article = require '../models/Article'
|
||||
app.get('/db/article', mw.rest.get(Article))
|
||||
app.post('/db/article', mw.auth.checkHasPermission(['admin', 'artisan']), mw.rest.post(Article))
|
||||
app.get('/db/article/names', mw.named.names(Article))
|
||||
app.post('/db/article/names', mw.named.names(Article))
|
||||
app.get('/db/article/:handle', mw.rest.getByHandle(Article))
|
||||
app.put('/db/article/:handle', mw.auth.checkHasPermission(['admin']), mw.rest.put(Article))
|
||||
app.patch('/db/article/:handle', mw.auth.checkHasPermission(['admin']), mw.rest.put(Article))
|
||||
app.post('/db/article/:handle/new-version', mw.auth.checkLoggedIn(), mw.versions.postNewVersion(Article, { hasPermissionsOrTranslations: 'artisan' }))
|
||||
app.get('/db/article/:handle/versions', mw.versions.versions(Article))
|
||||
app.get('/db/article/:handle/version/?(:version)?', mw.versions.getLatestVersion(Article))
|
||||
app.get('/db/article/:handle/files', mw.files.files(Article, {module: 'article'}))
|
||||
app.get('/db/article/:handle/patches', mw.patchable.patches(Article))
|
||||
app.post('/db/article/:handle/watchers', mw.patchable.joinWatchers(Article))
|
||||
app.delete('/db/article/:handle/watchers', mw.patchable.leaveWatchers(Article))
|
||||
|
||||
app.get '/db/products', require('./db/product').get
|
||||
|
||||
app.get '/healthcheck', (req, res) ->
|
||||
|
|
|
@ -45,6 +45,14 @@ UserSchema.methods.isInGodMode = ->
|
|||
UserSchema.methods.isAdmin = ->
|
||||
p = @get('permissions')
|
||||
return p and 'admin' in p
|
||||
|
||||
UserSchema.methods.hasPermission = (neededPermissions) ->
|
||||
permissions = @get('permissions') or []
|
||||
if _.contains(permissions, 'admin')
|
||||
return true
|
||||
if _.isString(neededPermissions)
|
||||
neededPermissions = [neededPermissions]
|
||||
return _.size(_.intersection(permissions, neededPermissions))
|
||||
|
||||
UserSchema.methods.isArtisan = ->
|
||||
p = @get('permissions')
|
||||
|
|
|
@ -20,6 +20,7 @@ hipchat = require './server/hipchat'
|
|||
global.tv4 = require 'tv4' # required for TreemaUtils to work
|
||||
global.jsondiffpatch = require 'jsondiffpatch'
|
||||
global.stripe = require('stripe')(config.stripe.secretKey)
|
||||
errors = require './server/commons/errors'
|
||||
|
||||
|
||||
productionLogging = (tokens, req, res) ->
|
||||
|
@ -48,9 +49,21 @@ developmentLogging = (tokens, req, res) ->
|
|||
setupErrorMiddleware = (app) ->
|
||||
app.use (err, req, res, next) ->
|
||||
if err
|
||||
if err.name is 'MongoError' and err.code is 11000
|
||||
err = new errors.Conflict('MongoDB conflict error.')
|
||||
if err.code is 422 and err.response
|
||||
err = new errors.UnprocessableEntity(err.response)
|
||||
if err.code is 409 and err.response
|
||||
err = new errors.Conflict(err.response)
|
||||
|
||||
# TODO: Make all errors use this
|
||||
if err instanceof errors.NetworkError
|
||||
return res.status(err.code).send(err.toJSON())
|
||||
|
||||
if err.status and 400 <= err.status < 500
|
||||
res.status(err.status).send("Error #{err.status}")
|
||||
return
|
||||
|
||||
res.status(err.status ? 500).send(error: "Something went wrong!")
|
||||
message = "Express error: #{req.method} #{req.path}: #{err.message}"
|
||||
log.error "#{message}, stack: #{err.stack}"
|
||||
|
|
|
@ -8,7 +8,7 @@ if process.env.COCO_MONGO_HOST
|
|||
GLOBAL._ = require 'lodash'
|
||||
_.str = require 'underscore.string'
|
||||
_.mixin(_.str.exports())
|
||||
GLOBAL.mongoose = require 'mongoose'
|
||||
GLOBAL.mongoose = require 'mongoose' # TODO: Remove, otherwise it hides when the server is missing a mongoose require
|
||||
path = require 'path'
|
||||
GLOBAL.testing = true
|
||||
GLOBAL.tv4 = require 'tv4' # required for TreemaUtils to work
|
||||
|
@ -71,6 +71,8 @@ GLOBAL.saveModels = (models, done) ->
|
|||
GLOBAL.simplePermissions = [target: 'public', access: 'owner']
|
||||
GLOBAL.ObjectId = mongoose.Types.ObjectId
|
||||
GLOBAL.request = require 'request'
|
||||
Promise = require 'bluebird'
|
||||
Promise.promisifyAll(request, {multiArgs: true})
|
||||
|
||||
GLOBAL.unittest = {}
|
||||
unittest.users = unittest.users or {}
|
||||
|
|
|
@ -1,138 +1,679 @@
|
|||
require '../common'
|
||||
utils = require '../utils'
|
||||
_ = require 'lodash'
|
||||
Promise = require 'bluebird'
|
||||
requestAsync = Promise.promisify(request, {multiArgs: true})
|
||||
|
||||
describe '/db/article', ->
|
||||
request = require 'request'
|
||||
it 'clears the db first', (done) ->
|
||||
clearModels [User, Article], (err) ->
|
||||
throw err if err
|
||||
describe 'GET /db/article', ->
|
||||
articleData1 = { name: 'Article 1', body: 'Article 1 body cow', i18nCoverage: [] }
|
||||
articleData2 = { name: 'Article 2', body: 'Article 2 body moo' }
|
||||
|
||||
beforeEach utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
@admin = yield utils.initAdmin()
|
||||
yield utils.loginUser(@admin)
|
||||
yield request.postAsync(getURL('/db/article'), { json: articleData1 })
|
||||
yield request.postAsync(getURL('/db/article'), { json: articleData2 })
|
||||
yield utils.logout()
|
||||
done()
|
||||
|
||||
|
||||
it 'returns an array of Article objects', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL('/db/article'), json: true }
|
||||
expect(body.length).toBe(2)
|
||||
done()
|
||||
|
||||
|
||||
it 'accepts a limit parameter', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?limit=1'), json: true}
|
||||
expect(body.length).toBe(1)
|
||||
done()
|
||||
|
||||
|
||||
it 'returns 422 for an invalid limit parameter', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?limit=word'), json: true}
|
||||
expect(res.statusCode).toBe(422)
|
||||
done()
|
||||
|
||||
|
||||
it 'accepts a skip parameter', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?skip=1'), json: true}
|
||||
expect(body.length).toBe(1)
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?skip=2'), json: true}
|
||||
expect(body.length).toBe(0)
|
||||
done()
|
||||
|
||||
|
||||
it 'returns 422 for an invalid skip parameter', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?skip=???'), json: true}
|
||||
expect(res.statusCode).toBe(422)
|
||||
done()
|
||||
|
||||
|
||||
it 'accepts a custom project parameter', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?project=name,body'), json: true}
|
||||
expect(body.length).toBe(2)
|
||||
for doc in body
|
||||
expect(_.size(_.xor(_.keys(doc), ['_id', 'name', 'body']))).toBe(0)
|
||||
done()
|
||||
|
||||
|
||||
it 'returns a default projection if project is "true"', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?project=true'), json: true}
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.length).toBe(2)
|
||||
expect(body[0].body).toBeUndefined()
|
||||
expect(body[0].version).toBeDefined()
|
||||
done()
|
||||
|
||||
|
||||
it 'accepts custom filter parameters', utils.wrap (done) ->
|
||||
yield utils.loginUser(@admin)
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?filter[slug]="article-1"'), json: true}
|
||||
expect(body.length).toBe(1)
|
||||
done()
|
||||
|
||||
|
||||
it 'ignores custom filter parameters for non-admins', utils.wrap (done) ->
|
||||
user = yield utils.initUser()
|
||||
yield utils.loginUser(user)
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?filter[slug]="article-1"'), json: true}
|
||||
expect(body.length).toBe(2)
|
||||
done()
|
||||
|
||||
|
||||
it 'accepts custom condition parameters', utils.wrap (done) ->
|
||||
yield utils.loginUser(@admin)
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?conditions[select]="slug body"'), json: true}
|
||||
expect(body.length).toBe(2)
|
||||
for doc in body
|
||||
expect(_.size(_.xor(_.keys(doc), ['_id', 'slug', 'body']))).toBe(0)
|
||||
done()
|
||||
|
||||
|
||||
it 'ignores custom condition parameters for non-admins', utils.wrap (done) ->
|
||||
user = yield utils.initUser()
|
||||
yield utils.loginUser(user)
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?conditions[select]="slug body"'), json: true}
|
||||
expect(body.length).toBe(2)
|
||||
for doc in body
|
||||
expect(doc.name).toBeDefined()
|
||||
done()
|
||||
|
||||
|
||||
it 'allows non-admins to view by i18n-coverage', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?view=i18n-coverage'), json: true}
|
||||
expect(body.length).toBe(1)
|
||||
expect(body[0].slug).toBe('article-1')
|
||||
done()
|
||||
|
||||
|
||||
it 'allows non-admins to search by text', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL('/db/article?term=moo'), json: true}
|
||||
expect(body.length).toBe(1)
|
||||
expect(body[0].slug).toBe('article-2')
|
||||
done()
|
||||
|
||||
|
||||
describe 'POST /db/article', ->
|
||||
|
||||
articleData = { name: 'Article', body: 'Article', otherProp: 'not getting set' }
|
||||
|
||||
beforeEach utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
@admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(@admin)
|
||||
[@res, @body] = yield request.postAsync {
|
||||
uri: getURL('/db/article'), json: articleData
|
||||
}
|
||||
done()
|
||||
|
||||
|
||||
it 'creates a new Article, returning 201', utils.wrap (done) ->
|
||||
expect(@res.statusCode).toBe(201)
|
||||
article = yield Article.findById(@body._id).exec()
|
||||
expect(article).toBeDefined()
|
||||
done()
|
||||
|
||||
|
||||
it 'sets creator to the user who created it', ->
|
||||
expect(@res.body.creator).toBe(@admin.id)
|
||||
|
||||
|
||||
it 'sets original to _id', ->
|
||||
body = @res.body
|
||||
expect(body.original).toBe(body._id)
|
||||
|
||||
|
||||
it 'returns 422 when no input is provided', utils.wrap (done) ->
|
||||
[res, body] = yield request.postAsync { uri: getURL('/db/article') }
|
||||
expect(res.statusCode).toBe(422)
|
||||
done()
|
||||
|
||||
|
||||
it 'allows you to set Article\'s editableProperties', ->
|
||||
expect(@body.name).toBe('Article')
|
||||
|
||||
|
||||
it 'ignores properties not included in editableProperties', ->
|
||||
expect(@body.otherProp).toBeUndefined()
|
||||
|
||||
|
||||
it 'returns 422 when properties do not pass validation', utils.wrap (done) ->
|
||||
[res, body] = yield request.postAsync {
|
||||
uri: getURL('/db/article'), json: { i18nCoverage: 9001 }
|
||||
}
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(body.validationErrors).toBeDefined()
|
||||
done()
|
||||
|
||||
|
||||
it 'allows admins to create Articles', -> # handled in beforeEach
|
||||
|
||||
|
||||
it 'allows artisans to create Articles', utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
artisan = yield utils.initArtisan({})
|
||||
yield utils.loginUser(artisan)
|
||||
[res, body] = yield request.postAsync({uri: getURL('/db/article'), json: articleData })
|
||||
expect(res.statusCode).toBe(201)
|
||||
done()
|
||||
|
||||
|
||||
it 'does not allow normal users to create Articles', utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
user = yield utils.initUser({})
|
||||
yield utils.loginUser(user)
|
||||
[res, body] = yield request.postAsync({uri: getURL('/db/article'), json: articleData })
|
||||
expect(res.statusCode).toBe(403)
|
||||
done()
|
||||
|
||||
|
||||
it 'does not allow anonymous users to create Articles', utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
yield utils.logout()
|
||||
[res, body] = yield request.postAsync({uri: getURL('/db/article'), json: articleData })
|
||||
expect(res.statusCode).toBe(401)
|
||||
done()
|
||||
|
||||
|
||||
it 'does not allow creating Articles with reserved words', utils.wrap (done) ->
|
||||
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: { name: 'Names' } }
|
||||
expect(res.statusCode).toBe(422)
|
||||
done()
|
||||
|
||||
|
||||
it 'does not allow creating a second article of the same name', utils.wrap (done) ->
|
||||
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
|
||||
expect(res.statusCode).toBe(409)
|
||||
done()
|
||||
|
||||
|
||||
describe 'GET /db/article/:handle', ->
|
||||
|
||||
articleData = { name: 'Some Name', body: 'Article' }
|
||||
|
||||
beforeEach utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
@admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(@admin)
|
||||
[@res, @body] = yield request.postAsync {
|
||||
uri: getURL('/db/article'), json: articleData
|
||||
}
|
||||
done()
|
||||
|
||||
|
||||
it 'returns Article by id', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL("/db/article/#{@body._id}"), json: true}
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(_.isObject(body)).toBe(true)
|
||||
done()
|
||||
|
||||
|
||||
it 'returns Article by slug', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL("/db/article/some-name"), json: true}
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(_.isObject(body)).toBe(true)
|
||||
done()
|
||||
|
||||
|
||||
it 'returns not found if handle does not exist in the db', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync {uri: getURL("/db/article/dne"), json: true}
|
||||
expect(res.statusCode).toBe(404)
|
||||
done()
|
||||
|
||||
|
||||
putTests = (method='PUT') ->
|
||||
articleData = { name: 'Some Name', body: 'Article' }
|
||||
|
||||
beforeEach utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
@admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(@admin)
|
||||
[@res, @body] = yield request.postAsync {
|
||||
uri: getURL('/db/article'), json: articleData
|
||||
}
|
||||
done()
|
||||
|
||||
|
||||
it 'edits editable Article properties', utils.wrap (done) ->
|
||||
[res, body] = yield requestAsync {method: method, uri: getURL("/db/article/#{@body._id}"), json: { body: 'New body' }}
|
||||
expect(body.body).toBe('New body')
|
||||
done()
|
||||
|
||||
|
||||
it 'updates the slug when the name is changed', utils.wrap (done) ->
|
||||
[res, body] = yield requestAsync {method: method, uri: getURL("/db/article/#{@body._id}"), json: json = { name: 'New name' }}
|
||||
expect(body.name).toBe('New name')
|
||||
expect(body.slug).toBe('new-name')
|
||||
done()
|
||||
|
||||
|
||||
it 'does not allow normal artisan, non-admins to make changes', utils.wrap (done) ->
|
||||
artisan = yield utils.initArtisan({})
|
||||
yield utils.loginUser(artisan)
|
||||
[res, body] = yield requestAsync {method: method, uri: getURL("/db/article/#{@body._id}"), json: { name: 'Another name' }}
|
||||
expect(res.statusCode).toBe(403)
|
||||
done()
|
||||
|
||||
|
||||
describe 'PUT /db/article/:handle', -> putTests('PUT')
|
||||
describe 'PATCH /db/article/:handle', -> putTests('PATCH')
|
||||
|
||||
|
||||
describe 'POST /db/article/:handle/new-version', ->
|
||||
articleData = { name: 'Article name', body: 'Article body', i18n: {} }
|
||||
articleID = null
|
||||
|
||||
beforeEach utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
@admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(@admin)
|
||||
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
|
||||
expect(res.statusCode).toBe(201)
|
||||
articleID = body._id
|
||||
done()
|
||||
|
||||
postNewVersion = Promise.promisify (json, expectedStatus=201, done) ->
|
||||
if _.isFunction(expectedStatus)
|
||||
done = expectedStatus
|
||||
expectedStatus = 201
|
||||
url = getURL("/db/article/#{articleID}/new-version")
|
||||
request.post { uri: url, json: json }, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(expectedStatus)
|
||||
done(err)
|
||||
|
||||
testArrayEqual = (given, expected) ->
|
||||
expect(_.isEqual(given, expected)).toBe(true)
|
||||
|
||||
|
||||
|
||||
it 'creates a new major version, updating model and version properties', utils.wrap (done) ->
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body' })
|
||||
yield postNewVersion({ name: 'New name', body: 'New new body' })
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(3)
|
||||
versions = (article.get('version') for article in articles)
|
||||
articles = (article.toObject() for article in articles)
|
||||
|
||||
testArrayEqual(_.pluck(versions, 'major'), [0, 1, 2])
|
||||
testArrayEqual(_.pluck(versions, 'minor'), [0, 0, 0])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, false, true])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [true, true, true])
|
||||
testArrayEqual(_.pluck(articles, 'name'), ['Article name', 'Article name', 'New name'])
|
||||
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body', 'New new body'])
|
||||
testArrayEqual(_.pluck(articles, 'slug'), [undefined, undefined, 'new-name'])
|
||||
testArrayEqual(_.pluck(articles, 'index'), [undefined, undefined, true])
|
||||
done()
|
||||
|
||||
|
||||
it 'works if there is no document with the appropriate version settings (new major)', utils.wrap (done) ->
|
||||
article = yield Article.findById(articleID)
|
||||
article.set({ 'version.isLatestMajor': false, 'version.isLatestMinor': false })
|
||||
yield article.save()
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body' })
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(2)
|
||||
|
||||
versions = (article.get('version') for article in articles)
|
||||
articles = (article.toObject() for article in articles)
|
||||
|
||||
testArrayEqual(_.pluck(versions, 'major'), [0, 1])
|
||||
testArrayEqual(_.pluck(versions, 'minor'), [0, 0])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, true])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, true]) # does not fix the old version's value
|
||||
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body'])
|
||||
testArrayEqual(_.pluck(articles, 'slug'), [undefined, 'article-name'])
|
||||
testArrayEqual(_.pluck(articles, 'index'), [undefined, true])
|
||||
done()
|
||||
|
||||
|
||||
it 'creates a new minor version if version.major is included', utils.wrap (done) ->
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body', version: { major: 0 } })
|
||||
yield postNewVersion({ name: 'Article name', body: 'New new body', version: { major: 0 } })
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(3)
|
||||
|
||||
versions = (article.get('version') for article in articles)
|
||||
articles = (article.toObject() for article in articles)
|
||||
|
||||
testArrayEqual(_.pluck(versions, 'major'), [0, 0, 0])
|
||||
testArrayEqual(_.pluck(versions, 'minor'), [0, 1, 2])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, false, true])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, false, true])
|
||||
testArrayEqual(_.pluck(articles, 'name'), ['Article name', 'Article name', 'Article name'])
|
||||
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body', 'New new body'])
|
||||
testArrayEqual(_.pluck(articles, 'slug'), [undefined, undefined, 'article-name'])
|
||||
testArrayEqual(_.pluck(articles, 'index'), [undefined, undefined, true])
|
||||
done()
|
||||
|
||||
|
||||
it 'works if there is no document with the appropriate version settings (new minor)', utils.wrap (done) ->
|
||||
article = yield Article.findById(articleID)
|
||||
article.set({ 'version.isLatestMajor': false, 'version.isLatestMinor': false })
|
||||
yield article.save()
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body', version: { major: 0 } })
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(2)
|
||||
|
||||
versions = (article.get('version') for article in articles)
|
||||
articles = (article.toObject() for article in articles)
|
||||
|
||||
testArrayEqual(_.pluck(versions, 'major'), [0, 0])
|
||||
testArrayEqual(_.pluck(versions, 'minor'), [0, 1])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, false])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, true])
|
||||
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body'])
|
||||
testArrayEqual(_.pluck(articles, 'slug'), [undefined, 'article-name'])
|
||||
testArrayEqual(_.pluck(articles, 'index'), [undefined, true])
|
||||
done()
|
||||
|
||||
|
||||
it 'allows adding new minor versions to old major versions', utils.wrap (done) ->
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body' })
|
||||
yield postNewVersion({ name: 'Article name', body: 'New new body', version: { major: 0 } })
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(3)
|
||||
|
||||
versions = (article.get('version') for article in articles)
|
||||
articles = (article.toObject() for article in articles)
|
||||
|
||||
testArrayEqual(_.pluck(versions, 'major'), [0, 1, 0])
|
||||
testArrayEqual(_.pluck(versions, 'minor'), [0, 0, 1])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMajor'), [false, true, false])
|
||||
testArrayEqual(_.pluck(versions, 'isLatestMinor'), [false, true, true])
|
||||
testArrayEqual(_.pluck(articles, 'name'), ['Article name', 'Article name', 'Article name'])
|
||||
testArrayEqual(_.pluck(articles, 'body'), ['Article body', 'New body', 'New new body'])
|
||||
testArrayEqual(_.pluck(articles, 'slug'), [undefined, 'article-name', undefined])
|
||||
testArrayEqual(_.pluck(articles, 'index'), [undefined, true, undefined])
|
||||
done()
|
||||
|
||||
|
||||
it 'unsets properties which are not included in the request', utils.wrap (done) ->
|
||||
yield postNewVersion({ name: 'Article name', version: { major: 0 } })
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(2)
|
||||
expect(articles[1].get('body')).toBeUndefined()
|
||||
done()
|
||||
|
||||
|
||||
it 'works for artisans', utils.wrap (done) ->
|
||||
yield utils.logout()
|
||||
artisan = yield utils.initArtisan()
|
||||
yield utils.loginUser(artisan)
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body' })
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(2)
|
||||
done()
|
||||
|
||||
|
||||
it 'works for normal users submitting translations', utils.wrap (done) ->
|
||||
yield utils.logout()
|
||||
user = yield utils.initUser()
|
||||
yield utils.loginUser(user)
|
||||
yield postNewVersion({ name: 'Article name', body: 'Article body', i18n: { fr: { name: 'Le Article' }}}, 201)
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(2)
|
||||
done()
|
||||
|
||||
|
||||
it 'does not work for normal users', utils.wrap (done) ->
|
||||
yield utils.logout()
|
||||
user = yield utils.initUser()
|
||||
yield utils.loginUser(user)
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body' }, 403)
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(1)
|
||||
done()
|
||||
|
||||
|
||||
it 'does not work for anonymous users', utils.wrap (done) ->
|
||||
yield utils.logout()
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body' }, 401)
|
||||
articles = yield Article.find()
|
||||
expect(articles.length).toBe(1)
|
||||
done()
|
||||
|
||||
|
||||
it 'notifies watchers of changes', utils.wrap (done) ->
|
||||
sendwithus = require '../../../server/sendwithus'
|
||||
spyOn(sendwithus.api, 'send').and.callFake (context, cb) ->
|
||||
expect(context.email_id).toBe(sendwithus.templates.change_made_notify_watcher)
|
||||
expect(context.recipient.address).toBe('test@gmail.com')
|
||||
done()
|
||||
user = yield User({email: 'test@gmail.com', name: 'a user'}).save()
|
||||
article = yield Article.findById(articleID)
|
||||
article.set('watchers', article.get('watchers').concat([user.get('_id')]))
|
||||
yield article.save()
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body', commitMessage: 'Commit message' })
|
||||
|
||||
|
||||
it 'sends a notification to artisan and main HipChat channels', utils.wrap (done) ->
|
||||
hipchat = require '../../../server/hipchat'
|
||||
spyOn(hipchat, 'sendHipChatMessage')
|
||||
yield postNewVersion({ name: 'Article name', body: 'New body' })
|
||||
expect(hipchat.sendHipChatMessage).toHaveBeenCalled()
|
||||
done()
|
||||
|
||||
describe 'version fetching endpoints', ->
|
||||
articleData = { name: 'Original version', body: 'Article body' }
|
||||
articleOriginal = null
|
||||
|
||||
article = {name: 'Yo', body: 'yo ma'}
|
||||
article2 = {name: 'Original', body: 'yo daddy'}
|
||||
postNewVersion = Promise.promisify (json, expectedStatus=201, done) ->
|
||||
if _.isFunction(expectedStatus)
|
||||
done = expectedStatus
|
||||
expectedStatus = 201
|
||||
url = getURL("/db/article/#{articleOriginal}/new-version")
|
||||
request.post { uri: url, json: json }, (err, res) ->
|
||||
expect(res.statusCode).toBe(expectedStatus)
|
||||
done(err)
|
||||
|
||||
url = getURL('/db/article')
|
||||
articles = {}
|
||||
|
||||
it 'does not allow non-admins to create Articles.', (done) ->
|
||||
loginJoe ->
|
||||
request.post {uri: url, json: article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(403)
|
||||
done()
|
||||
beforeEach utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
@admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(@admin)
|
||||
[res, body] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
|
||||
expect(res.statusCode).toBe(201)
|
||||
articleOriginal = body._id
|
||||
yield postNewVersion({ name: 'Latest minor version', body: 'New body', version: {major: 0} })
|
||||
yield postNewVersion({ name: 'Latest major version', body: 'New new body' })
|
||||
done()
|
||||
|
||||
it 'allows admins to create Articles', (done) ->
|
||||
loginAdmin ->
|
||||
request.post {uri: url, json: article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.slug).toBeDefined()
|
||||
expect(body.body).toBeDefined()
|
||||
expect(body.name).toBeDefined()
|
||||
expect(body.original).toBeDefined()
|
||||
expect(body.creator).toBeDefined()
|
||||
articles[0] = body
|
||||
|
||||
# Having two articles allow for testing article search and such
|
||||
request.post {uri: url, json: article2}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.slug).toBeDefined()
|
||||
expect(body.body).toBeDefined()
|
||||
expect(body.name).toBeDefined()
|
||||
expect(body.original).toBeDefined()
|
||||
expect(body.creator).toBeDefined()
|
||||
articles[0] = body
|
||||
|
||||
done()
|
||||
|
||||
it 'allows admins to make new minor versions', (done) ->
|
||||
new_article = _.clone(articles[0])
|
||||
new_article.body = 'yo daddy'
|
||||
request.post {uri: url, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.version.major).toBe(0)
|
||||
expect(body.version.minor).toBe(1)
|
||||
expect(body._id).not.toBe(articles[0]._id)
|
||||
expect(body.parent).toBe(articles[0]._id)
|
||||
expect(body.creator).toBeDefined()
|
||||
articles[1] = body
|
||||
describe 'GET /db/article/:handle/version/:version', ->
|
||||
|
||||
it 'returns the latest version for the given original article when :version is empty', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/version"), json: true }
|
||||
expect(body.name).toBe('Latest major version')
|
||||
done()
|
||||
|
||||
it 'allows admins to make new major versions', (done) ->
|
||||
new_article = _.clone(articles[1])
|
||||
delete new_article.version
|
||||
request.post {uri: url, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.version.major).toBe(1)
|
||||
expect(body.version.minor).toBe(0)
|
||||
expect(body._id).not.toBe(articles[1]._id)
|
||||
expect(body.parent).toBe(articles[1]._id)
|
||||
articles[2] = body
|
||||
|
||||
it 'returns the latest of a given major version when :version is X', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/version/0"), json: true }
|
||||
expect(body.name).toBe('Latest minor version')
|
||||
done()
|
||||
|
||||
it 'grants access for regular users', (done) ->
|
||||
loginJoe ->
|
||||
request.get {uri: url+'/'+articles[0]._id}, (err, res, body) ->
|
||||
body = JSON.parse(body)
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.body).toBe(articles[0].body)
|
||||
done()
|
||||
|
||||
it 'does not allow regular users to make new versions', (done) ->
|
||||
new_article = _.clone(articles[2])
|
||||
request.post {uri: url, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(403)
|
||||
|
||||
it 'returns a specific version when :version is X.Y', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/version/0.0"), json: true }
|
||||
expect(body.name).toBe('Original version')
|
||||
done()
|
||||
|
||||
it 'allows name changes from one version to the next', (done) ->
|
||||
loginAdmin ->
|
||||
new_article = _.clone(articles[0])
|
||||
new_article.name = 'Yo mama now is the larger'
|
||||
request.post {uri: url, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.name).toBe(new_article.name)
|
||||
done()
|
||||
|
||||
it 'get schema', (done) ->
|
||||
request.get {uri: url+'/schema'}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
body = JSON.parse(body)
|
||||
expect(body.type).toBeDefined()
|
||||
|
||||
it 'returns 422 when the original value is invalid', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL('/db/article/dne/version'), json: true }
|
||||
expect(res.statusCode).toBe(422)
|
||||
done()
|
||||
|
||||
it 'returns 404 when the original value cannot be found', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL('/db/article/012345678901234567890123/version'), json: true }
|
||||
expect(res.statusCode).toBe(404)
|
||||
done()
|
||||
|
||||
|
||||
describe 'GET /db/article/:handle/versions', ->
|
||||
|
||||
it 'returns an array of versions sorted by creation for the given original article', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/versions"), json: true }
|
||||
expect(body.length).toBe(3)
|
||||
expect(body[0].name).toBe('Latest major version')
|
||||
expect(body[1].name).toBe('Latest minor version')
|
||||
expect(body[2].name).toBe('Original version')
|
||||
done()
|
||||
|
||||
it 'projects most properties by default', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync { uri: getURL("/db/article/#{articleOriginal}/versions"), json: true }
|
||||
expect(body[0].body).toBeUndefined()
|
||||
done()
|
||||
|
||||
|
||||
describe 'GET /db/article/:handle/files', ->
|
||||
|
||||
it 'returns an array of file metadata for the given original article', utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
articleData = { name: 'Article', body: 'Article' }
|
||||
admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(admin)
|
||||
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
|
||||
expect(res.statusCode).toBe(201)
|
||||
[res, body] = yield request.postAsync(getURL('/file'), { json: {
|
||||
url: getURL('/assets/main.html')
|
||||
filename: 'test.html'
|
||||
path: 'db/article/'+article.original
|
||||
mimetype: 'text/html'
|
||||
}})
|
||||
[res, body] = yield request.getAsync(getURL('/db/article/'+article.original+'/files'), {json: true})
|
||||
expect(body.length).toBe(1)
|
||||
expect(body[0].filename).toBe('test.html')
|
||||
expect(body[0].metadata.path).toBe('db/article/'+article.original)
|
||||
done()
|
||||
|
||||
|
||||
describe 'GET and POST /db/article/:handle/names', ->
|
||||
articleData1 = { name: 'Article 1', body: 'Article 1 body' }
|
||||
articleData2 = { name: 'Article 2', body: 'Article 2 body' }
|
||||
|
||||
it 'does not allow naming an article a reserved word', (done) ->
|
||||
loginAdmin ->
|
||||
new_article = {name: 'Names', body: 'is a reserved word'}
|
||||
request.post {uri: url, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(422)
|
||||
done()
|
||||
it 'returns an object mapping ids to names', utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(admin)
|
||||
[res, article1] = yield request.postAsync(getURL('/db/article'), { json: articleData1 })
|
||||
[res, article2] = yield request.postAsync(getURL('/db/article'), { json: articleData2 })
|
||||
yield utils.logout()
|
||||
[res, body] = yield request.getAsync { uri: getURL('/db/article/names?ids='+[article1._id, article2._id].join(',')), json: true }
|
||||
expect(body.length).toBe(2)
|
||||
expect(body[0].name).toBe('Article 1')
|
||||
[res, body] = yield request.postAsync { uri: getURL('/db/article/names?ids='+[article1._id, article2._id].join(',')), json: true }
|
||||
expect(body.length).toBe(2)
|
||||
expect(body[0].name).toBe('Article 1')
|
||||
done()
|
||||
|
||||
|
||||
describe 'GET /db/article/:handle/patches', ->
|
||||
|
||||
it 'returns pending patches for the given original article', utils.wrap (done) ->
|
||||
yield utils.clearModels([Article])
|
||||
articleData = { name: 'Article', body: 'Article' }
|
||||
admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(admin)
|
||||
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
|
||||
expect(res.statusCode).toBe(201)
|
||||
[res, patch] = yield request.postAsync { uri: getURL('/db/patch'), json: {
|
||||
delta: []
|
||||
commitMessage: 'Test commit'
|
||||
target: {
|
||||
collection: 'article'
|
||||
id: article._id
|
||||
}
|
||||
}}
|
||||
[res, patches] = yield request.getAsync getURL("/db/article/#{article._id}/patches"), { json: true }
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(patches.length).toBe(1)
|
||||
expect(patches[0]._id).toBe(patch._id)
|
||||
done()
|
||||
|
||||
it 'returns 422 for invalid object ids', utils.wrap (done) ->
|
||||
[res, body] = yield request.getAsync getURL("/db/article/invalid/patches"), { json: true }
|
||||
expect(res.statusCode).toBe(422)
|
||||
done()
|
||||
|
||||
|
||||
describe 'POST /db/article/:handle/watchers', ->
|
||||
|
||||
it 'adds self to the list of watchers, and is idempotent', utils.wrap (done) ->
|
||||
# create article
|
||||
yield utils.clearModels([Article])
|
||||
articleData = { name: 'Article', body: 'Article' }
|
||||
admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(admin)
|
||||
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
|
||||
expect(res.statusCode).toBe(201)
|
||||
|
||||
# add new user as watcher
|
||||
yield utils.logout()
|
||||
user = yield utils.initUser()
|
||||
yield utils.loginUser(user)
|
||||
[res, article] = yield request.postAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(_.contains(article.watchers, user.id)).toBe(true)
|
||||
|
||||
it 'allows regular users to get all articles', (done) ->
|
||||
loginJoe ->
|
||||
request.get {uri: url, json: {}}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.length).toBe(2)
|
||||
done()
|
||||
# check idempotence, db
|
||||
numWatchers = article.watchers.length
|
||||
[res, article] = yield request.postAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(numWatchers).toBe(article.watchers.length)
|
||||
article = yield Article.findById(article._id)
|
||||
expect(_.last(article.get('watchers')).toString()).toBe(user.id)
|
||||
done()
|
||||
|
||||
|
||||
it 'allows regular users to get articles and use projection', (done) ->
|
||||
loginJoe ->
|
||||
# default projection
|
||||
request.get {uri: url + '?project=true', json: {}}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.length).toBe(2)
|
||||
expect(body[0].body).toBeUndefined()
|
||||
expect(body[0].version).toBeDefined()
|
||||
describe 'DELETE /db/article/:handle/watchers', ->
|
||||
|
||||
it 'removes self from the list of watchers, and is idempotent', utils.wrap (done) ->
|
||||
# create article
|
||||
yield utils.clearModels([Article])
|
||||
articleData = { name: 'Article', body: 'Article' }
|
||||
admin = yield utils.initAdmin({})
|
||||
yield utils.loginUser(admin)
|
||||
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
|
||||
expect(res.statusCode).toBe(201)
|
||||
|
||||
# custom projection
|
||||
request.get {uri: url + '?project=original', json: {}}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.length).toBe(2)
|
||||
expect(Object.keys(body[0]).length).toBe(2)
|
||||
expect(body[0].original).toBeDefined()
|
||||
done()
|
||||
# add new user as watcher
|
||||
yield utils.logout()
|
||||
user = yield utils.initUser()
|
||||
yield utils.loginUser(user)
|
||||
[res, article] = yield request.postAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
|
||||
expect(_.contains(article.watchers, user.id)).toBe(true)
|
||||
|
||||
it 'allows regular users to perform a text search', (done) ->
|
||||
loginJoe ->
|
||||
request.get {uri: url + '?term="daddy"', json: {}}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(body.length).toBe(1)
|
||||
expect(body[0].name).toBe(article2.name)
|
||||
expect(body[0].body).toBe(article2.body)
|
||||
done()
|
||||
# remove user as watcher
|
||||
[res, article] = yield request.delAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(_.contains(article.watchers, user.id)).toBe(false)
|
||||
|
||||
# check idempotence, db
|
||||
numWatchers = article.watchers.length
|
||||
[res, article] = yield request.delAsync { uri: getURL("/db/article/#{article._id}/watchers"), json: true }
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(numWatchers).toBe(article.watchers.length)
|
||||
article = yield Article.findById(article._id)
|
||||
ids = (id.toString() for id in article.get('watchers'))
|
||||
expect(_.contains(ids, user.id)).toBe(false)
|
||||
done()
|
|
@ -14,17 +14,18 @@ describe '/db/<id>/version', ->
|
|||
it 'sets up', (done) ->
|
||||
loginAdmin ->
|
||||
request.post {uri: url, json: article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.statusCode).toBe(201)
|
||||
articles[0] = body
|
||||
new_article = _.clone(articles[0])
|
||||
new_article.body = '...'
|
||||
request.post {uri: url, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
newVersionURL = "#{url}/#{new_article._id}/new-version"
|
||||
request.post {uri: newVersionURL, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(201)
|
||||
articles[1] = body
|
||||
new_article = _.clone(articles[1])
|
||||
delete new_article.version
|
||||
request.post {uri: url, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(200)
|
||||
request.post {uri: newVersionURL, json: new_article}, (err, res, body) ->
|
||||
expect(res.statusCode).toBe(201)
|
||||
articles[2] = body
|
||||
done()
|
||||
|
||||
|
|
|
@ -726,7 +726,7 @@ describe '/db/prepaid', ->
|
|||
expect(res.statusCode).not.toEqual(200)
|
||||
done()
|
||||
|
||||
it 'Test a bunch of people trying to redeem at once', (done) ->
|
||||
xit 'Test a bunch of people trying to redeem at once', (done) ->
|
||||
nockUtils.setupNock 'db-sub-redeem-test-3.json', (err, nockDone) ->
|
||||
doRedeem = (userX, code, testnum, retry, fnDone) =>
|
||||
loginUser userX, () =>
|
||||
|
|
|
@ -385,18 +385,19 @@ describe 'Statistics', ->
|
|||
expect(carl.get User.statsMapping.edits.article).toBeUndefined()
|
||||
article.creator = carl.get 'id'
|
||||
|
||||
# Create major version 1.0
|
||||
# Create major version 0.0
|
||||
request.post {uri:url, json: article}, (err, res, body) ->
|
||||
expect(err).toBeNull()
|
||||
expect(res.statusCode).toBe 200
|
||||
expect(res.statusCode).toBe 201
|
||||
article = body
|
||||
|
||||
User.findById carl.get('id'), (err, guy) ->
|
||||
expect(err).toBeNull()
|
||||
expect(guy.get User.statsMapping.edits.article).toBe 1
|
||||
|
||||
# Create minor version 1.1
|
||||
request.post {uri:url, json: article}, (err, res, body) ->
|
||||
# Create minor version 0.1
|
||||
newVersionURL = "#{url}/#{article._id}/new-version"
|
||||
request.post {uri:newVersionURL, json: article}, (err, res, body) ->
|
||||
expect(err).toBeNull()
|
||||
|
||||
User.findById carl.get('id'), (err, guy) ->
|
||||
|
|
64
spec/server/utils.coffee
Normal file
64
spec/server/utils.coffee
Normal file
|
@ -0,0 +1,64 @@
|
|||
async = require 'async'
|
||||
utils = require '../../server/lib/utils'
|
||||
co = require 'co'
|
||||
Promise = require 'bluebird'
|
||||
|
||||
module.exports = mw =
|
||||
getURL: (path) -> 'http://localhost:3001' + path
|
||||
|
||||
clearModels: Promise.promisify (models, done) ->
|
||||
funcs = []
|
||||
for model in models
|
||||
wrapped = (m) ->
|
||||
(callback) ->
|
||||
m.remove {}, (err) ->
|
||||
callback(err, true)
|
||||
funcs.push(wrapped(model))
|
||||
async.parallel funcs, done
|
||||
|
||||
initUser: (options, done) ->
|
||||
if _.isFunction(options)
|
||||
done = options
|
||||
options = {}
|
||||
options = _.extend({permissions: []}, options)
|
||||
doc = {
|
||||
email: 'user'+_.uniqueId()+'@gmail.com'
|
||||
password: 'password'
|
||||
permissions: options.permissions
|
||||
}
|
||||
user = new User(doc)
|
||||
promise = user.save()
|
||||
return promise
|
||||
|
||||
loginUser: Promise.promisify (user, done) ->
|
||||
form = {
|
||||
username: user.get('email')
|
||||
password: 'password'
|
||||
}
|
||||
request.post mw.getURL('/auth/login'), { form: form }, (err, res) ->
|
||||
expect(err).toBe(null)
|
||||
expect(res.statusCode).toBe(200)
|
||||
done(err, user)
|
||||
|
||||
initAdmin: (options) ->
|
||||
if _.isFunction(options)
|
||||
done = options
|
||||
options = {}
|
||||
options = _.extend({permissions: ['admin']}, options)
|
||||
return @initUser(options)
|
||||
|
||||
initArtisan: (options) ->
|
||||
if _.isFunction(options)
|
||||
done = options
|
||||
options = {}
|
||||
options = _.extend({permissions: ['artisan']}, options)
|
||||
return @initUser(options)
|
||||
|
||||
logout: Promise.promisify (done) ->
|
||||
request.post mw.getURL('/auth/logout'), done
|
||||
|
||||
wrap: (gen) ->
|
||||
fn = co.wrap(gen)
|
||||
return (done) ->
|
||||
fn.apply(@, [done]).catch (err) -> done.fail(err)
|
||||
|
Loading…
Reference in a new issue