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

Filter measurements search by focal and observed species #1338

Merged
merged 18 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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,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 {

Check warning on line 14 in api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts

View check run for this annotation

Codecov / codecov/patch

api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts#L14

Added line #L14 was not covered by tests
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