Skip to content

Commit

Permalink
SIMSBIOHUB-587 API: Bulk Captures (#1324)
Browse files Browse the repository at this point in the history
- Bulk import captures endpoint
- Updated importCSV strategy
  • Loading branch information
MacQSL authored Jul 31, 2024
1 parent a281456 commit 76fd32f
Show file tree
Hide file tree
Showing 33 changed files with 2,019 additions and 1,307 deletions.
8 changes: 4 additions & 4 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"winston": "^3.3.3",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz",
"xml2js": "^0.4.23",
"zod": "^3.22.4"
"zod": "^3.23.0"
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.1",
Expand Down
12 changes: 11 additions & 1 deletion api/src/openapi/schemas/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,17 @@ export const csvFileSchema: OpenAPIV3.SchemaObject = {
mimetype: {
description: 'CSV File type.',
type: 'string',
enum: ['text/csv']
// https://christianwood.net/posts/csv-file-upload-validation/
enum: [
'text/csv',
'text/x-csv',
'application/vnd.ms-excel',
'application/csv',
'application/x-csv',
'text/comma-seperated-values',
'text/x-comma-seperated-values',
'text/tab-seperated-values'
]
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles';
import { getDBConnection } from '../../../../../../../database/db';
import { HTTP400 } from '../../../../../../../errors/http-error';
import { csvFileSchema } from '../../../../../../../openapi/schemas/file';
import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization';
import { ImportCapturesService } from '../../../../../../../services/import-services/capture/import-captures-service';
import { importCSV } from '../../../../../../../services/import-services/csv-import-strategy';
import { scanFileForVirus } from '../../../../../../../utils/file-utils';
import { getLogger } from '../../../../../../../utils/logger';
import { parseMulterFile } from '../../../../../../../utils/media/media-utils';
import { getFileFromRequest } from '../../../../../../../utils/request';

const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/captures/import');

export const POST: Operation = [
authorizeRequestHandler((req) => {
return {
or: [
{
validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR],
surveyId: Number(req.params.surveyId),
discriminator: 'ProjectPermission'
},
{
validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR],
discriminator: 'SystemRole'
}
]
};
}),
importCsv()
];

POST.apiDoc = {
description: 'Upload Critterbase CSV Captures file',
tags: ['observations'],
security: [
{
Bearer: []
}
],
parameters: [
{
in: 'path',
description: 'SIMS survey id',
name: 'projectId',
required: true,
schema: {
type: 'integer',
minimum: 1
}
},
{
in: 'path',
description: 'SIMS survey id',
name: 'surveyId',
required: true,
schema: {
type: 'integer',
minimum: 1
}
}
],
requestBody: {
description: 'Critterbase Captures CSV import file.',
content: {
'multipart/form-data': {
schema: {
type: 'object',
additionalProperties: false,
required: ['media'],
properties: {
media: {
description: 'Critterbase Captures CSV import file.',
type: 'array',
minItems: 1,
maxItems: 1,
items: csvFileSchema
}
}
}
}
}
},
responses: {
201: {
description: 'Capture import success.',
content: {
'application/json': {
schema: {
type: 'object',
additionalProperties: false,
properties: {
capturesCreated: {
description: 'Number of Critterbase captures created.',
type: 'integer'
}
}
}
}
}
},
400: {
$ref: '#/components/responses/400'
},
401: {
$ref: '#/components/responses/401'
},
403: {
$ref: '#/components/responses/403'
},
500: {
$ref: '#/components/responses/500'
},
default: {
$ref: '#/components/responses/default'
}
}
};

/**
* Imports a `Critterbase Capture CSV` which bulk adds captures to Critterbase.
*
* @return {*} {RequestHandler}
*/
export function importCsv(): RequestHandler {
return async (req, res) => {
const surveyId = Number(req.params.surveyId);
const rawFile = getFileFromRequest(req);

const connection = getDBConnection(req.keycloak_token);

try {
await connection.open();

// Check for viruses / malware
const virusScanResult = await scanFileForVirus(rawFile);

if (!virusScanResult) {
throw new HTTP400('Malicious content detected, import cancelled.');
}

const importCsvCaptures = new ImportCapturesService(connection, surveyId);

// Pass CSV file and importer as dependencies
const capturesCreated = await importCSV(parseMulterFile(rawFile), importCsvCaptures);

await connection.commit();

return res.status(201).json({ capturesCreated });
} catch (error) {
defaultLog.error({ label: 'importCritterCsv', message: 'error', error });
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import { expect } from 'chai';
import sinon from 'sinon';
import * as db from '../../../../../../database/db';
import { HTTP400 } from '../../../../../../errors/http-error';
import { ImportCrittersService } from '../../../../../../services/import-services/import-critters-service';
import { parseMulterFile } from '../../../../../../utils/media/media-utils';
import * as strategy from '../../../../../../services/import-services/csv-import-strategy';
import * as fileUtils from '../../../../../../utils/file-utils';
import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db';
import { importCsv } from './import';

import * as fileUtils from '../../../../../../utils/file-utils';

describe('importCsv', () => {
afterEach(() => {
sinon.restore();
Expand All @@ -17,7 +15,7 @@ describe('importCsv', () => {
it('returns imported critters', async () => {
const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() });
const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
const mockImportCsv = sinon.stub(ImportCrittersService.prototype, 'import').resolves([1, 2]);
const mockImportCSV = sinon.stub(strategy, 'importCSV').resolves([1, 2]);
const mockFileScan = sinon.stub(fileUtils, 'scanFileForVirus').resolves(true);

const mockFile = { originalname: 'test.csv', mimetype: 'test.csv', buffer: Buffer.alloc(1) } as Express.Multer.File;
Expand All @@ -36,7 +34,9 @@ describe('importCsv', () => {
expect(mockFileScan).to.have.been.calledOnceWithExactly(mockFile);

expect(mockGetDBConnection.calledOnce).to.be.true;
expect(mockImportCsv).to.have.been.calledOnceWithExactly(1, parseMulterFile(mockFile));

expect(mockImportCSV).to.have.been.calledOnce;

expect(mockRes.json).to.have.been.calledOnceWithExactly({ survey_critter_ids: [1, 2] });

expect(mockDBConnection.commit).to.have.been.calledOnce;
Expand All @@ -51,7 +51,6 @@ describe('importCsv', () => {
rollback: sinon.stub()
});
const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
const mockImportCsv = sinon.stub(ImportCrittersService.prototype, 'import').resolves([1, 2]);

const mockFileScan = sinon.stub(fileUtils, 'scanFileForVirus').resolves(false);

Expand Down Expand Up @@ -80,7 +79,6 @@ describe('importCsv', () => {
expect(mockFileScan).to.have.been.calledOnceWithExactly(mockFile);

expect(mockGetDBConnection.calledOnce).to.be.true;
expect(mockImportCsv).to.not.have.been.called;
expect(mockRes.json).to.not.have.been.called;

expect(mockDBConnection.rollback).to.have.been.called;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getDBConnection } from '../../../../../../database/db';
import { HTTP400 } from '../../../../../../errors/http-error';
import { csvFileSchema } from '../../../../../../openapi/schemas/file';
import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization';
import { ImportCrittersService } from '../../../../../../services/import-services/import-critters-service';
import { ImportCrittersService } from '../../../../../../services/import-services/critter/import-critters-service';
import { importCSV } from '../../../../../../services/import-services/csv-import-strategy';
import { scanFileForVirus } from '../../../../../../utils/file-utils';
import { getLogger } from '../../../../../../utils/logger';
import { parseMulterFile } from '../../../../../../utils/media/media-utils';
Expand Down Expand Up @@ -61,7 +62,7 @@ POST.apiDoc = {
}
],
requestBody: {
description: 'Survey critters submission file to import',
description: 'Survey critters csv file to import',
content: {
'multipart/form-data': {
schema: {
Expand Down Expand Up @@ -129,32 +130,32 @@ POST.apiDoc = {
export function importCsv(): RequestHandler {
return async (req, res) => {
const surveyId = Number(req.params.surveyId);
const rawMediaFile = getFileFromRequest(req);
const rawFile = getFileFromRequest(req);

const connection = getDBConnection(req.keycloak_token);

try {
await connection.open();

// Check for viruses / malware
const virusScanResult = await scanFileForVirus(rawMediaFile);
const virusScanResult = await scanFileForVirus(rawFile);

if (!virusScanResult) {
throw new HTTP400('Malicious content detected, import cancelled.');
}

const csvImporter = new ImportCrittersService(connection);
// Critter CSV import service - child of CSVImportStrategy
const importCsvCritters = new ImportCrittersService(connection, surveyId);

// Pass the survey id and the csv (MediaFile) to the importer
const surveyCritterIds = await csvImporter.import(surveyId, parseMulterFile(rawMediaFile));
const surveyCritterIds = await importCSV(parseMulterFile(rawFile), importCsvCritters);

defaultLog.info({ label: 'importCritterCsv', message: 'result', survey_critter_ids: surveyCritterIds });

await connection.commit();

return res.status(200).json({ survey_critter_ids: surveyCritterIds });
} catch (error) {
defaultLog.error({ label: 'uploadMedia', message: 'error', error });
defaultLog.error({ label: 'importCritterCsv', message: 'error', error });
await connection.rollback();
throw error;
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export function getCrittersFromSurvey(): RequestHandler {
await connection.open();

const surveyService = new SurveyCritterService(connection);

const surveyCritters = await surveyService.getCrittersInSurvey(surveyId);

// Exit early if surveyCritters list is empty
Expand Down
21 changes: 21 additions & 0 deletions api/src/repositories/survey-critter-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ export class SurveyCritterRepository extends BaseRepository {
return response.rows;
}

/**
* Get critter in survey
*
* @param {number} surveyId
* @param {number} critterId
* @return {*} {Promise<SurveyCritterRecord>}
* @memberof SurveyCritterRepository
*/
async getCritterInSurvey(surveyId: number, critterId: number): Promise<SurveyCritterRecord | undefined> {
defaultLog.debug({ label: 'getCritter', critterId });

const queryBuilder = getKnex().table('critter').select().where({
survey_id: surveyId,
critter_id: critterId
});

const response = await this.connection.knex(queryBuilder);

return response.rows[0];
}

/**
* Constructs a non-paginated query to retrieve critters that are available to the user based on the user's
* permissions and filter criteria.
Expand Down
Loading

0 comments on commit 76fd32f

Please sign in to comment.