From 4518d3693c2dd06f5f23ce5a71e85014533317f2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Jan 2016 20:28:54 +0100 Subject: [PATCH] add exportChallengeCsv route (missing tests) --- package.json | 1 + website/src/controllers/api-v3/challenges.js | 74 ++++++++++++++++++++ website/src/libs/api-v3/csvStringify.js | 11 +++ website/src/models/challenge.js | 4 +- 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 website/src/libs/api-v3/csvStringify.js diff --git a/package.json b/package.json index 0f9ee88db0..b7de9c1f5a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cookie-parser": "^1.4.0", "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", + "csv-stringify": "^1.0.1", "domain-middleware": "~0.1.0", "estraverse": "^4.1.1", "express": "~4.13.3", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3c45f0e167..ea3e787602 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -5,6 +5,7 @@ import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; import { model as User, + nameFields, } from '../../models/user'; import { NotFound, @@ -15,6 +16,7 @@ import * as Tasks from '../../models/task'; import { txnEmail } from '../../libs/api-v3/email'; import pushNotify from '../../libs/api-v3/pushNotifications'; import Q from 'q'; +import csvStringify from '../../libs/api-v3/csvStringify'; let api = {}; @@ -239,6 +241,78 @@ api.getChallenge = { }, }; +/** + * @api {get} /challenges/:challengeId/export/csv Export a challenge in CSV + * @apiVersion 3.0.0 + * @apiName ExportChallengeCsv + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge object + */ +api.exportChallengeCsv = { + method: 'GET', + url: '/challenges/:challengeId/export/csv', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findById(challengeId).select('_id groupId leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + + // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all + // results on the server so the perf difference isn't that big (hopefully) + + let challengeTasks = _.reduce(challenge.tasksOrder, (result, array) => { + return result.concat(array); + }, []).sort(); + + let [members, tasks] = await Q.all([ + User.find({challenges: challengeId}) + .select(nameFields) + .sortBy({_id: 1}) + .lean() // so we don't involve mongoose + .exec(), + + Tasks.Task.find({'task.challenge.id': challengeId, userId: {$exists: true}}) + .sortBy({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), + ]); + + let resArray = members.map(member => [member._id, member.profile.name]); + + // We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0] + let lastUserId; + let index = -1; + tasks.forEach(task => { + if (task.userId !== lastUserId) { + lastUserId = task.userId; + index++; + } + + resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes); + }); + + // The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks + resArray.unshift(['UUID', 'name']); + _.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes')); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=${challengeId}.csv`, + }); + res.status(200).send(await csvStringify(resArray)); + }, +}; + /** * @api {put} /challenges/:challengeId Update a challenge * @apiVersion 3.0.0 diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js new file mode 100644 index 0000000000..3a597ff55c --- /dev/null +++ b/website/src/libs/api-v3/csvStringify.js @@ -0,0 +1,11 @@ +import csvStringify from 'csv-stringify'; +import Q from 'q'; + +export default function (input) { + return Q.promise((resolve, reject) => { + csvStringify(input, (err, output) => { + if (err) return reject(err); + return resolve(output); + }); + }); +} diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index c044caaba6..040a6c503f 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -48,9 +48,9 @@ schema.methods.canModify = function canModifyChallenge (user) { // Returns true if user has access to the challenge (can join) schema.methods.hasAccess = function hasAccessToChallenge (user) { - let userGroups = user.guilds.slice(0); + let userGroups = user.guilds.slice(0); // clone user.guilds so we don't modify the original if (user.party._id) userGroups.push(user.party._id); - userGroups.push('habitrpg'); // tavern challenges + userGroups.push('habitrpg'); // tavern return this.canModify(user) || userGroups.indexOf(this.groupId) !== -1; };