Skip to content

Commit 51ec9d1

Browse files
jackieo5023james9909
authored andcommitted
Add download queue data button (#290)
* 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
1 parent 60e43f1 commit 51ec9d1

6 files changed

Lines changed: 185 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ When a new version is tagged, the changes since the last deploy should be labele
66
with the current semantic version and the next changes should go under a **[Next]** header.
77

88
## [Next]
9+
* Add button to download all queue data for a course. ([@jackieo5023](https://github.com/jackieo5023) in [#290](https://github.com/illinois/queue/pull/290))
910

1011
## v1.2.1
1112

src/actions/course.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export function addCourseStaff(courseId, netid, name) {
128128
}
129129

130130
/**
131-
* Add a user as staff for a course
131+
* Remove a user as staff for a course
132132
*/
133133
const removeCourseStaffRequest = makeActionCreator(
134134
types.REMOVE_COURSE_STAFF.REQUEST,

src/api/courses.js

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,73 @@ const router = require('express').Router({
44

55
const { check } = require('express-validator/check')
66
const { matchedData } = require('express-validator/filter')
7+
const moment = require('moment')
78

8-
const { Course, Queue, User } = require('../models')
9+
const { Course, Queue, Question, User, Sequelize } = require('../models')
910
const { requireCourse, requireUser, failIfErrors } = require('./util')
1011
const requireAdmin = require('../middleware/requireAdmin')
1112
const requireCourseStaff = require('../middleware/requireCourseStaff')
1213
const safeAsync = require('../middleware/safeAsync')
1314

15+
const getCsv = questions => {
16+
const columns = new Set([
17+
'id',
18+
'topic',
19+
'enqueueTime',
20+
'dequeueTime',
21+
'answerStartTime',
22+
'answerFinishTime',
23+
'comments',
24+
'preparedness',
25+
'UserLocation',
26+
'answeredBy.AnsweredBy_netid',
27+
'answeredBy.AnsweredBy_UniversityName',
28+
'askedBy.AskedBy_netid',
29+
'askedBy.AskedBy_UniversityName',
30+
'queue.queueId',
31+
'queue.courseId',
32+
'queue.QueueName',
33+
'queue.QueueLocation',
34+
'queue.Queue_CreatedAt',
35+
'queue.course.CourseName',
36+
])
37+
const timeFields = new Set([
38+
'queue.Queue_CreatedAt',
39+
'enqueueTime',
40+
'dequeueTime',
41+
'answerStartTime',
42+
'answerFinishTime',
43+
])
44+
45+
// Taken from https://stackoverflow.com/questions/8847766/how-to-convert-json-to-csv-format-and-store-in-a-variable
46+
const header = Array.from(columns)
47+
const replacer = (key, value) => (value === null ? '' : value)
48+
const csv = questions.map(row =>
49+
header
50+
.map(fieldName => {
51+
if (timeFields.has(fieldName)) {
52+
const time = row[fieldName]
53+
const formattedTime =
54+
time !== null
55+
? moment
56+
.tz(time, 'YYYY-MM-DD HH:mm:ss.SSS Z', 'US/Central')
57+
.format('YYYY-MM-DD HH:mm:ss')
58+
: ''
59+
return JSON.stringify(formattedTime, replacer)
60+
}
61+
return JSON.stringify(row[fieldName], replacer)
62+
})
63+
.join(',')
64+
)
65+
const splitHeader = header.map(h => {
66+
const headerSplit = h.split('.')
67+
return headerSplit[headerSplit.length - 1]
68+
})
69+
csv.unshift(splitHeader.join(','))
70+
71+
return csv.join('\n')
72+
}
73+
1474
// Get all courses
1575
router.get(
1676
'/',
@@ -69,6 +129,77 @@ router.get(
69129
})
70130
)
71131

132+
// Get course queue data
133+
router.get(
134+
'/:courseId/data/questions',
135+
[requireCourseStaff, requireCourse, failIfErrors],
136+
safeAsync(async (req, res, _next) => {
137+
const { id: courseId } = res.locals.course
138+
const questions = await Question.findAll({
139+
include: [
140+
{
141+
model: Queue,
142+
include: [
143+
{
144+
model: Course,
145+
attributes: [['name', 'CourseName']],
146+
required: true,
147+
where: { id: Sequelize.col('queue.courseId') },
148+
},
149+
],
150+
attributes: [
151+
['id', 'queueId'],
152+
'courseId',
153+
['name', 'QueueName'],
154+
['location', 'QueueLocation'],
155+
['createdAt', 'Queue_CreatedAt'],
156+
],
157+
required: true,
158+
where: { courseId, id: Sequelize.col('question.queueId') },
159+
},
160+
{
161+
model: User,
162+
as: 'askedBy',
163+
attributes: [
164+
['netid', 'AskedBy_netid'],
165+
['universityName', 'AskedBy_UniversityName'],
166+
],
167+
required: true,
168+
where: { id: Sequelize.col('question.askedById') },
169+
},
170+
{
171+
model: User,
172+
as: 'answeredBy',
173+
attributes: [
174+
['netid', 'AnsweredBy_netid'],
175+
['universityName', 'AnsweredBy_UniversityName'],
176+
],
177+
required: false,
178+
where: { id: Sequelize.col('question.answeredById') },
179+
},
180+
],
181+
attributes: [
182+
'id',
183+
'topic',
184+
'enqueueTime',
185+
'dequeueTime',
186+
'answerStartTime',
187+
'answerFinishTime',
188+
'comments',
189+
'preparedness',
190+
['location', 'UserLocation'],
191+
],
192+
order: [['enqueueTime', 'DESC']],
193+
raw: true,
194+
})
195+
196+
res
197+
.type('text/csv')
198+
.attachment('queueData.csv')
199+
.send(getCsv(questions))
200+
})
201+
)
202+
72203
// Create a new course
73204
router.post(
74205
'/',

src/api/courses.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,30 @@ describe('Courses API', () => {
7676
})
7777
})
7878

79+
describe('GET /api/courses/:courseId/data/questions', () => {
80+
const expectedCsv =
81+
'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"'
82+
test('succeeds for admin', async () => {
83+
const request = await requestAsUser(app, 'admin')
84+
const res = await request.get('/api/courses/1/data/questions')
85+
expect(res.statusCode).toBe(200)
86+
expect(res.text).toEqual(expectedCsv)
87+
})
88+
89+
test('succeeds for course staff', async () => {
90+
const request = await requestAsUser(app, '225staff')
91+
const res = await request.get('/api/courses/1/data/questions')
92+
expect(res.statusCode).toBe(200)
93+
expect(res.text).toEqual(expectedCsv)
94+
})
95+
96+
test('fails for student', async () => {
97+
const request = await requestAsUser(app, 'student')
98+
const res = await request.get('/api/courses/1/data/questions')
99+
expect(res.statusCode).toBe(403)
100+
})
101+
})
102+
79103
describe('POST /api/courses', () => {
80104
test('succeeds for admin', async () => {
81105
const course = { name: 'CS423', shortcode: 'cs423' }

src/pages/course.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { connect } from 'react-redux'
44
import { Container, Row, Card, CardBody, Button } from 'reactstrap'
55

66
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
7-
import { faPlus, faUsers } from '@fortawesome/free-solid-svg-icons'
7+
import { faPlus, faUsers, faDownload } from '@fortawesome/free-solid-svg-icons'
88

99
import { Link } from '../routes'
1010
import { fetchCourseRequest, fetchCourse } from '../actions/course'
1111
import { createQueue } from '../actions/queue'
12-
import { mapObjectToArray } from '../util'
12+
import { mapObjectToArray, withBaseUrl } from '../util'
1313

1414
import Error from '../components/Error'
1515
import PageWithUser from '../components/PageWithUser'
@@ -57,6 +57,16 @@ const Course = props => {
5757
{props.course.name}
5858
</h1>
5959
<ShowForCourseStaff courseId={props.courseId}>
60+
<Button
61+
color="primary"
62+
className="mr-3 mt-3"
63+
href={withBaseUrl(
64+
`/api/courses/${props.courseId}/data/questions`
65+
)}
66+
>
67+
<FontAwesomeIcon icon={faDownload} className="mr-2" />
68+
Download Queue Data
69+
</Button>
6070
<Link
6171
route="courseStaff"
6272
params={{ id: props.courseId }}

src/test/util.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,31 @@ module.exports.createTestCourses = async () => {
4242

4343
module.exports.createTestQueues = async () => {
4444
await models.Queue.bulkCreate([
45-
{ name: 'CS225 Queue', location: 'Here', courseId: 1 },
46-
{ name: 'CS241 Queue', location: 'There', courseId: 2 },
45+
{
46+
name: 'CS225 Queue',
47+
location: 'Here',
48+
courseId: 1,
49+
createdAt: '2019-10-05 22:05:41.000 +00:00',
50+
},
51+
{
52+
name: 'CS241 Queue',
53+
location: 'There',
54+
courseId: 2,
55+
createdAt: '2019-10-05 22:10:41.000 +00:00',
56+
},
4757
{
4858
name: 'CS225 Fixed Location',
4959
fixedLocation: true,
5060
location: 'Everywhere',
5161
courseId: 1,
62+
createdAt: '2019-10-05 22:15:41.000 +00:00',
5263
},
5364
{
5465
name: 'CS225 Closed',
5566
open: false,
5667
location: 'Everywhere',
5768
courseId: 1,
69+
createdAt: '2019-10-05 22:25:41.000 +00:00',
5870
},
5971
{
6072
name: 'CS225 Confidential Queue',
@@ -63,6 +75,7 @@ module.exports.createTestQueues = async () => {
6375
isConfidential: true,
6476
messageEnabled: true,
6577
courseId: 1,
78+
createdAt: '2019-10-05 22:35:41.000 +00:00',
6679
},
6780
])
6881
}

0 commit comments

Comments
 (0)