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 26 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
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 All @@ -23,7 +23,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
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,12 @@ export function getCrittersFromSurvey(): RequestHandler {
const surveyId = Number(req.params.surveyId);
const connection = getDBConnection(req['keycloak_token']);

const surveyService = new SurveyCritterService(connection);
const critterbaseService = new CritterbaseService(user);
try {
await connection.open();

const surveyService = new SurveyCritterService(connection);
const critterbaseService = new CritterbaseService(user);

const surveyCritters = await surveyService.getCrittersInSurvey(surveyId);

// Exit early if surveyCritters list is empty
Expand Down
8 changes: 4 additions & 4 deletions api/src/repositories/survey-critter-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,16 @@ describe('SurveyRepository', () => {
});
});

describe('addCritterToSurvey', () => {
describe('addCrittersToSurvey', () => {
it('should return result', async () => {
const mockResponse = { rows: [{ submissionId: 1 }], rowCount: 1 } as any as Promise<QueryResult<any>>;
const mockResponse = { rows: [{ critter_id: 1 }], rowCount: 1 } as any as Promise<QueryResult<any>>;
const dbConnection = getMockDBConnection({ knex: () => mockResponse });

const repository = new SurveyCritterRepository(dbConnection);

const response = await repository.addCritterToSurvey(1, 'critter_id');
const response = await repository.addCrittersToSurvey(1, ['critter_id']);

expect(response).to.be.undefined;
expect(response).to.be.deep.equal([1]);
});
});

Expand Down
12 changes: 6 additions & 6 deletions api/src/repositories/survey-critter-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,24 @@ export class SurveyCritterRepository extends BaseRepository {
}

/**
* Add critter to survey
* Add critters to survey.
*
* @param {number} surveyId
* @param {string} critterId
* @param {string[]} critterIds
* @return {*} {Promise<number>}
* @memberof SurveyCritterRepository
*/
async addCritterToSurvey(surveyId: number, critterId: string): Promise<number> {
defaultLog.debug({ label: 'addCritterToSurvey', surveyId });
async addCrittersToSurvey(surveyId: number, critterIds: string[]): Promise<number[]> {
defaultLog.debug({ label: 'addCrittersToSurvey', surveyId });

const queryBuilder = getKnex()
.table('critter')
.insert({ survey_id: surveyId, critterbase_critter_id: critterId })
.insert(critterIds.map((critterId) => ({ survey_id: surveyId, critterbase_critter_id: critterId })))
.returning('critter_id');

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

return response.rows[0].critter_id;
return response.rows.map((row) => row.critter_id);
}

/**
Expand Down
5 changes: 2 additions & 3 deletions api/src/services/critterbase-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AxiosResponse } from 'axios';
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { CritterbaseService, ICritter } from './critterbase-service';
import { CritterbaseService, ICreateCritter } from './critterbase-service';
import { KeycloakService } from './keycloak-service';

chai.use(sinonChai);
Expand Down Expand Up @@ -145,12 +145,11 @@ describe('CritterbaseService', () => {

describe('createCritter', () => {
it('should create a critter', async () => {
const data: ICritter = {
const data: ICreateCritter = {
wlh_id: 'aaaa',
animal_id: 'aaaa',
sex: 'male',
itis_tsn: 1,
itis_scientific_name: 'Name',
critter_comment: 'None.'
};
const axiosStub = sinon.stub(cb.axiosInstance, 'post').resolves({ data: [] });
Expand Down
96 changes: 81 additions & 15 deletions api/src/services/critterbase-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@ export interface QueryParam {
}

export interface ICritter {
critter_id?: string;
wlh_id: string;
animal_id: string;
critter_id: string;
wlh_id: string | null;
animal_id: string | null;
sex: string;
itis_tsn: number;
itis_scientific_name: string;
critter_comment: string;
critter_comment: string | null;
}

export interface ICreateCritter {
critter_id?: string;
wlh_id?: string | null;
animal_id: string; // NOTE: In critterbase this is optional. For SIMS it should be required.
sex: string;
itis_tsn: number;
critter_comment?: string | null;
}

export interface ICapture {
Expand Down Expand Up @@ -110,15 +119,41 @@ export interface ICollection {
}

export interface IBulkCreate {
critters: ICritter[];
captures: ICapture[];
collections: ICollection[];
mortalities: IMortality[];
locations: ILocation[];
markings: IMarking[];
quantitative_measurements: IQuantMeasurement[];
qualitative_measurements: IQualMeasurement[];
families: IFamilyPayload[];
critters?: ICreateCritter[];
captures?: ICapture[];
collections?: ICollection[];
mortalities?: IMortality[];
locations?: ILocation[];
markings?: IMarking[];
quantitative_measurements?: IQuantMeasurement[];
qualitative_measurements?: IQualMeasurement[];
families?: IFamilyPayload[];
}

interface IBulkResponse {
critters: number;
captures: number;
collections: number;
mortalities: number;
locations: number;
markings: number;
quantitative_measurements: number;
qualitative_measurements: number;
families: number;
family_parents: number;
family_chidren: number;
}

export interface IBulkCreateResponse {
created: IBulkResponse;
}

export interface ICollectionUnitWithCategory {
collection_unit_id: string;
collection_category_id: string;
category_name: string;
unit_name: string;
description: string | null;
}

/**
Expand Down Expand Up @@ -366,7 +401,7 @@ export class CritterbaseService {
return this._makeGetRequest(`${CRITTER_ENDPOINT}/${critter_id}`, [{ key: 'format', value: 'detail' }]);
}

async createCritter(data: ICritter) {
async createCritter(data: ICreateCritter) {
const response = await this.axiosInstance.post(`${CRITTER_ENDPOINT}/create`, data);
return response.data;
}
Expand All @@ -376,7 +411,12 @@ export class CritterbaseService {
return response.data;
}

async getMultipleCrittersByIds(critter_ids: string[]) {
async bulkCreate(data: IBulkCreate): Promise<IBulkCreateResponse> {
const response = await this.axiosInstance.post(BULK_ENDPOINT, data);
return response.data;
}

async getMultipleCrittersByIds(critter_ids: string[]): Promise<ICritter[]> {
const response = await this.axiosInstance.post(CRITTER_ENDPOINT, { critter_ids });
return response.data;
}
Expand Down Expand Up @@ -419,4 +459,30 @@ export class CritterbaseService {

return data;
}

/**
* Find collection categories by tsn. Includes hierarchies.
*
* @async
* @param {string} tsn - ITIS TSN
* @returns {Promise<>} Collection categories
*/
async findTaxonCollectionCategories(tsn: string) {
MacQSL marked this conversation as resolved.
Show resolved Hide resolved
const response = await this.axiosInstance.get(`/xref/taxon-collection-categories?tsn=${tsn}`);

return response.data;
}

/**
* Find collection units by tsn. Includes hierarchies.
*
* @async
* @param {string} tsn - ITIS TSN
* @returns {Promise<ICollectionUnitWithCategory[]>} Collection units
*/
async findTaxonCollectionUnits(tsn: string): Promise<ICollectionUnitWithCategory[]> {
const response = await this.axiosInstance.get(`/xref/taxon-collection-units?tsn=${tsn}`);

return response.data;
}
}
Loading
Loading