feat(api): get challenges directly from /curriculum
This commit is contained in:
@ -31,20 +31,25 @@ import { oldDataVizId } from '../../../config/misc';
|
|||||||
import certTypes from '../utils/certTypes.json';
|
import certTypes from '../utils/certTypes.json';
|
||||||
import superBlockCertTypeMap from '../utils/superBlockCertTypeMap';
|
import superBlockCertTypeMap from '../utils/superBlockCertTypeMap';
|
||||||
import { completeCommitment$ } from '../utils/commit';
|
import { completeCommitment$ } from '../utils/commit';
|
||||||
|
import { getChallenges } from '../utils/get-curriculum';
|
||||||
|
|
||||||
const log = debug('fcc:certification');
|
const log = debug('fcc:certification');
|
||||||
|
|
||||||
export default function bootCertificate(app) {
|
export default function bootCertificate(app, done) {
|
||||||
const api = app.loopback.Router();
|
const api = app.loopback.Router();
|
||||||
|
// TODO: rather than getting all the challenges, then grabbing the certs,
|
||||||
|
// consider just getting the certs.
|
||||||
|
getChallenges().then(allChallenges => {
|
||||||
|
const certTypeIds = createCertTypeIds(allChallenges);
|
||||||
|
const showCert = createShowCert(app);
|
||||||
|
const verifyCert = createVerifyCert(certTypeIds, app);
|
||||||
|
|
||||||
const certTypeIds = createCertTypeIds(app);
|
api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert);
|
||||||
const showCert = createShowCert(app);
|
api.get('/certificate/showCert/:username/:cert', showCert);
|
||||||
const verifyCert = createVerifyCert(certTypeIds, app);
|
|
||||||
|
|
||||||
api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert);
|
app.use(api);
|
||||||
api.get('/certificate/showCert/:username/:cert', showCert);
|
done();
|
||||||
|
});
|
||||||
app.use(api);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFallbackFrontEndDate(completedChallenges, completedDate) {
|
export function getFallbackFrontEndDate(completedChallenges, completedDate) {
|
||||||
@ -97,33 +102,37 @@ const renderCertifiedEmail = loopback.template(
|
|||||||
path.join(__dirname, '..', 'views', 'emails', 'certified.ejs')
|
path.join(__dirname, '..', 'views', 'emails', 'certified.ejs')
|
||||||
);
|
);
|
||||||
|
|
||||||
function createCertTypeIds(app) {
|
function createCertTypeIds(allChallenges) {
|
||||||
const { Challenge } = app.models;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// legacy
|
// legacy
|
||||||
[certTypes.frontEnd]: getIdsForCert$(legacyFrontEndChallengeId, Challenge),
|
[certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, allChallenges),
|
||||||
[certTypes.backEnd]: getIdsForCert$(legacyBackEndChallengeId, Challenge),
|
[certTypes.backEnd]: getCertById(legacyBackEndChallengeId, allChallenges),
|
||||||
[certTypes.dataVis]: getIdsForCert$(legacyDataVisId, Challenge),
|
[certTypes.dataVis]: getCertById(legacyDataVisId, allChallenges),
|
||||||
[certTypes.infosecQa]: getIdsForCert$(legacyInfosecQaId, Challenge),
|
[certTypes.infosecQa]: getCertById(legacyInfosecQaId, allChallenges),
|
||||||
[certTypes.fullStack]: getIdsForCert$(legacyFullStackId, Challenge),
|
[certTypes.fullStack]: getCertById(legacyFullStackId, allChallenges),
|
||||||
|
|
||||||
// modern
|
// modern
|
||||||
[certTypes.respWebDesign]: getIdsForCert$(respWebDesignId, Challenge),
|
[certTypes.respWebDesign]: getCertById(respWebDesignId, allChallenges),
|
||||||
[certTypes.frontEndLibs]: getIdsForCert$(frontEndLibsId, Challenge),
|
[certTypes.frontEndLibs]: getCertById(frontEndLibsId, allChallenges),
|
||||||
[certTypes.dataVis2018]: getIdsForCert$(dataVis2018Id, Challenge),
|
[certTypes.dataVis2018]: getCertById(dataVis2018Id, allChallenges),
|
||||||
[certTypes.jsAlgoDataStruct]: getIdsForCert$(jsAlgoDataStructId, Challenge),
|
[certTypes.jsAlgoDataStruct]: getCertById(
|
||||||
[certTypes.apisMicroservices]: getIdsForCert$(
|
jsAlgoDataStructId,
|
||||||
apisMicroservicesId,
|
allChallenges
|
||||||
Challenge
|
|
||||||
),
|
),
|
||||||
[certTypes.qaV7]: getIdsForCert$(qaV7Id, Challenge),
|
[certTypes.apisMicroservices]: getCertById(
|
||||||
[certTypes.infosecV7]: getIdsForCert$(infosecV7Id, Challenge),
|
apisMicroservicesId,
|
||||||
[certTypes.sciCompPyV7]: getIdsForCert$(sciCompPyV7Id, Challenge),
|
allChallenges
|
||||||
[certTypes.dataAnalysisPyV7]: getIdsForCert$(dataAnalysisPyV7Id, Challenge),
|
),
|
||||||
[certTypes.machineLearningPyV7]: getIdsForCert$(
|
[certTypes.qaV7]: getCertById(qaV7Id, allChallenges),
|
||||||
|
[certTypes.infosecV7]: getCertById(infosecV7Id, allChallenges),
|
||||||
|
[certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, allChallenges),
|
||||||
|
[certTypes.dataAnalysisPyV7]: getCertById(
|
||||||
|
dataAnalysisPyV7Id,
|
||||||
|
allChallenges
|
||||||
|
),
|
||||||
|
[certTypes.machineLearningPyV7]: getCertById(
|
||||||
machineLearningPyV7Id,
|
machineLearningPyV7Id,
|
||||||
Challenge
|
allChallenges
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -188,13 +197,16 @@ const completionHours = {
|
|||||||
[certTypes.machineLearningPyV7]: 400
|
[certTypes.machineLearningPyV7]: 400
|
||||||
};
|
};
|
||||||
|
|
||||||
function getIdsForCert$(id, Challenge) {
|
// returns an array with a single element, to be flatMap'd by createdVerifyCert
|
||||||
return observeQuery(Challenge, 'findById', id, {
|
function getCertById(anId, allChallenges) {
|
||||||
id: true,
|
return allChallenges
|
||||||
tests: true,
|
.filter(({ id }) => id === anId)
|
||||||
name: true,
|
.map(({ id, tests, name, challengeType }) => ({
|
||||||
challengeType: true
|
id,
|
||||||
}).shareReplay();
|
tests,
|
||||||
|
name,
|
||||||
|
challengeType
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const superBlocks = Object.keys(superBlockCertTypeMap);
|
const superBlocks = Object.keys(superBlockCertTypeMap);
|
||||||
|
@ -18,6 +18,7 @@ import { ifNoUserSend } from '../utils/middleware';
|
|||||||
import { dasherize } from '../../../utils/slugs';
|
import { dasherize } from '../../../utils/slugs';
|
||||||
import _pathMigrations from '../resources/pathMigration.json';
|
import _pathMigrations from '../resources/pathMigration.json';
|
||||||
import { fixCompletedChallengeItem } from '../../common/utils';
|
import { fixCompletedChallengeItem } from '../../common/utils';
|
||||||
|
import { getChallenges } from '../utils/get-curriculum';
|
||||||
|
|
||||||
const log = debug('fcc:boot:challenges');
|
const log = debug('fcc:boot:challenges');
|
||||||
|
|
||||||
@ -26,7 +27,9 @@ export default async function bootChallenge(app, done) {
|
|||||||
const api = app.loopback.Router();
|
const api = app.loopback.Router();
|
||||||
const router = app.loopback.Router();
|
const router = app.loopback.Router();
|
||||||
const redirectToLearn = createRedirectToLearn(_pathMigrations);
|
const redirectToLearn = createRedirectToLearn(_pathMigrations);
|
||||||
const challengeUrlResolver = await createChallengeUrlResolver(app);
|
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||||
|
await getChallenges()
|
||||||
|
);
|
||||||
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
||||||
challengeUrlResolver
|
challengeUrlResolver
|
||||||
);
|
);
|
||||||
@ -148,44 +151,46 @@ export function buildChallengeUrl(challenge) {
|
|||||||
return `/learn/${dasherize(superBlock)}/${dasherize(block)}/${dashedName}`;
|
return `/learn/${dasherize(superBlock)}/${dasherize(block)}/${dashedName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFirstChallenge(Challenge) {
|
// this is only called once during boot, so it can be slow.
|
||||||
return new Promise(resolve => {
|
export function getFirstChallenge(allChallenges) {
|
||||||
Challenge.findOne(
|
const first = allChallenges.find(
|
||||||
{ where: { challengeOrder: 0, superOrder: 1, order: 0 } },
|
({ challengeOrder, superOrder, order }) =>
|
||||||
(err, challenge) => {
|
challengeOrder === 0 && superOrder === 1 && order === 0
|
||||||
if (err || isEmpty(challenge)) {
|
);
|
||||||
return resolve('/learn');
|
|
||||||
}
|
return first ? buildChallengeUrl(first) : '/learn';
|
||||||
return resolve(buildChallengeUrl(challenge));
|
}
|
||||||
}
|
|
||||||
);
|
function getChallengeById(allChallenges, targetId) {
|
||||||
});
|
return allChallenges.find(({ id }) => id === targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createChallengeUrlResolver(
|
export async function createChallengeUrlResolver(
|
||||||
app,
|
allChallenges,
|
||||||
{ _getFirstChallenge = getFirstChallenge } = {}
|
{ _getFirstChallenge = getFirstChallenge } = {}
|
||||||
) {
|
) {
|
||||||
const { Challenge } = app.models;
|
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
const firstChallenge = await _getFirstChallenge(Challenge);
|
const firstChallenge = _getFirstChallenge(allChallenges);
|
||||||
|
|
||||||
return function resolveChallengeUrl(id) {
|
return function resolveChallengeUrl(id) {
|
||||||
if (isEmpty(id)) {
|
if (isEmpty(id)) {
|
||||||
return Promise.resolve(firstChallenge);
|
return Promise.resolve(firstChallenge);
|
||||||
}
|
} else {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
if (cache.has(id)) {
|
if (cache.has(id)) {
|
||||||
return resolve(cache.get(id));
|
resolve(cache.get(id));
|
||||||
}
|
}
|
||||||
return Challenge.findById(id, (err, challenge) => {
|
|
||||||
if (err || isEmpty(challenge)) {
|
const challenge = getChallengeById(allChallenges, id);
|
||||||
return resolve(firstChallenge);
|
if (isEmpty(challenge)) {
|
||||||
|
resolve(firstChallenge);
|
||||||
|
} else {
|
||||||
|
const challengeUrl = buildChallengeUrl(challenge);
|
||||||
|
cache.set(id, challengeUrl);
|
||||||
|
resolve(challengeUrl);
|
||||||
}
|
}
|
||||||
const challengeUrl = buildChallengeUrl(challenge);
|
|
||||||
cache.set(id, challengeUrl);
|
|
||||||
return resolve(challengeUrl);
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/* global describe xdescribe it expect */
|
/* global describe xdescribe it expect */
|
||||||
import { isEqual, first, find } from 'lodash';
|
import { first, find } from 'lodash';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { mockReq, mockRes } from 'sinon-express-mock';
|
import { mockReq, mockRes } from 'sinon-express-mock';
|
||||||
|
|
||||||
@ -16,12 +16,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
firstChallengeUrl,
|
firstChallengeUrl,
|
||||||
requestedChallengeUrl,
|
requestedChallengeUrl,
|
||||||
|
mockAllChallenges,
|
||||||
mockChallenge,
|
mockChallenge,
|
||||||
mockFirstChallenge,
|
|
||||||
mockUser,
|
mockUser,
|
||||||
mockApp,
|
|
||||||
mockGetFirstChallenge,
|
mockGetFirstChallenge,
|
||||||
firstChallengeQuery,
|
|
||||||
mockCompletedChallenge,
|
mockCompletedChallenge,
|
||||||
mockCompletedChallenges,
|
mockCompletedChallenges,
|
||||||
mockPathMigrationMap
|
mockPathMigrationMap
|
||||||
@ -175,20 +173,26 @@ describe('boot/challenge', () => {
|
|||||||
|
|
||||||
describe('challengeUrlResolver', () => {
|
describe('challengeUrlResolver', () => {
|
||||||
it('resolves to the first challenge url by default', async () => {
|
it('resolves to the first challenge url by default', async () => {
|
||||||
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
|
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||||
_getFirstChallenge: mockGetFirstChallenge
|
mockAllChallenges,
|
||||||
});
|
{
|
||||||
|
_getFirstChallenge: mockGetFirstChallenge
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return challengeUrlResolver().then(url => {
|
return challengeUrlResolver().then(url => {
|
||||||
expect(url).toEqual(firstChallengeUrl);
|
expect(url).toEqual(firstChallengeUrl);
|
||||||
});
|
});
|
||||||
});
|
}, 10000);
|
||||||
|
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
it('returns the first challenge url if the provided id does not relate to a challenge', async () => {
|
it('returns the first challenge url if the provided id does not relate to a challenge', async () => {
|
||||||
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
|
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||||
_getFirstChallenge: mockGetFirstChallenge
|
mockAllChallenges,
|
||||||
});
|
{
|
||||||
|
_getFirstChallenge: mockGetFirstChallenge
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return challengeUrlResolver('not-a-real-challenge').then(url => {
|
return challengeUrlResolver('not-a-real-challenge').then(url => {
|
||||||
expect(url).toEqual(firstChallengeUrl);
|
expect(url).toEqual(firstChallengeUrl);
|
||||||
@ -196,9 +200,12 @@ describe('boot/challenge', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('resolves the correct url for the requested challenge', async () => {
|
it('resolves the correct url for the requested challenge', async () => {
|
||||||
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
|
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||||
_getFirstChallenge: mockGetFirstChallenge
|
mockAllChallenges,
|
||||||
});
|
{
|
||||||
|
_getFirstChallenge: mockGetFirstChallenge
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return challengeUrlResolver('123abc').then(url => {
|
return challengeUrlResolver('123abc').then(url => {
|
||||||
expect(url).toEqual(requestedChallengeUrl);
|
expect(url).toEqual(requestedChallengeUrl);
|
||||||
@ -207,28 +214,14 @@ describe('boot/challenge', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getFirstChallenge', () => {
|
describe('getFirstChallenge', () => {
|
||||||
const createMockChallengeModel = success =>
|
|
||||||
success
|
|
||||||
? {
|
|
||||||
findOne(query, cb) {
|
|
||||||
return isEqual(query, firstChallengeQuery)
|
|
||||||
? cb(null, mockFirstChallenge)
|
|
||||||
: cb(new Error('no challenge found'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
findOne(_, cb) {
|
|
||||||
return cb(new Error('no challenge found'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
it('returns the correct challenge url from the model', async () => {
|
it('returns the correct challenge url from the model', async () => {
|
||||||
const result = await getFirstChallenge(createMockChallengeModel(true));
|
const result = await getFirstChallenge(mockAllChallenges);
|
||||||
|
|
||||||
expect(result).toEqual(firstChallengeUrl);
|
expect(result).toEqual(firstChallengeUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the learn base if no challenges found', async () => {
|
it('returns the learn base if no challenges found', async () => {
|
||||||
const result = await getFirstChallenge(createMockChallengeModel(false));
|
const result = await getFirstChallenge([]);
|
||||||
|
|
||||||
expect(result).toEqual('/learn');
|
expect(result).toEqual('/learn');
|
||||||
});
|
});
|
||||||
@ -356,9 +349,12 @@ describe('boot/challenge', () => {
|
|||||||
|
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
it('redirects to the url provided by the challengeUrlResolver', async done => {
|
it('redirects to the url provided by the challengeUrlResolver', async done => {
|
||||||
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
|
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||||
_getFirstChallenge: mockGetFirstChallenge
|
mockAllChallenges,
|
||||||
});
|
{
|
||||||
|
_getFirstChallenge: mockGetFirstChallenge
|
||||||
|
}
|
||||||
|
);
|
||||||
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
|
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
|
||||||
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
||||||
challengeUrlResolver,
|
challengeUrlResolver,
|
||||||
@ -377,9 +373,12 @@ describe('boot/challenge', () => {
|
|||||||
|
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
it('redirects to the first challenge for users without a currentChallengeId', async done => {
|
it('redirects to the first challenge for users without a currentChallengeId', async done => {
|
||||||
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
|
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||||
_getFirstChallenge: mockGetFirstChallenge
|
mockAllChallenges,
|
||||||
});
|
{
|
||||||
|
_getFirstChallenge: mockGetFirstChallenge
|
||||||
|
}
|
||||||
|
);
|
||||||
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
||||||
challengeUrlResolver,
|
challengeUrlResolver,
|
||||||
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
|
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
|
||||||
|
@ -16,7 +16,10 @@ export const mockFirstChallenge = {
|
|||||||
id: '456def',
|
id: '456def',
|
||||||
block: 'first',
|
block: 'first',
|
||||||
superBlock: 'the',
|
superBlock: 'the',
|
||||||
dashedName: 'challenge'
|
dashedName: 'challenge',
|
||||||
|
challengeOrder: 0,
|
||||||
|
superOrder: 1,
|
||||||
|
order: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockCompletedChallenge = {
|
export const mockCompletedChallenge = {
|
||||||
@ -117,16 +120,6 @@ export function createNewUserFromEmail(email) {
|
|||||||
|
|
||||||
export const mockApp = {
|
export const mockApp = {
|
||||||
models: {
|
models: {
|
||||||
Challenge: {
|
|
||||||
find() {
|
|
||||||
return firstChallengeUrl;
|
|
||||||
},
|
|
||||||
findById(id, cb) {
|
|
||||||
return id === mockChallenge.id
|
|
||||||
? cb(null, mockChallenge)
|
|
||||||
: cb(new Error('challenge not found'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Donation: {
|
Donation: {
|
||||||
findOne(query, cb) {
|
findOne(query, cb) {
|
||||||
return isEqual(query, matchSubscriptionIdQuery)
|
return isEqual(query, matchSubscriptionIdQuery)
|
||||||
@ -157,6 +150,8 @@ export const mockApp = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mockAllChallenges = [mockFirstChallenge, mockChallenge];
|
||||||
|
|
||||||
export const mockGetFirstChallenge = () => firstChallengeUrl;
|
export const mockGetFirstChallenge = () => firstChallengeUrl;
|
||||||
|
|
||||||
export const matchEmailQuery = {
|
export const matchEmailQuery = {
|
||||||
|
30
api-server/server/utils/get-curriculum.js
Normal file
30
api-server/server/utils/get-curriculum.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
|
import { getChallengesForLang } from '../../../curriculum/getChallenges';
|
||||||
|
|
||||||
|
// TODO: this caching is handy if we want to field requests that need to 'query'
|
||||||
|
// the curriculum, but if we force the client to handle
|
||||||
|
// redirectToCurrentChallenge and, instead, only report the current challenge
|
||||||
|
// id via the user object, then we should *not* store this so it can be garbage
|
||||||
|
// collected.
|
||||||
|
|
||||||
|
let curriculum;
|
||||||
|
export async function getCurriculum() {
|
||||||
|
curriculum = curriculum
|
||||||
|
? curriculum
|
||||||
|
: getChallengesForLang(process.env.LOCALE);
|
||||||
|
return curriculum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChallenges() {
|
||||||
|
return getCurriculum().then(curriculum => {
|
||||||
|
return Object.keys(curriculum)
|
||||||
|
.map(key => curriculum[key].blocks)
|
||||||
|
.reduce((challengeArray, superBlock) => {
|
||||||
|
const challengesForBlock = Object.keys(superBlock).map(
|
||||||
|
key => superBlock[key].challenges
|
||||||
|
);
|
||||||
|
return [...challengeArray, ...flatten(challengesForBlock)];
|
||||||
|
}, []);
|
||||||
|
});
|
||||||
|
}
|
Reference in New Issue
Block a user