Skip to content

Commit

Permalink
Add download queue data button (#290)
Browse files Browse the repository at this point in the history
* Add sequelize query to fetch course data

* Add ability to download csv of data

* Fix lint

* Move csv logic to api

* Add test for data/questions endpoint

* Filter columns by what we want to include

* Move timezone calculation to javascript side to get rid of sqlite/mysql errors

* Add time format

* Change test times to UTC

* Change how data is downloaded, add changelog entry

* Get rid of getColumns, add withBaseUrl

* Fix user location column name

* Fix test

* Fix test for real

* Fix tests for real for real I promise
  • Loading branch information
jackieo5023 authored and james9909 committed Oct 14, 2019
1 parent 60e43f1 commit 51ec9d1
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/actions/course.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
133 changes: 132 additions & 1 deletion src/api/courses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'/',
Expand Down Expand Up @@ -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(
'/',
Expand Down
24 changes: 24 additions & 0 deletions src/api/courses.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
14 changes: 12 additions & 2 deletions src/pages/course.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -57,6 +57,16 @@ const Course = props => {
{props.course.name}
</h1>
<ShowForCourseStaff courseId={props.courseId}>
<Button
color="primary"
className="mr-3 mt-3"
href={withBaseUrl(
`/api/courses/${props.courseId}/data/questions`
)}
>
<FontAwesomeIcon icon={faDownload} className="mr-2" />
Download Queue Data
</Button>
<Link
route="courseStaff"
params={{ id: props.courseId }}
Expand Down
17 changes: 15 additions & 2 deletions src/test/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,31 @@ module.exports.createTestCourses = async () => {

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',
Expand All @@ -63,6 +75,7 @@ module.exports.createTestQueues = async () => {
isConfidential: true,
messageEnabled: true,
courseId: 1,
createdAt: '2019-10-05 22:35:41.000 +00:00',
},
])
}
Expand Down

0 comments on commit 51ec9d1

Please sign in to comment.