diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcab416..019112fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ When a new version is tagged, the changes since the last deploy should be labele with the current semantic version and the next changes should go under a **[Next]** header. ## [Next] +* Add button to download all queue data for a course. ([@jackieo5023](https://github.com/jackieo5023) in [#290](https://github.com/illinois/queue/pull/290)) ## v1.2.1 diff --git a/src/actions/course.js b/src/actions/course.js index 02e0288d..1f48471f 100644 --- a/src/actions/course.js +++ b/src/actions/course.js @@ -128,7 +128,7 @@ export function addCourseStaff(courseId, netid, name) { } /** - * Add a user as staff for a course + * Remove a user as staff for a course */ const removeCourseStaffRequest = makeActionCreator( types.REMOVE_COURSE_STAFF.REQUEST, diff --git a/src/api/courses.js b/src/api/courses.js index 5fe8664d..3a5492be 100644 --- a/src/api/courses.js +++ b/src/api/courses.js @@ -4,13 +4,73 @@ const router = require('express').Router({ const { check } = require('express-validator/check') const { matchedData } = require('express-validator/filter') +const moment = require('moment') -const { Course, Queue, User } = require('../models') +const { Course, Queue, Question, User, Sequelize } = require('../models') const { requireCourse, requireUser, failIfErrors } = require('./util') const requireAdmin = require('../middleware/requireAdmin') const requireCourseStaff = require('../middleware/requireCourseStaff') const safeAsync = require('../middleware/safeAsync') +const getCsv = questions => { + const columns = new Set([ + 'id', + 'topic', + 'enqueueTime', + 'dequeueTime', + 'answerStartTime', + 'answerFinishTime', + 'comments', + 'preparedness', + 'UserLocation', + 'answeredBy.AnsweredBy_netid', + 'answeredBy.AnsweredBy_UniversityName', + 'askedBy.AskedBy_netid', + 'askedBy.AskedBy_UniversityName', + 'queue.queueId', + 'queue.courseId', + 'queue.QueueName', + 'queue.QueueLocation', + 'queue.Queue_CreatedAt', + 'queue.course.CourseName', + ]) + const timeFields = new Set([ + 'queue.Queue_CreatedAt', + 'enqueueTime', + 'dequeueTime', + 'answerStartTime', + 'answerFinishTime', + ]) + + // Taken from https://stackoverflow.com/questions/8847766/how-to-convert-json-to-csv-format-and-store-in-a-variable + const header = Array.from(columns) + const replacer = (key, value) => (value === null ? '' : value) + const csv = questions.map(row => + header + .map(fieldName => { + if (timeFields.has(fieldName)) { + const time = row[fieldName] + const formattedTime = + time !== null + ? moment + .tz(time, 'YYYY-MM-DD HH:mm:ss.SSS Z', 'US/Central') + .format('YYYY-MM-DD HH:mm:ss') + : '' + return JSON.stringify(formattedTime, replacer) + } + return JSON.stringify(row[fieldName], replacer) + }) + .join(',') + ) + const splitHeader = header.map(h => { + const headerSplit = h.split('.') + return headerSplit[headerSplit.length - 1] + }) + csv.unshift(splitHeader.join(',')) + + return csv.join('\n') +} + // Get all courses router.get( '/', @@ -69,6 +129,77 @@ router.get( }) ) +// Get course queue data +router.get( + '/:courseId/data/questions', + [requireCourseStaff, requireCourse, failIfErrors], + safeAsync(async (req, res, _next) => { + const { id: courseId } = res.locals.course + const questions = await Question.findAll({ + include: [ + { + model: Queue, + include: [ + { + model: Course, + attributes: [['name', 'CourseName']], + required: true, + where: { id: Sequelize.col('queue.courseId') }, + }, + ], + attributes: [ + ['id', 'queueId'], + 'courseId', + ['name', 'QueueName'], + ['location', 'QueueLocation'], + ['createdAt', 'Queue_CreatedAt'], + ], + required: true, + where: { courseId, id: Sequelize.col('question.queueId') }, + }, + { + model: User, + as: 'askedBy', + attributes: [ + ['netid', 'AskedBy_netid'], + ['universityName', 'AskedBy_UniversityName'], + ], + required: true, + where: { id: Sequelize.col('question.askedById') }, + }, + { + model: User, + as: 'answeredBy', + attributes: [ + ['netid', 'AnsweredBy_netid'], + ['universityName', 'AnsweredBy_UniversityName'], + ], + required: false, + where: { id: Sequelize.col('question.answeredById') }, + }, + ], + attributes: [ + 'id', + 'topic', + 'enqueueTime', + 'dequeueTime', + 'answerStartTime', + 'answerFinishTime', + 'comments', + 'preparedness', + ['location', 'UserLocation'], + ], + order: [['enqueueTime', 'DESC']], + raw: true, + }) + + res + .type('text/csv') + .attachment('queueData.csv') + .send(getCsv(questions)) + }) +) + // Create a new course router.post( '/', diff --git a/src/api/courses.test.js b/src/api/courses.test.js index becc161a..cf7f783a 100644 --- a/src/api/courses.test.js +++ b/src/api/courses.test.js @@ -76,6 +76,30 @@ describe('Courses API', () => { }) }) + describe('GET /api/courses/:courseId/data/questions', () => { + const expectedCsv = + 'id,topic,enqueueTime,dequeueTime,answerStartTime,answerFinishTime,comments,preparedness,UserLocation,AnsweredBy_netid,AnsweredBy_UniversityName,AskedBy_netid,AskedBy_UniversityName,queueId,courseId,QueueName,QueueLocation,Queue_CreatedAt,CourseName\n1,"Queue","","","","","","","Siebel","","","admin","Admin",1,1,"CS225 Queue","Here","2019-10-05 17:05:41","CS225"\n2,"Canada","","","","","","","ECEB","","","student","",1,1,"CS225 Queue","Here","2019-10-05 17:05:41","CS225"\n3,"Sauce","","","","","","","","","","admin","Admin",3,1,"CS225 Fixed Location","Everywhere","2019-10-05 17:15:41","CS225"\n4,"Secret","","","","","","","","","","student","",5,1,"CS225 Confidential Queue","Everywhere","2019-10-05 17:35:41","CS225"\n5,"Secret","","","","","","","","","","otherstudent","",5,1,"CS225 Confidential Queue","Everywhere","2019-10-05 17:35:41","CS225"' + test('succeeds for admin', async () => { + const request = await requestAsUser(app, 'admin') + const res = await request.get('/api/courses/1/data/questions') + expect(res.statusCode).toBe(200) + expect(res.text).toEqual(expectedCsv) + }) + + test('succeeds for course staff', async () => { + const request = await requestAsUser(app, '225staff') + const res = await request.get('/api/courses/1/data/questions') + expect(res.statusCode).toBe(200) + expect(res.text).toEqual(expectedCsv) + }) + + test('fails for student', async () => { + const request = await requestAsUser(app, 'student') + const res = await request.get('/api/courses/1/data/questions') + expect(res.statusCode).toBe(403) + }) + }) + describe('POST /api/courses', () => { test('succeeds for admin', async () => { const course = { name: 'CS423', shortcode: 'cs423' } diff --git a/src/pages/course.js b/src/pages/course.js index 5f239fd7..963c886c 100644 --- a/src/pages/course.js +++ b/src/pages/course.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux' import { Container, Row, Card, CardBody, Button } from 'reactstrap' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPlus, faUsers } from '@fortawesome/free-solid-svg-icons' +import { faPlus, faUsers, faDownload } from '@fortawesome/free-solid-svg-icons' import { Link } from '../routes' import { fetchCourseRequest, fetchCourse } from '../actions/course' import { createQueue } from '../actions/queue' -import { mapObjectToArray } from '../util' +import { mapObjectToArray, withBaseUrl } from '../util' import Error from '../components/Error' import PageWithUser from '../components/PageWithUser' @@ -57,6 +57,16 @@ const Course = props => { {props.course.name} + { module.exports.createTestQueues = async () => { await models.Queue.bulkCreate([ - { name: 'CS225 Queue', location: 'Here', courseId: 1 }, - { name: 'CS241 Queue', location: 'There', courseId: 2 }, + { + name: 'CS225 Queue', + location: 'Here', + courseId: 1, + createdAt: '2019-10-05 22:05:41.000 +00:00', + }, + { + name: 'CS241 Queue', + location: 'There', + courseId: 2, + createdAt: '2019-10-05 22:10:41.000 +00:00', + }, { name: 'CS225 Fixed Location', fixedLocation: true, location: 'Everywhere', courseId: 1, + createdAt: '2019-10-05 22:15:41.000 +00:00', }, { name: 'CS225 Closed', open: false, location: 'Everywhere', courseId: 1, + createdAt: '2019-10-05 22:25:41.000 +00:00', }, { name: 'CS225 Confidential Queue', @@ -63,6 +75,7 @@ module.exports.createTestQueues = async () => { isConfidential: true, messageEnabled: true, courseId: 1, + createdAt: '2019-10-05 22:35:41.000 +00:00', }, ]) }