Merge master branch

This commit is contained in:
Imperadeiro98 2016-02-24 19:40:21 +00:00
commit 42f84de394
62 changed files with 3365 additions and 1566 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

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

View file

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

View file

@ -0,0 +1,5 @@
PatchModel = require 'models/Patch'
CocoCollection = require 'collections/CocoCollection'
module.exports = class Patches extends CocoCollection
model: PatchModel

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
# TODO: Migrate Article to here
module.exports = require '../articles/Article'

View file

@ -0,0 +1,3 @@
# TODO: Migrate Patch to here
module.exports = require '../patches/Patch'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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