diff --git a/__tests__/__snapshots__/index.js.snap b/__tests__/__snapshots__/index.js.snap index 87dd0545..f2f97d81 100644 --- a/__tests__/__snapshots__/index.js.snap +++ b/__tests__/__snapshots__/index.js.snap @@ -318,8 +318,10 @@ Object { }, }, "submission": Object { + "default": undefined, "getFinalScore": [Function], "getProvisionalScore": [Function], + "processMMSubmissions": [Function], }, "tc": Object { "COMPETITION_TRACKS": Object { diff --git a/package.json b/package.json index 3cb777eb..16f4613c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint:js": "./node_modules/.bin/eslint --ext .js,.jsx .", "test": "npm run lint && npm run jest" }, - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "auth0-js": "^6.8.4", "config": "^3.2.0", diff --git a/src/actions/challenge.js b/src/actions/challenge.js index 2a0cd1e8..4627c0a3 100644 --- a/src/actions/challenge.js +++ b/src/actions/challenge.js @@ -3,12 +3,48 @@ * @desc Actions related to Topcoder challenges APIs. */ +/* global CONFIG */ import _ from 'lodash'; import { config } from 'topcoder-react-utils'; import { createActions } from 'redux-actions'; import { getService as getChallengesService } from '../services/challenges'; import { getService as getSubmissionService } from '../services/submissions'; +import { getService as getMemberService } from '../services/members'; import { getApi } from '../services/api'; +import * as submissionUtil from '../utils/submission'; + +const { PAGE_SIZE } = CONFIG; + +/** + * Private. Loads from the backend all data matching some conditions. + * @param {Function} getter Given params object of shape { limit, offset } + * loads from the backend at most "limit" data, skipping the first + * "offset" ones. Returns loaded data as an array. + * @param {Number} page Optional. Next page of data to load. + * @param {Number} perPage Optional. The size of the page content to load. + * @param {Array} prev Optional. data loaded so far. + */ +function getAll(getter, page = 1, perPage = PAGE_SIZE, prev) { + /* Amount of submissions to fetch in one API call. 50 is the current maximum + * amount of submissions the backend returns, event when the larger limit is + * explicitely required. */ + return getter({ + page, + perPage, + }).then((res) => { + if (res.length === 0) { + return prev || res; + } + // parse submissions + let current = []; + if (prev) { + current = prev.concat(res); + } else { + current = res; + } + return getAll(getter, 1 + page, perPage, current); + }); +} /** * @static @@ -106,10 +142,26 @@ function getMMSubmissionsInit(challengeId) { * @param {String} tokenV3 Topcoder auth token v3. * @return {Action} */ -async function getMMSubmissionsDone(challengeId, registrants, tokenV3) { +function getMMSubmissionsDone(challengeId, registrants, tokenV3) { + const filter = { challengeId }; + const memberService = getMemberService(tokenV3); const submissionsService = getSubmissionService(tokenV3); - const submissions = await submissionsService.getSubmissions(challengeId); - return { challengeId, submissions, tokenV3 }; + + // TODO: Move those numbers to configs + return getAll(params => submissionsService.getSubmissions(filter, params), 1, 500) + .then((submissions) => { + const userIds = _.uniq(_.map(submissions, sub => sub.memberId)); + return memberService.getMembersInformation(userIds) + .then((resources) => { + const finalSubmissions = submissionUtil + .processMMSubmissions(submissions, resources, registrants); + return { + challengeId, + submissions: finalSubmissions, + tokenV3, + }; + }); + }); } /** @@ -319,8 +371,8 @@ function getActiveChallengesCountDone(handle, tokenV3) { * @param {String} submissionId The submission id * @return {Action} */ -function getSubmissionInformationInit(submissionId) { - return _.toString(submissionId); +function getSubmissionInformationInit(challengeId, submissionId) { + return { challengeId: _.toString(challengeId), submissionId: _.toString(submissionId) }; } /** @@ -330,12 +382,16 @@ function getSubmissionInformationInit(submissionId) { * @param {String} tokenV3 Topcoder auth token v3. * @return {Action} */ -function getSubmissionInformationDone(submissionId, tokenV3) { - return getSubmissionService(tokenV3) - .getSubmissionInformation(submissionId) - .then(response => ({ - submissionId, submission: response, - })); +function getSubmissionInformationDone(challengeId, submissionId, tokenV3) { + const filter = { challengeId }; + const submissionsService = getSubmissionService(tokenV3); + + return getAll(params => submissionsService.getSubmissions(filter, params), 1, 500) + .then((submissions) => { + const submission = _.find(submissions, { id: submissionId }); + _.remove(submission.review, review => review.typeId === CONFIG.AV_SCAN_SCORER_REVIEW_TYPE_ID); + return { submissionId, submission }; + }); } export default createActions({ diff --git a/src/reducers/challenge.js b/src/reducers/challenge.js index 11ff0ef1..b2edf441 100644 --- a/src/reducers/challenge.js +++ b/src/reducers/challenge.js @@ -327,7 +327,8 @@ function onGetActiveChallengesCountDone(state, { payload, error }) { function onGetSubmissionInformationInit(state, action) { return { ...state, - loadingSubmissionInformationForSubmissionId: action.payload, + loadingSubmissionInformationForChallengeId: action.payload.challengeId, + loadingSubmissionInformationForSubmissionId: action.payload.submissionId, submissionInformation: null, }; } diff --git a/src/services/groups.js b/src/services/groups.js index 9ebba0d5..69603c0f 100644 --- a/src/services/groups.js +++ b/src/services/groups.js @@ -140,10 +140,6 @@ export function checkUserGroups(groupIds, userGroups, knownGroups) { function handleApiResponse(response) { if (!response.ok) throw new Error(response.statusText); return response.json(); - // return response.json().then(({ result }) => { - // return result; - // if (result.status !== 200) throw new Error(result.content); - // }); } /** diff --git a/src/services/submissions.js b/src/services/submissions.js index e420c39e..c8b20052 100644 --- a/src/services/submissions.js +++ b/src/services/submissions.js @@ -3,7 +3,7 @@ * @desc This module provides a service for convenient manipulation with * Topcoder submissions via TC API. Currently only used for MM challenges */ - +import qs from 'qs'; import { getApi } from './api'; /** @@ -16,20 +16,27 @@ class SubmissionsService { */ constructor(tokenV3) { this.private = { - broker: getApi('MM_BROKER', tokenV3), + apiV5: getApi('V5', tokenV3), tokenV3, }; } /** * Get submissions of challenge - * @param {Object} challengeId + * @param {Object} filters + * @param {Object} params * @return {Promise} Resolves to the api response. */ - async getSubmissions(challengeId) { - const url = `/v5/submissions?challengeId=${challengeId}`; - return this.private.broker.get(url) - .then(res => (res.ok ? res.json() : new Error(res.statusText))); + async getSubmissions(filters, params) { + const query = { + ...filters, + ...params, + }; + + const url = `/submissions?${qs.stringify(query, { encode: false })}`; + return this.private.apiV5.get(url) + .then(res => (res.ok ? res.json() : new Error(res.statusText))) + .then(res => res); } /** @@ -38,9 +45,10 @@ class SubmissionsService { * @returns {Promise} Resolves to the api response. */ async getSubmissionInformation(submissionId) { - const url = `/v5/submissions/${submissionId}`; - return this.private.broker.get(url) - .then(res => (res.ok ? res.json() : new Error(res.statusText))); + const url = `/submissions/${submissionId}`; + return this.private.apiV5.get(url) + .then(res => (res.ok ? res.json() : new Error(res.statusText))) + .then(res => res); } } diff --git a/src/utils/submission.js b/src/utils/submission.js index 0b581004..970fea14 100644 --- a/src/utils/submission.js +++ b/src/utils/submission.js @@ -1,6 +1,84 @@ /** * Various submissions functions. */ +/* global CONFIG */ +/* eslint-disable no-param-reassign */ +import _ from 'lodash'; + +const { AV_SCAN_SCORER_REVIEW_TYPE_ID } = CONFIG; + +function removeDecimal(num) { + const re = new RegExp('^-?\\d+'); + return num.toString().match(re)[0]; +} + +function toAcurateFixed(num, decimal) { + const re = new RegExp(`^-?\\d+(?:.\\d{0,${(decimal)}})?`); + return num.toString().match(re)[0]; +} + +function toFixed(num, decimal) { + if (_.isNaN(parseFloat(num))) return num; + num = parseFloat(num); + + const result = _.toFinite(toAcurateFixed(num, decimal)); + const integerResult = _.toFinite(removeDecimal(num)); + + if (_.isInteger(result)) { + return integerResult; + } + return result; +} + +function getMMChallengeHandleStyle(handle, registrants) { + const style = _.get(_.find(registrants, m => m.handle === handle), 'colorStyle', null); + if (style) return JSON.parse(style.replace(/(\w+):\s*([^;]*)/g, '{"$1": "$2"}')); + return {}; +} + +/** + * Process each submission rank of MM challenge + * @param submissions the array of submissions + */ +function processRanks(submissions) { + let maxFinalScore = 0; + submissions.sort((a, b) => { + let pA = _.get(a, 'submissions[0]', { provisionalScore: 0 }).provisionalScore; + let pB = _.get(b, 'submissions[0]', { provisionalScore: 0 }).provisionalScore; + if (pA === '-') pA = 0; + if (pB === '-') pB = 0; + if (pA === pB) { + const timeA = new Date(_.get(a, 'submissions[0].submissionTime')); + const timeB = new Date(_.get(b, 'submissions[0].submissionTime')); + return timeA - timeB; + } + return pB - pA; + }); + _.each(submissions, (submission, i) => { + submissions[i].provisionalRank = i + 1; + }); + + submissions.sort((a, b) => { + let pA = _.get(a, 'submissions[0]', { finalScore: 0 }).finalScore; + let pB = _.get(b, 'submissions[0]', { finalScore: 0 }).finalScore; + if (pA === '-') pA = 0; + if (pB === '-') pB = 0; + if (pA > 0) maxFinalScore = pA; + if (pB > 0) maxFinalScore = pB; + if (pA === pB) { + const timeA = new Date(_.get(a, 'submissions[0].submissionTime')); + const timeB = new Date(_.get(b, 'submissions[0].submissionTime')); + return timeA - timeB; + } + return pB - pA; + }); + if (maxFinalScore > 0) { + _.each(submissions, (submission, i) => { + submissions[i].finalRank = i + 1; + }); + } + return { submissions, maxFinalScore }; +} /** * Get provisional score of submission @@ -33,3 +111,66 @@ export function getFinalScore(submission) { } return finalScore; } + +/** + * Process submissions of MM challenge + * @param submissions the array of submissions + * @param resources the challenge resources + * @param registrants the challenge registrants + */ +export function processMMSubmissions(submissions, resources, registrants) { + const data = {}; + const result = []; + + _.each(submissions, (submission) => { + const { memberId } = submission; + let memberHandle; + const resource = _.find(resources, r => _.get(r, 'userId').toString() === memberId.toString()); + if (_.isEmpty(resource)) { + memberHandle = memberId; + } else { + memberHandle = _.has(resource, 'handle') ? _.get(resource, 'handle') : memberId.toString(); + } + if (!data[memberHandle]) { + data[memberHandle] = []; + } + const validReviews = _.filter(submission.review, + r => !_.isEmpty(r) && (r.typeId !== AV_SCAN_SCORER_REVIEW_TYPE_ID)); + validReviews.sort((a, b) => { + const dateA = new Date(a.created); + const dateB = new Date(b.created); + return dateB - dateA; + }); + + const provisionalScore = toFixed(_.get(validReviews, '[0].score', '-'), 5); + const finalScore = toFixed(_.get(submission, 'reviewSummation[0].aggregateScore', '-'), 5); + + data[memberHandle].push({ + submissionId: submission.id, + submissionTime: submission.created, + provisionalScore, + finalScore, + }); + }); + + _.each(data, (value, key) => { + result.push({ + submissions: [...value.sort((a, b) => new Date(b.submissionTime) + .getTime() - new Date(a.submissionTime).getTime())], + member: key, + colorStyle: getMMChallengeHandleStyle(key, registrants), + }); + }); + + const { submissions: finalSubmissions, maxFinalScore } = processRanks(result); + finalSubmissions.sort((a, b) => { + if (maxFinalScore === 0) { + return a.provisionalRank - b.provisionalRank; + } + return a.finalRank - b.finalRank; + }); + + return finalSubmissions; +} + +export default undefined;