Skip to content

Commit

Permalink
add classifier-jobs/;id/results endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
rassokhin-s committed Jul 19, 2023
1 parent fe52a9e commit 6fec6e5
Show file tree
Hide file tree
Showing 10 changed files with 361 additions and 5 deletions.
10 changes: 8 additions & 2 deletions common/converter/conversion.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,15 @@ module.exports = class Conversion {

isEqualToAny (arr) {
this.conversions.push(() => {
if (!arr.includes(this.value)) {
this.throwError(`should be one of these values: ${arr.join(', ')}`)
let value = this.value
if (!Array.isArray(this.value)) {
value = [this.value]
}
value.forEach(v => {
if (!arr.includes(v)) {
this.throwError(`should be one of these values: ${arr.join(', ')}`)
}
})
})

return this
Expand Down
48 changes: 48 additions & 0 deletions core/_docs/modelSchemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -1158,5 +1158,53 @@
}
}
}
},
"ClassifierJobReviewStatus": {
"properties": {
"total": {
"type": "integer",
"example": 1
},
"rejected": {
"type": "integer",
"example": 2
},
"uncertain": {
"type": "integer",
"example": 0
},
"confirmed": {
"type": "integer",
"example": 1
}
}
},
"ClassificationSummary": {
"allOf": [
{
"$ref": "#/components/schemas/ClassificationLite"
},
{
"properties": {
"total": {
"type": "integer",
"example": 2
}
}
}
]
},
"ClassifierJobResults": {
"properties": {
"reviewStatus": {
"$ref": "#/components/schemas/ClassifierJobReviewStatus"
},
"classificationsSummary": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ClassificationSummary"
}
}
}
}
}
5 changes: 3 additions & 2 deletions core/_models/classifiers/classifier-output.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ module.exports = (sequelize, DataTypes) => {
})
ClassifierOutput.associate = function (models) {
ClassifierOutput.belongsTo(models.Classifier, { as: 'classifier', foreignKey: 'classifier_id' })
ClassifierOutput.belongsTo(models.Classification, { as: 'classification', foreignKey: 'classification_id' })
}
ClassifierOutput.attributes = {
full: ['classification_id', 'output_class_name', 'ignore_threshold'],
lite: ['classification_id', 'output_class_name', 'ignore_threshold']
full: ['classification_id', 'classifier_id', 'output_class_name', 'ignore_threshold'],
lite: ['classification_id', 'classifier_id', 'output_class_name', 'ignore_threshold']
}
ClassifierOutput.include = includeBuilder(ClassifierOutput, 'classifier_output', ClassifierOutput.attributes.lite)
return ClassifierOutput
Expand Down
6 changes: 6 additions & 0 deletions core/_models/detections/detection-review.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable quote-props */
const includeBuilder = require('../../_utils/db/include-builder')

module.exports = (sequelize, DataTypes) => {
Expand Down Expand Up @@ -29,5 +30,10 @@ module.exports = (sequelize, DataTypes) => {
full: ['id', 'detection_id', 'user_id', 'status', 'created_at', 'updated_at']
}
DetectionReview.include = includeBuilder(DetectionReview, 'reviews', DetectionReview.attributes.lite)
DetectionReview.statusMapping = {
'-1': 'rejected',
'0': 'uncertain',
'1': 'confirmed'
}
return DetectionReview
}
4 changes: 3 additions & 1 deletion core/classifier-jobs/bl/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ const { ForbiddenError } = require('../../../common/error-handling/errors')
* Search for classifier job with given id
* @param {integer} id Classifier job id
* @param {*} options
* @param {string[]} options.attributes Custom attributes
* @param {string[]} options.fields Custom attributes
* @param {transaction} options.transaction Sql transaction
* @param {string} options.readableBy User id who wants to get data
* @throws EmptyResultError when job not found
* @throws ForbiddenError when user does not have permissions
*/
async function get (id, options = {}) {
if (options.fields && options.fields.length) {
Expand Down
60 changes: 60 additions & 0 deletions core/classifier-jobs/bl/results.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { get } = require('./index')
const detectionsDao = require('../../detections/dao/index')
const classifierOutputsDao = require('../../classifiers/dao/outputs')
const { DetectionReview } = require('../../_models')

async function getResults (id, options = {}) {
const job = await get(id, { ...options, fields: ['query_start', 'query_end', 'classifier_id'] })

const detections = await detectionsDao.query({
// TODO: replace it with job.queryStreams once we change queryStreams from names to ids
projects: [job.projectId],
start: `${job.queryStart}T00:00:00.000Z`,
end: `${job.queryEnd}T23:59:59.999Z`,
classifierJobs: [id]
}, {
user: options.user,
fields: ['review_status', 'updated_at']
})
const reviewStatus = {
total: detections.length,
rejected: 0,
uncertain: 0,
confirmed: 0
}
let classificationsSummary
if (options.fields.includes('classifications_summary')) {
const classifierOuputs = (await classifierOutputsDao.query({ classifiers: [job.classifierId] }, { fields: ['classification'] })).results
classificationsSummary = classifierOuputs.reduce((acc, cur) => {
acc[cur.classification.value] = {
value: cur.classification.value,
title: cur.classification.title,
image: cur.classification.image,
total: 0
}
return acc
}, {})
}
detections.forEach(d => {
const mappedStatus = DetectionReview.statusMapping[`${d.review_status}`]
if (reviewStatus[mappedStatus] !== undefined) {
reviewStatus[mappedStatus]++
}
if (options.fields.includes('classifications_summary')) {
if (classificationsSummary[d.classification.value] && d.review_status === 1) {
classificationsSummary[d.classification.value].total++
}
}
})

const result = { reviewStatus }
if (options.fields.includes('classifications_summary')) {
result.classificationsSummary = Object.values(classificationsSummary)
}

return result
}

module.exports = {
getResults
}
1 change: 1 addition & 0 deletions core/classifier-jobs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const router = require('express').Router()

router.get('/', require('./list'))
router.get('/:id', require('./get'))
router.get('/:id/results', require('./results'))
router.post('/', require('./create'))
router.patch('/:id', require('./update'))

Expand Down
149 changes: 149 additions & 0 deletions core/classifier-jobs/results.int.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
const routes = require('./index')
const models = require('../_models')
const { expressApp, seedValues, truncateNonBase, muteConsole } = require('../../common/testing/sequelize')
const request = require('supertest')
const { WAITING, DONE } = require('./classifier-job-status')

const CLASSIFICATION_1 = { id: 232, value: 'chainsaw', title: 'Chainsaw', typeId: 1, source_id: 1 }
const CLASSIFICATION_2 = { id: 233, value: 'vehicle', title: 'Vehicle', typeId: 1, source_id: 1 }
const CLASSIFICATION_3 = { id: 234, value: 'gunshot', title: 'Gunshot', typeId: 1, source_id: 1 }
const CLASSIFICATIONS = [CLASSIFICATION_1, CLASSIFICATION_2, CLASSIFICATION_3]
const CLASSIFIER_1 = { id: 151, name: 'sounds of the underground', version: 1, externalId: '555666', createdById: seedValues.primaryUserId, modelRunner: 'tf2', modelUrl: '???', lastExecutedAt: null, isPublic: true }
const CLASSIFIER_OUTPUT_1 = { id: 100, classifierId: CLASSIFIER_1.id, classificationId: CLASSIFICATION_1.id, outputClassName: 'chainsaw_custom', ignoreThreshold: 0.5 }
const CLASSIFIER_OUTPUT_2 = { id: 101, classifierId: CLASSIFIER_1.id, classificationId: CLASSIFICATION_2.id, outputClassName: 'chainsaw_custom', ignoreThreshold: 0.5 }
const CLASSIFIER_OUTPUT_3 = { id: 102, classifierId: CLASSIFIER_1.id, classificationId: CLASSIFICATION_3.id, outputClassName: 'chainsaw_custom', ignoreThreshold: 0.5 }
const CLASSIFIER_OUTPUTS = [CLASSIFIER_OUTPUT_1, CLASSIFIER_OUTPUT_2, CLASSIFIER_OUTPUT_3]

const PROJECT_1 = { id: 'testprojec01', name: 'Test project', createdById: seedValues.otherUserId }
const PROJECT_2 = { id: 'testprojec02', name: 'Test project 2', createdById: seedValues.otherUserId }
const PROJECT_3 = { id: 'testprojec03', name: 'Test project 3', createdById: seedValues.primaryUserId }
const PROJECT_4 = { id: 'testprojec04', name: 'Test project 4', createdById: seedValues.anotherUserId }
const PROJECTS = [PROJECT_1, PROJECT_2, PROJECT_3, PROJECT_4]

const STREAM_1 = { id: 'stream000001', name: 'Test stream', createdById: seedValues.otherUserId, projectId: PROJECT_1.id }
const STREAMS = [STREAM_1]

const JOB_1 = { classifierId: CLASSIFIER_1.id, projectId: PROJECT_1.id, status: WAITING, queryStreams: 'Test stream, Test stream 2', queryStart: '2021-01-01', queryEnd: '2021-02-01', queryHours: '0,1,2', minutesTotal: 2, minutesCompleted: 0, createdById: seedValues.otherUserId, created_at: '2022-06-08T08:07:49.158Z', updated_at: '2022-09-07T08:07:49.158Z', startedAt: null, completedAt: null }
const JOB_2 = { classifierId: CLASSIFIER_1.id, projectId: PROJECT_1.id, status: WAITING, queryStreams: 'Test stream', queryStart: '2021-03-13', queryEnd: '2022-04-01', queryHours: '11,13', minutesTotal: 4, minutesCompleted: 0, createdById: seedValues.otherUserId, created_at: '2022-06-08T08:07:49.158Z', updated_at: '2022-10-07T08:07:49.158Z', startedAt: null, completedAt: null }
const JOB_3 = { classifierId: CLASSIFIER_1.id, projectId: PROJECT_2.id, status: DONE, queryStreams: 'Test stream 2', queryStart: '2021-03-13', queryEnd: '2022-04-01', queryHours: '1,2', minutesTotal: 2, minutesCompleted: 0, createdById: seedValues.otherUserId, created_at: '2022-06-08T08:07:49.158Z', updated_at: '2022-07-07T08:07:49.158Z', startedAt: null, completedAt: null }
const JOB_4 = { classifierId: CLASSIFIER_1.id, projectId: PROJECT_3.id, status: DONE, queryStreams: 'Test stream 3', queryStart: '2021-03-13', queryEnd: '2022-04-01', queryHours: '1,2', minutesTotal: 2, minutesCompleted: 0, createdById: seedValues.primaryUserId, created_at: '2022-06-08T08:07:49.158Z', updated_at: '2022-07-07T08:07:49.158Z', startedAt: null, completedAt: null }
const JOB_5 = { classifierId: CLASSIFIER_1.id, projectId: PROJECT_4.id, status: DONE, queryStreams: 'Not accessible project', queryStart: '2021-03-13', queryEnd: '2022-04-01', queryHours: '1,2', minutesTotal: 2, minutesCompleted: 0, createdById: seedValues.anotherUserId, created_at: '2022-06-08T08:07:49.158Z', updated_at: '2022-07-07T08:07:49.158Z', startedAt: null, completedAt: null }

beforeAll(() => {
muteConsole('warn')
})

beforeEach(async () => {
await truncateNonBase(models)
})

afterAll(async () => {
await truncateNonBase(models)
await models.sequelize.close()
})

async function seedTestData () {
for (const classification of CLASSIFICATIONS) {
await models.Classification.findOrCreate({ where: classification })
}
await models.Classifier.findOrCreate({ where: CLASSIFIER_1 })
for (const output of CLASSIFIER_OUTPUTS) {
await models.ClassifierOutput.findOrCreate({ where: output })
}
for (const project of PROJECTS) {
await models.Project.findOrCreate({ where: project })
}
for (const stream of STREAMS) {
await models.Stream.findOrCreate({ where: stream })
}
await models.UserProjectRole.findOrCreate({ where: { user_id: seedValues.primaryUserId, project_id: PROJECT_1.id, role_id: seedValues.roleMember } })
await models.UserProjectRole.findOrCreate({ where: { user_id: seedValues.primaryUserId, project_id: PROJECT_2.id, role_id: seedValues.roleGuest } })
await models.UserProjectRole.findOrCreate({ where: { user_id: seedValues.primaryUserId, project_id: PROJECT_3.id, role_id: seedValues.roleAdmin } })
await models.UserProjectRole.findOrCreate({ where: { user_id: seedValues.anotherUserId, project_id: PROJECT_4.id, role_id: seedValues.roleAdmin } })

const job1 = (await models.ClassifierJob.findOrCreate({ where: JOB_1 }))[0]
const job2 = (await models.ClassifierJob.findOrCreate({ where: JOB_2 }))[0]
const job3 = (await models.ClassifierJob.findOrCreate({ where: JOB_3 }))[0]
const job4 = (await models.ClassifierJob.findOrCreate({ where: JOB_4 }))[0]
const job5 = (await models.ClassifierJob.findOrCreate({ where: JOB_5 }))[0]

return { job1, job2, job3, job4, job5 }
}

describe('GET /classifier-jobs/{id}/results', () => {
const app = expressApp()
app.use('/', routes)

test('returns forbidden error', async () => {
const { job5 } = await seedTestData()
const response = await request(app).get(`/${job5.id}/results`)

expect(response.statusCode).toBe(403)
})

test('returns validation error if fields is not correct', async () => {
const { job1 } = await seedTestData()
const response = await request(app).get(`/${job1.id}/results`).query({ fields: ['some'] })

expect(response.statusCode).toBe(400)
})

test('returns validation error if fields includes not correct value', async () => {
const { job1 } = await seedTestData()
const response = await request(app).get(`/${job1.id}/results`).query({ fields: ['review_status', 'some'] })

expect(response.statusCode).toBe(400)
})

test('returns empty error', async () => {
const response = await request(app).get('/230000123/results')

expect(response.statusCode).toBe(404)
})

test('returns only review status by default', async () => {
const { job1 } = await seedTestData()
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:00.000Z`, end: `${JOB_1.queryStart}T00:00:01.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_1.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: 1 } })
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:01.000Z`, end: `${JOB_1.queryStart}T00:00:02.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_1.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: -1 } })
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:02.000Z`, end: `${JOB_1.queryStart}T00:00:03.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_2.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: null } })
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:03.000Z`, end: `${JOB_1.queryStart}T00:00:04.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_2.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: 0 } })

const response = await request(app).get(`/${job1.id}/results`)

const result = response.body
expect(response.statusCode).toBe(200)
expect(result.reviewStatus.total).toBe(4)
expect(result.reviewStatus.confirmed).toBe(1)
expect(result.reviewStatus.rejected).toBe(1)
expect(result.reviewStatus.uncertain).toBe(1)
})

test('returns full data', async () => {
const { job1 } = await seedTestData()
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:00.000Z`, end: `${JOB_1.queryStart}T00:00:01.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_1.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: 1 } })
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:01.000Z`, end: `${JOB_1.queryStart}T00:00:02.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_1.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: -1 } })
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:02.000Z`, end: `${JOB_1.queryStart}T00:00:03.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_2.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: null } })
await models.Detection.findOrCreate({ where: { start: `${JOB_1.queryStart}T00:00:03.000Z`, end: `${JOB_1.queryStart}T00:00:04.000Z`, streamId: STREAM_1.id, classificationId: CLASSIFICATION_2.id, classifierId: CLASSIFIER_1.id, classifierJobId: job1.id, confidence: 0.99, reviewStatus: 0 } })

const response = await request(app).get(`/${job1.id}/results`).query({ fields: ['review_status', 'classifications_summary'] })

const result = response.body
expect(response.statusCode).toBe(200)
expect(result.reviewStatus.total).toBe(4)
expect(result.reviewStatus.confirmed).toBe(1)
expect(result.reviewStatus.rejected).toBe(1)
expect(result.reviewStatus.uncertain).toBe(1)
const output1 = result.classificationsSummary.find(o => CLASSIFICATION_1.value === o.value)
expect(output1.value).toBe(CLASSIFICATION_1.value)
expect(output1.label).toBe(CLASSIFICATION_1.label)
expect(output1.total).toBe(1)
const output2 = result.classificationsSummary.find(o => CLASSIFICATION_2.value === o.value)
expect(output2.value).toBe(CLASSIFICATION_2.value)
expect(output2.label).toBe(CLASSIFICATION_2.label)
expect(output2.total).toBe(0)
const output3 = result.classificationsSummary.find(o => CLASSIFICATION_3.value === o.value)
expect(output3.value).toBe(CLASSIFICATION_3.value)
expect(output3.label).toBe(CLASSIFICATION_3.label)
expect(output3.total).toBe(0)
})
})
51 changes: 51 additions & 0 deletions core/classifier-jobs/results.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const { httpErrorHandler } = require('../../common/error-handling/http')
const { getResults } = require('./bl/results')
const Converter = require('../../common/converter')

/**
* @swagger
*
* /classifier-jobs/{id}/results:
* get:
* summary: Get classifier job's results
* tags:
* - classifier-jobs
* parameters:
* - name: id
* description: Classifier job id
* in: path
* required: true
* type: string
* - name: fields
* description: Customize included fields ('review_status' and/or 'classifications_summary')
* in: query
* type: array
* default: ['review_status']
* responses:
* 200:
* description: Classifier jobs review status object
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ClassifierJobResults'
* 403:
* description: Insufficient privileges
* 404:
* description: Classifier job not found
*/
module.exports = (req, res) => {
const converter = new Converter(req.query, {}, true)
const user = req.rfcx.auth_token_info
const readableBy = user && (user.is_super || user.has_system_role) ? undefined : user.id

converter.convert('fields').optional().toArray().isEqualToAny(['review_status', 'classifications_summary']).default(['review_status'])

return converter.validate()
.then(async params => {
const { fields } = params
const options = { readableBy, user, fields } // user is needed for detections dao
const results = await getResults(req.params.id, options)
res.json(results)
})
.catch(httpErrorHandler(req, res))
}
Loading

0 comments on commit 6fec6e5

Please sign in to comment.