From 119e158aa70ebbc44ec5ab53df29ce788107cef7 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Thu, 21 Apr 2016 13:06:19 -0400 Subject: [PATCH 01/16] Start Exams API --- src/api/exams/index.js | 36 ++++++++++++++++++++++++++++++++++ src/api/exams/model.js | 20 +++++++++++++++++++ src/api/exams/routes/list.js | 18 +++++++++++++++++ src/api/exams/routes/search.js | 18 +++++++++++++++++ src/api/exams/routes/show.js | 20 +++++++++++++++++++ src/db/index.js | 16 ++++++++++++--- src/index.js | 2 ++ 7 files changed, 127 insertions(+), 3 deletions(-) create mode 100755 src/api/exams/index.js create mode 100755 src/api/exams/model.js create mode 100755 src/api/exams/routes/list.js create mode 100755 src/api/exams/routes/search.js create mode 100755 src/api/exams/routes/show.js diff --git a/src/api/exams/index.js b/src/api/exams/index.js new file mode 100755 index 0000000..69413c0 --- /dev/null +++ b/src/api/exams/index.js @@ -0,0 +1,36 @@ +import express from 'express' +let router = express.Router() + +import list from './routes/list' +import show from './routes/show' +import search from './routes/search' +import filter from './routes/filter' + +import validation from '../validation' + +router.get('/', + validation.limit, + validation.skip, + validation.sort, + list) + +router.get('/search', + validation.query, + validation.limit, + validation.skip, + validation.sort, + search) + +router.get('/filter', + validation.query, + validation.filterQuery, + validation.limit, + validation.skip, + validation.sort, + filter) + +router.get('/:id', + validation.id, + show) + +export default router diff --git a/src/api/exams/model.js b/src/api/exams/model.js new file mode 100755 index 0000000..dc8d4d5 --- /dev/null +++ b/src/api/exams/model.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose' +var Schema = mongoose.Schema + +var examsSchema = new Schema({ + id: String, + course_id: String, + course_code: String, + period: String, + date: Date, + start_time: Date, + end_time: Date, + sections: [{ + section: String, + location: String + }] +}) + +examsSchema.index({ id: 'text', course_code: 'text', period: 'text' }) + +export default mongoose.model('exams', examsSchema) diff --git a/src/api/exams/routes/list.js b/src/api/exams/routes/list.js new file mode 100755 index 0000000..29da5a7 --- /dev/null +++ b/src/api/exams/routes/list.js @@ -0,0 +1,18 @@ +import Exams from '../model' +import co from 'co' + +export default function list(req, res, next) { + co(function* () { + try { + let docs = yield Exams + .find({}, '-__v -_id -sections._id') + .limit(req.query.limit) + .skip(req.query.skip) + .sort(req.query.sort) + .exec() + res.json(docs) + } catch (e) { + return next(e) + } + }) +} diff --git a/src/api/exams/routes/search.js b/src/api/exams/routes/search.js new file mode 100755 index 0000000..6ffc156 --- /dev/null +++ b/src/api/exams/routes/search.js @@ -0,0 +1,18 @@ +import Exams from '../model' +import co from 'co' + +export default function search(req, res, next) { + co(function* () { + try { + let docs = yield Exams + .find({ $text: { $search: req.query.q } }, '-__v -_id -sections._id') + .limit(req.query.limit) + .skip(req.query.skip) + .sort(req.query.sort) + .exec() + res.json(docs) + } catch (e) { + return next(e) + } + }) +} diff --git a/src/api/exams/routes/show.js b/src/api/exams/routes/show.js new file mode 100755 index 0000000..862a02f --- /dev/null +++ b/src/api/exams/routes/show.js @@ -0,0 +1,20 @@ +import Exams from '../model' +import co from 'co' + +export default function show(req, res, next) { + co(function* (){ + try { + let doc = yield Exams + .findOne({ id: req.params.id }, '-__v -_id -sections._id') + .exec() + if (!doc) { + let err = new Error('An entry with the specified identifier does not exist.') + err.status = 400 + return next(err) + } + res.json(doc) + } catch (e) { + return next(e) + } + }) +} diff --git a/src/db/index.js b/src/db/index.js index b01ea61..2318f07 100755 --- a/src/db/index.js +++ b/src/db/index.js @@ -40,9 +40,18 @@ db.update = (collection) => { winston.info(`Synced ${collection}.`) // TODO clean this up - if (collection === 'athletics') { - let cmd = 'mongo cobalt --eval "db.athletics.find().forEach(doc=>{doc.date=new Date(doc.date);doc.events.forEach((_,i)=>{doc.events[i].start_time=new Date(doc.events[i].start_time);doc.events[i].end_time=new Date(doc.events[i].end_time)});db.athletics.save(doc)});"' - childProcess.exec(cmd, error => { + if (collection === 'athletics' || collection === 'exams') { + let cmd = '' + + switch (collection) { + case 'athletics': + cmd = 'db.athletics.find().forEach(doc=>{doc.date=new Date(doc.date);doc.events.forEach((_,i)=>{doc.events[i].start_time=new Date(doc.events[i].start_time);doc.events[i].end_time=new Date(doc.events[i].end_time)});db.athletics.save(doc)});' + break + case 'exams': + cmd = 'db.exams.find().forEach(doc=>{doc.date=new Date(doc.date);doc.start_time=new Date(doc.start_time);doc.end_time=new Date(doc.end_time);db.exams.save(doc)});' + break + } + childProcess.exec(`mongo cobalt --eval "${cmd}"`, error => { if (!error) { winston.info(`Updated dates for ${collection}.`) } else { @@ -70,6 +79,7 @@ db.sync = () => { db.update('textbooks') db.update('courses') db.update('athletics') + db.update('exams') } db.check = (callback) => { diff --git a/src/index.js b/src/index.js index e885597..94dfb87 100755 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import buildings from './api/buildings' import textbooks from './api/textbooks' import food from './api/food' import athletics from './api/athletics' +import exams from './api/exams' import db from './db' let test = process.argv.join().match('/ava/') @@ -34,6 +35,7 @@ app.use(`/${apiVersion}/buildings`, buildings) app.use(`/${apiVersion}/textbooks`, textbooks) app.use(`/${apiVersion}/food`, food) app.use(`/${apiVersion}/athletics`, athletics) +app.use(`/${apiVersion}/exams`, exams) // Error handlers app.use((req, res, next) => { From 8ceef9f9e122a969f52615021d8536ec2dc6b5e7 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Thu, 21 Apr 2016 13:07:23 -0400 Subject: [PATCH 02/16] Update README --- src/api/exams/README.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100755 src/api/exams/README.md diff --git a/src/api/exams/README.md b/src/api/exams/README.md new file mode 100755 index 0000000..8ffbd07 --- /dev/null +++ b/src/api/exams/README.md @@ -0,0 +1,6 @@ +UofT Exams API +================= + +This is a RESTful web API built to interface with the University of Toronto exams schedules across all three campuses. It is developed as part of a collection of open data APIs for UofT called [Cobalt](https://cobalt.qas.im). + +For more information on how to use Cobalt's APIs, see the [documentation](https://cobalt.qas.im/documentation). From d4a0f85cae8cb18f1d654939142a52ba7623d8e3 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Thu, 21 Apr 2016 13:19:55 -0400 Subject: [PATCH 03/16] Add filter endpoint --- src/api/exams/routes/filter.js | 206 ++++++++++++++++++++++++ src/api/exams/routes/filterMapReduce.js | 60 +++++++ 2 files changed, 266 insertions(+) create mode 100755 src/api/exams/routes/filter.js create mode 100644 src/api/exams/routes/filterMapReduce.js diff --git a/src/api/exams/routes/filter.js b/src/api/exams/routes/filter.js new file mode 100755 index 0000000..19fa4bc --- /dev/null +++ b/src/api/exams/routes/filter.js @@ -0,0 +1,206 @@ +import Athletics from '../model' +import co from 'co' +import mapReduce from './filterMapReduce' + +const KEYMAP = { + code: 'course_code', + date: 'date', + start: 'start_time', + end: 'end_time', + section: 'section', + location: 'location' +} + +const ABSOLUTE_KEYMAP = { + code: 'course_code', + date: 'date', + start: 'start_time', + end: 'end_time', + section: 'sections.section', + location: 'sections.location' +} + +export default function filter(req, res, next) { + let q = req.query.q + q = q.split(' AND ') + + let queries = 0 + let isMapReduce = false + let mapReduceData = [] + + let filter = { $and: q } + + for (let i = 0; i < filter.$and.length; i++) { + filter.$and[i] = { $or: q[i].trim().split(' OR ') } + let mapReduceOr = [] + for (let j = 0; j < filter.$and[i].$or.length; j++) { + let part = filter.$and[i].$or[j].trim().split(':') + let x = formatPart(part[0], part[1]) + + if (x.isValid) { + if (x.isMapReduce) { + isMapReduce = true + x.mapReduceData.key = KEYMAP[x.key] + mapReduceOr.push(x.mapReduceData) + } + + filter.$and[i].$or[j] = x.query + queries++ + } else if (x.error) { + return next(x.error) + } + + if (mapReduceOr.length > 0) { + mapReduceData.push(mapReduceOr) + } + } + } + if(queries > 0) { + if(isMapReduce) { + var o = { + query: filter, + scope: { + data: mapReduceData + }, + limit: req.query.limit, + map: mapReduce.map, + reduce: mapReduce.reduce + } + + co(function* () { + try { + let docs = yield Athletics.mapReduce(o) + + let formattedDocs = [] + for (let doc of docs) { + formattedDocs.push(doc.value) + } + res.json(formattedDocs) + } catch(e) { + return next(e) + } + }) + } else { + co(function* () { + try { + let docs = yield Athletics + .find(filter, '-__v -_id -sections._id') + .limit(req.query.limit) + .skip(req.query.skip) + .sort(req.query.sort) + .exec() + res.json(docs) + } catch(e) { + return next(e) + } + }) + } + } +} + +function formatPart(key, part) { + // Response format + let response = { + key: key, + error: null, + isValid: true, + isMapReduce: false, + mapReduceData: {}, + query: {} + } + + // Checking if the start of the segment is an operator (-, >, <, .>, .<) + + if (part.indexOf('-') === 0) { + // Negation + part = { + operator: '-', + value: part.substring(1) + } + } else if (part.indexOf('>=') === 0) { + part = { + operator: '>=', + value: part.substring(2) + } + } else if (part.indexOf('<=') === 0) { + part = { + operator: '<=', + value: part.substring(2) + } + } else if (part.indexOf('>') === 0) { + part = { + operator: '>', + value: part.substring(1) + } + } else if (part.indexOf('<') === 0) { + part = { + operator: '<', + value: part.substring(1) + } + } else { + part = { + operator: undefined, + value: part + } + } + + if (isNaN(parseFloat(part.value)) || !isFinite(part.value)) { + // Is not a number + part.value = part.value.substring(1, part.value.length - 1) + } else { + part.value = parseFloat(part.value) + } + + if (['date', 'start', 'end'].indexOf(key) > -1) { + // Dates + + let dateValues = part.value.split(',') + let d = dateValues.concat(new Array(7 - dateValues.length).fill(0)) + + // Months[1] start at index 0 (Jan->0, Dec->11), hours[3] are altered for EST + let date = new Date(d[0], d[1]-1, d[2], d[3]-4, d[4], d[5], d[6], d[7]) + + if (isNaN(date)) { + response.isValid = false + response.error = new Error('Invalid date parameter.') + response.error.status = 400 + return response + } + + if (part.operator === '-') { + response.query[ABSOLUTE_KEYMAP[key]] = { $ne: date } + } else if (part.operator === '>') { + response.query[ABSOLUTE_KEYMAP[key]] = { $gt: date } + } else if (part.operator === '<') { + response.query[ABSOLUTE_KEYMAP[key]] = { $lt: date } + } else if (part.operator === '>=') { + response.query[ABSOLUTE_KEYMAP[key]] = { $gte: date } + } else if (part.operator === '<=') { + response.query[ABSOLUTE_KEYMAP[key]] = { $lte: date } + } else { + // Assume equality if no operator + response.query[ABSOLUTE_KEYMAP[key]] = date + } + } else { + // Strings + if (['location', 'section'].indexOf(key) > -1) { + response.isMapReduce = true + response.mapReduceData = part + } + + if (part.operator === '-') { + response.query[ABSOLUTE_KEYMAP[key]] = { + $regex: '^((?!' + escapeRe(part.value) + ').)*$', + $options: 'i' + } + } else { + response.query[ABSOLUTE_KEYMAP[key]] = { $regex: '(?i).*' + escapeRe(part.value) + '.*' } + } + } + + return response +} + +function escapeRe(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&') +} diff --git a/src/api/exams/routes/filterMapReduce.js b/src/api/exams/routes/filterMapReduce.js new file mode 100644 index 0000000..9e22c4d --- /dev/null +++ b/src/api/exams/routes/filterMapReduce.js @@ -0,0 +1,60 @@ +let o = {} + +o.map = function() { + let matchedSections = [] + + for (let h = 0; h < this.sections.length; h++) { + delete this.sections[h]._id + + let s = this.sections[h] + + let currentData = [] + + for (let i = 0; i < data.length; i++) { + currentData[i] = [] + + for (let j = 0; j < data[i].length; j++) { + currentData[i][j] = false + let p = data[i][j] + let value = s[p.key] + + if (p.operator === '-') { + currentData[i][j] = !value.match(p.value) + } else if (p.operator === '>') { + currentData[i][j] = value > p.value + } else if (p.operator === '<') { + currentData[i][j] = value < p.value + } else if (p.operator === '>=') { + currentData[i][j] = value >= p.value + } else if (p.operator === '<=') { + currentData[i][j] = value <= p.value + } else { + currentData[i][j] = value.toLowerCase().includes(p.value.toLowerCase()) + } + } + } + + for (let i = 0; i < currentData.length; i++) { + currentData[i] = currentData[i].some(Boolean) + } + + currentData = currentData.every(Boolean) + + if (currentData) { + matchedSections.push(s) + } + } + + if (matchedSections.length > 0) { + this.matched_sections = matchedSections + delete this._id + delete this.__v + emit(this.id, this) + } +} + +o.reduce = function(key, values) { + values[0] +} + +export default o From 562569ed73e344ce2d5618b4077483504db05a4a Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Thu, 21 Apr 2016 13:20:38 -0400 Subject: [PATCH 04/16] Add default case --- src/db/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/db/index.js b/src/db/index.js index 2318f07..848c265 100755 --- a/src/db/index.js +++ b/src/db/index.js @@ -41,7 +41,7 @@ db.update = (collection) => { // TODO clean this up if (collection === 'athletics' || collection === 'exams') { - let cmd = '' + let cmd = null switch (collection) { case 'athletics': @@ -50,7 +50,11 @@ db.update = (collection) => { case 'exams': cmd = 'db.exams.find().forEach(doc=>{doc.date=new Date(doc.date);doc.start_time=new Date(doc.start_time);doc.end_time=new Date(doc.end_time);db.exams.save(doc)});' break + default: + cmd = '' + break } + childProcess.exec(`mongo cobalt --eval "${cmd}"`, error => { if (!error) { winston.info(`Updated dates for ${collection}.`) From 50d6dcb95ca80c8f2fd5248e53101e2c7357b713 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sat, 23 Apr 2016 22:06:32 -0400 Subject: [PATCH 05/16] Use updated schema --- src/api/exams/model.js | 6 ++++-- src/db/index.js | 7 +++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/api/exams/model.js b/src/api/exams/model.js index dc8d4d5..5a61fb3 100755 --- a/src/api/exams/model.js +++ b/src/api/exams/model.js @@ -5,16 +5,18 @@ var examsSchema = new Schema({ id: String, course_id: String, course_code: String, + campus: String, period: String, date: Date, start_time: Date, end_time: Date, sections: [{ - section: String, + lecture_code: String, + exam_section: String, location: String }] }) -examsSchema.index({ id: 'text', course_code: 'text', period: 'text' }) +examsSchema.index({ id: 'text', course_code: 'text', campus: 'text', period: 'text' }) export default mongoose.model('exams', examsSchema) diff --git a/src/db/index.js b/src/db/index.js index 848c265..38b2785 100755 --- a/src/db/index.js +++ b/src/db/index.js @@ -40,8 +40,8 @@ db.update = (collection) => { winston.info(`Synced ${collection}.`) // TODO clean this up - if (collection === 'athletics' || collection === 'exams') { - let cmd = null + if (['athletics', 'exams'].indexOf(collection) !== -1) { + let cmd = undefined switch (collection) { case 'athletics': @@ -51,8 +51,7 @@ db.update = (collection) => { cmd = 'db.exams.find().forEach(doc=>{doc.date=new Date(doc.date);doc.start_time=new Date(doc.start_time);doc.end_time=new Date(doc.end_time);db.exams.save(doc)});' break default: - cmd = '' - break + cmd = undefined } childProcess.exec(`mongo cobalt --eval "${cmd}"`, error => { From 7ce605970d56f93feb0bd9ba9ef4d2012178e50a Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sun, 24 Apr 2016 00:38:33 -0400 Subject: [PATCH 06/16] Multiple date formats --- src/api/exams/routes/filter.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/api/exams/routes/filter.js b/src/api/exams/routes/filter.js index 19fa4bc..fd8b53a 100755 --- a/src/api/exams/routes/filter.js +++ b/src/api/exams/routes/filter.js @@ -34,7 +34,9 @@ export default function filter(req, res, next) { filter.$and[i] = { $or: q[i].trim().split(' OR ') } let mapReduceOr = [] for (let j = 0; j < filter.$and[i].$or.length; j++) { - let part = filter.$and[i].$or[j].trim().split(':') + let query = filter.$and[i].$or[j].trim() + let part = [query.slice(0, query.indexOf(':')), query.slice(query.indexOf(':') + 1)] + let x = formatPart(part[0], part[1]) if (x.isValid) { @@ -154,11 +156,20 @@ function formatPart(key, part) { if (['date', 'start', 'end'].indexOf(key) > -1) { // Dates - let dateValues = part.value.split(',') - let d = dateValues.concat(new Array(7 - dateValues.length).fill(0)) + let value = part.value + let dateValue = undefined + + if (typeof value !== 'number' && value.indexOf(',') !== -1) { + // Date format is Y,m,d,H,M,S + let d = part.value.split(',') + d = d.concat(new Array(7 - d.length).fill(0)) + dateValue = new Date(d[0], d[1]-1, d[2], d[3]-4, d[4], d[5], d[6], d[7]) + } else { + // Date format is ISO-8601, milliseconds since 01-01-1970, or empty + dateValue = part.value + } - // Months[1] start at index 0 (Jan->0, Dec->11), hours[3] are altered for EST - let date = new Date(d[0], d[1]-1, d[2], d[3]-4, d[4], d[5], d[6], d[7]) + let date = dateValue ? new Date(dateValue) : new Date if (isNaN(date)) { response.isValid = false From 65cba89efbef39ad0d6920512a8753e90b4801b8 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sun, 24 Apr 2016 21:21:03 -0400 Subject: [PATCH 07/16] Remove id index --- src/api/exams/model.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/exams/model.js b/src/api/exams/model.js index 5a61fb3..ae7cab7 100755 --- a/src/api/exams/model.js +++ b/src/api/exams/model.js @@ -17,6 +17,6 @@ var examsSchema = new Schema({ }] }) -examsSchema.index({ id: 'text', course_code: 'text', campus: 'text', period: 'text' }) +examsSchema.index({ course_code: 'text', campus: 'text', period: 'text' }) export default mongoose.model('exams', examsSchema) From 73b03b4cbb6874a72596b6c03c8958f6cc29f201 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sun, 24 Apr 2016 21:22:52 -0400 Subject: [PATCH 08/16] Update keymap --- src/api/exams/routes/filter.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api/exams/routes/filter.js b/src/api/exams/routes/filter.js index fd8b53a..f8cdd35 100755 --- a/src/api/exams/routes/filter.js +++ b/src/api/exams/routes/filter.js @@ -3,20 +3,28 @@ import co from 'co' import mapReduce from './filterMapReduce' const KEYMAP = { + id: 'course_id', code: 'course_code', + campus: 'campus', + period: 'period', date: 'date', start: 'start_time', end: 'end_time', - section: 'section', + lecture: 'lecture_code', + section: 'exam_section', location: 'location' } const ABSOLUTE_KEYMAP = { + id: 'course_id', code: 'course_code', + campus: 'campus', + period: 'period', date: 'date', start: 'start_time', end: 'end_time', - section: 'sections.section', + lecture: 'sections.lecture_code', + section: 'sections.exam_section', location: 'sections.location' } From e9fd01316309606e9a347253f25cbe2f7ee183a9 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sun, 24 Apr 2016 23:52:40 -0400 Subject: [PATCH 09/16] Allow indentation in switch statement --- .eslintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index e288b86..add2cb4 100755 --- a/.eslintrc +++ b/.eslintrc @@ -5,7 +5,7 @@ }, "rules": { "strict": [2, "never"], - "indent": [2, 2], + "indent": [2, 2, "SwitchCase": 1], "semi": [2, "never"], "quotes": [2, "single"], "no-shadow": 0, From 4be20fc20e42dd43a6035b3ec3a56fde9f357aea Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sun, 24 Apr 2016 23:53:00 -0400 Subject: [PATCH 10/16] Exclude exam map reduce --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e5af0e4..e1540c8 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "exclude": [ "src/db/index.js", "src/api/courses/routes/filterMapReduce.js", - "src/api/athletics/routes/filterMapReduce.js" + "src/api/athletics/routes/filterMapReduce.js", + "src/api/exams/routes/filterMapReduce.js" ] }, "bin": { From 3abe7b81ed19900d9b5b6ae69c98bb1f03a47d6e Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Mon, 25 Apr 2016 00:54:37 -0400 Subject: [PATCH 11/16] Use correct keys for map reduce --- src/api/exams/routes/filter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/exams/routes/filter.js b/src/api/exams/routes/filter.js index f8cdd35..4c3f9f6 100755 --- a/src/api/exams/routes/filter.js +++ b/src/api/exams/routes/filter.js @@ -202,7 +202,7 @@ function formatPart(key, part) { } } else { // Strings - if (['location', 'section'].indexOf(key) > -1) { + if (['lecture', 'section', 'location'].indexOf(key) > -1) { response.isMapReduce = true response.mapReduceData = part } From b330f042e105118b26972eb77b65a6147d811d98 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Fri, 29 Apr 2016 23:21:00 -0400 Subject: [PATCH 12/16] Simplify post import cmd --- src/db/index.js | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/db/index.js b/src/db/index.js index 38b2785..9d256f7 100755 --- a/src/db/index.js +++ b/src/db/index.js @@ -40,19 +40,8 @@ db.update = (collection) => { winston.info(`Synced ${collection}.`) // TODO clean this up - if (['athletics', 'exams'].indexOf(collection) !== -1) { - let cmd = undefined - - switch (collection) { - case 'athletics': - cmd = 'db.athletics.find().forEach(doc=>{doc.date=new Date(doc.date);doc.events.forEach((_,i)=>{doc.events[i].start_time=new Date(doc.events[i].start_time);doc.events[i].end_time=new Date(doc.events[i].end_time)});db.athletics.save(doc)});' - break - case 'exams': - cmd = 'db.exams.find().forEach(doc=>{doc.date=new Date(doc.date);doc.start_time=new Date(doc.start_time);doc.end_time=new Date(doc.end_time);db.exams.save(doc)});' - break - default: - cmd = undefined - } + if (['athletics', 'exams'].indexOf(collection) > -1) { + let cmd = `db.${collection}.find().forEach(doc=>{doc.date=new Date(doc.date);db.${collection}.save(doc)});` childProcess.exec(`mongo cobalt --eval "${cmd}"`, error => { if (!error) { From caf75443be3b84ec2b4345748923f3d73dbf3b7d Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Fri, 29 Apr 2016 23:22:04 -0400 Subject: [PATCH 13/16] Use new schema --- src/api/exams/model.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/exams/model.js b/src/api/exams/model.js index ae7cab7..ac6297a 100755 --- a/src/api/exams/model.js +++ b/src/api/exams/model.js @@ -8,8 +8,9 @@ var examsSchema = new Schema({ campus: String, period: String, date: Date, - start_time: Date, - end_time: Date, + start_time: Number, + end_time: Number, + duration: Number, sections: [{ lecture_code: String, exam_section: String, From a8616ca58063efd8548aa19fad3e3c39513922ed Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Fri, 29 Apr 2016 23:23:04 -0400 Subject: [PATCH 14/16] Update filters for new schema --- src/api/exams/routes/filter.js | 58 ++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/src/api/exams/routes/filter.js b/src/api/exams/routes/filter.js index 4c3f9f6..e891dd9 100755 --- a/src/api/exams/routes/filter.js +++ b/src/api/exams/routes/filter.js @@ -2,6 +2,8 @@ import Athletics from '../model' import co from 'co' import mapReduce from './filterMapReduce' +// TODO add filters for sections.exam_section + const KEYMAP = { id: 'course_id', code: 'course_code', @@ -9,9 +11,9 @@ const KEYMAP = { period: 'period', date: 'date', start: 'start_time', + duration: 'duration', end: 'end_time', lecture: 'lecture_code', - section: 'exam_section', location: 'location' } @@ -23,8 +25,8 @@ const ABSOLUTE_KEYMAP = { date: 'date', start: 'start_time', end: 'end_time', + duration: 'duration', lecture: 'sections.lecture_code', - section: 'sections.exam_section', location: 'sections.location' } @@ -161,44 +163,46 @@ function formatPart(key, part) { part.value = parseFloat(part.value) } - if (['date', 'start', 'end'].indexOf(key) > -1) { - // Dates - let value = part.value - let dateValue = undefined + if (['date', 'start', 'end', 'duration'].indexOf(key) > -1) { + if (key == 'date') { + let dateValue = undefined - if (typeof value !== 'number' && value.indexOf(',') !== -1) { - // Date format is Y,m,d,H,M,S - let d = part.value.split(',') - d = d.concat(new Array(7 - d.length).fill(0)) - dateValue = new Date(d[0], d[1]-1, d[2], d[3]-4, d[4], d[5], d[6], d[7]) - } else { - // Date format is ISO-8601, milliseconds since 01-01-1970, or empty - dateValue = part.value - } + if (typeof part.value !== 'number' && part.value.indexOf(',') !== -1) { + // Date format is Y,m,d,H,M,S + let d = part.value.split(',') + d = d.concat(new Array(7 - d.length).fill(0)) + dateValue = new Date(d[0], d[1]-1, d[2], d[3]-4, d[4], d[5], d[6], d[7]) + } else { + // Date format is ISO-8601, milliseconds since 01-01-1970, or empty + dateValue = part.value + } + + let date = dateValue && dateValue.length ? new Date(dateValue) : new Date - let date = dateValue ? new Date(dateValue) : new Date + if (isNaN(date)) { + response.isValid = false + response.error = new Error('Invalid date parameter.') + response.error.status = 400 + return response + } - if (isNaN(date)) { - response.isValid = false - response.error = new Error('Invalid date parameter.') - response.error.status = 400 - return response + part.value = date } if (part.operator === '-') { - response.query[ABSOLUTE_KEYMAP[key]] = { $ne: date } + response.query[ABSOLUTE_KEYMAP[key]] = { $ne: part.value } } else if (part.operator === '>') { - response.query[ABSOLUTE_KEYMAP[key]] = { $gt: date } + response.query[ABSOLUTE_KEYMAP[key]] = { $gt: part.value } } else if (part.operator === '<') { - response.query[ABSOLUTE_KEYMAP[key]] = { $lt: date } + response.query[ABSOLUTE_KEYMAP[key]] = { $lt: part.value } } else if (part.operator === '>=') { - response.query[ABSOLUTE_KEYMAP[key]] = { $gte: date } + response.query[ABSOLUTE_KEYMAP[key]] = { $gte: part.value } } else if (part.operator === '<=') { - response.query[ABSOLUTE_KEYMAP[key]] = { $lte: date } + response.query[ABSOLUTE_KEYMAP[key]] = { $lte: part.value } } else { // Assume equality if no operator - response.query[ABSOLUTE_KEYMAP[key]] = date + response.query[ABSOLUTE_KEYMAP[key]] = part.value } } else { // Strings From 74c724479bf1c442d7dce156cffef5c1b1a02e3e Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sat, 30 Apr 2016 00:03:45 -0400 Subject: [PATCH 15/16] Remove length check --- src/api/exams/routes/filter.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/exams/routes/filter.js b/src/api/exams/routes/filter.js index e891dd9..c39da22 100755 --- a/src/api/exams/routes/filter.js +++ b/src/api/exams/routes/filter.js @@ -178,7 +178,7 @@ function formatPart(key, part) { dateValue = part.value } - let date = dateValue && dateValue.length ? new Date(dateValue) : new Date + let date = dateValue ? new Date(dateValue) : new Date if (isNaN(date)) { response.isValid = false @@ -220,7 +220,6 @@ function formatPart(key, part) { response.query[ABSOLUTE_KEYMAP[key]] = { $regex: '(?i).*' + escapeRe(part.value) + '.*' } } } - return response } From 8b86e837c08d10a89c0dee9e205c46aeb67002a5 Mon Sep 17 00:00:00 2001 From: Kashav Madan Date: Sat, 30 Apr 2016 00:21:38 -0400 Subject: [PATCH 16/16] Add tests --- package.json | 6 +- test/exams/test.js | 460 ++++++++++++++++++++++++++ test/exams/testData.json | 677 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1138 insertions(+), 5 deletions(-) create mode 100644 test/exams/test.js create mode 100644 test/exams/testData.json diff --git a/package.json b/package.json index e1540c8..f553695 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,7 @@ }, "ava": { "files": [ - "test/courses", - "test/buildings", - "test/textbooks", - "test/food", - "test/athletics" + "test/*" ], "failFast": true, "require": [ diff --git a/test/exams/test.js b/test/exams/test.js new file mode 100644 index 0000000..3a71e55 --- /dev/null +++ b/test/exams/test.js @@ -0,0 +1,460 @@ +import test from 'ava' +import testData from './testData.json' +import request from 'supertest' + +import cobalt from '../../src/index' +import Exams from '../../src/api/exams/model' + +test.cb.before('setup', t => { + // Drop all documents + Exams.remove({}, err => { + if (err) t.fail(err.message) + // Insert test data + Exams.create(testData, err => { + if (err) t.fail(err.message) + t.end() + }) + }) +}) + +/* list tests */ + +test.cb('/', t => { + request(cobalt.Server) + .get('/1.0/exams') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/?limit=0', t => { + request(cobalt.Server) + .get('/1.0/exams?limit=0') + .expect('Content-Type', /json/) + .expect(400) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/?limit=2', t => { + request(cobalt.Server) + .get('/1.0/exams?limit=2') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.slice(0, 2)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/?limit=200', t => { + request(cobalt.Server) + .get('/1.0/exams?limit=200') + .expect('Content-Type', /json/) + .expect(400) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/?skip=10', t => { + request(cobalt.Server) + .get('/1.0/exams?skip=10') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.slice(10, 20)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/?skip=200', t => { + request(cobalt.Server) + .get('/1.0/exams?skip=200') + .expect('Content-Type', /json/) + .expect(200) + .expect('[]') + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/?skip=2&limit=2', t => { + request(cobalt.Server) + .get('/1.0/exams?skip=2&limit=2') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.slice(2, 4)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +/* show tests */ + +test.cb(`/${testData[0].id}`, t => { + request(cobalt.Server) + .get(`/1.0/exams/${testData[0].id}`) + .expect('Content-Type', /json/) + .expect(200) + .expect(testData[0]) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb(`/${testData[0].id}`, t => { + request(cobalt.Server) + .get(`/1.0/exams/${testData[0].id}`) + .expect('Content-Type', /json/) + .expect(200) + .expect(testData[0]) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/CSC165', t => { + request(cobalt.Server) + .get('/1.0/exams/CSC165') + .expect('Content-Type', /json/) + .expect(400) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +/* search tests */ + +test.cb('/search?q=', t => { + request(cobalt.Server) + .get('/1.0/exams/search?q=') + .expect('Content-Type', /json/) + .expect(400) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/search?q=DEC15', t => { + request(cobalt.Server) + .get('/1.0/exams/search?q=DEC15') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.period.match('DEC15')).slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/search?q=DEC14', t => { + request(cobalt.Server) + .get('/1.0/exams/search?q=DEC14') + .expect('Content-Type', /json/) + .expect(200) + .expect('[]') + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +/* filter tests */ + +test.cb('/filter?q=', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=') + .expect('Content-Type', /json/) + .expect(400) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=campus:"utm"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=campus:%22utm%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.campus.match('UTM')).slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=campus:-"utm"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=campus:-%22utm%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => !doc.campus.match('UTM')).slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=campus:"utm" OR campus:"utsc" OR campus:"utsg"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=campus:%22utm%22%20OR%20campus:%22utsc%22%20OR%20campus:%22utsg%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=period:"APR16"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=period:%22APR16%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.period.match('APR16')).slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=code:"ECO100"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=code:%22ECO100%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.course_code.includes('ECO100'))) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=code:"ECO100" AND lecture:"L0201"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=code:%22ECO100%22%20AND%20lecture:%22L0201%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(() => { + let expected = testData.filter((doc) => doc.course_code.includes('ECO100')) + expected['matched_sections'] = [ + { + lecture_code: 'L0201', + exam_section: 'A - GA', + location: 'BN 3' + }, + { + lecture_code: 'L0201', + exam_section: 'GE - Y', + location: 'EX 100' + }, + { + lecture_code: 'L0201', + exam_section: 'Z - Z', + location: 'EX 200' + } + ] + return expected + }) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=date:"2016-04-28"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=date:%222016-04-28%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.id.match('CHM343H1S20161APR16'))) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=date:>=1461988800', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=date:%3E=1461988800') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=date:<="2016,04,30"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=date:%3E=%222016,04,30%22') + .expect('Content-Type', /json/) + .expect(200) + .expect('[]') + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=duration:<10800', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=duration:%3C10800') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.duration < 10800).slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=duration:<=10800', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=duration:%3C=10800') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=duration:-7200', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=duration:-7200') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.duration !== 7200).slice(0, 10)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=location:"HI CART"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=location:%22HI%20CART%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(() => { + let expected = testData.filter(doc => doc.id.match('ENG364Y1Y20159APR16')) + expected['matched_sections'] = [{ + lecture_code: 'L5101', + exam_section: '', + location: 'HI CART' + }] + return expected + }) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=start:79200 OR end:79200', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=start:79200%20OR%20end:79200') + .expect('Content-Type', /json/) + .expect(200) + .expect(testData.filter(doc => doc.start_time === 79200 || doc.end_time === 79200)) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=start:>50000 AND end:<62000 AND location:"SS"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=start:%3E50000%20AND%20end:%3C62000%20AND%20location:%22SS%22') + .expect('Content-Type', /json/) + .expect(200) + .expect(() => { + let expected = testData.filter(doc => doc.id.match('ABS201Y1Y20159APR16')) + expected['matched_sections'] = [ + { + lecture_code: '', + exam_section: 'A - L', + location: 'SS 2102' + }, + { + lecture_code: '', + exam_section: 'M - Z', + location: 'SS 2135' + } + ] + return expected + }) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb('/filter?q=date:"today"', t => { + request(cobalt.Server) + .get('/1.0/exams/filter?q=date:%22today22') + .expect('Content-Type', /json/) + .expect(400) + .end((err, res) => { + if (err) t.fail(err.message) + t.pass() + t.end() + }) +}) + +test.cb.after('cleanup', t => { + Exams.remove({}, err => { + if (err) t.fail(err.message) + t.end() + }) +}) diff --git a/test/exams/testData.json b/test/exams/testData.json new file mode 100644 index 0000000..5252f8d --- /dev/null +++ b/test/exams/testData.json @@ -0,0 +1,677 @@ +[ + { + "id":"ABS201Y1Y20149DEC15", + "course_id":"ABS201Y1Y20149", + "course_code":"ABS201Y1Y", + "campus":"UTSG", + "period":"DEC15", + "date":"2015-12-15T00:00:00.000Z", + "start_time":50400, + "end_time":61200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"A - K", + "location":"MP 202" + }, + { + "lecture_code":"", + "exam_section":"L - Z", + "location":"MP 203" + } + ] + }, + { + "id":"ABS201Y1Y20159APR16", + "course_id":"ABS201Y1Y20159", + "course_code":"ABS201Y1Y", + "campus":"UTSG", + "period":"APR16", + "date":"2016-04-15T00:00:00.000Z", + "start_time":50400, + "end_time":61200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"A - L", + "location":"SS 2102" + }, + { + "lecture_code":"", + "exam_section":"M - Z", + "location":"SS 2135" + } + ] + }, + { + "id":"ACMA01H3S20161APR16", + "course_id":"ACMA01H3S20161", + "course_code":"ACMA01H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-11T00:00:00.000Z", + "start_time":32400, + "end_time":39600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"AC223" + } + ] + }, + { + "id":"ACMA02H3S20161APR16", + "course_id":"ACMA02H3S20161", + "course_code":"ACMA02H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-19T00:00:00.000Z", + "start_time":50400, + "end_time":57600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"MW170" + } + ] + }, + { + "id":"ACT230H1F20159DEC15", + "course_id":"ACT230H1F20159", + "course_code":"ACT230H1F", + "campus":"UTSG", + "period":"DEC15", + "date":"2015-12-16T00:00:00.000Z", + "start_time":50400, + "end_time":57600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"ST VLAD" + } + ] + }, + { + "id":"CHM342H1F20159DEC15", + "course_id":"CHM342H1F20159", + "course_code":"CHM342H1F", + "campus":"UTSG", + "period":"DEC15", + "date":"2015-12-21T00:00:00.000Z", + "start_time":50400, + "end_time":61200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"MC 102" + } + ] + }, + { + "id":"CHM343H1S20161APR16", + "course_id":"CHM343H1S20161", + "course_code":"CHM343H1S", + "campus":"UTSG", + "period":"APR16", + "date":"2016-04-28T00:00:00.000Z", + "start_time":32400, + "end_time":43200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"BN 2S" + } + ] + }, + { + "id":"CHM345H5S20161APR16", + "course_id":"CHM345H5S20161", + "course_code":"CHM345H5S", + "campus":"UTM", + "period":"APR16", + "date":"2016-04-12T00:00:00.000Z", + "start_time":61200, + "end_time":72000, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IB 345" + } + ] + }, + { + "id":"CHM347H1F20159DEC15", + "course_id":"CHM347H1F20159", + "course_code":"CHM347H1F", + "campus":"UTSG", + "period":"DEC15", + "date":"2015-12-14T00:00:00.000Z", + "start_time":68400, + "end_time":79200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"A - LIN", + "location":"RW 110" + }, + { + "lecture_code":"", + "exam_section":"LIU - Z", + "location":"RW 117" + } + ] + }, + { + "id":"CHM348H1F20159DEC15", + "course_id":"CHM348H1F20159", + "course_code":"CHM348H1F", + "campus":"UTSG", + "period":"DEC15", + "date":"2015-12-11T00:00:00.000Z", + "start_time":32400, + "end_time":43200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"BN 3" + } + ] + }, + { + "id":"CSCD37H3S20161APR16", + "course_id":"CSCD37H3S20161", + "course_code":"CSCD37H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-22T00:00:00.000Z", + "start_time":50400, + "end_time":61200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IC230" + } + ] + }, + { + "id":"CSCD58H3S20161APR16", + "course_id":"CSCD58H3S20161", + "course_code":"CSCD58H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-11T00:00:00.000Z", + "start_time":32400, + "end_time":43200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IC220" + } + ] + }, + { + "id":"CSCD84H3S20161APR16", + "course_id":"CSCD84H3S20161", + "course_code":"CSCD84H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-20T00:00:00.000Z", + "start_time":50400, + "end_time":59400, + "duration":9000, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IC200" + } + ] + }, + { + "id":"CTLA01H3S20161APR16", + "course_id":"CTLA01H3S20161", + "course_code":"CTLA01H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-18T00:00:00.000Z", + "start_time":32400, + "end_time":43200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"MW120" + } + ] + }, + { + "id":"DRE122H5S20161APR16", + "course_id":"DRE122H5S20161", + "course_code":"DRE122H5S", + "campus":"UTM", + "period":"APR16", + "date":"2016-04-16T00:00:00.000Z", + "start_time":61200, + "end_time":72000, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"Gym C" + } + ] + }, + { + "id": "ECO100Y1Y20159APR16", + "course_id": "ECO100Y1Y20159", + "course_code": "ECO100Y1Y", + "campus": "UTSG", + "period": "APR16", + "date": "2016-04-12T00:00:00.000Z", + "start_time": 68400, + "end_time": 79200, + "duration": 10800, + "sections": [ + { + "lecture_code": "L0101", + "exam_section": "A - LE", + "location": "BN 2N" + }, + { + "lecture_code": "L0101", + "exam_section": "LI - WA", + "location": "BN 2S" + }, + { + "lecture_code": "L0101", + "exam_section": "WE - Z", + "location": "BN 3" + }, + { + "lecture_code": "L0201", + "exam_section": "A - GA", + "location": "BN 3" + }, + { + "lecture_code": "L0201", + "exam_section": "GE - Y", + "location": "EX 100" + }, + { + "lecture_code": "L0201", + "exam_section": "Z - Z", + "location": "EX 200" + }, + { + "lecture_code": "L0301", + "exam_section": "A - P", + "location": "EX 200" + }, + { + "lecture_code": "L0301", + "exam_section": "Q - W", + "location": "EX 300" + }, + { + "lecture_code": "L0301", + "exam_section": "X - Z", + "location": "EX 310" + }, + { + "lecture_code": "L0401", + "exam_section": "A - DO", + "location": "GB 304" + }, + { + "lecture_code": "L0401", + "exam_section": "DU - J", + "location": "GB 404" + }, + { + "lecture_code": "L0401", + "exam_section": "K - ME", + "location": "GB 405" + }, + { + "lecture_code": "L0401", + "exam_section": "MI - V", + "location": "NR 25" + }, + { + "lecture_code": "L0401", + "exam_section": "W - Z", + "location": "ST VLAD" + }, + { + "lecture_code": "L2001", + "exam_section": "", + "location": "HA 401" + }, + { + "lecture_code": "L5101", + "exam_section": "A - LE", + "location": "BR 200" + }, + { + "lecture_code": "L5101", + "exam_section": "LI - O", + "location": "EM 119" + }, + { + "lecture_code": "L5101", + "exam_section": "P - WA", + "location": "TC 239" + }, + { + "lecture_code": "L5101", + "exam_section": "WE - Z", + "location": "WY 119" + }, + { + "lecture_code": "L5201", + "exam_section": "A - CHA", + "location": "EX 310" + }, + { + "lecture_code": "L5201", + "exam_section": "CHE - KI", + "location": "EX 320" + }, + { + "lecture_code": "L5201", + "exam_section": "KO - Q", + "location": "SF 3202" + }, + { + "lecture_code": "L5201", + "exam_section": "R - X", + "location": "UC 266" + }, + { + "lecture_code": "L5201", + "exam_section": "Y - Z", + "location": "UC 273" + } + ] + }, + { + "id":"ENG353Y5Y20159APR16", + "course_id":"ENG353Y5Y20159", + "course_code":"ENG353Y5Y", + "campus":"UTM", + "period":"APR16", + "date":"2016-04-14T00:00:00.000Z", + "start_time":46800, + "end_time":54000, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IB 335" + } + ] + }, + { + "id":"ENG354Y1Y20159APR16", + "course_id":"ENG354Y1Y20159", + "course_code":"ENG354Y1Y", + "campus":"UTSG", + "period":"APR16", + "date":"2016-04-25T00:00:00.000Z", + "start_time":68400, + "end_time":75600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"EX 320" + } + ] + }, + { + "id":"ENG354Y5Y20159APR16", + "course_id":"ENG354Y5Y20159", + "course_code":"ENG354Y5Y", + "campus":"UTM", + "period":"APR16", + "date":"2016-04-16T00:00:00.000Z", + "start_time":61200, + "end_time":68400, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"TFC Cafeteria" + } + ] + }, + { + "id":"ENG357H5S20161APR16", + "course_id":"ENG357H5S20161", + "course_code":"ENG357H5S", + "campus":"UTM", + "period":"APR16", + "date":"2016-04-18T00:00:00.000Z", + "start_time":32400, + "end_time":43200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"Gym A/B" + } + ] + }, + { + "id":"ENG364Y1Y20159APR16", + "course_id":"ENG364Y1Y20159", + "course_code":"ENG364Y1Y", + "campus":"UTSG", + "period":"APR16", + "date":"2016-04-13T00:00:00.000Z", + "start_time":68400, + "end_time":75600, + "duration":7200, + "sections":[ + { + "lecture_code":"L5101", + "exam_section":"", + "location":"HI CART" + } + ] + }, + { + "id":"GGR333H5S20161APR16", + "course_id":"GGR333H5S20161", + "course_code":"GGR333H5S", + "campus":"UTM", + "period":"APR16", + "date":"2016-04-13T00:00:00.000Z", + "start_time":46800, + "end_time":54000, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"Gym C" + } + ] + }, + { + "id":"GGR334H1F20159DEC15", + "course_id":"GGR334H1F20159", + "course_code":"GGR334H1F", + "campus":"UTSG", + "period":"DEC15", + "date":"2015-12-21T00:00:00.000Z", + "start_time":50400, + "end_time":57600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"A - LE", + "location":"RW 110" + }, + { + "lecture_code":"", + "exam_section":"LI - Z", + "location":"RW 117" + } + ] + }, + { + "id":"GGR335H5S20161APR16", + "course_id":"GGR335H5S20161", + "course_code":"GGR335H5S", + "campus":"UTM", + "period":"APR16", + "date":"2016-04-21T00:00:00.000Z", + "start_time":32400, + "end_time":39600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"Gym C" + } + ] + }, + { + "id":"GGR337H1S20161APR16", + "course_id":"GGR337H1S20161", + "course_code":"GGR337H1S", + "campus":"UTSG", + "period":"APR16", + "date":"2016-04-19T00:00:00.000Z", + "start_time":68400, + "end_time":75600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"BR 200" + } + ] + }, + { + "id":"GGR338H1F20159DEC15", + "course_id":"GGR338H1F20159", + "course_code":"GGR338H1F", + "campus":"UTSG", + "period":"DEC15", + "date":"2015-12-17T00:00:00.000Z", + "start_time":32400, + "end_time":39600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"HA 403" + } + ] + }, + { + "id":"MGAD20H3S20161APR16", + "course_id":"MGAD20H3S20161", + "course_code":"MGAD20H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-22T00:00:00.000Z", + "start_time":50400, + "end_time":61200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IC220" + } + ] + }, + { + "id":"MGAD40H3S20161APR16", + "course_id":"MGAD40H3S20161", + "course_code":"MGAD40H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-19T00:00:00.000Z", + "start_time":32400, + "end_time":39600, + "duration":7200, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IC204, IC208" + } + ] + }, + { + "id":"MGAD50H3S20161APR16", + "course_id":"MGAD50H3S20161", + "course_code":"MGAD50H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-15T00:00:00.000Z", + "start_time":68400, + "end_time":79200, + "duration":10800, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IC130" + } + ] + }, + { + "id":"MGEA05H3S20161APR16", + "course_id":"MGEA05H3S20161", + "course_code":"MGEA05H3S", + "campus":"UTSC", + "period":"APR16", + "date":"2016-04-12T00:00:00.000Z", + "start_time":50400, + "end_time":59400, + "duration":9000, + "sections":[ + { + "lecture_code":"", + "exam_section":"", + "location":"IC120, IC130, IC220" + } + ] + } +]