Skip to content

Commit

Permalink
Merge branch 'dev' of github.com:bcgov/biohubbc into access-request-seed
Browse files Browse the repository at this point in the history
  • Loading branch information
mauberti-bc committed Sep 4, 2024
2 parents 87c3457 + 981ee0c commit 3512add
Show file tree
Hide file tree
Showing 20 changed files with 1,450 additions and 267 deletions.
3 changes: 3 additions & 0 deletions api/.docker/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ ENV PATH ${HOME}/node_modules/.bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/us
# Copy the rest of the files
COPY . ./

# Update log directory file permissions, prevents permission errors for linux environments
RUN chmod -R a+rw data/logs/*

VOLUME ${HOME}

# start api with live reload
Expand Down
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 { importCSV } from '../../../../../../../services/import-services/import-csv';
import { ImportMeasurementsStrategy } from '../../../../../../../services/import-services/measurement/import-measurements-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}/measurements/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 Measurements 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 Measurements CSV import file.',
content: {
'multipart/form-data': {
schema: {
type: 'object',
additionalProperties: false,
required: ['media'],
properties: {
media: {
description: 'Critterbase Measurements CSV import file.',
type: 'array',
minItems: 1,
maxItems: 1,
items: csvFileSchema
}
}
}
}
}
},
responses: {
201: {
description: 'Measurement import success.',
content: {
'application/json': {
schema: {
type: 'object',
additionalProperties: false,
properties: {
measurementsCreated: {
description: 'Number of Critterbase measurements 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 Measurement CSV` which bulk adds measurements 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 importCsvMeasurementsStrategy = new ImportMeasurementsStrategy(connection, surveyId);

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

await connection.commit();

return res.status(201).json({ measurementsCreated });
} catch (error) {
defaultLog.error({ label: 'importMeasurementsCSV', message: 'error', error });
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
}
4 changes: 2 additions & 2 deletions api/src/services/critterbase-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ export interface IQualMeasurement {
capture_id?: string;
mortality_id?: string;
qualitative_option_id: string;
measurement_comment: string;
measured_timestamp: string;
measurement_comment?: string;
measured_timestamp?: string;
}

export interface IQuantMeasurement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { v4 as uuid } from 'uuid';
import { z } from 'zod';
import { IDBConnection } from '../../../database/db';
import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases';
import { generateCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils';
import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils';
import { IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils';
import { ICapture, ILocation } from '../../critterbase-service';
import { DBService } from '../../db-service';
Expand Down Expand Up @@ -64,32 +64,32 @@ export class ImportCapturesStrategy extends DBService implements CSVImportStrate
*/
async validateRows(rows: Row[]) {
// Generate type-safe cell getter from column validator
const getCellValue = generateCellGetterFromColumnValidator(this.columnValidator);
const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator);
const critterAliasMap = await this.surveyCritterService.getSurveyCritterAliasMap(this.surveyId);

const rowsToValidate = [];

for (const row of rows) {
let critterId, captureId;

const alias = getCellValue<string>(row, 'ALIAS');
const alias = getColumnCell<string>(row, 'ALIAS');

const releaseLatitude = getCellValue(row, 'RELEASE_LATITUDE');
const releaseLongitude = getCellValue(row, 'RELEASE_LONGITUDE');
const captureDate = getCellValue(row, 'CAPTURE_DATE');
const captureTime = getCellValue(row, 'CAPTURE_TIME');
const releaseTime = getCellValue(row, 'RELEASE_TIME');
const releaseLatitude = getColumnCell(row, 'RELEASE_LATITUDE');
const releaseLongitude = getColumnCell(row, 'RELEASE_LONGITUDE');
const captureDate = getColumnCell(row, 'CAPTURE_DATE');
const captureTime = getColumnCell(row, 'CAPTURE_TIME');
const releaseTime = getColumnCell(row, 'RELEASE_TIME');

const releaseLocationId = releaseLatitude && releaseLongitude ? uuid() : undefined;
const formattedCaptureTime = formatTimeString(captureTime);
const formattedReleaseTime = formatTimeString(releaseTime);
const releaseLocationId = releaseLatitude.cell && releaseLongitude.cell ? uuid() : undefined;
const formattedCaptureTime = formatTimeString(captureTime.cell);
const formattedReleaseTime = formatTimeString(releaseTime.cell);

// If the alias is included attempt to retrieve the critterId from row
// Checks if date time fields are unique for the critter's captures
if (alias) {
const critter = critterAliasMap.get(alias.toLowerCase());
if (alias.cell) {
const critter = critterAliasMap.get(alias.cell.toLowerCase());
if (critter) {
const captures = findCapturesFromDateTime(critter.captures, captureDate, captureTime);
const captures = findCapturesFromDateTime(critter.captures, captureDate.cell, captureTime.cell);
critterId = critter.critter_id;
// Only set the captureId if a capture does not exist with matching date time
captureId = captures.length > 0 ? undefined : uuid();
Expand All @@ -100,17 +100,17 @@ export class ImportCapturesStrategy extends DBService implements CSVImportStrate
capture_id: captureId, // this will be undefined if capture exists with same date / time
critter_id: critterId,
capture_location_id: uuid(),
capture_date: captureDate,
capture_date: captureDate.cell,
capture_time: formattedCaptureTime,
capture_latitude: getCellValue(row, 'CAPTURE_LATITUDE'),
capture_longitude: getCellValue(row, 'CAPTURE_LONGITUDE'),
capture_latitude: getColumnCell(row, 'CAPTURE_LATITUDE').cell,
capture_longitude: getColumnCell(row, 'CAPTURE_LONGITUDE').cell,
release_location_id: releaseLocationId,
release_date: getCellValue(row, 'RELEASE_DATE'),
release_date: getColumnCell(row, 'RELEASE_DATE').cell,
release_time: formattedReleaseTime,
release_latitude: getCellValue(row, 'RELEASE_LATITUDE'),
release_longitude: getCellValue(row, 'RELEASE_LONGITUDE'),
capture_comment: getCellValue(row, 'CAPTURE_COMMENT'),
release_comment: getCellValue(row, 'RELEASE_COMMENT')
release_latitude: getColumnCell(row, 'RELEASE_LATITUDE').cell,
release_longitude: getColumnCell(row, 'RELEASE_LONGITUDE').cell,
capture_comment: getColumnCell(row, 'CAPTURE_COMMENT').cell,
release_comment: getColumnCell(row, 'RELEASE_COMMENT').cell
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { IDBConnection } from '../../../database/db';
import { ApiGeneralError } from '../../../errors/api-error';
import { getLogger } from '../../../utils/logger';
import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases';
import { generateCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils';
import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils';
import { getNonStandardColumnNamesFromWorksheet, IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils';
import {
CritterbaseService,
Expand Down Expand Up @@ -181,17 +181,17 @@ export class ImportCrittersStrategy extends DBService implements CSVImportStrate
* @returns {PartialCsvCritter[]} CSV critters before validation
*/
_getRowsToValidate(rows: Row[], collectionUnitColumns: string[]): PartialCsvCritter[] {
const getCellValue = generateCellGetterFromColumnValidator(this.columnValidator);
const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator);

return rows.map((row) => {
// Standard critter properties from CSV
const standardCritterRow = {
critter_id: uuid(), // Generate a uuid for each critter for convienence
sex: getCellValue(row, 'SEX'),
itis_tsn: getCellValue(row, 'ITIS_TSN'),
wlh_id: getCellValue(row, 'WLH_ID'),
animal_id: getCellValue(row, 'ALIAS'),
critter_comment: getCellValue(row, 'DESCRIPTION')
sex: getColumnCell(row, 'SEX').cell,
itis_tsn: getColumnCell(row, 'ITIS_TSN').cell,
wlh_id: getColumnCell(row, 'WLH_ID').cell,
animal_id: getColumnCell(row, 'ALIAS').cell,
critter_comment: getColumnCell(row, 'DESCRIPTION').cell
};

// All other properties must be collection units ie: `population unit` or `herd unit` etc...
Expand Down
5 changes: 5 additions & 0 deletions api/src/services/import-services/import-csv.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ export type ValidationError = {
*
*/
row: number;
/**
* CSV column header
*
*/
col?: string;
/**
* CSV row error message
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'zod';
import { IDBConnection } from '../../../database/db';
import { getLogger } from '../../../utils/logger';
import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases';
import { generateCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils';
import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils';
import { IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils';
import { IAsSelectLookup, ICritterDetailed } from '../../critterbase-service';
import { DBService } from '../../db-service';
Expand Down Expand Up @@ -98,7 +98,7 @@ export class ImportMarkingsStrategy extends DBService implements CSVImportStrate
*/
async validateRows(rows: Row[]) {
// Generate type-safe cell getter from column validator
const getCellValue = generateCellGetterFromColumnValidator(this.columnValidator);
const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator);

// Get validation reference data
const [critterAliasMap, colours, markingTypes] = await Promise.all([
Expand All @@ -116,18 +116,18 @@ export class ImportMarkingsStrategy extends DBService implements CSVImportStrate
for (const row of rows) {
let critterId, captureId;

const alias = getCellValue<string>(row, 'ALIAS');
const alias = getColumnCell<string>(row, 'ALIAS');

// If the alias is included attempt to retrieve the critter_id and capture_id for the row
if (alias) {
const captureDate = getCellValue(row, 'CAPTURE_DATE');
const captureTime = getCellValue(row, 'CAPTURE_TIME');
if (alias.cell) {
const captureDate = getColumnCell(row, 'CAPTURE_DATE');
const captureTime = getColumnCell(row, 'CAPTURE_TIME');

const critter = critterAliasMap.get(alias.toLowerCase());
const critter = critterAliasMap.get(alias.cell.toLowerCase());

if (critter) {
// Find the capture_id from the date time columns
const captures = findCapturesFromDateTime(critter.captures, captureDate, captureTime);
const captures = findCapturesFromDateTime(critter.captures, captureDate.cell, captureTime.cell);
captureId = captures.length === 1 ? captures[0].capture_id : undefined;
critterId = critter.critter_id;
rowCritters.push(critter);
Expand All @@ -137,12 +137,12 @@ export class ImportMarkingsStrategy extends DBService implements CSVImportStrate
rowsToValidate.push({
critter_id: critterId, // Found using alias
capture_id: captureId, // Found using capture date and time
body_location: getCellValue(row, 'BODY_LOCATION'),
marking_type: getCellValue(row, 'MARKING_TYPE'),
identifier: getCellValue(row, 'IDENTIFIER'),
primary_colour: getCellValue(row, 'PRIMARY_COLOUR'),
secondary_colour: getCellValue(row, 'SECONDARY_COLOUR'),
comment: getCellValue(row, 'DESCRIPTION')
body_location: getColumnCell(row, 'BODY_LOCATION').cell,
marking_type: getColumnCell(row, 'MARKING_TYPE').cell,
identifier: getColumnCell(row, 'IDENTIFIER').cell,
primary_colour: getColumnCell(row, 'PRIMARY_COLOUR').cell,
secondary_colour: getColumnCell(row, 'SECONDARY_COLOUR').cell,
comment: getColumnCell(row, 'DESCRIPTION').cell
});
}
// Get the critter_id -> taxonBodyLocations[] Map
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { z } from 'zod';

export const CsvQualitativeMeasurementSchema = z.object({
critter_id: z.string().uuid(),
capture_id: z.string().uuid(),
taxon_measurement_id: z.string().uuid(),
qualitative_option_id: z.string().uuid()
});

export const CsvQuantitativeMeasurementSchema = z.object({
critter_id: z.string().uuid(),
capture_id: z.string().uuid(),
taxon_measurement_id: z.string().uuid(),
value: z.number()
});

export const CsvMeasurementSchema = CsvQualitativeMeasurementSchema.or(CsvQuantitativeMeasurementSchema);

// Zod inferred types
export type CsvMeasurement = z.infer<typeof CsvMeasurementSchema>;
export type CsvQuantitativeMeasurement = z.infer<typeof CsvQuantitativeMeasurementSchema>;
export type CsvQualitativeMeasurement = z.infer<typeof CsvQualitativeMeasurementSchema>;
Loading

0 comments on commit 3512add

Please sign in to comment.