Skip to content

Commit

Permalink
Filter measurements search by focal and observed species (#1338)
Browse files Browse the repository at this point in the history
* wip: styling on configure table dialog

* wip: configure columns styling

* configure obs dialog styling

* add applicable label to measurements search

* write unit tests

* linter

* no data overlay in configure columns dialog

* rename variable for clarity

* add eslint ignore & update prop names

* fix dependency array warning

* feat: removed log file from git cache

---------

Co-authored-by: Mac Deluca <[email protected]>
  • Loading branch information
mauberti-bc and MacQSL authored Sep 3, 2024
1 parent b856afd commit bb198fa
Show file tree
Hide file tree
Showing 24 changed files with 759 additions and 241 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import chai, { expect } from 'chai';
import { describe } from 'mocha';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { getSurveyObservedSpecies } from '.';
import * as db from '../../../../../../../database/db';
import { HTTPError } from '../../../../../../../errors/http-error';
import { ObservationService } from '../../../../../../../services/observation-service';
import { PlatformService } from '../../../../../../../services/platform-service';
import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db';

chai.use(sinonChai);

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

it('gets species observed in a survey', async () => {
const dbConnectionObj = getMockDBConnection();

sinon.stub(db, 'getDBConnection').returns(dbConnectionObj);

const mockSurveyId = 2;
const mockProjectId = 1;
const mockTsns = [1, 2, 3];
const mockSpecies = mockTsns.map((tsn) => ({ itis_tsn: tsn }));
const mockItisResponse = [
{ tsn: '1', commonNames: ['common name 1'], scientificName: 'scientific name 1' },
{ tsn: '2', commonNames: ['common name 2'], scientificName: 'scientific name 2' },
{ tsn: '3', commonNames: ['common name 3'], scientificName: 'scientific name 3' }
];
const mockFormattedItisResponse = mockItisResponse.map((species) => ({ ...species, tsn: Number(species.tsn) }));

const getObservedSpeciesForSurveyStub = sinon
.stub(ObservationService.prototype, 'getObservedSpeciesForSurvey')
.resolves(mockSpecies);

const getTaxonomyByTsnsStub = sinon.stub(PlatformService.prototype, 'getTaxonomyByTsns').resolves(mockItisResponse);

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

mockReq.params = {
projectId: String(mockProjectId),
surveyId: String(mockSurveyId)
};

const requestHandler = getSurveyObservedSpecies();

await requestHandler(mockReq, mockRes, mockNext);

expect(getObservedSpeciesForSurveyStub).to.have.been.calledOnceWith(mockSurveyId);
expect(getTaxonomyByTsnsStub).to.have.been.calledOnceWith(mockTsns);
expect(mockRes.statusValue).to.equal(200);
expect(mockRes.jsonValue).to.eql(mockFormattedItisResponse);
});

it('catches and re-throws error', async () => {
const dbConnectionObj = getMockDBConnection({ release: sinon.stub() });

sinon.stub(db, 'getDBConnection').returns(dbConnectionObj);

const mockSurveyId = 2;
const mockProjectId = 1;

sinon.stub(ObservationService.prototype, 'getObservedSpeciesForSurvey').rejects(new Error('a test error'));

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

mockReq.params = {
projectId: String(mockProjectId),
surveyId: String(mockSurveyId)
};

try {
const requestHandler = getSurveyObservedSpecies();

await requestHandler(mockReq, mockRes, mockNext);

expect.fail();
} catch (actualError) {
expect(dbConnectionObj.release).to.have.been.called;
expect((actualError as HTTPError).message).to.equal('a test error');
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles';
import { getDBConnection } from '../../../../../../../database/db';
import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization';
import { ObservationService } from '../../../../../../../services/observation-service';
import { PlatformService } from '../../../../../../../services/platform-service';
import { getLogger } from '../../../../../../../utils/logger';

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

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

GET.apiDoc = {
description: 'Get observed species for a survey',
tags: ['observation'],
security: [
{
Bearer: []
}
],
parameters: [
{
in: 'path',
name: 'projectId',
schema: {
type: 'integer',
minimum: 1
},
required: true
},
{
in: 'path',
name: 'surveyId',
schema: {
type: 'integer',
minimum: 1
},
required: true
}
],
responses: {
200: {
description: 'Survey observed species get response.',
content: {
'application/json': {
schema: {
type: 'array',
description: 'Array of objects describing observed species in the survey',
items: {
type: 'object',
additionalProperties: false,
properties: {
tsn: { type: 'number', description: 'The TSN of the observed species' },
commonNames: {
type: 'array',
items: { type: 'string' },
description: 'Common names of the observed species'
},
scientificName: { type: 'string', description: 'Scientific name of the observed species' }
}
}
}
}
}
},
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'
}
}
};

/**
* Fetch species that were observed in the survey
*
* @export
* @return {*} {RequestHandler}
*/
export function getSurveyObservedSpecies(): RequestHandler {
return async (req, res) => {
const surveyId = Number(req.params.surveyId);
defaultLog.debug({ label: 'getSurveyObservedSpecies', surveyId });

const connection = getDBConnection(req.keycloak_token);

try {
await connection.open();

const observationService = new ObservationService(connection);
const platformService = new PlatformService(connection);

const observedSpecies = await observationService.getObservedSpeciesForSurvey(surveyId);

const species = await platformService.getTaxonomyByTsns(observedSpecies.flatMap((species) => species.itis_tsn));

const formattedResponse = species.map((taxon) => ({ ...taxon, tsn: Number(taxon.tsn) }));

return res.status(200).json(formattedResponse);
} catch (error) {
defaultLog.error({ label: 'getSurveyObservedSpecies', message: 'error', error });
await connection.rollback();
throw error;
} finally {
connection.release();
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,22 @@ describe('ObservationRepository', () => {
});
});

describe('getObservedSpeciesForSurvey', () => {
it('gets observed species for a given survey', async () => {
const mockQueryResponse = { rows: [{ itis_tsn: 5 }], rowCount: 1 } as unknown as QueryResult<any>;

const mockDBConnection = getMockDBConnection({
knex: sinon.stub().resolves(mockQueryResponse)
});

const repo = new ObservationRepository(mockDBConnection);

const response = await repo.getObservedSpeciesForSurvey(1);

expect(response).to.eql([{ itis_tsn: 5 }]);
});
});

describe('getObservationsCountBySampleSiteIds', () => {
it('gets the observation count by sample site ids', async () => {
const mockQueryResponse = { rows: [{ count: 50 }], rowCount: 1 } as unknown as QueryResult<any>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export const ObservationRecord = z.object({

export type ObservationRecord = z.infer<typeof ObservationRecord>;

export const ObservationSpecies = z.object({
itis_tsn: z.number()
});

export type ObservationSpecies = z.infer<typeof ObservationSpecies>;

const ObservationSamplingData = z.object({
survey_sample_site_name: z.string().nullable(),
survey_sample_method_name: z.string().nullable(),
Expand Down Expand Up @@ -416,6 +422,25 @@ export class ObservationRepository extends BaseRepository {
return response.rows;
}

/**
* Retrieves species observed in a given survey
*
* @param {number} surveyId
* @return {*} {Promise<ObservationSpecies[]>}
* @memberof ObservationRepository
*/
async getObservedSpeciesForSurvey(surveyId: number): Promise<ObservationSpecies[]> {
const knex = getKnex();
const allRowsQuery = knex
.queryBuilder()
.distinct('itis_tsn')
.from('survey_observation')
.where('survey_id', surveyId);

const response = await this.connection.knex(allRowsQuery, ObservationSpecies);
return response.rows;
}

/**
* Retrieves the count of survey observations for the given survey
*
Expand Down
21 changes: 21 additions & 0 deletions api/src/services/observation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,25 @@ describe('ObservationService', () => {
expect(response).to.eql(mockObservationCount);
});
});

describe('getObservedSpeciesForSurvey', () => {
it('Gets the species observed in a survey', async () => {
const mockDBConnection = getMockDBConnection();

const mockTsns = [1, 2, 3];
const surveyId = 1;
const mockSpecies = mockTsns.map((tsn) => ({ itis_tsn: tsn }));

const getObservedSpeciesForSurveyStub = sinon
.stub(ObservationRepository.prototype, 'getObservedSpeciesForSurvey')
.resolves(mockSpecies);

const observationService = new ObservationService(mockDBConnection);

const response = await observationService.getObservedSpeciesForSurvey(surveyId);

expect(getObservedSpeciesForSurveyStub).to.be.calledOnceWith(surveyId);
expect(response).to.eql(mockSpecies);
});
});
});
12 changes: 12 additions & 0 deletions api/src/services/observation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ObservationRecord,
ObservationRecordWithSamplingAndSubcountData,
ObservationRepository,
ObservationSpecies,
ObservationSubmissionRecord,
UpdateObservation
} from '../repositories/observation-repository/observation-repository';
Expand Down Expand Up @@ -246,6 +247,17 @@ export class ObservationService extends DBService {
return this.observationRepository.getAllSurveyObservations(surveyId);
}

/**
* Retrieves all species observed in a given survey
*
* @param {number} surveyId
* @return {*} {Promise<ObservationSpecies[]>}
* @memberof ObservationRepository
*/
async getObservedSpeciesForSurvey(surveyId: number): Promise<ObservationSpecies[]> {
return this.observationRepository.getObservedSpeciesForSurvey(surveyId);
}

/**
* Retrieves a single observation records by ID
*
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/chips/ColouredRectangleChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const ColouredRectangleChip = (props: IColouredRectangleChipProps) => {
p: 1,
textTransform: 'uppercase'
},
userSelect: 'none'
...props.sx
}}
/>
);
Expand Down
Loading

0 comments on commit bb198fa

Please sign in to comment.