Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SIMSBIOHUB-584 Bulk Animals #1319

Merged
merged 50 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
5af46cf
feat: added new button for manage animals
MacQSL Jun 18, 2024
a873982
feat: added new useCsvApi
MacQSL Jun 18, 2024
15a9db0
feat: added validation for critter csv import
MacQSL Jun 19, 2024
126ebca
refactor: updated import endpoint and restructured services
MacQSL Jun 20, 2024
668a1ec
feat: updated services for critterbase and sims
MacQSL Jun 20, 2024
b3db32d
refactor: moved column utils to shared file
MacQSL Jun 20, 2024
1dcf2e6
fix: updated createCritters repository method to correctly parse crit…
MacQSL Jun 20, 2024
301ea3b
feat: now validating rows with valid tsns
MacQSL Jun 20, 2024
723c2be
fix: commit before moving shared code to import-critter-service
MacQSL Jun 24, 2024
178cd67
fix: updated dynamic zod validation schema to include collection units
MacQSL Jun 24, 2024
bab449a
feat: commit before creating import critter service
MacQSL Jun 25, 2024
f31165b
refactor: moved logic from survey-critter-service -> import-critters-…
MacQSL Jun 26, 2024
1c5fed7
feat: added validation for collection units / categories
MacQSL Jun 26, 2024
3ecb71c
fix: updated zod validation config
MacQSL Jun 26, 2024
a75a72c
feat: successfully imported collection units with CSV
MacQSL Jun 26, 2024
99c9224
feat: added util for getting column validator specification
MacQSL Jun 26, 2024
6b10903
fix: updated validation methods
MacQSL Jun 27, 2024
6d76680
fix: removed unused util file
MacQSL Jun 27, 2024
addd160
test: updated tests for column utils and critter importer
MacQSL Jun 27, 2024
92ce028
chore: partially done validation tests for importer
MacQSL Jun 27, 2024
142a89c
feat: successfully can import critters from CSV
MacQSL Jun 28, 2024
0f3f132
test: updated tests for import and broken survey tests
MacQSL Jun 28, 2024
ac80043
chore: continuing validation tests
MacQSL Jun 28, 2024
9f2dae1
chore: moved tests to correct file
MacQSL Jun 28, 2024
f41b40f
fix: updated loggin for validation
MacQSL Jun 28, 2024
27a3d96
fix: closing import dialog once upload completes / fails
MacQSL Jun 28, 2024
a36ebda
fix: updated some todos
MacQSL Jun 28, 2024
8826d9a
fix: updated generateCellValueGetter syntax
MacQSL Jun 28, 2024
9bb89b9
feat: updated observation nonStandardColoumUtil
MacQSL Jul 2, 2024
bdd3429
Merge branch 'dev' into SIMSBIOHUB-584-Bulk-Animals
MacQSL Jul 2, 2024
427580f
feat: updated tests and regex
MacQSL Jul 2, 2024
0a0509c
Merge branch 'SIMSBIOHUB-584-Bulk-Animals' of https://github.com/bcgo…
MacQSL Jul 2, 2024
6582bd7
doc: updated documentation in column-cell-utils
MacQSL Jul 2, 2024
5ebc1f6
fix: removed surveyContext from useSurveyApi
MacQSL Jul 2, 2024
83b3a2a
test: updated test for import endpoint
MacQSL Jul 2, 2024
671016b
fix: removed only from test
MacQSL Jul 2, 2024
69b045c
Merge remote-tracking branch 'origin/dev' into SIMSBIOHUB-584-Bulk-An…
NickPhura Jul 9, 2024
30bf726
fix: case insensitivity
MacQSL Jul 12, 2024
995706b
fix: pulled remote
MacQSL Jul 12, 2024
e936fb1
Merge branch 'SIMSBIOHUB-584-Bulk-Animals' of https://github.com/bcgo…
MacQSL Jul 12, 2024
3bf54d4
fix: incorrectly passing project id as survey id
MacQSL Jul 15, 2024
91eae5c
fix: fixed create single critter from failing + updated casing for ta…
MacQSL Jul 15, 2024
c3b1934
feat: updated openapi spec for critter import endpoint
MacQSL Jul 15, 2024
1977292
fix: updated tests for useTaxonomy
MacQSL Jul 15, 2024
0a2f984
Move service instantiate below connection open
NickPhura Jul 16, 2024
856547e
Move service instantiate below connection open, Con't
NickPhura Jul 16, 2024
b79a3c4
feat: updated types for IXLSXCoumnValidator
MacQSL Jul 16, 2024
ec1c7c6
fix: removed incorrect type from IXLSXColumnValidator
MacQSL Jul 16, 2024
e8bccb5
fix: forgot to remove test property from column validator
MacQSL Jul 16, 2024
c5e1341
Merge branch 'dev' into SIMSBIOHUB-584-Bulk-Animals
MacQSL Jul 16, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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 { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db';
import { importCsv } from './import';

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

describe('importCsv', () => {
afterEach(() => {
sinon.restore();
});

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 mockFileScan = sinon.stub(fileUtils, 'scanFileForVirus').resolves(true);

const mockFile = { originalname: 'test.csv', mimetype: 'test.csv', buffer: Buffer.alloc(1) } as Express.Multer.File;

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

mockReq.files = [mockFile];
mockReq.params.surveyId = '1';

const requestHandler = importCsv();

await requestHandler(mockReq, mockRes, mockNext);

expect(mockDBConnection.open).to.have.been.calledOnce;

expect(mockFileScan).to.have.been.calledOnceWithExactly(mockFile);

expect(mockGetDBConnection.calledOnce).to.be.true;
expect(mockImportCsv).to.have.been.calledOnceWithExactly(1, parseMulterFile(mockFile));
expect(mockRes.json).to.have.been.calledOnceWithExactly({ survey_critter_ids: [1, 2] });

expect(mockDBConnection.commit).to.have.been.calledOnce;
expect(mockDBConnection.release).to.have.been.calledOnce;
});

it('should catch error and rollback if no files in request', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
release: sinon.stub(),
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(true);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

mockReq.files = [];
mockReq.params.surveyId = '1';

const requestHandler = importCsv();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail();
} catch (err: any) {
expect(err.message).to.be.equal('Invalid number of files included. Expected 1 CSV file.');
}

expect(mockDBConnection.open).to.have.been.calledOnce;

expect(mockFileScan).to.not.have.been.calledOnce;

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;
expect(mockDBConnection.commit).to.not.have.been.called;
expect(mockDBConnection.release).to.have.been.called;
});

it('should catch error and rollback if more than 1 file in request', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
release: sinon.stub(),
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(true);

const mockFile = { originalname: 'test.csv', mimetype: 'test.csv', buffer: Buffer.alloc(1) } as Express.Multer.File;

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

mockReq.files = [mockFile, mockFile];
mockReq.params.surveyId = '1';

const requestHandler = importCsv();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail();
} catch (err: any) {
expect(err.message).to.be.equal('Invalid number of files included. Expected 1 CSV file.');
}

expect(mockDBConnection.open).to.have.been.calledOnce;

expect(mockFileScan).to.not.have.been.calledOnce;

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;
expect(mockDBConnection.commit).to.not.have.been.called;
expect(mockDBConnection.release).to.have.been.called;
});

it('should catch error and rollback if file is not a csv', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
release: sinon.stub(),
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(true);

const mockFile = {
originalname: 'test.oops',
mimetype: 'test.oops',
buffer: Buffer.alloc(1)
} as Express.Multer.File;

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

mockReq.files = [mockFile];
mockReq.params.surveyId = '1';

const requestHandler = importCsv();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail();
} catch (err: any) {
expect(err).to.be.instanceof(HTTP400);
expect(err.message).to.be.contains('Invalid file type.');
}

expect(mockDBConnection.open).to.have.been.calledOnce;

expect(mockFileScan).to.not.have.been.calledOnce;

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;
expect(mockDBConnection.commit).to.not.have.been.called;
expect(mockDBConnection.release).to.have.been.called;
});

it('should catch error and rollback if file contains malware', async () => {
const mockDBConnection = getMockDBConnection({
open: sinon.stub(),
commit: sinon.stub(),
release: sinon.stub(),
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);

const mockFile = {
originalname: 'test.csv',
mimetype: 'test.csv',
buffer: Buffer.alloc(1)
} as Express.Multer.File;

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

mockReq.files = [mockFile];
mockReq.params.surveyId = '1';

const requestHandler = importCsv();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail();
} catch (err: any) {
expect(err).to.be.instanceof(HTTP400);
expect(err.message).to.be.contains('Malicious content detected');
}

expect(mockDBConnection.open).to.have.been.calledOnce;
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;
expect(mockDBConnection.commit).to.not.have.been.called;
expect(mockDBConnection.release).to.have.been.called;
});
});
159 changes: 159 additions & 0 deletions api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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 { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization';
import { ImportCrittersService } from '../../../../../../services/import-services/import-critters-service';
import { scanFileForVirus } from '../../../../../../utils/file-utils';
import { getLogger } from '../../../../../../utils/logger';
import { parseMulterFile } from '../../../../../../utils/media/media-utils';

const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/critters/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 survey critters submission file',
tags: ['observations'],
MacQSL marked this conversation as resolved.
Show resolved Hide resolved
security: [
{
Bearer: []
}
],
parameters: [
{
in: 'path',
name: 'projectId',
required: true
},
{
in: 'path',
name: 'surveyId',
required: true
}
MacQSL marked this conversation as resolved.
Show resolved Hide resolved
],
requestBody: {
description: 'Survey critters submission file to import',
content: {
'multipart/form-data': {
schema: {
type: 'object',
additionalProperties: false,
required: ['media'],
properties: {
media: {
description: 'Survey Critters submission import file.',
type: 'string',
format: 'binary'
}
}
}
}
}
},
responses: {
200: {
description: 'Import OK',
content: {
'application/json': {
schema: {
type: 'object',
additionalProperties: false,
properties: {
MacQSL marked this conversation as resolved.
Show resolved Hide resolved
survey_critter_ids: {
type: 'array',
items: {
type: 'number'
MacQSL marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}
}
}
},
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 `Critter CSV` which adds critters to `survey_critter` table and creates critters in Critterbase.
*
* @return {*} {RequestHandler}
*/
export function importCsv(): RequestHandler {
return async (req, res) => {
const surveyId = Number(req.params.surveyId);
const rawFiles = req.files as Express.Multer.File[];
const rawFile = rawFiles[0];

const connection = getDBConnection(req['keycloak_token']);

try {
await connection.open();

if (rawFiles.length !== 1) {
throw new HTTP400('Invalid number of files included. Expected 1 CSV file.');
}

if (!rawFile?.originalname.endsWith('.csv')) {
throw new HTTP400('Invalid file type. Expected a CSV file.');
}

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

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

const csvImporter = new ImportCrittersService(connection);

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

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 });
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect } from 'chai';
import sinon from 'sinon';
import { addCritterToSurvey, getCrittersFromSurvey } from '.';
import * as db from '../../../../../../database/db';
import { CritterbaseService } from '../../../../../../services/critterbase-service';
import { CritterbaseService, ICritter } from '../../../../../../services/critterbase-service';
import { SurveyCritterService } from '../../../../../../services/survey-critter-service';
import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db';

Expand Down Expand Up @@ -31,7 +31,7 @@ describe('getCrittersFromSurvey', () => {
.resolves([mockSurveyCritter]);
const mockGetMultipleCrittersByIds = sinon
.stub(CritterbaseService.prototype, 'getMultipleCrittersByIds')
.resolves([mockCBCritter]);
.resolves([mockCBCritter] as unknown as ICritter[]);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();

Expand Down
Loading