Added employer_list and activity tracking.

This commit is contained in:
Nick Winter 2014-06-10 16:30:07 -07:00
parent 9c1213d3c4
commit f3d18efa90
15 changed files with 301 additions and 21 deletions

View file

@ -436,6 +436,7 @@
av_entities_sub_title: "Entities"
av_entities_users_url: "Users"
av_entities_active_instances_url: "Active Instances"
av_entities_employer_list_url: "Employer List"
av_other_sub_title: "Other"
av_other_debug_base_url: "Base (for debugging base.jade)"
u_title: "User List"

View file

@ -113,8 +113,10 @@ UserSchema = c.object {},
signedEmployerAgreement: c.object {},
linkedinID: c.shortString {title:"LinkedInID", description: "The user's LinkedIn ID when they signed the contract."}
date: c.date {title: "Date signed employer agreement"}
data: c.object {description: "Cached LinkedIn data slurped from profile."}
data: c.object {description: "Cached LinkedIn data slurped from profile.", additionalProperties: true}
points: {type:'number'}
activity: {type: 'object', description: 'Summary statistics about user activity', additionalProperties: c.activity}
c.extendBasicProperties UserSchema, 'user'

View file

@ -54,7 +54,7 @@ basicProps = (linkFragment) ->
me.extendBasicProperties = (schema, linkFragment) ->
schema.properties = {} unless schema.properties?
_.extend(schema.properties, basicProps(linkFragment))
# PATCHABLE
patchableProps = ->
@ -65,7 +65,7 @@ patchableProps = ->
allowPatches: { type: 'boolean' }
watchers: me.array({title:'Watchers'},
me.objectId(links: [{rel: 'extra', href: "/db/user/{($)}"}]))
me.extendPatchableProperties = (schema) ->
schema.properties = {} unless schema.properties?
_.extend(schema.properties, patchableProps())
@ -170,3 +170,8 @@ me.FunctionArgumentSchema = me.object {
title: "Default"
description: "Default value of the argument. (Your code should set this.)"
"default": null
me.activity = me.object {description: "Stats on an activity"},
first: me.date()
last: me.date()
count: {type: 'integer', minimum: 0}

View file

@ -44,11 +44,6 @@ block content
span(data-i18n='account_profile.not_featured') Not Featured
if me.isAdmin() && !myProfile
button.btn.edit-settings-button#enter-espionage-mode 007
//if editing && myProfile
// a.sample-profile(href="http://codecombat.com/images/pages/account/profile/sample_profile.png", target="_blank")
// button.btn
// i.icon-user
// span(data-i18n="account_settings.sample_profile") See a sample profile
if profile && allowedToViewJobProfile
div(class="job-profile-container" + (editing ? " editable-profile" : ""))

View file

@ -23,6 +23,8 @@ block content
a(href="/admin/users", data-i18n="admin.av_entities_users_url") Users
li
a(href="/admin/level_sessions", data-i18n="admin.av_entities_active_instances_url") Active Instances
li
a(href="/admin/employer_list", data-i18n="admin.av_entities_employer_list_url") Employer List
h4(data-i18n="admin.av_other_sub_title") Other

View file

@ -0,0 +1,65 @@
extends /templates/base
block content
if !me.isAdmin()
h1 Admin Only
else
h1(data-i18n="admin.av_entities_employer_list_url") Employer List
p
| We currently have
if employers.length
| #{employers.length}
else
| ...
| employers in the system.
if employers.length
table.table.table-condensed.table-hover.table-responsive.tablesorter
thead
tr
th(data-i18n="general.name") Name
th Company
th(data-i18n="general.email") Email
th Logins
th Candidates Viewed
th Candidates Contacted
th Signed Up
tbody
for employer, index in employers
- var activity = employer.get('activity') || {};
- var linkedIn = employer.get('signedEmployerAgreement').data
tr(data-employer-id=employer.id)
td
img(src=employer.getPhotoURL(50), height=50)
p
if employer.get('firstName')
span= employer.get('firstName') + ' ' + employer.get('lastName')
if employer.get('name')
| -
else if linkedIn.firstName
span= linkedIn.firstName + ' ' + linkedIn.lastName
if employer.get('name')
| -
if employer.get('name')
span= employer.get('name')
if !employer.get('firstName') && !linkedIn.firstName && !employer.get('name')
| Anoner
td
a(href=employer.get('signedEmployerAgreement').data.publicProfileUrl)= employer.get('employerAt')
td= employer.get('email')
for a in ['login', 'view_candidate', 'contact_candidate']
- var act = activity[a];
if act
td
strong= act.count
|
br
span= moment(activity.login.first).fromNow()
br
span= moment(activity.login.last).fromNow()
else
td 0
td(data-employer-age=(new Date() - new Date(employer.get('signedEmployerAgreement').date)) / 86400 / 1000)= moment(employer.get('signedEmployerAgreement').date).fromNow()

View file

@ -18,6 +18,11 @@ block content
|
a(href="http://blog.codecombat.com/multiplayer-programming-tournament", data-i18n="ladder.tournament_blurb_blog") on our blog
| .
p
| At 5PM PDT today, we'll take a snapshot of submitted code and run an exhaustive pairwise ranking between the top N (at least 200) players. Winners will be sorted by
code wins - losses
| , and especially given deduplication on each team, final rankings may look significantly different than these preliminary rankings.
| We will announce winners as soon as we can. Thanks for playing!
.sponsor-logos
a(href="https://heapanalytics.com/")

View file

@ -59,6 +59,8 @@ module.exports = class ProfileView extends View
@user.fetch()
@listenTo @user, "sync", =>
@render()
$.post "/db/user/#{me.id}/track/view_candidate"
$.post "/db/user/#{@userID}/track/viewed_by_employer" unless me.isAdmin()
else
@user = User.getByID(@userID)
@ -113,7 +115,7 @@ module.exports = class ProfileView extends View
for position in p["positions"]["values"]
workObj = {}
descriptionMaxLength = workSchema.description.maxLength
workObj.description = position.summary?.slice(0,descriptionMaxLength)
workObj.description ?= ""
if position.startDate?.year?

View file

@ -0,0 +1,160 @@
View = require 'views/kinds/RootView'
template = require 'templates/admin/employer_list'
app = require 'application'
User = require 'models/User'
{me} = require 'lib/auth'
CocoCollection = require 'collections/CocoCollection'
ModelModal = require 'views/modal/model_modal'
class EmployersCollection extends CocoCollection
url: '/db/user/x/employers'
model: User
module.exports = class EmployersView extends View
id: "employers-view"
template: template
events:
'click tbody tr td:first-child': 'onEmployerClicked'
constructor: (options) ->
super options
@getEmployers()
afterRender: ->
super()
@sortTable() if @employers.models.length
getRenderData: ->
ctx = super()
ctx.employers = @employers.models
ctx.moment = moment
ctx
getEmployers: ->
@employers = new EmployersCollection()
@employers.fetch()
# Re-render when we have fetched them, but don't wait and show a progress bar while loading.
@listenToOnce @employers, 'all', => @render()
sortTable: ->
# http://mottie.github.io/tablesorter/docs/example-widget-bootstrap-theme.html
$.extend $.tablesorter.themes.bootstrap,
# these classes are added to the table. To see other table classes available,
# look here: http://twitter.github.com/bootstrap/base-css.html#tables
table: "table table-bordered"
caption: "caption"
header: "bootstrap-header" # give the header a gradient background
footerRow: ""
footerCells: ""
icons: "" # add "icon-white" to make them white; this icon class is added to the <i> in the header
sortNone: "bootstrap-icon-unsorted"
sortAsc: "icon-chevron-up" # glyphicon glyphicon-chevron-up" # we are still using v2 icons
sortDesc: "icon-chevron-down" # glyphicon-chevron-down" # we are still using v2 icons
active: "" # applied when column is sorted
hover: "" # use custom css here - bootstrap class may not override it
filterRow: "" # filter row class
even: "" # odd row zebra striping
odd: "" # even row zebra striping
# e = exact text from cell
# n = normalized value returned by the column parser
# f = search filter input value
# i = column index
# $r = ???
filterSelectExactMatch = (e, n, f, i, $r) -> e is f
# call the tablesorter plugin and apply the uitheme widget
@$el.find(".tablesorter").tablesorter
theme: "bootstrap"
widthFixed: true
headerTemplate: "{content} {icon}"
textSorter:
6: (a, b, direction, column, table) ->
days = []
for s in [a, b]
n = parseInt s
n = 0 unless _.isNumber n
n = 1 if /^a/.test s
for [duration, factor] in [
[/second/i, 1 / (86400 * 1000)]
[/minute/i, 1 / 1440]
[/hour/i, 1 / 24]
[/week/i, 7]
[/month/i, 30.42]
[/year/i, 365.2425]
]
if duration.test s
n *= factor
break
if /^in /i.test s
n *= -1
days.push n
days[0] - days[1]
sortList: [[6, 0]]
# widget code contained in the jquery.tablesorter.widgets.js file
# use the zebra stripe widget if you plan on hiding any rows (filter widget)
widgets: ["uitheme", "zebra", "filter"]
widgetOptions:
# using the default zebra striping class name, so it actually isn't included in the theme variable above
# this is ONLY needed for bootstrap theming if you are using the filter widget, because rows are hidden
zebra: ["even", "odd"]
# extra css class applied to the table row containing the filters & the inputs within that row
filter_cssFilter: ""
# If there are child rows in the table (rows with class name from "cssChildRow" option)
# and this option is true and a match is found anywhere in the child row, then it will make that row
# visible; default is false
filter_childRows: false
# if true, filters are collapsed initially, but can be revealed by hovering over the grey bar immediately
# below the header row. Additionally, tabbing through the document will open the filter row when an input gets focus
filter_hideFilters: false
# Set this option to false to make the searches case sensitive
filter_ignoreCase: true
# jQuery selector string of an element used to reset the filters
filter_reset: ".reset"
# Use the $.tablesorter.storage utility to save the most recent filters
filter_saveFilters: true
# Delay in milliseconds before the filter widget starts searching; This option prevents searching for
# every character while typing and should make searching large tables faster.
filter_searchDelay: 150
# Set this option to true to use the filter to find text from the start of the column
# So typing in "a" will find "albert" but not "frank", both have a's; default is false
filter_startsWith: false
filter_functions:
3:
"0-1": (e, n, f, i, $r) -> parseInt(e) <= 1
"2-5": (e, n, f, i, $r) -> 2 <= parseInt(e) <= 5
"6+": (e, n, f, i, $r) -> 6 <= parseInt(e)
4:
"0-1": (e, n, f, i, $r) -> parseInt(e) <= 1
"2-5": (e, n, f, i, $r) -> 2 <= parseInt(e) <= 5
"6+": (e, n, f, i, $r) -> 6 <= parseInt(e)
5:
"0-1": (e, n, f, i, $r) -> parseInt(e) <= 1
"2-5": (e, n, f, i, $r) -> 2 <= parseInt(e) <= 5
"6+": (e, n, f, i, $r) -> 6 <= parseInt(e)
6:
"Last day": (e, n, f, i, $r) ->
days = parseFloat $($r.find('td')[i]).data('employer-age')
days <= 1
"Last week": (e, n, f, i, $r) ->
days = parseFloat $($r.find('td')[i]).data('employer-age')
days <= 7
"Last 4 weeks": (e, n, f, i, $r) ->
days = parseFloat $($r.find('td')[i]).data('employer-age')
days <= 28
onEmployerClicked: (e) ->
return unless id = $(e.target).closest('tr').data('employer-id')
employer = new User _id: id
@openModalView new ModelModal models: [employer]

View file

@ -181,9 +181,6 @@ module.exports = class EmployersView extends View
7:
"": filterSelectExactMatch
"": filterSelectExactMatch
8:
"": filterSelectExactMatch
"": filterSelectExactMatch
onCandidateClicked: (e) ->
id = $(e.target).closest('tr').data('candidate-id')

View file

@ -33,3 +33,4 @@ module.exports = class ContactView extends View
return forms.applyErrorsToForm @$el, res.errors unless res.valid
window.tracker?.trackEvent 'Sent Feedback', message: contactMessage
sendContactMessage contactMessage, @$el
$.post "/db/user/#{me.id}/track/contact_codecombat"

View file

@ -39,3 +39,5 @@ module.exports = class JobProfileContactView extends ContactView
contactMessage.message += '\n\n\n\n[CodeCombat says: please let us know if you end up accepting this job. Thanks!]'
window.tracker?.trackEvent 'Sent Job Profile Message', message: contactMessage
sendContactMessage contactMessage, @$el
$.post "/db/user/#{me.id}/track/contact_candidate"
$.post "/db/user/#{@options.recipientID}/track/contacted_by_employer" unless me.isAdmin()

View file

@ -61,8 +61,12 @@ module.exports.setup = (app) ->
req.logIn(user, (err) ->
return next(err) if (err)
res.send(UserHandler.formatEntity(req, req.user))
return res.end()
activity = req.user.trackActivity 'login', 1
console.log "updating", activity
user.update {activity: activity}, (err) ->
return next(err) if (err)
res.send(UserHandler.formatEntity(req, req.user))
return res.end()
)
)(req, res, next)
)
@ -134,12 +138,12 @@ module.exports.setup = (app) ->
emails = _.clone(user.get('emails')) or {}
msg = ''
if req.query.recruitNotes
emails.recruitNotes ?= {}
emails.recruitNotes.enabled = false
msg = "Unsubscribed #{req.query.email} from recruiting emails."
else
msg = "Unsubscribed #{req.query.email} from all CodeCombat emails. Sorry to see you go!"
emailSettings.enabled = false for emailSettings in _.values(emails)
@ -147,7 +151,7 @@ module.exports.setup = (app) ->
emails.generalNews.enabled = false
emails.anyNotes ?= {}
emails.anyNotes.enabled = false
user.update {$set: {emails: emails}}, {}, =>
return errors.serverError res, 'Database failure.' if err
res.send msg + "<p><a href='/account/settings'>Account settings</a></p>"
@ -172,7 +176,7 @@ module.exports.makeNewUser = makeNewUser = (req) ->
user = new User({anonymous:true})
user.set 'testGroupNumber', Math.floor(Math.random() * 256) # also in app/lib/auth
user.set 'preferredLanguage', languages.languageCodeFromAcceptedLanguages req.acceptedLanguages
createMailOptions = (receiver, password) ->
# TODO: use email templates here
options =
@ -181,4 +185,3 @@ createMailOptions = (receiver, password) ->
replyTo: config.mail.username
subject: "[CodeCombat] Password Reset"
text: "You can log into your account with: #{password}"

View file

@ -29,6 +29,17 @@ UserSchema.methods.isAdmin = ->
p = @get('permissions')
return p and 'admin' in p
UserSchema.methods.trackActivity = (activityName, increment) ->
now = new Date()
increment ?= parseInt increment or 1
increment = Math.max increment, 0
activity = @get('activity') ? {}
activity[activityName] ?= {first: now, count: 0}
activity[activityName].count += increment
activity[activityName].last = now
@set 'activity', activity
activity
emailNameMap =
generalNews: 'announcement'
adventurerNews: 'tester'

View file

@ -48,7 +48,7 @@ UserHandler = class UserHandler extends Handler
delete obj[prop] for prop in serverProperties
includePrivates = req.user and (req.user.isAdmin() or req.user._id.equals(document._id))
delete obj[prop] for prop in privateProperties unless includePrivates
includeCandidate = includePrivates or (obj.jobProfileApproved and req.user and ('employer' in (req.user.get('permissions') ? [])) and @employerCanViewCandidate req.user, obj)
includeCandidate = includePrivates or (obj.jobProfile?.active and req.user and ('employer' in (req.user.get('permissions') ? [])) and @employerCanViewCandidate req.user, obj)
delete obj[prop] for prop in candidateProperties unless includeCandidate
return obj
@ -191,9 +191,11 @@ UserHandler = class UserHandler extends Handler
return @nameToID(req, res, args[0]) if args[1] is 'nameToID'
return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions'
return @getCandidates(req, res) if args[1] is 'candidates'
return @getEmployers(req, res) if args[1] is 'employers'
return @getSimulatorLeaderboard(req, res, args[0]) if args[1] is 'simulatorLeaderboard'
return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank'
return @getEarnedAchievements(req, res, args[0]) if args[1] is 'achievements'
return @trackActivity(req, res, args[0], args[2], args[3]) if args[1] is 'track' and args[2]
return @sendNotFoundError(res)
super(arguments...)
@ -249,6 +251,25 @@ UserHandler = class UserHandler extends Handler
doc.save()
@sendSuccess(res, cleandocs)
trackActivity: (req, res, userID, activityName, increment=1) ->
return @sendMethodNotAllowed res unless req.method is 'POST'
isMe = userID is req.user._id + ''
isAuthorized = isMe or req.user.isAdmin()
isAuthorized ||= ('employer' in req.user.get('permissions')) and (activityName in ['viewed_by_employer', 'messaged_by_employer'])
return @sendUnauthorizedError res unless isAuthorized
updateUser = (user) =>
activity = user.trackActivity activityName, increment
user.update {activity: activity}, (err) =>
return @sendDatabaseError res, err if err
@sendSuccess res, result: 'success'
if isMe
updateUser(req.user)
else
@getDocumentForIdOrSlug userID, (err, user) =>
return @sendDatabaseError res, err if err
return @sendNotFoundError res unless user
updateUser user
agreeToEmployerAgreement: (req, res) ->
userIsAnonymous = req.user?.get('anonymous')
if userIsAnonymous then return errors.unauthorized(res, "You need to be logged in to agree to the employer agreeement.")
@ -281,7 +302,7 @@ UserHandler = class UserHandler extends Handler
query = {'jobProfile.updated': {$gt: since}}
#query.jobProfileApproved = true unless req.user.isAdmin() # We split into featured and other now.
query['jobProfile.active'] = true unless req.user.isAdmin()
selection = 'jobProfile jobProfileApproved'
selection = 'jobProfile jobProfileApproved photoURL'
selection += ' email' if authorized
User.find(query).select(selection).exec (err, documents) =>
return @sendDatabaseError(res, err) if err
@ -309,6 +330,14 @@ UserHandler = class UserHandler extends Handler
return false if job.employer?.toLowerCase() is employer.get('employerAt')?.toLowerCase()
true
getEmployers: (req, res) ->
return @sendUnauthorizedError(res) unless req.user.isAdmin()
query = {employerAt: {$exists: true}}
selection = 'name firstName lastName email activity signedEmployerAgreement photoURL employerAt'
User.find(query).select(selection).lean().exec (err, documents) =>
return @sendDatabaseError res, err if err
@sendSuccess res, documents
buildGravatarURL: (user, size, fallback) ->
emailHash = @buildEmailHash user
fallback ?= "http://codecombat.com/file/db/thang.type/52a00d55cf1818f2be00000b/portrait.png"