Merge branch 'master' into production

This commit is contained in:
phoenixeliot 2016-07-25 11:12:41 -07:00
commit d1206ba692
11 changed files with 356 additions and 193 deletions

View file

@ -17,6 +17,9 @@
<script src="/javascripts/app/vendor/aether-html.js"></script>
<style>
@import 'https://fonts.googleapis.com/css?family=Holtwood+One+SC';
/* Import that font for demoing web-dev levels until @import ordering bug is fixed */
* {
transition: 1s ease-in-out;
}

View file

@ -209,10 +209,10 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
not: "no"
"!": "no"
"=": "asigne a"
"==": "iguala"
"===": "iguala estrictamente"
"!=": "no iguala"
# "!==": "does not strictly equal"
"==": "igual a"
"===": "igual a estrictamente"
"!=": "no igual a"
"!==": "no estrictamente igual"
">": "es mayor que"
">=": "es mayor que o igual"
"<": "es menor que"
@ -511,14 +511,14 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
tip_mistakes_proof_of_trying: "Errores en tu código son solo evidencia de que estas intentando."
# tip_adding_orgres: "Rounding up ogros."
tip_sharpening_swords: "Afilando las espadas."
# tip_ratatouille: "You must not let anyone define your limits because of where you come from. Your only limit is your soul. - Gusteau, Ratatouille"
tip_ratatouille: "No debes dejar que nadie defina tus límites a causa de donde venas. Tu único límite es tu alma. - Gusteau, Ratatouille"
tip_nemo: "¿Cuando huye la suerte, sabes que hay que hacer? Sigue nadando, sigue nadando. - Dory, Finding Nemo"
# tip_internet_weather: "Just move to the internet, it's great here. We get to live inside where the weather is always awesome. - John Green"
# tip_nerds: "Nerds are allowed to love stuff, like jump-up-and-down-in-the-chair-can't-control-yourself love it. - John Green"
# tip_self_taught: "I taught myself 90% of what I've learned. And that's normal! - Hank Green"
tip_internet_weather: "Debes moverte al intenet, es genial aquí. Tenemos la oportunidad de vivir dentro donde el clima es siempre sorprendente. - John Green"
tip_nerds: "A los Nerds se les permite amor cosas, como saltar-arriba-y-abajo-en-la-silla-no-te-puedes-controlar-tu-mismo quiérelo. - John Green"
tip_self_taught: "Me enseñé a mí mismo el 90% de lo que he aprendido. ¡Y eso es normal! - Hank Green"
tip_luna_lovegood: "No te preocupes, estas tan cuerdo como yo. - Luna Lovegood"
# tip_good_idea: "The best way to have a good idea is to have a lot of ideas. - Linus Pauling"
# tip_programming_not_about_computers: "La ciencia cpomputacional is no more about computers than astronomy is about telescopes. - Edsger Dijkstra"
tip_good_idea: "La mejor forma de tener una buena idea es tener un montón de ideas. - Linus Pauling"
tip_programming_not_about_computers: "En las Ciencias de la Computación no es más acerca de computadoras que la astronomía acerca de telescopios. - Edsger Dijkstra"
tip_mulan: "Si crees que puedes, entonces lo harás. - Mulan"
game_menu:
@ -755,7 +755,7 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
josh_blurb: "El piso es Lava"
phoenix_title: "Ingeniero de Software"
nolan_title: "Administrador de territorio"
# elliot_title: "Administrador de Partnership"
elliot_title: "Administrador de Asociación"
retrostyle_title: "Ilustración"
retrostyle_blurb: "Juegos con estilo Retro"
jose_title: "Música"
@ -827,14 +827,14 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
primary_role_label: "Tu Rol Primario"
role_default: "Seleccione Rol"
primary_role_default: "Seleccione Rol Primario"
# purchaser_role_default: "Seleccione Purchaser Role"
purchaser_role_default: "Seleccione Rol de Comprador"
tech_coordinator: "Coordinador de tecnología"
advisor: "Tutor"
principal: "Director"
superintendent: "Supervisor"
parent: "Padre"
# purchaser_role_label: "Your Purchaser Role"
# influence_advocate: "Influence/Advocate"
purchaser_role_label: "Tu Rol de Comprador"
influence_advocate: "Influencia/Partidario"
evaluate_recommend: "Evaluar/Recomendar"
approve_funds: "Aprobar Fondos"
# no_purchaser_role: "No role in purchase decisions"
@ -1245,8 +1245,8 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
additional_resources_4_pref: "Consulte nuestra"
additional_resources_4_mid: "Página de Escuelas"
additional_resources_4_suff: "para aprender más sobre la oferta para el aula de CodeCombat."
# educator_wiki_pref: "Or check out our new"
# educator_wiki_mid: "educator wiki"
educator_wiki_pref: "O echa un vistazo a nuestra nieva"
educator_wiki_mid: "wiki de educador"
educator_wiki_suff: "busca en nuestra guía en línea."
your_classes: "Tus Clases"
no_classes: "Aún no hay clases!"
@ -1278,105 +1278,105 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
language_cannot_change: "El lenguaje no puede ser cambiado una vez que el estudiante ingreso a la clase."
learn_p: "Aprender Python"
learn_j: "Aprender JavaScript"
# avg_student_exp_label: "Average Student Programming Experience"
# avg_student_exp_desc: "This will help us understand how to pace courses better."
# avg_student_exp_select: "Select the best option"
# avg_student_exp_none: "No Experience - little to no experience"
# avg_student_exp_beginner: "Beginner - some exposure or block-based"
# avg_student_exp_intermediate: "Intermediate - some experience with typed code"
# avg_student_exp_advanced: "Advanced - extensive experience with typed code"
# avg_student_exp_varied: "Varied Levels of Experience"
# student_age_range_label: "Student Age Range"
student_age_range_younger: "Menor que than 6"
avg_student_exp_label: "Promedio de la experiencia en programación del estudiante"
avg_student_exp_desc: "Esto nos ayudará a entender como llevar el ritmo de los cursos."
avg_student_exp_select: "Selecciona la mejor opción"
avg_student_exp_none: "Sin experiencia - poca o ninguna experiencia"
avg_student_exp_beginner: "Principiante - cierto grado de exposición o basado en bloque"
avg_student_exp_intermediate: "Intermedio - un poco de experiencia con código tipeado"
avg_student_exp_advanced: "Avanzado - amplia experiencia con código tipeado"
avg_student_exp_varied: "Niveles Variados de Experiencia"
student_age_range_label: "Rango de Edad del Estudiante"
student_age_range_younger: "Menor que 6"
student_age_range_older: "Mayor que 18"
# student_age_range_to: "to"
student_age_range_to: "a"
create_class: "Crear Grupo"
class_name: "Nombre de clase"
# teacher_account_restricted: "Your account is a teacher account, and so cannot access student content."
teacher_account_restricted: "Tu cuenta es una cuenta de maestro, y no puedes acceder al contenido del estudiante."
teacher:
# teacher_dashboard: "Teacher Dashboard" # Navbar
# my_classes: "My Classes"
# courses: "Courses"
teacher_dashboard: "Tablero del maestro" # Navbar
my_classes: "Mis Clases"
courses: "Cursos"
enrollments: "Recursos"
resources: "Resources"
help: "Ayuda"
# students: "Students" # Shared
students: "Estudiantes" # Shared
language: "Lenguaje"
# edit_class_settings: "edit class settings"
# complete: "Complete"
# access_restricted: "Account Update Required"
# teacher_account_required: "A teacher account is required to access this content."
create_teacher_account: "Crear Cuenta de Maestra"
what_is_a_teacher_account: "Cuál es una Cuenta de Maestra?"
# teacher_account_explanation: "Una Cuenta de Maestra en CodeCombat Teacher da permiso a crear grupo, monitor students progress as they work through courses, manage enrollments and access resources to aid in your curriculum-building."
# current_classes: "Current Classes"
# archived_classes: "Archived Classes"
# archived_classes_blurb: "Classes can be archived for future reference. Unarchive a class to view it in the Current Classes list again."
# view_class: "view class"
# archive_class: "archive class"
# unarchive_class: "unarchive class"
# unarchive_this_class: "Unarchive this class"
# no_students_yet: "This class has no students yet."
# add_students: "Add Students"
# create_new_class: "Create a New Class"
# class_overview: "Class Overview" # View Class page
# avg_playtime: "Average level playtime"
# total_playtime: "Total play time"
# avg_completed: "Average levels completed"
# total_completed: "Total levels completed"
# created: "Created"
# concepts_covered: "Concepts covered"
# earliest_incomplete: "Earliest incomplete level"
# latest_complete: "Latest completed level"
# enroll_student: "Enroll student"
# adding_students: "Adding students"
# course_progress: "Course Progress"
edit_class_settings: "editar configuración de la clase"
complete: "Completo"
access_restricted: "Se requiere Actualización de la Cuenta"
teacher_account_required: "Una cuenta de Maestro es requerida para acceder a este contenido."
create_teacher_account: "Crear Cuenta de Maestro"
what_is_a_teacher_account: "Qué es una Cuenta de Maestro?"
teacher_account_explanation: "Una Cuenta de Maestro en CodeCombat da permiso a crear salones de clases, monitorear el progreso de los estudiantes mientras ellos trabajan a través de los cursos, manejar inscripciones y acceder a recursos para la creación de su plan de estudio."
current_classes: "Clases Recientes"
archived_classes: "Clases Archivadas"
archived_classes_blurb: "Las Clases pueden ser archivadas para futuras referencias. Desarchiva una Clase para verla en la lista de Clases Recientes de nuevo."
view_class: "ver clase"
archive_class: "almacenar clase"
unarchive_class: "desarchivar clase"
unarchive_this_class: "desarchivar esta clase"
no_students_yet: "Esta clase no tienes estudiantes todavía."
add_students: "Agregar Estudiantes"
create_new_class: "Crear una Nueva Clase"
class_overview: "Resumen de la Clase" # View Class page
avg_playtime: "Nivel medio de tiempo de juego"
total_playtime: "Tiempo Total de juego"
avg_completed: "Promedio de niveles completados"
total_completed: "Total de niveles completados"
created: "Creado"
concepts_covered: "Conceptos cubiertos"
earliest_incomplete: "Nivel incompleto más Reciente"
latest_complete: "Último nivel completado"
enroll_student: "Inscribir estudiante"
adding_students: "Agregar estudiantes"
course_progress: "Progreso del curso"
# not_applicable: "N/A"
# edit: "edit"
# remove: "remove"
# latest_completed: "Latest Completed"
# sort_by: "Sort by"
# progress: "Progress"
# completed: "Completed"
# started: "Started"
# click_to_view_progress: "click to view progress"
# no_progress: "No progress"
# select_course: "Select course to view"
# course_overview: "Course Overview"
# copy_class_code: "Copy Class Code"
# class_code_blurb: "New students can enter this class code on their dashboard or visit codecombat.com/courses to join the class."
# copy_class_url: "Copy Class URL"
# class_join_url_blurb: "New students can visit this URL while logged in to join the class."
# add_students_manually: "Add Students Manually"
# bulk_assign: "Bulk-assign"
# assign_to_selected_students: "Assign to Selected Students"
# assigned: "Assigned"
# enroll_selected_students: "Enroll Selected Students"
# cant_assign_to_unenrolled: "Course cannot be assigned to students who are not enrolled."
# no_students_selected: "No students were selected."
# guides_coming_soon: "Guides coming soon!" # Courses
# show_students_from: "Show students from" # Enroll students modal
# enroll_the_following_students: "Enroll the following students"
# all_students: "All Students"
# enroll_students: "Enroll Students"
# not_enough_enrollments: "Not enough Enrollments available."
# enrollments_blurb_1: "Students taking Computer Science" # Enrollments page
# enrollments_blurb_2: "require enrollments to access the courses."
# credits_available: "Credits Available"
# total_unique_students: "Total Unique Students"
# total_enrolled_students: "Total Enrolled Students"
# unenrolled_students: "Unenrolled Students"
# add_enrollment_credits: "Add Enrollment Credits"
edit: "editar"
remove: "eliminar"
latest_completed: "Último Completado"
sort_by: "Ordenado por"
progress: "Progreso"
completed: "Completado"
started: "Iniciado"
click_to_view_progress: "click para ver el progreso"
no_progress: "Sin progreso"
select_course: "Selecciona el curso a ver"
course_overview: "Resumen del Curso"
copy_class_code: "Copiar código de la Clase"
class_code_blurb: "Nuevos estudiantes pueden entrar a este código de la clase en su tablero o visitar codecombat.com/courses para unirse a la clase."
copy_class_url: "Copia la URL de la Clase"
class_join_url_blurb: "Nuevos estudiantes pueden visitar esta URL mientras esten logeados para unirse a la clase."
add_students_manually: "Agregar Estudiantes Manualmente"
bulk_assign: "Mayor a asignar"
assign_to_selected_students: "Asignar a los Estudiantes Seleccionados"
assigned: "Asignado"
enroll_selected_students: "Inscribir Estudiantes Seleccionados"
cant_assign_to_unenrolled: "El curso no puede ser asignado a estudiantes que no están inscritos."
no_students_selected: "No fueron seleccionados estudiantes."
guides_coming_soon: "¡Guías próximamente!" # Courses
show_students_from: "Muestra estudiantes de" # Enroll students modal
enroll_the_following_students: "Inscribir los siguientes estudianes"
all_students: "Todos los estudiantes"
enroll_students: "Inscribir Estudiantes"
not_enough_enrollments: "No tienes suficientes Inscripciones disponibles."
enrollments_blurb_1: "Estudiantes tomando Ciencias de la Computación" # Enrollments page
enrollments_blurb_2: "se requieren inscritos para acceder al curso."
credits_available: "Cŕeditos Disponibles"
total_unique_students: "Total de Estudiantes Únicos"
total_enrolled_students: "Total de Estudiantes Inscritos"
unenrolled_students: "Estudiantes no Inscritos"
add_enrollment_credits: "Agregar Cŕeditos de Inscripción"
purchasing: "Adquiriendo..."
purchased: "Adquirido!"
# purchase_now: "Purchase Now"
# how_to_enroll: "How to Enroll Students"
purchase_now: "Adquirir Ahora"
how_to_enroll: "¿Cómo inscribir estudiantes?"
how_to_enroll_blurb_1: "Si un estudiante no está inscrito aún, encontrará un botón \"Inscribirse\" al lado del progreso de curso en su clase."
how_to_enroll_blurb_2: "Para inscribir en bloque a estudiantes, seleccionelos utilizando la casilla de verificación al lado izquierdo de la página de la clase, luego de clic al botón \"Inscribir Estudiantes Seleccionados\" ."
how_to_enroll_blurb_3: "Una vez que el estudiante se ha inscrito, podrá acceder a todo el contenido del curso."
bulk_pricing_blurb: "Quieres comprar más de 25 cuentas de estudiante? Contáctanos para hablar del siguiente paso."
# total_unenrolled: "Total unenrolled"
total_unenrolled: "Total de Estudiantes no Inscritos"
export_student_progress: "Exportar el Progreso del Estudiante(CSV)"
classes:

View file

@ -89,7 +89,7 @@ module.exports = class User extends CocoModel
isSessionless: ->
# TODO: Fix old users who got mis-tagged as teachers
# TODO: Should this just be isTeacher, eventually?
Boolean(me.isTeacher() and utils.getQueryVariable('course', false))
Boolean((utils.getQueryVariable('dev', false) or me.isTeacher()) and utils.getQueryVariable('course', false))
setRole: (role, force=false) ->
return if me.isAdmin()

View file

@ -4,17 +4,26 @@ extends /templates/base
block content
h3 Classroom Levels
if !me.isAdmin()
div You must be logged in as an admin to view this page.
else
p
- var levelsTotal = 0;
each course in view.courses.models
- var campaign = view.campaigns.get(course.get('campaignID'));
- var levels = campaign.getLevels().models;
- levelsTotal += levels.length;
div #{levels.length} #{course.get('name')}
div #{levelsTotal} levels total
table.table.table-striped.table-condensed
tr
th Levels
th Course
each course in view.courses.models
- var campaign = view.campaigns.get(course.get('campaignID'));
- var levels = campaign.getLevels().models;
- levelsTotal += levels.length;
tr
td= levels.length
td= course.get('name')
tr
td= levelsTotal
td All
each course in view.courses.models
- var campaign = view.campaigns.get(course.get('campaignID'));
- var levels = campaign.getLevels().models;
@ -29,6 +38,7 @@ block content
th Type
th Practice
th Practice Threshold (m)
th Shareable
each level, levelIndex in levels
- var levelNumber = campaign.getLevelNumber(level.get('original'), levelIndex + 1)
tr
@ -37,3 +47,4 @@ block content
td= level.get('type')
td= level.get('practice')
td= level.get('practiceThresholdMinutes')
td= level.get('shareable')

View file

@ -122,10 +122,11 @@ block content
li(class=(activeTab === "#enrollment-status-tab" ? 'active' : ''))
a.course-progress-tab-btn(href='#enrollment-status-tab')
.small-details.text-center(data-i18n='teacher.enrollment_status')
//.tab-spacer
//li(class=(activeTab === "#student-projects-tab" ? 'active' : ''))
// a.course-progress-tab-btn(href='#student-projects-tab')
// .small-details.text-center(data-i18n='teacher.projects')
if _.find(view.courses.models, function(c) { return /dev/.test(c.get('slug')); })
.tab-spacer
li(class=(activeTab === "#student-projects-tab" ? 'active' : ''))
a.course-progress-tab-btn(href='#student-projects-tab')
.small-details.text-center(data-i18n='teacher.projects')
.tab-filler
.tab-content

View file

@ -102,6 +102,11 @@ block header
a(data-toggle="coco-modal", data-target="editor/level/modals/GenerateTerrainModal", data-i18n="editor.generate_terrain", disabled=anonymous).generate-terrain-button Generate Terrain
li(class=anonymous ? "disabled": "")
a(data-i18n="editor.pop_i18n")#pop-level-i18n-button Populate i18n
if view.courseID
li(class=anonymous ? "disabled": "")
a.play-classroom-level(data-classroom=true, data-code-language="javascript") Play Classroom JavaScript
li(class=anonymous ? "disabled": "")
a.play-classroom-level(data-classroom=true, data-code-language="python") Play Classroom Python
li.divider
li.dropdown-header(data-i18n="common.info") Info
li#level-history-button

View file

@ -6,6 +6,10 @@ World = require 'lib/world/world'
DocumentFiles = require 'collections/DocumentFiles'
LevelLoader = require 'lib/LevelLoader'
Campaigns = require 'collections/Campaigns'
CocoCollection = require 'collections/CocoCollection'
Course = require 'models/Course'
# in the template, but need to require them to load them
require 'views/modal/RevertModal'
require 'views/editor/level/modals/GenerateTerrainModal'
@ -49,6 +53,7 @@ module.exports = class LevelEditView extends RootView
'click #play-button': 'onPlayLevel'
'click .play-with-team-button': 'onPlayLevel'
'click .play-with-team-parent': 'onPlayLevelTeamSelect'
'click .play-classroom-level': 'onPlayLevel'
'click #commit-level-start-button': 'startCommittingLevel'
'click li:not(.disabled) > #fork-start-button': 'startForking'
'click #level-history-button': 'showVersionHistory'
@ -73,6 +78,10 @@ module.exports = class LevelEditView extends RootView
@level = @levelLoader.level
@files = new DocumentFiles(@levelLoader.level)
@supermodel.loadCollection(@files, 'file_names')
@campaigns = new Campaigns()
@supermodel.trackRequest @campaigns.fetchByType('course', { data: { project: 'levels' } })
@courses = new CocoCollection([], { url: "/db/course", model: Course})
@supermodel.loadCollection(@courses, 'courses')
destroy: ->
clearInterval @timerIntervalID
@ -89,6 +98,12 @@ module.exports = class LevelEditView extends RootView
@world = @levelLoader.world
@render()
@timerIntervalID = setInterval @incrementBuildTime, 1000
campaignCourseMap = {}
campaignCourseMap[course.get('campaignID')] = course.id for course in @courses.models
for campaign in @campaigns.models
for levelID, level of campaign.get('levels') when levelID is @level.get('original')
@courseID = campaignCourseMap[campaign.id]
break if @courseID
getRenderData: (context={}) ->
context = super(context)
@ -130,9 +145,11 @@ module.exports = class LevelEditView extends RootView
onPlayLevel: (e) ->
team = $(e.target).data('team')
opponentSessionID = $(e.target).data('opponent')
newClassMode = $(e.target).data('classroom')
newClassLanguage = $(e.target).data('code-language')
sendLevel = =>
@childWindow.Backbone.Mediator.publish 'level:reload-from-data', level: @level, supermodel: @supermodel
if @childWindow and not @childWindow.closed
if @childWindow and not @childWindow.closed and @playClassMode is newClassMode and @playClassLanguage is newClassLanguage
# Reset the LevelView's world, but leave the rest of the state alone
sendLevel()
else
@ -140,6 +157,11 @@ module.exports = class LevelEditView extends RootView
scratchLevelID = @level.get('slug') + '?dev=true'
scratchLevelID += "&team=#{team}" if team
scratchLevelID += "&opponent=#{opponentSessionID}" if opponentSessionID
@playClassMode = newClassMode
@playClassLanguage = newClassLanguage
if @playClassMode
scratchLevelID += "&course=#{@courseID}"
scratchLevelID += "&codeLanguage=#{@playClassLanguage}"
if me.get('name') is 'Nick'
@childWindow = window.open("/play/level/#{scratchLevelID}", 'child_window', 'width=2560,height=1080,left=0,top=-1600,location=1,menubar=1,scrollbars=1,status=0,titlebar=1,toolbar=1', true)
else

View file

@ -131,7 +131,10 @@ module.exports = class SpellView extends CocoView
name: 'run-code-real-time'
bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'}
exec: =>
if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0
doneButton = @$('.done-button:visible')
if doneButton.length
doneButton.trigger 'click'
else if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0
Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit
else
Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}

127
scripts/fixCloseIoOpps.js Normal file
View file

@ -0,0 +1,127 @@
// Fix Close.io opportunity owners
'use strict';
if (process.argv.length !== 8) {
log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <Close.io EU mail API key>");
process.exit();
}
const scriptStartTime = new Date();
const closeIoApiKey = process.argv[2];
const closeIoMailApiKeys = [process.argv[3], process.argv[4], process.argv[5], process.argv[6], process.argv[7]];
const async = require('async');
const request = require('request');
// ** Main program
getUsers((err, ownerId, userApiKeyMap) => {
if (err) {
console.error(err);
return;
}
getOpps(ownerId, (err, opps) => {
if (err) {
console.error(err);
return;
}
log(`${opps.length} opps owned by ${userApiKeyMap[ownerId].data.first_name}`);
const tasks = [];
for (const opp of opps) {
tasks.push(createUpdateOppFn(ownerId, userApiKeyMap, opp));
}
async.parallel(tasks, (err, results) => {
if (err) console.error(err);
log("Script runtime: " + (new Date() - scriptStartTime));
});
});
});
function getUsers(done) {
let ownerId = null;
const userApiKeyMap = {};
let createGetUserFn = (apiKey) => {
return (done) => {
const url = `https://${apiKey}:X@app.close.io/api/v1/me/`;
request.get(url, (error, response, body) => {
if (error) return done();
const results = JSON.parse(body);
userApiKeyMap[results.id] = {key: apiKey, data: results};
if (apiKey === closeIoApiKey) {
ownerId = results.id;
}
return done();
});
};
}
const tasks = [createGetUserFn(closeIoApiKey)];
for (const apiKey of closeIoMailApiKeys) {
tasks.push(createGetUserFn(apiKey));
}
async.parallel(tasks, (err, results) => {
if (err) return done(err);
return done(null, ownerId, userApiKeyMap);
});
}
function getOpps(ownerId, done) {
const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/opportunity/?user_id=${ownerId}`;
request.get(url, (err, response, body) => {
if (err) return done(err);
const results = JSON.parse(body);
return done(null, results.data);
});
}
function createUpdateOppFn(ownerId, userApiKeyMap, opp) {
return (done) => {
findOwner(ownerId, userApiKeyMap, opp, (err, userId) => {
if (err) return done(err);
// console.log(`DEBUG: ${opp.lead_id} owner ${userApiKeyMap[userId].data.first_name}`);
return updateOpp(opp, userId, userApiKeyMap, done);
});
};
}
function findOwner(ownerId, userApiKeyMap, opp, done) {
const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/activity/?lead_id=${opp.lead_id}`;
request.get(url, (err, response, body) => {
if (err) return done(err);
const results = JSON.parse(body);
if (results.has_more) {
console.log(`ERROR: ${lead.id} has more activities than ${results.data.length} returned!`);
}
for (const activity of results.data) {
if (activity._type === 'Email' && userApiKeyMap[activity.user_id] && activity.user_id !== ownerId) {
return done(null, activity.user_id);
}
}
return done(`ERROR: No owner found for ${opp.lead_id}`);
});
}
function updateOpp(opp, userId, userApiKeyMap, done) {
const putData = {
user_id: userId,
user_name: `${userApiKeyMap[userId].data.first_name} ${userApiKeyMap[userId].data.last_name}`
};
console.log(`DEBUG: updating ${opp.lead_id} ${opp.id} to ${putData.user_name}`);
const options = {
uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/opportunity/${opp.id}/`,
body: JSON.stringify(putData)
};
request.put(options, (err, response, body) => {
if (err) return done(err);
const result = JSON.parse(body);
if (result.errors || result['field-errors']) {
console.log(`PUT error for ${opp.lead_id} ${opp.id}`);
return done(result.errors || result['field-errors']);
}
return done();
});
}
// ** Utilities
function log(str) {
console.log(new Date().toISOString() + " " + str);
}

View file

@ -1,8 +1,8 @@
// Follow up on Close.io leads
'use strict';
if (process.argv.length !== 8) {
log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3> <mongo connection Url>");
if (process.argv.length !== 7) {
log("Usage: node <script> <Close.io general API key> <Close.io mail API key1> <Close.io mail API key2> <Close.io mail API key3>");
process.exit();
}
@ -12,17 +12,20 @@ if (process.argv.length !== 8) {
// TODO: sendMail copied from updateCloseIoLeads.js
// TODO: template values copied from updateCloseIoLeads.js
// TODO: status change is not related to specific lead contacts
// TODO: update status after adding a call task
const createTeacherEmailTemplatesAuto1 = ['tmpl_i5bQ2dOlMdZTvZil21bhTx44JYoojPbFkciJ0F560mn', 'tmpl_CEZ9PuE1y4PRvlYiKB5kRbZAQcTIucxDvSeqvtQW57G'];
const demoRequestEmailTemplatesAuto1 = ['tmpl_s7BZiydyCHOMMeXAcqRZzqn0fOtk0yOFlXSZ412MSGm', 'tmpl_cGb6m4ssDvqjvYd8UaG6cacvtSXkZY3vj9b9lSmdQrf'];
const createTeacherInternationalEmailTemplateAuto1 = 'tmpl_8vsXwcr6dWefMnAEfPEcdHaxqSfUKUY8UKq6WfReGqG';
const demoRequestInternationalEmailTemplateAuto1 = 'tmpl_nnH1p3II7G7NJYiPOIHphuj4XUaDptrZk1mGQb2d9Xa';
const createTeacherEmailTemplatesAuto2 = ['tmpl_pGPtKa07ioISupdSc1MAzNC57K40XoA4k0PI1igi8Ec', 'tmpl_AYAcviU8NQGLbMGKSp3EmcBLha0gQw4cHSOR55Fmoha'];
const demoRequestEmailTemplatesAuto2 = ['tmpl_HJ5zebh1SqC1QydDto05VPUMu4F7i5M35Llq7bzgfTw', 'tmpl_dmnK7IVpkyYfPYAl1rChhm9lClH5lJ9pQAZoPr7cvLt'];
const createTeacherInternationalEmailTemplatesAuto2 = ['tmpl_a6Syzzy6ri9MErfXQySM5UfaF5iNIv1VCArYowAEICT', 'tmpl_jOqWLgT0G19Eqs7qZaAeNwtiull7UrSX4ZuvkYRM2gC'];
const demoRequestInternationalEmailTemplatesAuto2 = ['tmpl_wz4SnDZMjNmAhp3MIuZaSMmjJTy5IW75Rcy3MYGb6Ti', 'tmpl_5oJ0YQMZFqNi3DgW7hplD6JS2zHqkB4Gt7Fj1u19Nks'];
const scriptStartTime = new Date();
const closeIoApiKey = process.argv[2];
const closeIoMailApiKeys = [process.argv[3], process.argv[4], process.argv[5], process.argv[6]]; // Automatic mails sent as API owners
const mongoConnUrl = process.argv[7];
const MongoClient = require('mongodb').MongoClient;
const async = require('async');
const request = require('request');
@ -39,13 +42,28 @@ async.series([
(err, results) => {
if (err) console.error(err);
log("Script runtime: " + (new Date() - scriptStartTime));
}
);
});
// ** Utilities
function getRandomEmailTemplateAuto2(template) {
if (createTeacherEmailTemplatesAuto1.indexOf(template) >= 0) {
return getRandomEmailTemplate(createTeacherEmailTemplatesAuto2);
}
if (demoRequestEmailTemplatesAuto1.indexOf(template) >= 0) {
return getRandomEmailTemplate(demoRequestEmailTemplatesAuto2);
}
if (createTeacherInternationalEmailTemplateAuto1 == template) {
return getRandomEmailTemplate(createTeacherInternationalEmailTemplatesAuto2);
}
if (demoRequestInternationalEmailTemplateAuto1 === template) {
return getRandomEmailTemplate(demoRequestInternationalEmailTemplatesAuto2);
}
return null;
}
function getRandomEmailTemplate(templates) {
if (templates.length < 0) return '';
if (templates.length < 0) return null;
return templates[Math.floor(Math.random() * templates.length)];
}
@ -59,20 +77,20 @@ function isSameEmailTemplateType(template1, template2) {
return false;
}
function isDemoRequestTemplateAuto1(template) {
return demoRequestEmailTemplatesAuto1.indexOf(template) >= 0;
function isTemplateAuto1(template) {
if (createTeacherEmailTemplatesAuto1.indexOf(template) >= 0) return true;
if (demoRequestEmailTemplatesAuto1.indexOf(template) >= 0) return true;
if (createTeacherInternationalEmailTemplateAuto1 == template) return true;
if (demoRequestInternationalEmailTemplateAuto1 === template) return true;
return false;
}
function isCreateTeacherTemplateAuto1(template) {
return createTeacherEmailTemplatesAuto1.indexOf(template) >= 0;
}
function isDemoRequestTemplateAuto2(template) {
return demoRequestEmailTemplatesAuto2.indexOf(template) >= 0;
}
function isCreateTeacherTemplateAuto2(template) {
return createTeacherEmailTemplatesAuto2.indexOf(template) >= 0;
function isTemplateAuto2(template) {
if (createTeacherEmailTemplatesAuto2.indexOf(template) >= 0) return true;
if (demoRequestEmailTemplatesAuto2.indexOf(template) >= 0) return true;
if (createTeacherInternationalEmailTemplatesAuto2.indexOf(template) >= 0) return true;
if (demoRequestInternationalEmailTemplatesAuto2.indexOf(template) >= 0) return true;
return false;
}
function log(str) {
@ -137,7 +155,7 @@ function sendMail(toEmail, leadId, contactId, template, emailApiKey, delayMinute
}
function updateLeadStatus(lead, status, done) {
// console.log("DEBUG: updateLeadStatus", lead.id, status);
// console.log(`DEBUG: updateLeadStatus ${lead.id} ${status}`);
const putData = {status: status};
const options = {
uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/${lead.id}/`,
@ -202,25 +220,14 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
}
// Find first auto mail
let sentFirstCreateTeacherEmail = false;
let sentFirstDemoRequestEmail = false;
let firstMailActivity;
for (const activity of results.data) {
if (activity._type === 'Email' && activity.to[0] === email) {
if (isCreateTeacherTemplateAuto1(activity.template_id)) {
if (sentFirstCreateTeacherEmail || sentFirstDemoRequestEmail) {
console.log(`ERROR: ${lead.id} sent multiple auto1 emails!? ${sentFirstCreateTeacherEmail} ${sentFirstDemoRequestEmail}`);
if (isTemplateAuto1(activity.template_id)) {
if (firstMailActivity) {
console.log(`ERROR: ${lead.id} sent multiple auto1 emails!?`);
return done();
}
sentFirstCreateTeacherEmail = true;
firstMailActivity = activity;
}
else if (isDemoRequestTemplateAuto1(activity.template_id)) {
if (sentFirstCreateTeacherEmail || sentFirstDemoRequestEmail) {
console.log(`ERROR: ${lead.id} sent multiple auto1 emails!? ${sentFirstCreateTeacherEmail} ${sentFirstDemoRequestEmail}`);
return done();
}
sentFirstDemoRequestEmail = true;
firstMailActivity = activity;
}
}
@ -235,11 +242,6 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
return done();
}
if (sentFirstCreateTeacherEmail && sentFirstDemoRequestEmail) {
console.log(`ERROR: ${lead.id} sent multiple auto1 emails!? ${sentFirstCreateTeacherEmail} ${sentFirstDemoRequestEmail}`);
return done();
}
// Find activity since first auto mail, that's not email to a different contact's email
let recentActivity;
for (const activity of results.data) {
@ -251,17 +253,9 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
}
if (!recentActivity) {
let template;
if (sentFirstCreateTeacherEmail) {
// console.log(`Create teacher auto 1 sent: ${lead.id} ${firstMailUserId} ${userApiKeyMap[firstMailUserId]}`);
template = getRandomEmailTemplate(createTeacherEmailTemplatesAuto2);
}
else if (sentFirstDemoRequestEmail) {
// console.log(`Demo request auto 1 sent: ${lead.id} ${firstMailUserId} ${userApiKeyMap[firstMailUserId]}`);
template = getRandomEmailTemplate(demoRequestEmailTemplatesAuto2);
}
let template = getRandomEmailTemplateAuto2(firstMailActivity.template_id);
if (!template) {
console.log(`ERROR: no template selected ${lead.id}`);
console.log(`ERROR: no auto2 template selected for ${lead.id} ${firstMailActivity.template_id}`);
return done();
}
// console.log(`TODO: ${firstMailActivity.to[0]} ${lead.id} ${firstMailActivity.contact_id} ${template} ${userApiKeyMap[firstMailActivity.user_id]}`);
@ -271,12 +265,25 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) {
// TODO: some sort of callback problem that stops the series here
// TODO: manage this status mapping better
if (lead.status_label === "Auto Attempt 1") {
return updateLeadStatus(lead, "Auto Attempt 2", done);
}
else if (lead.status_label === "New US Schools Auto Attempt 1") {
return updateLeadStatus(lead, "New US Schools Auto Attempt 2", done);
}
else if (lead.status_label === "Inbound AU Auto Attempt 1") {
return updateLeadStatus(lead, "Inbound AU Auto Attempt 2", done);
}
else if (lead.status_label === "Inbound Canada Auto Attempt 1") {
return updateLeadStatus(lead, "Inbound Canada Auto Attempt 2", done);
}
else if (lead.status_label === "Inbound NZ Auto Attempt 1") {
return updateLeadStatus(lead, "Inbound NZ Auto Attempt 2", done);
}
else if (lead.status_label === "Inbound UK Auto Attempt 1") {
return updateLeadStatus(lead, "Inbound UK Auto Attempt 2", done);
}
else {
console.log(`ERROR: unknown lead status ${lead.id} ${lead.status_label}`)
return done();
@ -322,7 +329,8 @@ function sendSecondFollowupMails(done) {
if (err) console.log(err);
const latestDate = new Date();
latestDate.setUTCDate(latestDate.getUTCDate() - 3);
const query = `date_created > ${earliestDate.toISOString().substring(0, 19)} (lead_status:"Auto Attempt 1" or lead_status:"New US Schools Auto Attempt 1")"`;
// TODO: manage this status list better
const query = `date_created > ${earliestDate.toISOString().substring(0, 19)} (lead_status:"Auto Attempt 1" or lead_status:"New US Schools Auto Attempt 1" or lead_status:"Inbound Canada Auto Attempt 1" or lead_status:"Inbound AU Auto Attempt 1" or lead_status:"Inbound NZ Auto Attempt 1" or lead_status:"Inbound UK Auto Attempt 1")`;
const limit = 100;
const nextPage = (skip) => {
let has_more = false;
@ -337,15 +345,15 @@ function sendSecondFollowupMails(done) {
has_more = results.has_more;
const tasks = [];
for (const lead of results.data) {
// console.log(`${lead.id}\t${lead.status_label}\t${lead.name}`);
// if (lead.id !== 'lead_KYuI2HVOiUdJANvkOe1uLJBuuQVaaGSRveklhTWbHv2') continue;
// console.log(`DEBUG: ${lead.id}\t${lead.status_label}\t${lead.name}`);
// if (lead.id !== 'lead_8YZlEVQ4w3lETSlF43RQHK7cJQaBQ4tpbbxUUNA2uGC') continue;
const existingContacts = lead.contacts || [];
for (const contact of existingContacts) {
if (contact.emails && contact.emails.length > 0) {
tasks.push(createSendFollowupMailFn(userApiKeyMap, latestDate, lead, contact.emails[0].email.toLowerCase()));
}
else {
console.log(`ERROR: lead ${lead.id} contact has non-1 emails`);
console.log(`ERROR: lead ${lead.id} contact ${contact.id} has no email`);
}
}
}
@ -370,8 +378,8 @@ function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) {
// Check for activity since second auto mail and status update
// Add call task
// TODO: Very similar function to createSendFollowupMailFn
const auto1Statuses = ["Auto Attempt 1", "New US Schools Auto Attempt 1"];
const auto2Statuses = ["Auto Attempt 2", "New US Schools Auto Attempt 2"];
const auto1Statuses = ["Auto Attempt 1", "New US Schools Auto Attempt 1", "Inbound Canada Auto Attempt 1", "Inbound AU Auto Attempt 1", "Inbound NZ Auto Attempt 1", "Inbound UK Auto Attempt 1"];
const auto2Statuses = ["Auto Attempt 2", "New US Schools Auto Attempt 2", "Inbound Canada Auto Attempt 2", "Inbound AU Auto Attempt 2", "Inbound NZ Auto Attempt 2", "Inbound UK Auto Attempt 2"];
return (done) => {
// console.log("DEBUG: addCallTask", lead.id);
@ -408,27 +416,15 @@ function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) {
}
// Find second auto mail and status change
let sentSecondCreateTeacherEmail = false;
let sentSecondDemoRequestEmail = false;
let secondMailActivity;
let statusUpdateActivity;
let contactReplyMail;
for (const activity of results.data) {
if (activity._type === 'Email' && activity.to[0] === email) {
if (isCreateTeacherTemplateAuto2(activity.template_id)) {
if (sentSecondCreateTeacherEmail || sentSecondDemoRequestEmail) {
console.log(`ERROR: ${lead.id} ${email} sent multiple auto2 emails!? ${sentSecondCreateTeacherEmail} ${sentSecondDemoRequestEmail}`);
return done();
if (isTemplateAuto2(activity.template_id)) {
if (secondMailActivity) {
console.log(`ERROR: ${lead.id} sent multiple auto2 emails!?`);
return done();
}
sentSecondCreateTeacherEmail = true;
secondMailActivity = activity;
}
else if (isDemoRequestTemplateAuto2(activity.template_id)) {
if (sentSecondCreateTeacherEmail || sentSecondDemoRequestEmail) {
console.log(`ERROR: ${lead.id} ${email} sent multiple auto2 emails!? ${sentSecondCreateTeacherEmail} ${sentSecondDemoRequestEmail}`);
return done();
}
sentSecondDemoRequestEmail = true;
secondMailActivity = activity;
}
}
@ -451,12 +447,6 @@ function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) {
return done();
}
if (sentSecondCreateTeacherEmail && sentSecondDemoRequestEmail) {
console.log(`ERROR: ${lead.id} ${email} sent multiple auto2 emails!? ${sentSecondCreateTeacherEmail} ${sentSecondDemoRequestEmail}`);
return done();
}
// console.log(secondMailActivity);
// Find activity since second auto mail and status update
// Skip email to a different contact's email
// Skip note about different contact
@ -511,7 +501,7 @@ function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) {
}
catch (err) {
console.log(err);
console.log(body);
// console.log(body);
return done();
}
});
@ -543,7 +533,7 @@ function addCallTasks(done) {
if (err) console.log(err);
const latestDate = new Date();
latestDate.setUTCDate(latestDate.getUTCDate() - 3);
const query = `date_created > ${earliestDate.toISOString().substring(0, 19)} (lead_status:"Auto Attempt 2" or lead_status:"New US Schools Auto Attempt 2")"`;
const query = `date_created > ${earliestDate.toISOString().substring(0, 19)} (lead_status:"Auto Attempt 2" or lead_status:"New US Schools Auto Attempt 2" or lead_status:"Inbound Canada Auto Attempt 2" or lead_status:"Inbound AU Auto Attempt 2" or lead_status:"Inbound NZ Auto Attempt 2" or lead_status:"Inbound UK Auto Attempt 2")`;
const limit = 100;
const nextPage = (skip) => {
let has_more = false;
@ -568,10 +558,10 @@ function addCallTasks(done) {
}
}
else {
console.log(`ERROR: lead ${lead.id} contact has non-1 emails`);
console.log(`ERROR: lead ${lead.id} contact ${contact.id} has no email`);
}
}
// if (tasks.length > 10) break;
// if (tasks.length > 1) break;
}
async.series(tasks, (err, results) => {
if (err) return done(err);

View file

@ -9,6 +9,7 @@ describe 'UserModel', ->
expect(User.expForLevel 2).toBeGreaterThan User.expForLevel 1
it 'level is calculated correctly', ->
me.clear()
me.set 'points', 0
expect(me.level()).toBe 1