Skip to content
This repository has been archived by the owner on May 16, 2020. It is now read-only.

Exams API #64

Merged
merged 18 commits into from
Apr 30, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 3 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,7 @@
},
"ava": {
"files": [
"test/courses",
"test/buildings",
"test/textbooks",
"test/food",
"test/athletics"
"test/*"
],
"failFast": true,
"require": [
Expand All @@ -59,7 +55,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": {
Expand Down
6 changes: 6 additions & 0 deletions src/api/exams/README.md
Original file line number Diff line number Diff line change
@@ -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).
36 changes: 36 additions & 0 deletions src/api/exams/index.js
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions src/api/exams/model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import mongoose from 'mongoose'
var Schema = mongoose.Schema

var examsSchema = new Schema({
id: String,
course_id: String,
course_code: String,
campus: String,
period: String,
date: Date,
start_time: Number,
end_time: Number,
duration: Number,
sections: [{
lecture_code: String,
exam_section: String,
location: String
}]
})

examsSchema.index({ course_code: 'text', campus: 'text', period: 'text' })

export default mongoose.model('exams', examsSchema)
228 changes: 228 additions & 0 deletions src/api/exams/routes/filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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',
campus: 'campus',
period: 'period',
date: 'date',
start: 'start_time',
duration: 'duration',
end: 'end_time',
lecture: 'lecture_code',
location: 'location'
}

const ABSOLUTE_KEYMAP = {
id: 'course_id',
code: 'course_code',
campus: 'campus',
period: 'period',
date: 'date',
start: 'start_time',
end: 'end_time',
duration: 'duration',
lecture: 'sections.lecture_code',
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 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) {
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', 'duration'].indexOf(key) > -1) {
if (key == 'date') {
let dateValue = undefined

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 ? new Date(dateValue) : new Date

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: part.value }
} else if (part.operator === '>') {
response.query[ABSOLUTE_KEYMAP[key]] = { $gt: part.value }
} else if (part.operator === '<') {
response.query[ABSOLUTE_KEYMAP[key]] = { $lt: part.value }
} else if (part.operator === '>=') {
response.query[ABSOLUTE_KEYMAP[key]] = { $gte: part.value }
} else if (part.operator === '<=') {
response.query[ABSOLUTE_KEYMAP[key]] = { $lte: part.value }
} else {
// Assume equality if no operator
response.query[ABSOLUTE_KEYMAP[key]] = part.value
}
} else {
// Strings
if (['lecture', 'section', 'location'].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, '\\$&')
}
60 changes: 60 additions & 0 deletions src/api/exams/routes/filterMapReduce.js
Original file line number Diff line number Diff line change
@@ -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
Loading