From e6ca3b2432a469c32a03b20b51ab8f16edd6a557 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 27 Sep 2024 14:28:59 -0700 Subject: [PATCH 01/14] Initial support for bad data (warning) response from deployments endpoints. --- api/.docker/api/Dockerfile | 2 +- api/src/openapi/schemas/deployment.ts | 5 +- api/src/openapi/schemas/warning.ts | 42 +++++++ .../survey/{surveyId}/deployments/index.ts | 72 +++++++++--- .../deployments/{deploymentId}/index.ts | 73 +++++++++--- app/src/contexts/telemetryDataContext.tsx | 9 +- .../surveys/telemetry/TelemetryPage.tsx | 6 +- .../deployments/edit/EditDeploymentPage.tsx | 11 +- .../list/SurveyBadDeploymentListItem.tsx | 111 ++++++++++++++++++ .../telemetry/list/SurveyDeploymentList.tsx | 12 +- .../telemetry/table/TelemetryTable.tsx | 2 +- .../telemetry/SurveySpatialTelemetry.tsx | 11 +- .../telemetry/SurveySpatialTelemetryPopup.tsx | 2 +- .../telemetry/SurveySpatialTelemetryTable.tsx | 2 +- app/src/hooks/api/useSurveyApi.test.ts | 48 ++++---- app/src/hooks/api/useSurveyApi.ts | 16 ++- app/src/interfaces/useBioHubApi.interface.ts | 6 + 17 files changed, 354 insertions(+), 76 deletions(-) create mode 100644 api/src/openapi/schemas/warning.ts create mode 100644 app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx create mode 100644 app/src/interfaces/useBioHubApi.interface.ts diff --git a/api/.docker/api/Dockerfile b/api/.docker/api/Dockerfile index a5052b22ce..e2e49f5900 100644 --- a/api/.docker/api/Dockerfile +++ b/api/.docker/api/Dockerfile @@ -22,7 +22,7 @@ ENV PATH ${HOME}/node_modules/.bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/us COPY . ./ # Update log directory file permissions, prevents permission errors for linux environments -RUN chmod -R a+rw data/logs/* +RUN mkdir -p data/logs && chmod -R a+rw data/logs VOLUME ${HOME} diff --git a/api/src/openapi/schemas/deployment.ts b/api/src/openapi/schemas/deployment.ts index a742ebbc3f..3af2254591 100644 --- a/api/src/openapi/schemas/deployment.ts +++ b/api/src/openapi/schemas/deployment.ts @@ -2,6 +2,7 @@ import { OpenAPIV3 } from 'openapi-types'; import { GeoJSONFeatureCollection } from './geoJson'; export const getDeploymentSchema: OpenAPIV3.SchemaObject = { + title: 'Deployment', type: 'object', // TODO: REMOVE unnecessary columns from BCTW response additionalProperties: false, @@ -9,19 +10,19 @@ export const getDeploymentSchema: OpenAPIV3.SchemaObject = { // BCTW properties 'assignment_id', 'collar_id', - 'critter_id', - 'device_id', 'attachment_start_date', 'attachment_start_time', 'attachment_end_date', 'attachment_end_time', 'bctw_deployment_id', + 'device_id', 'device_make', 'device_model', 'frequency', 'frequency_unit', // SIMS properties 'deployment_id', + 'critter_id', 'critterbase_critter_id', 'critterbase_start_capture_id', 'critterbase_end_capture_id', diff --git a/api/src/openapi/schemas/warning.ts b/api/src/openapi/schemas/warning.ts new file mode 100644 index 0000000000..1746f13c0e --- /dev/null +++ b/api/src/openapi/schemas/warning.ts @@ -0,0 +1,42 @@ +import { OpenAPIV3 } from 'openapi-types'; + +export const warningSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'General warning object to inform the user of potential data issues.', + required: ['name', 'message', 'data'], + additionalProperties: false, + properties: { + name: { + type: 'string' + }, + message: { + type: 'string' + }, + data: { + type: 'object', + properties: { + // Allow any properties + } + }, + errors: { + type: 'array', + items: { + anyOf: [ + { + type: 'string' + }, + { + type: 'object' + } + ] + } + } + } +}; + +export type WarningSchema = { + name: string; + message: string; + data?: Record; + errors?: (string | Record)[]; +}; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts index 0ec5eb68e1..8201fd7c60 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts @@ -3,8 +3,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; -import { HTTP409 } from '../../../../../../errors/http-error'; import { getDeploymentSchema } from '../../../../../../openapi/schemas/deployment'; +import { WarningSchema, warningSchema } from '../../../../../../openapi/schemas/warning'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { BctwDeploymentService } from '../../../../../../services/bctw-service/bctw-deployment-service'; import { ICritterbaseUser } from '../../../../../../services/critterbase-service'; @@ -60,9 +60,18 @@ GET.apiDoc = { content: { 'application/json': { schema: { - title: 'Deployments', - type: 'array', - items: getDeploymentSchema + type: 'object', + properties: { + deployments: { + title: 'Deployments', + type: 'array', + items: getDeploymentSchema + }, + bad_deployments: { + type: 'array', + items: warningSchema + } + } } } } @@ -113,8 +122,8 @@ export function getDeploymentsInSurvey(): RequestHandler { // Return early if there are no deployments if (!deploymentIds.length) { - // TODO: 400 error instead? - return res.status(200).json([]); + // Return an empty array if there are no deployments in the survey + return res.status(200).json({ deployments: [], bad_deployments: [] }); } // Fetch additional deployment details from BCTW service @@ -122,6 +131,9 @@ export function getDeploymentsInSurvey(): RequestHandler { const surveyDeploymentsWithBctwData = []; + // Track deployments that exist in SIMS but have incorrect data in BCTW + const badDeployments: WarningSchema[] = []; + // For each SIMS survey deployment record, find the matching BCTW deployment record. // We expect exactly 1 matching record, otherwise we throw an error. // More than 1 matching active record indicates an error in the BCTW data. @@ -131,19 +143,45 @@ export function getDeploymentsInSurvey(): RequestHandler { ); if (matchingBctwDeployments.length > 1) { - throw new HTTP409('Multiple active deployments found for the same deployment ID', [ - 'This is an issue in the BC Telemetry Warehouse (BCTW) data. There should only be one active deployment record for a given deployment ID.', - `SIMS deployment ID: ${surveyDeployment.deployment_id}`, - `BCTW deployment ID: ${surveyDeployment.bctw_deployment_id}` - ]); + defaultLog.warn({ + label: 'getDeploymentById', + message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + }); + + badDeployments.push({ + name: 'BCTW Data Error', + message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', + data: { + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + } + }); + + // Don't continue processing this deployment + continue; } if (matchingBctwDeployments.length === 0) { - throw new HTTP409('No active deployments found for deployment ID', [ - 'There should be no deployments recorded in SIMS that have no matching deployment record in BCTW.', - `SIMS Deployment ID: ${surveyDeployment.deployment_id}`, - `BCTW Deployment ID: ${surveyDeployment.bctw_deployment_id}` - ]); + defaultLog.warn({ + label: 'getDeploymentById', + message: 'No active deployments found for deployment ID, when one should exist.', + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + }); + + badDeployments.push({ + name: 'BCTW Data Error', + message: 'No active deployments found for deployment ID, when one should exist.', + data: { + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + } + }); + + // Don't continue processing this deployment + continue; } surveyDeploymentsWithBctwData.push({ @@ -178,7 +216,7 @@ export function getDeploymentsInSurvey(): RequestHandler { }); } - return res.status(200).json(surveyDeploymentsWithBctwData); + return res.status(200).json({ deployments: surveyDeploymentsWithBctwData, bad_deployments: badDeployments }); } catch (error) { defaultLog.error({ label: 'getDeploymentsInSurvey', message: 'error', error }); await connection.rollback(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts index 52de5e3260..afb9c8eadc 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts @@ -4,8 +4,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; -import { HTTP409 } from '../../../../../../../errors/http-error'; import { getDeploymentSchema } from '../../../../../../../openapi/schemas/deployment'; +import { warningSchema } from '../../../../../../../openapi/schemas/warning'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { BctwDeploymentService } from '../../../../../../../services/bctw-service/bctw-deployment-service'; import { BctwDeviceService } from '../../../../../../../services/bctw-service/bctw-device-service'; @@ -87,7 +87,19 @@ GET.apiDoc = { content: { 'application/json': { schema: { - oneOf: [getDeploymentSchema, { type: 'null' }] + type: 'object', + required: ['deployment', 'bad_deployment'], + additionalProperties: false, + properties: { + deployment: { + ...getDeploymentSchema, + nullable: true + }, + bad_deployment: { + ...warningSchema, + nullable: true + } + } } } } @@ -135,8 +147,13 @@ export function getDeploymentById(): RequestHandler { // Return early if there are no deployments if (!surveyDeployment) { - // TODO: 400 error instead? - return res.status(200).send(); + // Return 400 if the provided deployment ID does not exist + return res.status(400).send({ + name: 'Deployment ID Invalid', + status: 400, + message: 'Deployment ID does not exist.', + errors: [{ sims_deployment_id: deploymentId }] + }); } // Fetch additional deployment details from BCTW service @@ -150,19 +167,45 @@ export function getDeploymentById(): RequestHandler { ); if (matchingBctwDeployments.length > 1) { - throw new HTTP409('Multiple active deployments found for the same deployment ID', [ - 'This is an issue in the BC Telemetry Warehouse (BCTW) data. There should only be one active deployment record for a given deployment ID.', - `SIMS deployment ID: ${surveyDeployment.deployment_id}`, - `BCTW deployment ID: ${surveyDeployment.bctw_deployment_id}` - ]); + defaultLog.warn({ + label: 'getDeploymentById', + message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + }); + + const badDeployment = { + name: 'BCTW Data Error', + message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', + data: { + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + } + }; + + // Don't continue processing this deployment + return res.status(200).json({ deployment: null, bad_deployment: badDeployment }); } if (matchingBctwDeployments.length === 0) { - throw new HTTP409('No active deployments found for deployment ID', [ - 'There should be no deployments recorded in SIMS that have no matching deployment record in BCTW.', - `SIMS Deployment ID: ${surveyDeployment.deployment_id}`, - `BCTW Deployment ID: ${surveyDeployment.bctw_deployment_id}` - ]); + defaultLog.warn({ + label: 'getDeploymentById', + message: 'No active deployments found for deployment ID, when one should exist.', + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + }); + + const badDeployment = { + name: 'BCTW Data Error', + message: 'No active deployments found for deployment ID, when one should exist.', + data: { + sims_deployment_id: surveyDeployment.deployment_id, + bctw_deployment_id: surveyDeployment.bctw_deployment_id + } + }; + + // Don't continue processing this deployment + return res.status(200).json({ deployment: null, bad_deployment: badDeployment }); } const surveyDeploymentWithBctwData = { @@ -196,7 +239,7 @@ export function getDeploymentById(): RequestHandler { critterbase_end_mortality_id: surveyDeployment.critterbase_end_mortality_id }; - return res.status(200).json(surveyDeploymentWithBctwData); + return res.status(200).json({ deployment: surveyDeploymentWithBctwData, bad_deployment: null }); } catch (error) { defaultLog.error({ label: 'getDeploymentById', message: 'error', error }); await connection.rollback(); diff --git a/app/src/contexts/telemetryDataContext.tsx b/app/src/contexts/telemetryDataContext.tsx index 414811ea52..2ccf15decf 100644 --- a/app/src/contexts/telemetryDataContext.tsx +++ b/app/src/contexts/telemetryDataContext.tsx @@ -1,5 +1,6 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; +import { WarningSchema } from 'interfaces/useBioHubApi.interface'; import { IAllTelemetry, IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; import { createContext, PropsWithChildren, useMemo } from 'react'; @@ -13,10 +14,14 @@ export interface ITelemetryDataContext { /** * The Data Loader used to load deployments. * - * @type {DataLoader<[project_id: number, survey_id: number], IAnimalDeployment[], unknown>} + * @type {DataLoader<[project_id: number, survey_id: number], { deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }, unknown>} * @memberof ITelemetryDataContext */ - deploymentsDataLoader: DataLoader<[project_id: number, survey_id: number], IAnimalDeployment[], unknown>; + deploymentsDataLoader: DataLoader< + [project_id: number, survey_id: number], + { deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }, + unknown + >; /** * The Data Loader used to load telemetry. * diff --git a/app/src/features/surveys/telemetry/TelemetryPage.tsx b/app/src/features/surveys/telemetry/TelemetryPage.tsx index 51e0123188..9d153842f1 100644 --- a/app/src/features/surveys/telemetry/TelemetryPage.tsx +++ b/app/src/features/surveys/telemetry/TelemetryPage.tsx @@ -23,6 +23,9 @@ export const TelemetryPage = () => { return ; } + const deploymentIds = + deploymentsDataLoader.data?.deployments.map((deployment) => deployment.bctw_deployment_id) ?? []; + return ( { {/* Telemetry Component */} - deployment.bctw_deployment_id) ?? []}> + diff --git a/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx index aceb5e4c7f..c29a5080cb 100644 --- a/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx +++ b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx @@ -43,16 +43,23 @@ export const EditDeploymentPage = () => { const critters = surveyContext.critterDataLoader.data ?? []; const deploymentDataLoader = useDataLoader(biohubApi.survey.getDeploymentById); - const deployment = deploymentDataLoader.data; useEffect(() => { deploymentDataLoader.load(surveyContext.projectId, surveyContext.surveyId, deploymentId); }, [deploymentDataLoader, deploymentId, surveyContext.projectId, surveyContext.surveyId]); - if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data || !deployment) { + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data || !deploymentDataLoader.data) { return ; } + const badDeployment = deploymentDataLoader.data.bad_deployment; + + if (badDeployment) { + return
; + } + + const deployment = deploymentDataLoader.data.deployment; + const deploymentFormInitialValues = { critter_id: deployment.critter_id, device_id: String(deployment.device_id), diff --git a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx new file mode 100644 index 0000000000..4b177bfc42 --- /dev/null +++ b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx @@ -0,0 +1,111 @@ +import { mdiChevronDown } from '@mdi/js'; +import Icon from '@mdi/react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import grey from '@mui/material/colors/grey'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { WarningSchema } from 'interfaces/useBioHubApi.interface'; + +export interface ISurveyBadDeploymentListItemProps { + data: WarningSchema; +} + +/** + * Renders a list item for a single bad deployment record. + * + * @param {ISurveyBadDeploymentListItemProps {} props + * @return {*} + */ +export const SurveyBadDeploymentListItem = (props: ISurveyBadDeploymentListItemProps) => { + const { data } = props; + + return ( + + + } + aria-controls="panel1bh-content" + sx={{ + flex: '1 1 auto', + py: 0, + pr: 7, + pl: 0, + height: 75, + overflow: 'hidden', + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + py: 0, + pl: 0, + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + + + + + + {data.name} + + + + + + + + + + + + SIMS Deployment ID: {data.data.sims_deployment_id as string} + + + BCTW Deployment ID: {data.data.bctw_deployment_id as string} + + + + + + + ); +}; diff --git a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx index 68204fc8c5..d6e31b7957 100644 --- a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx @@ -17,6 +17,7 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { LoadingGuard } from 'components/loading/LoadingGuard'; import { SkeletonList } from 'components/loading/SkeletonLoaders'; +import { SurveyBadDeploymentListItem } from 'features/surveys/telemetry/list/SurveyBadDeploymentListItem'; import { SurveyDeploymentListItem } from 'features/surveys/telemetry/list/SurveyDeploymentListItem'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; @@ -45,8 +46,12 @@ export const SurveyDeploymentList = () => { const deviceMakesDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('device_make')); const deploymentsDataLoader = telemetryDataContext.deploymentsDataLoader; - const deployments = deploymentsDataLoader.data ?? []; + + const deployments = deploymentsDataLoader.data?.deployments ?? []; + const badDeployments = deploymentsDataLoader.data?.bad_deployments ?? []; + const deploymentCount = deployments?.length ?? 0; + const badDeploymentCount = badDeployments?.length ?? 0; useEffect(() => { frequencyUnitDataLoader.load(); @@ -228,7 +233,7 @@ export const SurveyDeploymentList = () => { isLoading={deploymentsDataLoader.isLoading} isLoadingFallback={} isLoadingFallbackDelay={100} - hasNoData={!deploymentCount} + hasNoData={!deploymentCount && !badDeploymentCount} hasNoDataFallback={ { sx={{ background: grey[100] }}> + {badDeployments.map((badDeployment) => { + return ; + })} {deployments.map((deployment) => { const animal = surveyContext.critterDataLoader.data?.find( (animal) => animal.critterbase_critter_id === deployment.critterbase_critter_id diff --git a/app/src/features/surveys/telemetry/table/TelemetryTable.tsx b/app/src/features/surveys/telemetry/table/TelemetryTable.tsx index b01def63f0..1f1964690d 100644 --- a/app/src/features/surveys/telemetry/table/TelemetryTable.tsx +++ b/app/src/features/surveys/telemetry/table/TelemetryTable.tsx @@ -48,7 +48,7 @@ export const TelemetryTable = (props: IManualTelemetryTableProps) => { const critterDeployments: IAnimalDeploymentWithCritter[] = useMemo(() => { const critterDeployments: IAnimalDeploymentWithCritter[] = []; const critters = critterDataLoader.data ?? []; - const deployments = deploymentDataLoader.data ?? []; + const deployments = deploymentDataLoader.data?.deployments ?? []; if (!critters.length || !deployments.length) { return []; diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx index 2528ec0835..25011e2585 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx @@ -31,19 +31,22 @@ export const SurveySpatialTelemetry = () => { // Load telemetry data for all deployments useEffect(() => { - if (!deploymentDataLoader.data?.length) { + if (!deploymentDataLoader.data?.deployments.length) { // No deployments data, therefore no telemetry data to load return; } - telemetryDataLoader.load(deploymentDataLoader.data?.map((deployment) => deployment.bctw_deployment_id) ?? []); + telemetryDataLoader.load( + deploymentDataLoader.data?.deployments.map((deployment) => deployment.bctw_deployment_id) ?? [] + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [deploymentDataLoader.data]); const isLoading = deploymentDataLoader.isLoading || !deploymentDataLoader.isReady || - ((telemetryDataLoader.isLoading || !telemetryDataLoader.isReady) && !!deploymentDataLoader.data?.length); + ((telemetryDataLoader.isLoading || !telemetryDataLoader.isReady) && + !!deploymentDataLoader.data?.deployments.length); /** * Combines telemetry, deployment, and critter data into a single list of telemetry points. @@ -106,7 +109,7 @@ export const SurveySpatialTelemetry = () => { const telemetryPoints: IStaticLayerFeature[] = useMemo(() => { const telemetry = telemetryDataLoader.data ?? []; - const deployments = deploymentDataLoader.data ?? []; + const deployments = deploymentDataLoader.data?.deployments ?? []; const critters = surveyContext.critterDataLoader.data ?? []; return combineTelemetryData(telemetry, deployments, critters); diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup.tsx index 609dcaff08..b90911ca3d 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup.tsx @@ -36,7 +36,7 @@ export const SurveySpatialTelemetryPopup = (props: ISurveySpatialTelemetryPopupP return [{ label: 'Telemetry ID', value: telemetryId }]; } - const deploymentRecord = deploymentDataLoader.data?.find( + const deploymentRecord = deploymentDataLoader.data?.deployments.find( (deployment) => deployment.bctw_deployment_id === telemetryRecord.deployment_id ); diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx index 51275c9c88..c36cad35b5 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx @@ -70,7 +70,7 @@ export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProp const critterDeployments: IAnimalDeploymentWithCritter[] = useMemo(() => { const critterDeployments: IAnimalDeploymentWithCritter[] = []; const critters = critterDataLoader.data ?? []; - const deployments = deploymentDataLoader.data ?? []; + const deployments = deploymentDataLoader.data?.deployments ?? []; if (!critters.length || !deployments.length) { return []; diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index 6684597a85..f6cd02de27 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -8,7 +8,6 @@ import { IFindSurveysResponse, SurveyBasicFieldsObject } from 'interfaces/useSurveyApi.interface'; -import { IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; import { ApiPaginationResponseParams } from 'types/misc'; import { v4 } from 'uuid'; import useSurveyApi from './useSurveyApi'; @@ -117,25 +116,30 @@ describe('useSurveyApi', () => { describe('getDeploymentsInSurvey', () => { it('should get one deployment', async () => { - const response: IAnimalDeployment = { - assignment_id: v4(), - collar_id: v4(), - critterbase_critter_id: v4(), - critter_id: 123, - critterbase_start_capture_id: '', - critterbase_end_capture_id: '', - critterbase_end_mortality_id: '', - attachment_start_date: '', - attachment_start_time: '', - attachment_end_date: '', - attachment_end_time: '', - deployment_id: 123, - bctw_deployment_id: v4(), - device_id: 123, - device_make: 22, - device_model: 'a', - frequency: 1, - frequency_unit: 33 + const response = { + deployments: [ + { + assignment_id: v4(), + collar_id: v4(), + critterbase_critter_id: v4(), + critter_id: 123, + critterbase_start_capture_id: '', + critterbase_end_capture_id: '', + critterbase_end_mortality_id: '', + attachment_start_date: '', + attachment_start_time: '', + attachment_end_date: '', + attachment_end_time: '', + deployment_id: 123, + bctw_deployment_id: v4(), + device_id: 123, + device_make: 22, + device_model: 'a', + frequency: 1, + frequency_unit: 33 + } + ], + bad_deployments: [] }; mock.onGet(`/api/project/${projectId}/survey/${surveyId}/deployments`).reply(200, [response]); @@ -143,8 +147,8 @@ describe('useSurveyApi', () => { const result = await useSurveyApi(axios).getDeploymentsInSurvey(projectId, surveyId); expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(1); - expect(result[0].device_id).toBe(123); + expect(result.deployments.length).toBe(1); + expect(result.deployments[0].device_id).toBe(123); }); }); diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 18c73924e5..f27b00d550 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -5,6 +5,7 @@ import { ISurveyCritter } from 'contexts/animalPageContext'; import { ISurveyAdvancedFilters } from 'features/summary/list-data/survey/SurveysListFilterForm'; import { ICreateCritter } from 'features/surveys/view/survey-animals/animal'; import { SurveyExportConfig } from 'features/surveys/view/survey-export/SurveyExportForm'; +import { WarningSchema } from 'interfaces/useBioHubApi.interface'; import { ICritterDetailedResponse, ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { IGetReportDetails, IUploadAttachmentResponse } from 'interfaces/useProjectApi.interface'; import { @@ -483,9 +484,12 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise} + * @return {*} {Promise<{ deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }>} */ - const getDeploymentsInSurvey = async (projectId: number, surveyId: number): Promise => { + const getDeploymentsInSurvey = async ( + projectId: number, + surveyId: number + ): Promise<{ deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }> => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments`); return data; }; @@ -496,13 +500,17 @@ const useSurveyApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {number} deploymentId - * @return {*} {Promise} + * @return {*} {(Promise< + * { deployment: IAnimalDeployment; bad_deployment: null } | { deployment: null; bad_deployment: WarningSchema } + * >)} */ const getDeploymentById = async ( projectId: number, surveyId: number, deploymentId: number - ): Promise => { + ): Promise< + { deployment: IAnimalDeployment; bad_deployment: null } | { deployment: null; bad_deployment: WarningSchema } + > => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments/${deploymentId}`); return data; }; diff --git a/app/src/interfaces/useBioHubApi.interface.ts b/app/src/interfaces/useBioHubApi.interface.ts new file mode 100644 index 0000000000..7d059ecaab --- /dev/null +++ b/app/src/interfaces/useBioHubApi.interface.ts @@ -0,0 +1,6 @@ +export type WarningSchema = { + name: string; + message: string; + data: Record; + errors?: (string | Record)[]; +}; From aa78f2fa2f087edd634f72d7c8bbe5aeff839a0a Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 27 Sep 2024 16:05:19 -0700 Subject: [PATCH 02/14] Update deployment endpoint tests --- .../{surveyId}/deployments/index.test.ts | 118 ++++---- .../deployments/{deploymentId}/index.test.ts | 267 +++++++++++++++++- .../deployments/{deploymentId}/index.ts | 12 +- 3 files changed, 338 insertions(+), 59 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.test.ts index 0dfcbf2800..a6ca4e7545 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.test.ts @@ -2,7 +2,6 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { getDeploymentsInSurvey } from '.'; import * as db from '../../../../../../database/db'; -import { HTTPError } from '../../../../../../errors/http-error'; import { SurveyDeployment } from '../../../../../../models/survey-deployment'; import { BctwDeploymentRecordWithDeviceMeta, @@ -74,30 +73,33 @@ describe('getDeploymentsInSurvey', () => { expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); expect(getDeploymentsByIdsStub).calledOnceWith(['444']); - expect(mockRes.json).to.have.been.calledOnceWith([ - { - // BCTW properties - assignment_id: mockBCTWDeployments[0].assignment_id, - collar_id: mockBCTWDeployments[0].collar_id, - attachment_start_date: '2020-01-01', - attachment_start_time: '00:00:00', - attachment_end_date: '2020-01-02', - attachment_end_time: '12:12:12', - bctw_deployment_id: mockBCTWDeployments[0].deployment_id, - device_id: mockBCTWDeployments[0].device_id, - device_make: mockBCTWDeployments[0].device_make, - device_model: mockBCTWDeployments[0].device_model, - frequency: mockBCTWDeployments[0].frequency, - frequency_unit: mockBCTWDeployments[0].frequency_unit, - // SIMS properties - deployment_id: mockSIMSDeployments[0].deployment_id, - critter_id: mockSIMSDeployments[0].critter_id, - critterbase_critter_id: mockSIMSDeployments[0].critterbase_critter_id, - critterbase_start_capture_id: mockSIMSDeployments[0].critterbase_start_capture_id, - critterbase_end_capture_id: mockSIMSDeployments[0].critterbase_end_capture_id, - critterbase_end_mortality_id: mockSIMSDeployments[0].critterbase_end_mortality_id - } - ]); + expect(mockRes.json).to.have.been.calledOnceWith({ + deployments: [ + { + // BCTW properties + assignment_id: mockBCTWDeployments[0].assignment_id, + collar_id: mockBCTWDeployments[0].collar_id, + attachment_start_date: '2020-01-01', + attachment_start_time: '00:00:00', + attachment_end_date: '2020-01-02', + attachment_end_time: '12:12:12', + bctw_deployment_id: mockBCTWDeployments[0].deployment_id, + device_id: mockBCTWDeployments[0].device_id, + device_make: mockBCTWDeployments[0].device_make, + device_model: mockBCTWDeployments[0].device_model, + frequency: mockBCTWDeployments[0].frequency, + frequency_unit: mockBCTWDeployments[0].frequency_unit, + // SIMS properties + deployment_id: mockSIMSDeployments[0].deployment_id, + critter_id: mockSIMSDeployments[0].critter_id, + critterbase_critter_id: mockSIMSDeployments[0].critterbase_critter_id, + critterbase_start_capture_id: mockSIMSDeployments[0].critterbase_start_capture_id, + critterbase_end_capture_id: mockSIMSDeployments[0].critterbase_end_capture_id, + critterbase_end_mortality_id: mockSIMSDeployments[0].critterbase_end_mortality_id + } + ], + bad_deployments: [] + }); expect(mockRes.status).calledOnceWith(200); expect(mockDBConnection.release).to.have.been.calledOnce; }); @@ -150,12 +152,12 @@ describe('getDeploymentsInSurvey', () => { expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); expect(getDeploymentsByIdsStub).not.to.have.been.called; - expect(mockRes.json).calledOnceWith([]); + expect(mockRes.json).calledOnceWith({ deployments: [], bad_deployments: [] }); expect(mockRes.status).calledOnceWith(200); expect(mockDBConnection.release).to.have.been.calledOnce; }); - it('throws a 409 error if more than 1 active deployment found in BCTW for a single SIMS deployment record', async () => { + it('returns bad deployment records if more than 1 active deployment found in BCTW for a single SIMS deployment record', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); @@ -228,21 +230,28 @@ describe('getDeploymentsInSurvey', () => { const requestHandler = getDeploymentsInSurvey(); - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).message).to.equal( - 'Multiple active deployments found for the same deployment ID' - ); - expect((actualError as HTTPError).status).to.equal(409); - expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); - expect(getDeploymentsByIdsStub).calledOnceWith(['444']); - expect(mockDBConnection.release).to.have.been.calledOnce; - } + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockRes.json).calledOnceWith({ + deployments: [], + bad_deployments: [ + { + name: 'BCTW Data Error', + message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', + data: { + sims_deployment_id: 3, + bctw_deployment_id: '444' + } + } + ] + }); + expect(mockRes.status).calledOnceWith(200); + expect(mockDBConnection.release).to.have.been.calledOnce; }); - it('throws a 409 error if no active deployment found in BCTW for a single SIMS deployment record', async () => { + it('returns bad deployment records if no active deployment found in BCTW for a single SIMS deployment record', async () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); @@ -296,16 +305,25 @@ describe('getDeploymentsInSurvey', () => { const requestHandler = getDeploymentsInSurvey(); - try { - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).message).to.equal('No active deployments found for deployment ID'); - expect((actualError as HTTPError).status).to.equal(409); - expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); - expect(getDeploymentsByIdsStub).calledOnceWith(['444']); - expect(mockDBConnection.release).to.have.been.calledOnce; - } + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDeploymentsForSurveyIdStub).calledOnceWith(66); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockRes.json).calledOnceWith({ + deployments: [], + bad_deployments: [ + { + name: 'BCTW Data Error', + message: 'No active deployments found for deployment ID, when one should exist.', + data: { + sims_deployment_id: 3, + bctw_deployment_id: '444' + } + } + ] + }); + expect(mockRes.status).calledOnceWith(200); + expect(mockDBConnection.release).to.have.been.calledOnce; }); it('catches and re-throws errors', async () => { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts index e64d5af2b2..56304587a0 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.test.ts @@ -2,7 +2,11 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { deleteDeployment, getDeploymentById, updateDeployment } from '.'; import * as db from '../../../../../../../database/db'; -import { BctwDeploymentService } from '../../../../../../../services/bctw-service/bctw-deployment-service'; +import { HTTPError } from '../../../../../../../errors/http-error'; +import { + BctwDeploymentRecordWithDeviceMeta, + BctwDeploymentService +} from '../../../../../../../services/bctw-service/bctw-deployment-service'; import { BctwDeviceService } from '../../../../../../../services/bctw-service/bctw-device-service'; import { CritterbaseService, ICapture } from '../../../../../../../services/critterbase-service'; import { DeploymentService } from '../../../../../../../services/deployment-service'; @@ -65,6 +69,267 @@ describe('getDeploymentById', () => { expect(mockBctwService).to.have.been.calledOnce; expect(mockRes.status).to.have.been.calledWith(200); }); + + it('throws 400 error if no SIMS deployment record matches provided deployment ID', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]; + + const getDeploymentByIdStub = sinon.stub(DeploymentService.prototype, 'getDeploymentById').resolves(); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .resolves(mockBCTWDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66', + deploymentId: '77' + }; + + const requestHandler = getDeploymentById(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('Deployment ID does not exist.'); + expect((actualError as HTTPError).status).to.equal(400); + + expect(getDeploymentByIdStub).calledOnceWith(77); + expect(getDeploymentsByIdsStub).not.to.have.been.called; + expect(mockDBConnection.release).to.have.been.calledOnce; + } + }); + + it('returns bad deployment record if more than 1 active deployment found in BCTW for the SIMS deployment record', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployment = { + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + }; + + const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + }, + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]; + + const getDeploymentByIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentById') + .resolves(mockSIMSDeployment); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .resolves(mockBCTWDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66', + deploymentId: '77' + }; + + const requestHandler = getDeploymentById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDeploymentByIdStub).calledOnceWith(77); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockRes.json).calledOnceWith({ + deployment: null, + bad_deployment: { + name: 'BCTW Data Error', + message: 'Multiple active deployments found for the same deployment ID, when only one should exist.', + data: { + sims_deployment_id: 3, + bctw_deployment_id: '444' + } + } + }); + expect(mockRes.status).calledOnceWith(200); + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('returns bad deployment record if no active deployment found in BCTW for the SIMS deployment record', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployment = { + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + }; + + const mockBCTWDeployments: BctwDeploymentRecordWithDeviceMeta[] = [ + { + critter_id: '333', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: '444_no_match', // different deployment ID + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: null, + device_make: 17, + device_model: 'model', + frequency: 1, + frequency_unit: 2 + } + ]; + + const getDeploymentByIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentById') + .resolves(mockSIMSDeployment); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .resolves(mockBCTWDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66', + deploymentId: '77' + }; + + const requestHandler = getDeploymentById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDeploymentByIdStub).calledOnceWith(77); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockRes.json).calledOnceWith({ + deployment: null, + bad_deployment: { + name: 'BCTW Data Error', + message: 'No active deployments found for deployment ID, when one should exist.', + data: { + sims_deployment_id: 3, + bctw_deployment_id: '444' + } + } + }); + expect(mockRes.status).calledOnceWith(200); + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws errors', async () => { + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const mockSIMSDeployment = { + deployment_id: 3, + critter_id: 2, + critterbase_critter_id: '333', + bctw_deployment_id: '444', + critterbase_start_capture_id: '555', + critterbase_end_capture_id: null, + critterbase_end_mortality_id: null + }; + + const mockError = new Error('Test error'); + + const getDeploymentByIdStub = sinon + .stub(DeploymentService.prototype, 'getDeploymentById') + .resolves(mockSIMSDeployment); + const getDeploymentsByIdsStub = sinon + .stub(BctwDeploymentService.prototype, 'getDeploymentsByIds') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '55', + surveyId: '66', + deploymentId: '77' + }; + + const requestHandler = getDeploymentById(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(getDeploymentByIdStub).calledOnceWith(77); + expect(getDeploymentsByIdsStub).calledOnceWith(['444']); + expect(mockDBConnection.release).to.have.been.calledOnce; + } + }); }); describe('updateDeployment', () => { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts index afb9c8eadc..df1f4897f7 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts @@ -4,6 +4,7 @@ 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 { getDeploymentSchema } from '../../../../../../../openapi/schemas/deployment'; import { warningSchema } from '../../../../../../../openapi/schemas/warning'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; @@ -54,7 +55,7 @@ GET.apiDoc = { parameters: [ { in: 'path', - name: 'surveyId', + name: 'projectId', schema: { type: 'integer', minimum: 1 @@ -63,7 +64,7 @@ GET.apiDoc = { }, { in: 'path', - name: 'deploymentId', + name: 'surveyId', schema: { type: 'integer', minimum: 1 @@ -148,12 +149,7 @@ export function getDeploymentById(): RequestHandler { // Return early if there are no deployments if (!surveyDeployment) { // Return 400 if the provided deployment ID does not exist - return res.status(400).send({ - name: 'Deployment ID Invalid', - status: 400, - message: 'Deployment ID does not exist.', - errors: [{ sims_deployment_id: deploymentId }] - }); + throw new HTTP400('Deployment ID does not exist.', [{ sims_deployment_id: deploymentId }]); } // Fetch additional deployment details from BCTW service From 415039b5e8b41d9c714f90d4cd0e485da38d0901 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young Date: Fri, 27 Sep 2024 16:16:38 -0700 Subject: [PATCH 03/14] bad deployment item styling --- .../list/SurveyBadDeploymentListItem.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx index 4b177bfc42..f30d9d39d6 100644 --- a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx @@ -71,15 +71,19 @@ export const SurveyBadDeploymentListItem = (props: ISurveyBadDeploymentListItemP component="div" title="Device ID" variant="body2" + color="textSecondary" sx={{ flex: '1 1 auto', fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis' }}> - {data.name} + Unknown Deployment + + Something went wrong... +
@@ -95,14 +99,10 @@ export const SurveyBadDeploymentListItem = (props: ISurveyBadDeploymentListItemP } }}> - - - SIMS Deployment ID: {data.data.sims_deployment_id as string} - - - BCTW Deployment ID: {data.data.bctw_deployment_id as string} - - + + Deployment {data.data.bctw_deployment_id as string} does not exist. You can remove this + deployment from your Survey. + From c012a1eb4e0fed673596eee7cb656ea1705809d9 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young Date: Fri, 27 Sep 2024 16:24:22 -0700 Subject: [PATCH 04/14] add delete icon & styling for bad deployment list item --- .../telemetry/list/SurveyBadDeploymentListItem.tsx | 13 +++++++++++-- .../telemetry/list/SurveyDeploymentList.tsx | 14 +++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx index f30d9d39d6..f53468bbc3 100644 --- a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx @@ -1,4 +1,4 @@ -import { mdiChevronDown } from '@mdi/js'; +import { mdiChevronDown, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import Accordion from '@mui/material/Accordion'; import AccordionDetails from '@mui/material/AccordionDetails'; @@ -6,6 +6,7 @@ import AccordionSummary from '@mui/material/AccordionSummary'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; import List from '@mui/material/List'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; @@ -13,6 +14,7 @@ import { WarningSchema } from 'interfaces/useBioHubApi.interface'; export interface ISurveyBadDeploymentListItemProps { data: WarningSchema; + handleDelete: (deploymentId: number) => void; } /** @@ -22,7 +24,7 @@ export interface ISurveyBadDeploymentListItemProps { * @return {*} */ export const SurveyBadDeploymentListItem = (props: ISurveyBadDeploymentListItemProps) => { - const { data } = props; + const { data, handleDelete } = props; return ( + handleDelete(data.data.deployment_id as number)} + aria-label="deployment-settings"> + + { }); }; + /** + * Callback for deleting a bad deployment + */ + const handleDeleteBadDeployment = async (deploymentId: number) => { + await biohubApi.survey.deleteDeployment(surveyContext.projectId, surveyContext.surveyId, Number(deploymentId)); + }; + /** * Callback for when the delete deployment action is confirmed. */ @@ -298,7 +305,12 @@ export const SurveyDeploymentList = () => { background: grey[100] }}> {badDeployments.map((badDeployment) => { - return ; + return ( + handleDeleteBadDeployment(deploymentId)} + /> + ); })} {deployments.map((deployment) => { const animal = surveyContext.critterDataLoader.data?.find( From e64294a492064dd229bf331f676478e30f4ff5c6 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young Date: Fri, 27 Sep 2024 16:32:58 -0700 Subject: [PATCH 05/14] remove red error text on bad deployment warning --- .../surveys/telemetry/list/SurveyBadDeploymentListItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx index f53468bbc3..b877283022 100644 --- a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx @@ -108,9 +108,9 @@ export const SurveyBadDeploymentListItem = (props: ISurveyBadDeploymentListItemP } }}> - + Deployment {data.data.bctw_deployment_id as string} does not exist. You can remove this - deployment from your Survey. + deployment from the Survey. From 70b710b639a2bc4d147ca7708e7e2b89784e59bd Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Sun, 29 Sep 2024 18:00:56 -0700 Subject: [PATCH 06/14] Update deployment list controls and initial bulk control --- app/src/contexts/telemetryDataContext.tsx | 7 +- .../list/SurveyBadDeploymentListItem.tsx | 21 ++- .../telemetry/list/SurveyDeploymentList.tsx | 138 +++++++++++++++--- app/src/hooks/api/useSurveyApi.ts | 16 +- app/src/interfaces/useBioHubApi.interface.ts | 4 +- database/.docker/db/Dockerfile | 2 +- env_config/env.docker | 2 +- 7 files changed, 153 insertions(+), 37 deletions(-) diff --git a/app/src/contexts/telemetryDataContext.tsx b/app/src/contexts/telemetryDataContext.tsx index 2ccf15decf..86a0f70635 100644 --- a/app/src/contexts/telemetryDataContext.tsx +++ b/app/src/contexts/telemetryDataContext.tsx @@ -14,12 +14,15 @@ export interface ITelemetryDataContext { /** * The Data Loader used to load deployments. * - * @type {DataLoader<[project_id: number, survey_id: number], { deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }, unknown>} + * @type {DataLoader<[project_id: number, survey_id: number], { deployments: IAnimalDeployment[]; bad_deployments: WarningSchema<{ sims_deployment_id: number; bctw_deployment_id: string }>[] }, unknown>} * @memberof ITelemetryDataContext */ deploymentsDataLoader: DataLoader< [project_id: number, survey_id: number], - { deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }, + { + deployments: IAnimalDeployment[]; + bad_deployments: WarningSchema<{ sims_deployment_id: number; bctw_deployment_id: string }>[]; + }, unknown >; /** diff --git a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx index b877283022..6fd7adc17d 100644 --- a/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyBadDeploymentListItem.tsx @@ -13,8 +13,10 @@ import Typography from '@mui/material/Typography'; import { WarningSchema } from 'interfaces/useBioHubApi.interface'; export interface ISurveyBadDeploymentListItemProps { - data: WarningSchema; + data: WarningSchema<{ sims_deployment_id: number; bctw_deployment_id: string }>; + isChecked: boolean; handleDelete: (deploymentId: number) => void; + handleCheckboxChange: (deploymentId: number) => void; } /** @@ -24,7 +26,7 @@ export interface ISurveyBadDeploymentListItemProps { * @return {*} */ export const SurveyBadDeploymentListItem = (props: ISurveyBadDeploymentListItemProps) => { - const { data, handleDelete } = props; + const { data, isChecked, handleDelete, handleCheckboxChange } = props; return ( - + { + event.stopPropagation(); + handleCheckboxChange(data.data.sims_deployment_id); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> handleDelete(data.data.deployment_id as number)} + onClick={() => handleDelete(data.data.sims_deployment_id as number)} aria-label="deployment-settings"> @@ -108,7 +119,7 @@ export const SurveyBadDeploymentListItem = (props: ISurveyBadDeploymentListItemP } }}> - + Deployment {data.data.bctw_deployment_id as string} does not exist. You can remove this deployment from the Survey. diff --git a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx index abffe7afe0..f679915b37 100644 --- a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx @@ -37,7 +37,8 @@ export const SurveyDeploymentList = () => { const biohubApi = useBiohubApi(); - const [anchorEl, setAnchorEl] = useState(null); + const [bulkDeploymentAnchorEl, setBulkDeploymentAnchorEl] = useState(null); + const [deploymentAnchorEl, setDeploymentAnchorEl] = useState(null); const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); const [selectedDeploymentId, setSelectedDeploymentId] = useState(); @@ -50,8 +51,7 @@ export const SurveyDeploymentList = () => { const deployments = deploymentsDataLoader.data?.deployments ?? []; const badDeployments = deploymentsDataLoader.data?.bad_deployments ?? []; - const deploymentCount = deployments?.length ?? 0; - const badDeploymentCount = badDeployments?.length ?? 0; + const deploymentCount = (deployments?.length ?? 0) + (badDeployments?.length ?? 0); useEffect(() => { frequencyUnitDataLoader.load(); @@ -65,6 +65,10 @@ export const SurveyDeploymentList = () => { surveyContext.surveyId ]); + const handleBulkActionMenuClick = (event: React.MouseEvent) => { + setBulkDeploymentAnchorEl(event.currentTarget); + }; + /** * Callback for when a deployment action menu is clicked. * @@ -72,8 +76,8 @@ export const SurveyDeploymentList = () => { * @param {number} deploymentId */ const handledDeploymentMenuClick = (event: React.MouseEvent, deploymentId: number) => { - setAnchorEl(event.currentTarget); setSelectedDeploymentId(deploymentId); + setDeploymentAnchorEl(event.currentTarget); }; /** @@ -92,26 +96,49 @@ export const SurveyDeploymentList = () => { }; /** - * Callback for deleting a bad deployment + * Callback for when the bulk delete deployment action is confirmed. */ - const handleDeleteBadDeployment = async (deploymentId: number) => { - await biohubApi.survey.deleteDeployment(surveyContext.projectId, surveyContext.surveyId, Number(deploymentId)); + const handleBulkDeleteDeployment = async () => { + await biohubApi.survey + .deleteDeployment(surveyContext.projectId, surveyContext.surveyId, Number(selectedDeploymentId)) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setBulkDeploymentAnchorEl(null); + deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setBulkDeploymentAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Deployment + + + {String(error)} + + + ), + open: true + }); + }); }; /** * Callback for when the delete deployment action is confirmed. */ - const handleDeleteDeployment = async () => { + const handleDeleteDeployment = async (deploymentId: number) => { await biohubApi.survey - .deleteDeployment(surveyContext.projectId, surveyContext.surveyId, Number(selectedDeploymentId)) + .deleteDeployment(surveyContext.projectId, surveyContext.surveyId, deploymentId) .then(() => { dialogContext.setYesNoDialog({ open: false }); - setAnchorEl(null); + setDeploymentAnchorEl(null); deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); - setAnchorEl(null); + setDeploymentAnchorEl(null); dialogContext.setSnackbar({ snackbarMessage: ( <> @@ -128,10 +155,38 @@ export const SurveyDeploymentList = () => { }); }; + /** + * Display the bulk delete deployments confirmation dialog. + */ + const renderBulkDeleteDeploymentDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Deployments?', + dialogContent: ( + + Are you sure you want to delete these deployments? All telemetry data from these deployment will also be + permanently deleted. + + ), + yesButtonLabel: 'Delete Deployments', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleBulkDeleteDeployment(); + } + }); + }; + /** * Display the delete deployment confirmation dialog. */ - const renderDeleteDeploymentDialog = () => { + const renderDeleteDeploymentDialog = (deploymentId?: number) => { dialogContext.setYesNoDialog({ dialogTitle: 'Delete Deployment?', dialogContent: ( @@ -151,7 +206,13 @@ export const SurveyDeploymentList = () => { }, open: true, onYes: () => { - handleDeleteDeployment(); + const deploymentIdToDelete = deploymentId ?? selectedDeploymentId; + + if (!deploymentIdToDelete) { + return; + } + + handleDeleteDeployment(deploymentIdToDelete); } }); }; @@ -159,11 +220,37 @@ export const SurveyDeploymentList = () => { return ( <> { + setBulkDeploymentAnchorEl(null); + }} + anchorEl={bulkDeploymentAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + { + renderBulkDeleteDeploymentDialog(); + setBulkDeploymentAnchorEl(null); + }}> + + + + Delete + + + + { - setAnchorEl(null); + setDeploymentAnchorEl(null); }} - anchorEl={anchorEl} + anchorEl={deploymentAnchorEl} anchorOrigin={{ vertical: 'top', horizontal: 'right' @@ -175,7 +262,7 @@ export const SurveyDeploymentList = () => { setAnchorEl(null)}> + onClick={() => setDeploymentAnchorEl(null)}> @@ -184,7 +271,7 @@ export const SurveyDeploymentList = () => { { renderDeleteDeploymentDialog(); - setAnchorEl(null); + setDeploymentAnchorEl(null); }}> @@ -228,7 +315,7 @@ export const SurveyDeploymentList = () => { edge="end" aria-label="header-settings" disabled={!checkboxSelectedIds.length} - // onClick={handleHeaderMenuClick} // BULK ACTIONS BUTTON + onClick={handleBulkActionMenuClick} title="Bulk Actions"> @@ -240,7 +327,7 @@ export const SurveyDeploymentList = () => { isLoading={deploymentsDataLoader.isLoading} isLoadingFallback={} isLoadingFallbackDelay={100} - hasNoData={!deploymentCount && !badDeploymentCount} + hasNoData={!deploymentCount} hasNoDataFallback={ { } onClick={() => { if (checkboxSelectedIds.length === deploymentCount) { + // Unselect all setCheckboxSelectedIds([]); return; } + // Select all const deploymentIds = deployments.map((deployment) => deployment.deployment_id); - setCheckboxSelectedIds(deploymentIds); + const badDeploymentIds = badDeployments.map( + (deployment) => deployment.data.sims_deployment_id + ); + setCheckboxSelectedIds([...badDeploymentIds, ...deploymentIds]); }} inputProps={{ 'aria-label': 'controlled' }} /> @@ -308,7 +400,9 @@ export const SurveyDeploymentList = () => { return ( handleDeleteBadDeployment(deploymentId)} + isChecked={checkboxSelectedIds.includes(badDeployment.data.sims_deployment_id)} + handleDelete={(deploymentId) => renderDeleteDeploymentDialog(deploymentId)} + handleCheckboxChange={(deploymentId) => handleCheckboxChange(deploymentId)} /> ); })} diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index f27b00d550..04f032a62f 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -484,12 +484,18 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise<{ deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }>} + * @return {*} {Promise<{ + * deployments: IAnimalDeployment[]; + * bad_deployments: WarningSchema<{ sims_deployment_id: number; bctw_deployment_id: string }>[]; + * }>} */ const getDeploymentsInSurvey = async ( projectId: number, surveyId: number - ): Promise<{ deployments: IAnimalDeployment[]; bad_deployments: WarningSchema[] }> => { + ): Promise<{ + deployments: IAnimalDeployment[]; + bad_deployments: WarningSchema<{ sims_deployment_id: number; bctw_deployment_id: string }>[]; + }> => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments`); return data; }; @@ -501,7 +507,8 @@ const useSurveyApi = (axios: AxiosInstance) => { * @param {number} surveyId * @param {number} deploymentId * @return {*} {(Promise< - * { deployment: IAnimalDeployment; bad_deployment: null } | { deployment: null; bad_deployment: WarningSchema } + * | { deployment: IAnimalDeployment; bad_deployment: null } + * | { deployment: null; bad_deployment: WarningSchema<{ sims_deployment_id: number; bctw_deployment_id: string }> } * >)} */ const getDeploymentById = async ( @@ -509,7 +516,8 @@ const useSurveyApi = (axios: AxiosInstance) => { surveyId: number, deploymentId: number ): Promise< - { deployment: IAnimalDeployment; bad_deployment: null } | { deployment: null; bad_deployment: WarningSchema } + | { deployment: IAnimalDeployment; bad_deployment: null } + | { deployment: null; bad_deployment: WarningSchema<{ sims_deployment_id: number; bctw_deployment_id: string }> } > => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments/${deploymentId}`); return data; diff --git a/app/src/interfaces/useBioHubApi.interface.ts b/app/src/interfaces/useBioHubApi.interface.ts index 7d059ecaab..42cdf92828 100644 --- a/app/src/interfaces/useBioHubApi.interface.ts +++ b/app/src/interfaces/useBioHubApi.interface.ts @@ -1,6 +1,6 @@ -export type WarningSchema = { +export type WarningSchema = Record> = { name: string; message: string; - data: Record; + data: DataType; errors?: (string | Record)[]; }; diff --git a/database/.docker/db/Dockerfile b/database/.docker/db/Dockerfile index effd413ed7..c76168886e 100644 --- a/database/.docker/db/Dockerfile +++ b/database/.docker/db/Dockerfile @@ -2,7 +2,7 @@ # This DockerFile is used for local development (via compose.yml) only. # ######################################################################################################## -ARG POSTGRES_VERSION=12.5 +ARG POSTGRES_VERSION=14.2 FROM postgres:$POSTGRES_VERSION diff --git a/env_config/env.docker b/env_config/env.docker index b78a07268b..5c4bce03cb 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -126,7 +126,7 @@ CB_API_HOST=https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api # # See `biohubbc-creds` secret in openshift # ------------------------------------------------------------------------------ -POSTGRES_VERSION=12.5 +POSTGRES_VERSION=14.2 POSTGIS_VERSION=3 DB_HOST=db DB_ADMIN=postgres From ba68e7bbf2d4e8159f2628464207bf304f69bc2c Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 11:05:12 -0700 Subject: [PATCH 07/14] Add bulk delete deployment endpoint, add tests. --- .../{surveyId}/deployments/delete.test.ts | 72 +++++++++ .../survey/{surveyId}/deployments/delete.ts | 146 ++++++++++++++++++ .../survey/{surveyId}/deployments/index.ts | 12 +- .../telemetry/list/SurveyDeploymentList.tsx | 4 +- app/src/hooks/api/useSurveyApi.ts | 17 ++ 5 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.test.ts new file mode 100644 index 0000000000..7b3da2a723 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.test.ts @@ -0,0 +1,72 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../../database/db'; +import { BctwDeploymentService } from '../../../../../../services/bctw-service/bctw-deployment-service'; +import { DeploymentService } from '../../../../../../services/deployment-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../.././../../__mocks__/db'; +import { deleteDeploymentsInSurvey } from './delete'; + +chai.use(sinonChai); + +describe('deleteDeploymentsInSurvey', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should delete all provided deployment records from sims and bctw', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + mockReq.body = { + deployment_ids: [3, 4] + }; + + const mockDeleteSimsDeploymentResponse = { bctw_deployment_id: '123-456-789' }; + + sinon.stub(DeploymentService.prototype, 'deleteDeployment').resolves(mockDeleteSimsDeploymentResponse); + sinon.stub(BctwDeploymentService.prototype, 'deleteDeployment').resolves(); + + const requestHandler = deleteDeploymentsInSurvey(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.statusValue).to.equal(200); + }); + + it('should catch and re-throw an error', async () => { + const dbConnectionObj = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + mockReq.body = { + deployment_ids: [3, 4] + }; + + const mockDeleteSimsDeploymentResponse = { bctw_deployment_id: '123-456-789' }; + const mockError = new Error('test error'); + + sinon.stub(DeploymentService.prototype, 'deleteDeployment').resolves(mockDeleteSimsDeploymentResponse); + sinon.stub(BctwDeploymentService.prototype, 'deleteDeployment').throws(mockError); + + const requestHandler = deleteDeploymentsInSurvey(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.eql(mockError); + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts new file mode 100644 index 0000000000..b03200296c --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts @@ -0,0 +1,146 @@ +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 { BctwDeploymentService } from '../../../../../../services/bctw-service/bctw-deployment-service'; +import { ICritterbaseUser } from '../../../../../../services/critterbase-service'; +import { DeploymentService } from '../../../../../../services/deployment-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/deployments/delete'); + +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' + } + ] + }; + }), + deleteDeploymentsInSurvey() +]; + +POST.apiDoc = { + description: 'Delete deployments from a survey.', + tags: ['deployment', 'bctw'], + 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 + } + ], + requestBody: { + description: 'Array of one or more deployment IDs to delete.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + deployment_ids: { + type: 'array', + items: { + type: 'integer', + minimum: 1 + }, + minItems: 1 + } + } + } + } + } + }, + responses: { + 200: { + description: 'Delete OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 409: { + $ref: '#/components/responses/409' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Delete deployments from a survey. + * + * @export + * @return {*} {RequestHandler} + */ +export function deleteDeploymentsInSurvey(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const deploymentIds: number[] = req.body.deployment_ids; + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const user: ICritterbaseUser = { + keycloak_guid: connection.systemUserGUID(), + username: connection.systemUserIdentifier() + }; + + const deletePromises = deploymentIds.map(async (deploymentId) => { + const deploymentService = new DeploymentService(connection); + const { bctw_deployment_id } = await deploymentService.deleteDeployment(surveyId, deploymentId); + + const bctwDeploymentService = new BctwDeploymentService(user); + await bctwDeploymentService.deleteDeployment(bctw_deployment_id); + }); + + await Promise.all(deletePromises); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'deleteDeploymentsInSurvey', message: 'error', error }); + await connection.rollback(); + + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts index 8201fd7c60..afccc5b679 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts @@ -45,11 +45,21 @@ GET.apiDoc = { } ], parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, { in: 'path', name: 'surveyId', schema: { - type: 'number' + type: 'integer', + minimum: 1 }, required: true } diff --git a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx index f679915b37..134eadaeb7 100644 --- a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx @@ -100,7 +100,7 @@ export const SurveyDeploymentList = () => { */ const handleBulkDeleteDeployment = async () => { await biohubApi.survey - .deleteDeployment(surveyContext.projectId, surveyContext.surveyId, Number(selectedDeploymentId)) + .deleteDeployments(surveyContext.projectId, surveyContext.surveyId, checkboxSelectedIds) .then(() => { dialogContext.setYesNoDialog({ open: false }); setBulkDeploymentAnchorEl(null); @@ -113,7 +113,7 @@ export const SurveyDeploymentList = () => { snackbarMessage: ( <> - Error Deleting Deployment + Error Deleting Deployments {String(error)} diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 04f032a62f..f7557da36c 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -582,6 +582,22 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; + /** + * Deletes a list of deployments. Will trigger deletion in SIMS and invalidates the deployments in BCTW. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number[]} deploymentIds + * @return {*} {Promise} + */ + const deleteDeployments = async (projectId: number, surveyId: number, deploymentIds: number[]): Promise => { + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/deployments/delete`, { + deployment_ids: deploymentIds + }); + + return data; + }; + /** * Bulk upload Critters from CSV. * @@ -749,6 +765,7 @@ const useSurveyApi = (axios: AxiosInstance) => { importMeasurementsFromCsv, endDeployment, deleteDeployment, + deleteDeployments, exportData }; }; From 76f7b9de342a199a2aecf0b233b9b5f4d83b80f7 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 11:41:47 -0700 Subject: [PATCH 08/14] Handle attempting to edit a bad deployment --- .../surveys/telemetry/deployments/edit/EditDeploymentPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx index c29a5080cb..a3a9d1b2e3 100644 --- a/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx +++ b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx @@ -55,7 +55,7 @@ export const EditDeploymentPage = () => { const badDeployment = deploymentDataLoader.data.bad_deployment; if (badDeployment) { - return
; + return ; } const deployment = deploymentDataLoader.data.deployment; From 8ea5b0e3912e19eb604e34888d45a751ce5ce821 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 12:31:26 -0700 Subject: [PATCH 09/14] Tweak logger settings --- api/.docker/api/Dockerfile | 3 - api/src/utils/logger.test.ts | 38 ++----------- api/src/utils/logger.ts | 105 +++++++++++++++++++++++------------ 3 files changed, 73 insertions(+), 73 deletions(-) diff --git a/api/.docker/api/Dockerfile b/api/.docker/api/Dockerfile index e2e49f5900..2c658d84b8 100644 --- a/api/.docker/api/Dockerfile +++ b/api/.docker/api/Dockerfile @@ -21,9 +21,6 @@ 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 mkdir -p data/logs && chmod -R a+rw data/logs - VOLUME ${HOME} # start api with live reload diff --git a/api/src/utils/logger.test.ts b/api/src/utils/logger.test.ts index e1c83987a6..8934ae9719 100644 --- a/api/src/utils/logger.test.ts +++ b/api/src/utils/logger.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { getLogger, setLogLevel, setLogLevelFile } from './logger'; +import { getLogger, setLogLevel } from './logger'; describe('logger', () => { describe('getLogger', () => { @@ -30,46 +30,16 @@ describe('logger', () => { //const myLogger1 = require('./logger').getLogger('myLoggerA'); const myLogger1 = getLogger('myLoggerA'); - expect(myLogger1.transports[1].level).to.equal('info'); + expect(myLogger1.transports[0].level).to.equal('info'); setLogLevel('debug'); const myLogger2 = getLogger('myLoggerA'); - expect(myLogger2.transports[1].level).to.equal('debug'); + expect(myLogger2.transports[0].level).to.equal('debug'); const myNewLogger3 = getLogger('myNewLoggerA'); - expect(myNewLogger3.transports[1].level).to.equal('debug'); - }); - }); - - describe('setLogLevelFile', () => { - let currentLogLevelFile: string | undefined; - - beforeEach(() => { - currentLogLevelFile = process.env.LOG_LEVEL_FILE; - - // Set initial log level file value - process.env.LOG_LEVEL_FILE = 'warn'; - }); - - afterEach(() => { - // Restore the original log level - process.env.LOG_LEVEL_FILE = currentLogLevelFile; - }); - - it('sets the log level for the file transport', () => { - const myLogger4 = getLogger('myLoggerB'); - expect(myLogger4.transports[0].level).to.equal('warn'); - - setLogLevelFile('error'); - - const myLogger5 = getLogger('myLoggerB'); - expect(myLogger5.transports[0].level).to.equal('error'); - - const myNewLogger6 = getLogger('myNewLoggerB'); - - expect(myNewLogger6.transports[0].level).to.equal('error'); + expect(myNewLogger3.transports[0].level).to.equal('debug'); }); }); }); diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts index 8377cef755..d84219e57e 100644 --- a/api/src/utils/logger.ts +++ b/api/src/utils/logger.ts @@ -1,6 +1,25 @@ import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; +/** + * Get the transport types to use for the logger. + * + * @return {*} {string[]} + */ +const getLoggerTransportTypes = (): string[] => { + const transportTypes = []; + + if (process.env.npm_lifecycle_event !== 'test') { + transportTypes.push('file'); + } + + if (process.env.NODE_ENV !== 'production') { + transportTypes.push('console'); + } + + return transportTypes; +}; + /** * Get or create a logger for the given `logLabel`. * @@ -56,39 +75,43 @@ import DailyRotateFile from 'winston-daily-rotate-file'; * @returns */ export const getLogger = function (logLabel: string) { + const transportTypes = getLoggerTransportTypes(); + const transports = []; - // Output logs to file - transports.push( - new DailyRotateFile({ - dirname: process.env.LOG_FILE_DIR || 'data/logs', - filename: process.env.LOG_FILE_NAME || 'sims-api-%DATE%.log', - datePattern: process.env.LOG_FILE_DATE_PATTERN || 'YYYY-MM-DD-HH', - maxSize: process.env.LOG_FILE_MAX_SIZE || '50m', - maxFiles: process.env.LOG_FILE_MAX_FILES || '10', - level: process.env.LOG_LEVEL_FILE || 'debug', - format: winston.format.combine( - winston.format((info) => { - const { timestamp, level, ...rest } = info; - // Return the properties of info in a specific order - return { timestamp, level, logger: logLabel, ...rest }; - })(), - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.prettyPrint({ colorize: false, depth: 10 }) - ), - options: { - // https://nodejs.org/api/fs.html#file-system-flags - // Open file for reading and appending. The file is created if it does not exist. - flags: 'a+', - // https://nodejs.org/api/fs.html#fs_fs_createwritestream_path_options - // Set the file mode to be readable and writable by all users. - mode: 0o666 - } - }) - ); + if (transportTypes.includes('file')) { + // Output logs to file, except when running unit tests + transports.push( + new DailyRotateFile({ + dirname: process.env.LOG_FILE_DIR || 'data/logs', + filename: process.env.LOG_FILE_NAME || 'sims-api-%DATE%.log', + datePattern: process.env.LOG_FILE_DATE_PATTERN || 'YYYY-MM-DD-HH', + maxSize: process.env.LOG_FILE_MAX_SIZE || '50m', + maxFiles: process.env.LOG_FILE_MAX_FILES || '10', + level: process.env.LOG_LEVEL_FILE || 'debug', + format: winston.format.combine( + winston.format((info) => { + const { timestamp, level, ...rest } = info; + // Return the properties of info in a specific order + return { timestamp, level, logger: logLabel, ...rest }; + })(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.prettyPrint({ colorize: false, depth: 10 }) + ), + options: { + // https://nodejs.org/api/fs.html#file-system-flags + // Open file for reading and appending. The file is created if it does not exist. + flags: 'a+', + // https://nodejs.org/api/fs.html#fs_fs_createwritestream_path_options + // Set the file mode to be readable and writable by all users. + mode: 0o666 + } + }) + ); + } - if (process.env.NODE_ENV !== 'production') { - // Additionally output logs to console in non-production environments + if (transportTypes.includes('console')) { + // Output logs to console, except when running in production transports.push( new winston.transports.Console({ level: process.env.LOG_LEVEL || 'debug', @@ -118,15 +141,19 @@ export type WinstonLogLevel = (typeof WinstonLogLevels)[number]; * @param {WinstonLogLevel} logLevel */ export const setLogLevel = (logLevel: WinstonLogLevel) => { + const transportTypes = getLoggerTransportTypes(); + + if (!transportTypes.includes('console')) { + return; + } + // Update env var for future loggers process.env.LOG_LEVEL = logLevel; - if (process.env.NODE_ENV !== 'production') { - // Update console transport log level, which is the second transport in non-production environments - winston.loggers.loggers.forEach((logger) => { - logger.transports[1].level = logLevel; - }); - } + // Update console transport log level, which is the last transport in all environments + winston.loggers.loggers.forEach((logger) => { + logger.transports[transportTypes.length - 1].level = logLevel; + }); }; /** @@ -135,6 +162,12 @@ export const setLogLevel = (logLevel: WinstonLogLevel) => { * @param {WinstonLogLevel} logLevel */ export const setLogLevelFile = (logLevelFile: WinstonLogLevel) => { + const transportTypes = getLoggerTransportTypes(); + + if (!transportTypes.includes('file')) { + return; + } + // Update env var for future loggers process.env.LOG_LEVEL_FILE = logLevelFile; From f569aba1a786df4beaca329bad9f8d662aa7474f Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 14:34:32 -0700 Subject: [PATCH 10/14] Update telemetry components --- api/src/utils/logger.test.ts | 34 +--------- app/src/contexts/telemetryTableContext.tsx | 65 ++++++++----------- .../surveys/telemetry/TelemetryPage.tsx | 62 +++++++++++++++--- .../telemetry/list/SurveyDeploymentList.tsx | 51 +++++++++------ 4 files changed, 111 insertions(+), 101 deletions(-) diff --git a/api/src/utils/logger.test.ts b/api/src/utils/logger.test.ts index 8934ae9719..a4056716b0 100644 --- a/api/src/utils/logger.test.ts +++ b/api/src/utils/logger.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { getLogger, setLogLevel } from './logger'; +import { getLogger } from './logger'; describe('logger', () => { describe('getLogger', () => { @@ -10,36 +10,4 @@ describe('logger', () => { expect(logger).not.to.be.undefined; }); }); - - describe('setLogLevel', () => { - let currentLogLevel: string | undefined; - - beforeEach(() => { - currentLogLevel = process.env.LOG_LEVEL; - - // Set initial log level value - process.env.LOG_LEVEL = 'info'; - }); - - afterEach(() => { - // Restore the original log level - process.env.LOG_LEVEL = currentLogLevel; - }); - - it('sets the log level for the console transport', () => { - //const myLogger1 = require('./logger').getLogger('myLoggerA'); - const myLogger1 = getLogger('myLoggerA'); - - expect(myLogger1.transports[0].level).to.equal('info'); - - setLogLevel('debug'); - - const myLogger2 = getLogger('myLoggerA'); - expect(myLogger2.transports[0].level).to.equal('debug'); - - const myNewLogger3 = getLogger('myNewLoggerA'); - - expect(myNewLogger3.transports[0].level).to.equal('debug'); - }); - }); }); diff --git a/app/src/contexts/telemetryTableContext.tsx b/app/src/contexts/telemetryTableContext.tsx index 59c7ed8173..3be0d1c4ff 100644 --- a/app/src/contexts/telemetryTableContext.tsx +++ b/app/src/contexts/telemetryTableContext.tsx @@ -16,8 +16,8 @@ import { DialogContext } from 'contexts/dialogContext'; import { default as dayjs } from 'dayjs'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import useDataLoader from 'hooks/useDataLoader'; import { usePersistentState } from 'hooks/usePersistentState'; +import { IAllTelemetry } from 'interfaces/useTelemetryApi.interface'; import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { RowValidationError, TableValidationModel } from '../components/data-grid/DataGridValidationAlert'; @@ -147,17 +147,18 @@ export type IAllTelemetryTableContext = { export const TelemetryTableContext = createContext(undefined); type IAllTelemetryTableContextProviderProps = PropsWithChildren<{ - deployment_ids: string[]; + isLoading: boolean; + telemetryData: IAllTelemetry[]; + refreshRecords: () => Promise; }>; export const TelemetryTableContextProvider = (props: IAllTelemetryTableContextProviderProps) => { - const { children, deployment_ids } = props; + const { children, isLoading, telemetryData, refreshRecords } = props; const _muiDataGridApiRef = useGridApiRef(); const biohubApi = useBiohubApi(); - const telemetryDataLoader = useDataLoader(biohubApi.telemetry.getAllTelemetryByDeploymentIds); const dialogContext = useContext(DialogContext); // The data grid rows @@ -190,9 +191,6 @@ export const TelemetryTableContextProvider = (props: IAllTelemetryTableContextPr // Count of table records const recordCount = rows.length; - // True if telemetry is fetching - const isLoading = telemetryDataLoader.isLoading; - // True if table has unsaved changes, deferring value to prevent ui issue with controls rendering const hasUnsavedChanges = _modifiedRowIds.current.length > 0 || _stagedRowIds.current.length > 0; @@ -347,34 +345,6 @@ export const TelemetryTableContextProvider = (props: IAllTelemetryTableContextPr _modifiedRowIds.current = Array.from(new Set([..._modifiedRowIds.current, String(id)])); }, []); - /** - * Refresh the telemetry records and pre-parse to table date format - * - * @async - * @returns {Promise} - */ - const refreshRecords = useCallback(async () => { - const telemetry = (deployment_ids.length && (await telemetryDataLoader.refresh(deployment_ids))) || []; - - // Format the rows to use date and time - const rows: IManualTelemetryTableRow[] = telemetry.map((item) => { - return { - id: item.id, - deployment_id: item.deployment_id, - device_id: item.device_id, - latitude: item.latitude, - longitude: item.longitude, - date: dayjs(item.acquisition_date).format('YYYY-MM-DD'), - time: dayjs(item.acquisition_date).format('HH:mm:ss'), - telemetry_type: item.telemetry_type - }; - }); - - // Set initial rows for the table context - setRows(rows); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deployment_ids]); - /** * Validates all edited rows of table. * @@ -702,14 +672,31 @@ export const TelemetryTableContextProvider = (props: IAllTelemetryTableContextPr }, [_validateRows, _getEditedIds, _getEditedRows, _saveRecords]); /** - * Refetch the telemetry when the deployment ids change + * Parse the telemetry data to the table format and set the rows. * */ useEffect(() => { - if (deployment_ids.length) { - refreshRecords(); + if (!telemetryData) { + // No telemetry data, clear the table + setRows([]); + return; } - }, [deployment_ids, refreshRecords]); + + const rows: IManualTelemetryTableRow[] = telemetryData.map((item) => { + return { + id: item.id, + deployment_id: item.deployment_id, + device_id: item.device_id, + latitude: item.latitude, + longitude: item.longitude, + date: dayjs(item.acquisition_date).format('YYYY-MM-DD'), + time: dayjs(item.acquisition_date).format('HH:mm:ss'), + telemetry_type: item.telemetry_type + }; + }); + + setRows(rows); + }, [telemetryData]); const telemetryTableContext: IAllTelemetryTableContext = useMemo( () => ({ diff --git a/app/src/features/surveys/telemetry/TelemetryPage.tsx b/app/src/features/surveys/telemetry/TelemetryPage.tsx index 9d153842f1..c91b025ff6 100644 --- a/app/src/features/surveys/telemetry/TelemetryPage.tsx +++ b/app/src/features/surveys/telemetry/TelemetryPage.tsx @@ -5,27 +5,57 @@ import { TelemetryTableContextProvider } from 'contexts/telemetryTableContext'; import { SurveyDeploymentList } from 'features/surveys/telemetry/list/SurveyDeploymentList'; import { TelemetryTableContainer } from 'features/surveys/telemetry/table/TelemetryTableContainer'; import { TelemetryHeader } from 'features/surveys/telemetry/TelemetryHeader'; -import { useProjectContext, useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useProjectContext, useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; import { useEffect } from 'react'; export const TelemetryPage = () => { + const biohubApi = useBiohubApi(); + const projectContext = useProjectContext(); const surveyContext = useSurveyContext(); - const telemetryDataContext = useTelemetryDataContext(); - const deploymentsDataLoader = telemetryDataContext.deploymentsDataLoader; + const deploymentsDataLoader = useDataLoader(biohubApi.survey.getDeploymentsInSurvey); + const telemetryDataLoader = useDataLoader(biohubApi.telemetry.getAllTelemetryByDeploymentIds); + /** + * Load the deployments and telemetry data when the page is initially loaded. + */ useEffect(() => { - deploymentsDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [deploymentsDataLoader, surveyContext.projectId, surveyContext.surveyId]); + deploymentsDataLoader.load(surveyContext.projectId, surveyContext.surveyId).then((deployments) => { + const deploymentIds = deployments?.deployments.map((deployment) => deployment.bctw_deployment_id) ?? []; + + if (!deploymentIds.length) { + // No deployments, no telemetry to load + return; + } + + telemetryDataLoader.load(deploymentIds); + }); + }, [deploymentsDataLoader, surveyContext.projectId, surveyContext.surveyId, telemetryDataLoader]); + + /** + * Refresh the data for the telemetry page. + */ + const refreshData = async () => { + deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId).then((deployments) => { + const deploymentIds = deployments?.deployments.map((deployment) => deployment.bctw_deployment_id) ?? []; + + if (!deploymentIds.length) { + // No deployments, refresh (clear) the telemetry data + telemetryDataLoader.clearData(); + return; + } + + telemetryDataLoader.refresh(deploymentIds); + }); + }; if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data) { return ; } - const deploymentIds = - deploymentsDataLoader.data?.deployments.map((deployment) => deployment.bctw_deployment_id) ?? []; - return ( { {/* Telematry List */} - + { + refreshData(); + }} + /> {/* Telemetry Component */} - + { + refreshData(); + }}> diff --git a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx index 134eadaeb7..dc4b979405 100644 --- a/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx +++ b/app/src/features/surveys/telemetry/list/SurveyDeploymentList.tsx @@ -20,20 +20,44 @@ import { SkeletonList } from 'components/loading/SkeletonLoaders'; import { SurveyBadDeploymentListItem } from 'features/surveys/telemetry/list/SurveyBadDeploymentListItem'; import { SurveyDeploymentListItem } from 'features/surveys/telemetry/list/SurveyDeploymentListItem'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useDialogContext, useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; +import { WarningSchema } from 'interfaces/useBioHubApi.interface'; +import { IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; import { useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; +export interface ISurveyDeploymentListProps { + deployments: IAnimalDeployment[]; + badDeployments: WarningSchema<{ + sims_deployment_id: number; + bctw_deployment_id: string; + }>[]; + /** + * Flag to indicate if the deployments are loading. + * + * @type {boolean} + * @memberof ISurveyDeploymentListProps + */ + isLoading: boolean; + /** + * Refresh the deployments. + * + * @memberof ISurveyDeploymentListProps + */ + refreshRecords: () => void; +} + /** * Renders a list of all deployments in the survey * * @returns {*} */ -export const SurveyDeploymentList = () => { +export const SurveyDeploymentList = (props: ISurveyDeploymentListProps) => { + const { deployments, badDeployments, isLoading, refreshRecords } = props; + const dialogContext = useDialogContext(); const surveyContext = useSurveyContext(); - const telemetryDataContext = useTelemetryDataContext(); const biohubApi = useBiohubApi(); @@ -46,24 +70,12 @@ export const SurveyDeploymentList = () => { const frequencyUnitDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('frequency_unit')); const deviceMakesDataLoader = useDataLoader(() => biohubApi.telemetry.getCodeValues('device_make')); - const deploymentsDataLoader = telemetryDataContext.deploymentsDataLoader; - - const deployments = deploymentsDataLoader.data?.deployments ?? []; - const badDeployments = deploymentsDataLoader.data?.bad_deployments ?? []; - const deploymentCount = (deployments?.length ?? 0) + (badDeployments?.length ?? 0); useEffect(() => { frequencyUnitDataLoader.load(); deviceMakesDataLoader.load(); - deploymentsDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [ - deploymentsDataLoader, - deviceMakesDataLoader, - frequencyUnitDataLoader, - surveyContext.projectId, - surveyContext.surveyId - ]); + }, [deviceMakesDataLoader, frequencyUnitDataLoader, surveyContext.projectId, surveyContext.surveyId]); const handleBulkActionMenuClick = (event: React.MouseEvent) => { setBulkDeploymentAnchorEl(event.currentTarget); @@ -104,7 +116,7 @@ export const SurveyDeploymentList = () => { .then(() => { dialogContext.setYesNoDialog({ open: false }); setBulkDeploymentAnchorEl(null); - deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + refreshRecords(); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -134,7 +146,7 @@ export const SurveyDeploymentList = () => { .then(() => { dialogContext.setYesNoDialog({ open: false }); setDeploymentAnchorEl(null); - deploymentsDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + refreshRecords(); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -324,7 +336,7 @@ export const SurveyDeploymentList = () => { } isLoadingFallbackDelay={100} hasNoData={!deploymentCount} @@ -399,6 +411,7 @@ export const SurveyDeploymentList = () => { {badDeployments.map((badDeployment) => { return ( renderDeleteDeploymentDialog(deploymentId)} From b068ca3fb1317572a8b8728f9dbcb4008690fd50 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 14:35:37 -0700 Subject: [PATCH 11/14] Remove console logs --- .../import-services/critter/import-critters-strategy.ts | 2 -- .../grid-column-definitions/GridColumnDefinitions.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/api/src/services/import-services/critter/import-critters-strategy.ts b/api/src/services/import-services/critter/import-critters-strategy.ts index 561c7ceb95..08e2d04c2b 100644 --- a/api/src/services/import-services/critter/import-critters-strategy.ts +++ b/api/src/services/import-services/critter/import-critters-strategy.ts @@ -116,8 +116,6 @@ export class ImportCrittersStrategy extends DBService implements CSVImportStrate 'sex' as keyof CsvCritter ]); - console.log(partialRow); - // Keys of collection units const collectionUnitKeys = keys(partialRow); diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx index 984d164ad3..7001069440 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx @@ -307,7 +307,6 @@ export const ObservationQuantitativeMeasurementColDef = (props: { hasError: (params: GridCellParams) => boolean; }): GridColDef => { const { measurement, hasError } = props; - console.log(measurement.measurement_desc); return { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, From 2fc07425a8e1a372ee7bf184ce7475a742abdb87 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 14:47:10 -0700 Subject: [PATCH 12/14] Handle null deployment frequency in edit form --- .../survey/{surveyId}/deployments/{deploymentId}/index.ts | 3 ++- .../surveys/telemetry/deployments/edit/EditDeploymentPage.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts index df1f4897f7..d3848fa574 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts @@ -353,7 +353,8 @@ PUT.apiDoc = { nullable: true }, frequency: { - type: 'number' + type: 'number', + nullable: true }, frequency_unit: { type: 'number', diff --git a/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx index a3a9d1b2e3..68440c4ee3 100644 --- a/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx +++ b/app/src/features/surveys/telemetry/deployments/edit/EditDeploymentPage.tsx @@ -88,7 +88,7 @@ export const EditDeploymentPage = () => { critter_id: values.critter_id, device_id: Number(values.device_id), device_make: values.device_make, - frequency: values.frequency, + frequency: values.frequency || null, // nullify if empty string frequency_unit: values.frequency_unit, device_model: values.device_model, critterbase_start_capture_id: values.critterbase_start_capture_id, From b3967a2d0fcbedc7c391e35db7856fd90256f847 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 14:56:17 -0700 Subject: [PATCH 13/14] Fix app test --- app/src/hooks/api/useSurveyApi.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index f6cd02de27..7126dc1453 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -142,7 +142,7 @@ describe('useSurveyApi', () => { bad_deployments: [] }; - mock.onGet(`/api/project/${projectId}/survey/${surveyId}/deployments`).reply(200, [response]); + mock.onGet(`/api/project/${projectId}/survey/${surveyId}/deployments`).reply(200, response); const result = await useSurveyApi(axios).getDeploymentsInSurvey(projectId, surveyId); From f541585c6d70c0da497c2192cd547dad06a65c6e Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 1 Oct 2024 15:01:43 -0700 Subject: [PATCH 14/14] Fix unit test --- app/src/hooks/api/useSurveyApi.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index 7126dc1453..26c4814064 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -146,7 +146,6 @@ describe('useSurveyApi', () => { const result = await useSurveyApi(axios).getDeploymentsInSurvey(projectId, surveyId); - expect(Array.isArray(result)).toBe(true); expect(result.deployments.length).toBe(1); expect(result.deployments[0].device_id).toBe(123); });