From f77df849f0b86373f4093e5c35f5484a4545f2e1 Mon Sep 17 00:00:00 2001 From: Bhattarapong Somwong Date: Thu, 25 Apr 2024 22:27:32 +0700 Subject: [PATCH] #593 create `GET /detections/summary` --- CHANGELOG.md | 4 + core/_docs/modelSchemas.json | 27 ++ core/detections/best-detections.js | 2 +- core/detections/dao/index.js | 41 +++ core/detections/list-summary.int.test.js | 317 +++++++++++++++++++++++ core/detections/list-summary.js | 123 +++++++++ 6 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 core/detections/list-summary.int.test.js create mode 100644 core/detections/list-summary.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 822aea94c..7b04ac063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.4.0 (2024-04-xx) +### Common +* **core**: Create `GET /detections/summary` and `GET /classifier-id/{jobId}/best-detections/summary` to get detection counts based on each validation status. + ## 1.3.9 (2024-04-xx) ### Common * **core**: `GET /detections` return `Total-items` in response headers diff --git a/core/_docs/modelSchemas.json b/core/_docs/modelSchemas.json index e9142c267..bde5c8868 100644 --- a/core/_docs/modelSchemas.json +++ b/core/_docs/modelSchemas.json @@ -1248,5 +1248,32 @@ "$ref": "#/components/schemas/ClassifierJobReviewStatus" } } + }, + "DetectionsResultSummary": { + "type": "object", + "properties": { + "unreviewed": { + "type": "integer", + "example": 183 + }, + "confirmed": { + "type": "integer", + "example": 18 + }, + "uncertain": { + "type": "integer", + "example": 12 + }, + "rejected": { + "type": "integer", + "example": 0 + } + }, + "required": [ + "unreviewed", + "confirmed", + "uncertain", + "rejected" + ] } } diff --git a/core/detections/best-detections.js b/core/detections/best-detections.js index f90beacbf..c270f5fdb 100644 --- a/core/detections/best-detections.js +++ b/core/detections/best-detections.js @@ -6,7 +6,7 @@ const Converter = require('../../common/converter') /** * @swagger * - * /{jobId}/best-detections: + * /classifier-jobs/{jobId}/best-detections: * get: * summary: Get list of detections * description: diff --git a/core/detections/dao/index.js b/core/detections/dao/index.js index b8b46237e..5fcd75d92 100644 --- a/core/detections/dao/index.js +++ b/core/detections/dao/index.js @@ -7,6 +7,7 @@ const { toCamelObject } = require('../../_utils/formatters/string-cases') const { getAccessibleObjectsIDs, STREAM, PROJECT } = require('../../roles/dao') const { ValidationError } = require('../../../common/error-handling/errors') const { REVIEW_STATUS_MAPPING } = require('./review') +const _ = require('lodash') const availableIncludes = [ Stream.include(), @@ -255,6 +256,45 @@ async function timeAggregatedQuery (start, end, streams, streamsOnlyPublic, clas .then(detections => detections.map(propertyToFloat(aggregatedValueAttribute))) } +/** + * Get a count of detections based on given where options. + * @param {*} filters Additional query options + * @param {string[]} filters.streams Filter by one or more stream identifiers + * @param {string[]} filters.projects Filter by one or more project identifiers + * @param {string[]} filters.classifiers Filter by one or more classifier identifiers + * @param {string[]} filters.classifications Filter by one or more classification values + * @param {string[]} filters.minConfidence Filter by minimum confidence + * @param {string[]} filters.isReviewed Filter by reviewed/unreviewed detections + * @param {string[]} filters.isPositive Filter by approved/rejected detections + * @param {*} options Additional get options + * @param {number} options.readableBy Include only if the detection is accessible to the given user id + * @param {string[]} options.fields Attributes and relations to include in results (defaults to lite attributes) + * @param {boolean} options.descending Order the results in descending date order + * @param {number} options.limit + * @param {number} options.offset + * @returns {Record<'unreviewed' | 'rejected' | 'uncertain' | 'confirmed', number>} Detection counts + */ +async function queryDetectionsSummary (filters, options) { + const opts = await defaultQueryOptions(filters, options) + + const counts = await Detection.findAll({ + attributes: [ + 'review_status', + [sequelize.literal('COUNT(1)::integer'), 'count'] + ], + where: opts.where, + group: ['"Detection"."review_status"'], + raw: true + }) + + return { + unreviewed: counts.find(c => c.review_status === null)?.count ?? 0, + rejected: counts.find(c => c.review_status === -1)?.count ?? 0, + uncertain: counts.find(c => c.review_status === 0)?.count ?? 0, + confirmed: counts.find(c => c.review_status === 1)?.count ?? 0 + } +} + const DEFAULT_IGNORE_THRESHOLD = 0.5 module.exports = { @@ -262,5 +302,6 @@ module.exports = { query, queryBestDetections, timeAggregatedQuery, + queryDetectionsSummary, DEFAULT_IGNORE_THRESHOLD } diff --git a/core/detections/list-summary.int.test.js b/core/detections/list-summary.int.test.js new file mode 100644 index 000000000..1dd713f02 --- /dev/null +++ b/core/detections/list-summary.int.test.js @@ -0,0 +1,317 @@ +const request = require('supertest') +const router = require('./list-summary') +const models = require('../_models') +const { expressApp, truncateNonBase, seedValues } = require('../../common/testing/sequelize') + +const app = expressApp() + +app.use('/', router) + +beforeEach(async () => { + await truncateNonBase(models) +}) + +afterAll(async () => { + await models.sequelize.close() +}) + +async function commonSetup () { + const project = await models.Project.create({ + id: 'kdmi944kkkls', + name: 'Biodiversity of Brussels', + createdById: seedValues.primaryUserId + }) + await models.UserProjectRole.create({ + user_id: project.createdById, + project_id: project.id, + role_id: seedValues.roleOwner + }) + const stream = { + id: 'seib949gilfa', + name: 'B11', + createdById: seedValues.primaryUserId, + projectId: project.id + } + await models.Stream.create(stream) + const classification = { + id: 6, + value: 'chainsaw', + title: 'Chainsaw', + typeId: 1, + sourceId: 1 + } + await models.Classification.create(classification) + const classifier = { + id: 3, + externalId: 'cccddd', + name: 'chainsaw model', + version: 1, + createdById: seedValues.otherUserId, + modelRunner: 'tf2', + modelUrl: 's3://somewhere' + } + await models.Classifier.create(classifier) + const job1 = await models.ClassifierJob.create({ + created_at: '2023-01-01 07:00', + classifierId: classifier.id, + projectId: project.id, + createdById: seedValues.primaryUserId + }) + const job2 = await models.ClassifierJob.create({ + created_at: '2023-01-02 08:35', + classifierId: classifier.id, + projectId: project.id, + createdById: seedValues.primaryUserId + }) + + return { + project, + stream, + classification, + classifier, + job1, + job2 + } +} + +describe('GET /detections/summary', () => { + test('success', async () => { + const { + stream, + classifier, + classification + } = await commonSetup() + + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-12T02:00:00.000Z', + end: '2023-01-12T02:00:05.000Z', + confidence: 0.45 + }) + + const query = { + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-31T23:59:59.999Z', + min_confidence: 0.1 + } + + const response = await request(app).get('/summary').query(query) + + expect(response.statusCode).toEqual(200) + expect(response.body).toEqual({ + unreviewed: 1, + rejected: 0, + uncertain: 0, + confirmed: 0 + }) + }) + + test('get 1 unreviewed detection given start, end, and classifier_jobs', async () => { + const { + stream, + classifier, + classification, + job1, + job2 + } = await commonSetup() + + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:05:00.000Z', + end: '2023-01-11T00:05:05.000Z', + confidence: 0.95, + classifier_job_id: job2.id + }) + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:15:00.000Z', + end: '2023-01-11T00:15:05.000Z', + confidence: 0.95, + classifier_job_id: job1.id + }) + + const query = { + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-31T23:59:59.999Z', + classifier_jobs: [job1.id] + } + + const response = await request(app).get('/summary').query(query) + + expect(response.statusCode).toEqual(200) + expect(response.body).toEqual({ + unreviewed: 1, + rejected: 0, + uncertain: 0, + confirmed: 0 + }) + }) + + test('get information from 2 jobs', async () => { + const { + stream, + classifier, + classification, + job1, + job2 + } = await commonSetup() + + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:05:00.000Z', + end: '2023-01-11T00:05:05.000Z', + confidence: 0.95, + classifier_job_id: job2.id + }) + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:15:00.000Z', + end: '2023-01-11T00:15:05.000Z', + confidence: 0.95, + classifier_job_id: job1.id + }) + + const query = { + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-31T23:59:59.999Z', + classifier_jobs: [job1.id, job2.id] + } + + const response = await request(app).get('/summary').query(query) + + expect(response.statusCode).toEqual(200) + expect(response.body).toEqual({ + unreviewed: 2, + rejected: 0, + uncertain: 0, + confirmed: 0 + }) + }) + + test('get counts using all reviewed status', async () => { + const { stream, classifier, classification, job1, job2 } = await commonSetup() + + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:05:00.000Z', + end: '2023-01-11T00:05:05.000Z', + confidence: 0.95 + }) + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:15:00.000Z', + end: '2023-01-11T00:15:05.000Z', + confidence: 0.95, + classifier_job_id: job1.id, + reviewStatus: -1 + }) + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:25:00.000Z', + end: '2023-01-11T00:25:05.000Z', + confidence: 0.95, + classifier_job_id: job2.id, + reviewStatus: 0 + }) + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:35:00.000Z', + end: '2023-01-11T00:35:05.000Z', + confidence: 0.95, + classifier_job_id: job2.id, + reviewStatus: 1 + }) + + const query = { + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-31T23:59:59.999Z', + review_statuses: [ + 'rejected', + 'uncertain', + 'confirmed' + ] + } + + const response = await request(app).get('/summary').query(query) + + expect(response.statusCode).toEqual(200) + expect(response.body).toEqual({ + unreviewed: 0, + confirmed: 1, + rejected: 1, + uncertain: 1 + }) + }) + + test('get using unreviewed and confirmed status', async () => { + const { stream, classifier, classification } = await commonSetup() + + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:05:00.000Z', + end: '2023-01-11T00:05:05.000Z', + confidence: 0.95 + }) + await models.Detection.create({ + streamId: stream.id, + classifierId: classifier.id, + classificationId: classification.id, + start: '2023-01-11T00:15:00.000Z', + end: '2023-01-11T00:15:05.000Z', + confidence: 0.95, + reviewStatus: -1 + }) + + const query = { + start: '2023-01-01T00:00:00.000Z', + end: '2023-01-31T23:59:59.999Z', + review_statuses: [ + 'unreviewed', + 'confirmed' + ] + } + + const response = await request(app).get('/summary').query(query) + + expect(response.statusCode).toEqual(200) + expect(response.body).toEqual({ + unreviewed: 1, + rejected: 0, + uncertain: 0, + confirmed: 0 + }) + }) + + test('return 400 if last review_statuses is incorrect', async () => { + const query = { + start: '2021-05-11T00:00:00.000Z', + end: '2021-05-11T00:59:59.999Z', + review_statuses: ['uncertain', 'unreviewed', 'confirm'] + } + + const response = await request(app).get('/summary').query(query) + + expect(response.statusCode).toBe(400) + expect(response.body.message).toBe('Validation errors: Parameter \'review_statuses\' should be one of these values: unreviewed, rejected, uncertain, confirmed.') + }) +}) diff --git a/core/detections/list-summary.js b/core/detections/list-summary.js new file mode 100644 index 000000000..e34ba18d0 --- /dev/null +++ b/core/detections/list-summary.js @@ -0,0 +1,123 @@ +const router = require('express').Router() +const Converter = require('../../common/converter') +const { httpErrorHandler } = require('../../common/error-handling/http') +const dao = require('./dao') + +/** + * @swagger + * + * /detections/summary: + * get: + * summary: Get counts of detections on each validation status based on given filters + * description: + * tags: + * - detections + * parameters: + * - name: start + * description: Limit to a start date on or after (iso8601 or epoch) + * in: query + * required: true + * schema: + * type: string + * format: date-time + * example: 2020-01-01T00:00:00.000Z + * - name: end + * description: Limit to a start date before (iso8601 or epoch) + * in: query + * schema: + * type: string + * format: date-time + * required: true + * example: 2020-02-01T00:00:00.000Z + * - name: streams + * description: List of stream ids to limit results + * in: query + * required: false + * explode: true + * schema: + * type: array + * items: + * type: string + * example: ['km4ifoutpx9W'] + * - name: classifications + * description: List of classification values to limit results + * in: query + * required: false + * explode: true + * schema: + * type: array + * items: + * type: string + * example: [scirus_carolinenses_simple_call_1] + * - name: classifiers + * description: List of classifier ids to limit results + * in: query + * required: false + * explode: true + * schema: + * type: array + * items: + * type: string + * example: ['12'] + * - name: classifier_jobs + * description: List of classifier job ids + * in: query + * required: false + * explode: true + * schema: + * type: array + * items: + * type: string + * example: ['7'] + * - name: min_confidence + * description: Limit results to have detections count higher than the threshold + * in: query + * required: false + * type: float + * example: 0.95 + * - name: review_statuses + * description: Return only parts of calculations for given validation status, Other parts of the calculation will be 0. Possible values are `'rejected' | 'uncertain' | 'confirmed' | 'unreviewed'` + * in: query + * required: false + * explode: true + * schema: + * type: array + * items: + * type: string + * example: [unreviewed, rejected] + * + * responses: + * 200: + * description: Object with parameters of each review status with their counts by given filters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DetectionsResultSummary' + * 400: + * description: Invalid query parameters + * 5XX: + * description: other errors from the server + */ +router.get('/summary', (req, res) => { + const user = req.rfcx.auth_token_info + + const converter = new Converter(req.query, {}, true) + converter.convert('start').toMomentUtc() + converter.convert('end').toMomentUtc() + converter.convert('streams').optional().toArray() + converter.convert('projets').optional().toArray() + converter.convert('classifications').optional().toArray() + converter.convert('classifiers').optional().toArray() + converter.convert('classifier_jobs').optional().toArray() + converter.convert('min_confidence').optional().toFloat() + converter.convert('review_statuses').optional().toArray().isEqualToAny(['unreviewed', 'rejected', 'uncertain', 'confirmed']) + + return converter.validate() + .then(async (filters) => { + const result = await dao.queryDetectionsSummary(filters, { user }) + return res.json(result) + }) + .catch(httpErrorHandler(req, res, 'Failed getting detections summary')) +}) + +module.exports = router