From 4db96ea290749358c126ab449794cc9dc2160779 Mon Sep 17 00:00:00 2001 From: Sam <78538841+spwoodcock@users.noreply.github.com> Date: Mon, 8 Jul 2024 14:12:03 +0200 Subject: [PATCH] fix: prevent project generation if api failure (#1627) * fix(frontend): prevent project generation if api failure * Prevent project generation if api failure pt2 (#1635) * fix(hotfix): download of basemaps in ui, max zoom level 22 used for tms * ci: update all gh-workflows to latest v1.6.0 * build: upgrade rclone --> v1 pin to avoid CVE-2024-24790 * build: remove unnecessary tables and field from db schema (#1623) * build: remove unnecessary tables via migration & simplify schema * refactor: remove references to removed tables in sqlalchemy models * build: add IF EXISTS to DROP COLUMN in migration * build: remove default columns from previous migration * fix(backend): minor fixes to HTTPException on endpoints * fix(backend): addded created date on the project response, set expiry of access token to 1 hour (#1633) * docs: add placeholder for axiom repo activity * fix(createProjectSlice): set generateProject & drawToggle status to default * feat(commonUtils): isStatusSuccess function add * test: get the detailed task history for a project (#1626) * build(backend): add async-lru dep, remove cpuinfo dep * fix: replace lru_cache with async for getting odk creds * fix(splitTasks): dependency add to useEffect * fix(createProjectService): halt project creation if api failure --------- Co-authored-by: spwoodcock Co-authored-by: Sam <78538841+spwoodcock@users.noreply.github.com> Co-authored-by: Sujan Adhikari <109404840+Sujanadh@users.noreply.github.com> Co-authored-by: Azhar Ismagulova <31756707+azharcodeit@users.noreply.github.com> * build: update to latest ms playwright image v1.45.1 --------- Co-authored-by: Nishit Suwal <81785002+NSUWAL123@users.noreply.github.com> Co-authored-by: Sujan Adhikari <109404840+Sujanadh@users.noreply.github.com> Co-authored-by: Azhar Ismagulova <31756707+azharcodeit@users.noreply.github.com> --- .github/workflows/pr_test_frontend.yml | 2 +- docker-compose.yml | 2 +- src/frontend/src/api/CreateProjectService.ts | 200 +++++++++++------- .../createnewproject/SplitTasks.tsx | 10 +- .../src/store/slices/CreateProjectSlice.ts | 28 +-- .../src/store/types/ICreateProject.ts | 10 +- src/frontend/src/utilfunctions/commonUtils.ts | 7 + 7 files changed, 152 insertions(+), 107 deletions(-) diff --git a/.github/workflows/pr_test_frontend.yml b/.github/workflows/pr_test_frontend.yml index adeb02e3f3..d66df725f9 100644 --- a/.github/workflows/pr_test_frontend.yml +++ b/.github/workflows/pr_test_frontend.yml @@ -21,6 +21,6 @@ jobs: e2e-tests: uses: hotosm/gh-workflows/.github/workflows/test_pnpm.yml@main with: - container_config: '{"image": "mcr.microsoft.com/playwright:v1.44.1"}' + container_config: '{"image": "mcr.microsoft.com/playwright:v1.45.1"}' working_dir: src/frontend run_command: "test:e2e" diff --git a/docker-compose.yml b/docker-compose.yml index a78b01bc50..06d7941f6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -129,7 +129,7 @@ services: ui-test: profiles: ["ui-test"] - image: mcr.microsoft.com/playwright:v1.44.1 + image: mcr.microsoft.com/playwright:v1.45.1 working_dir: /app environment: - DISPLAY=:0 diff --git a/src/frontend/src/api/CreateProjectService.ts b/src/frontend/src/api/CreateProjectService.ts index 3129d62a7b..9b7e9702b2 100755 --- a/src/frontend/src/api/CreateProjectService.ts +++ b/src/frontend/src/api/CreateProjectService.ts @@ -8,6 +8,7 @@ import { } from '@/models/createproject/createProjectModel'; import { CommonActions } from '@/store/slices/CommonSlice'; import { ValidateCustomFormResponse } from '@/store/types/ICreateProject'; +import { isStatusSuccess } from '@/utilfunctions/commonUtils'; const CreateProjectService: Function = ( url: string, @@ -21,72 +22,91 @@ const CreateProjectService: Function = ( dispatch(CreateProjectActions.CreateProjectLoading(true)); dispatch(CommonActions.SetLoading(true)); - const postCreateProjectDetails = async (url, projectData, taskAreaGeojson, formUpload) => { - try { - // Create project - const postNewProjectDetails = await axios.post(url, projectData); - const resp: ProjectDetailsModel = postNewProjectDetails.data; - await dispatch(CreateProjectActions.PostProjectDetails(resp)); - - // Submit task boundaries - await dispatch( - UploadTaskAreasService( - `${import.meta.env.VITE_API_URL}/projects/${resp.id}/upload-task-boundaries`, - taskAreaGeojson, - ), - ); + let projectId: null | number = null; + try { + // halt project creation if any api call fails + let hasAPISuccess = false; - dispatch( - CommonActions.SetSnackBar({ - open: true, - message: 'Project Successfully Created Now Generating QR For Project', - variant: 'success', - duration: 2000, - }), - ); + const postNewProjectDetails = await API.post(url, projectData); + hasAPISuccess = isStatusSuccess(postNewProjectDetails.status); - if (isOsmExtract) { - // Upload data extract generated from raw-data-api - const response = await axios.get( - `${import.meta.env.VITE_API_URL}/projects/data-extract-url/?project_id=${resp.id}&url=${ - projectData.data_extract_url - }`, - ); - } else if (dataExtractFile) { - // Upload custom data extract from user - const dataExtractFormData = new FormData(); - dataExtractFormData.append('custom_extract_file', dataExtractFile); - const response = await axios.post( - `${import.meta.env.VITE_API_URL}/projects/upload-custom-extract/?project_id=${resp.id}`, - dataExtractFormData, - ); - } + const projectCreateResp: ProjectDetailsModel = postNewProjectDetails.data; + await dispatch(CreateProjectActions.PostProjectDetails(projectCreateResp)); + + if (!hasAPISuccess) { + throw new Error(`Request failed with status ${projectCreateResp.status}`); + } + projectId = projectCreateResp.id; + + // Submit task boundaries + hasAPISuccess = await dispatch( + UploadTaskAreasService( + `${import.meta.env.VITE_API_URL}/projects/${projectId}/upload-task-boundaries`, + taskAreaGeojson, + ), + ); + + if (!hasAPISuccess) { + throw new Error(`Request failed`); + } - // Generate QR codes - await dispatch( - GenerateProjectQRService( - `${import.meta.env.VITE_API_URL}/projects/${resp.id}/generate-project-data`, - projectData, - formUpload, - ), + // Upload data extract + let extractResponse; + if (isOsmExtract) { + // Generated extract from raw-data-api + extractResponse = await API.get( + `${import.meta.env.VITE_API_URL}/projects/data-extract-url/?project_id=${projectId}&url=${ + projectData.data_extract_url + }`, ); - } catch (error: any) { - // Added Snackbar toast for error message - dispatch( - CommonActions.SetSnackBar({ - open: true, - message: JSON.stringify(error?.response?.data?.detail) || 'Something went wrong.', - variant: 'error', - duration: 2000, - }), + } else if (dataExtractFile) { + // Custom data extract from user + const dataExtractFormData = new FormData(); + dataExtractFormData.append('custom_extract_file', dataExtractFile); + extractResponse = await API.post( + `${import.meta.env.VITE_API_URL}/projects/upload-custom-extract/?project_id=${projectId}`, + dataExtractFormData, ); - dispatch(CreateProjectActions.CreateProjectLoading(false)); - } finally { - dispatch(CommonActions.SetLoading(false)); } - }; + hasAPISuccess = isStatusSuccess(extractResponse.status); + + if (!hasAPISuccess) { + throw new Error(`Request failed with status ${extractResponse.status}`); + } - await postCreateProjectDetails(url, projectData, taskAreaGeojson, formUpload); + // Generate project files + const generateProjectFile = await dispatch( + GenerateProjectFilesService( + `${import.meta.env.VITE_API_URL}/projects/${projectId}/generate-project-data`, + projectData, + formUpload, + ), + ); + + hasAPISuccess = generateProjectFile; + if (!hasAPISuccess) { + throw new Error(`Request failed`); + } + dispatch(CreateProjectActions.GenerateProjectError(false)); + // dispatch(CreateProjectActions.CreateProjectLoading(false)); + } catch (error: any) { + if (projectId) { + await dispatch(DeleteProjectService(`${import.meta.env.VITE_API_URL}/projects/${projectId}`, false)); + } + + await dispatch(CreateProjectActions.GenerateProjectError(true)); + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: JSON.stringify(error?.response?.data?.detail) || 'Something went wrong. Please try again.', + variant: 'error', + duration: 2000, + }), + ); + dispatch(CreateProjectActions.CreateProjectLoading(false)); + } finally { + dispatch(CommonActions.SetLoading(false)); + } }; }; @@ -107,10 +127,12 @@ const FormCategoryService: Function = (url: string) => { await getFormCategoryList(url); }; }; + const UploadTaskAreasService: Function = (url: string, filePayload: any, projectData: any) => { return async (dispatch) => { dispatch(CreateProjectActions.UploadAreaLoading(true)); const postUploadArea = async (url, filePayload) => { + let isAPISuccess = true; try { const areaFormData = new FormData(); areaFormData.append('task_geojson', filePayload); @@ -119,10 +141,17 @@ const UploadTaskAreasService: Function = (url: string, filePayload: any, project 'Content-Type': 'multipart/form-data', }, }); - // const resp: UploadAreaDetailsModel = postNewProjectDetails.data; - await dispatch(CreateProjectActions.UploadAreaLoading(false)); - await dispatch(CreateProjectActions.PostUploadAreaSuccess(postNewProjectDetails.data)); + isAPISuccess = isStatusSuccess(postNewProjectDetails.status); + + if (isAPISuccess) { + await dispatch(CreateProjectActions.UploadAreaLoading(false)); + await dispatch(CreateProjectActions.PostUploadAreaSuccess(postNewProjectDetails.data)); + } else { + throw new Error(`Request failed with status ${postNewProjectDetails.status}`); + } } catch (error: any) { + isAPISuccess = false; + await dispatch(CreateProjectActions.GenerateProjectError(true)); dispatch( CommonActions.SetSnackBar({ open: true, @@ -133,39 +162,48 @@ const UploadTaskAreasService: Function = (url: string, filePayload: any, project ); dispatch(CreateProjectActions.UploadAreaLoading(false)); } + return isAPISuccess; }; - await postUploadArea(url, filePayload); + return await postUploadArea(url, filePayload); }; }; -const GenerateProjectQRService: Function = (url: string, projectData: any, formUpload: any) => { + +const GenerateProjectFilesService: Function = (url: string, projectData: any, formUpload: any) => { return async (dispatch) => { - dispatch(CreateProjectActions.GenerateProjectQRLoading(true)); + dispatch(CreateProjectActions.GenerateProjectLoading(true)); dispatch(CommonActions.SetLoading(true)); const postUploadArea = async (url, projectData: any, formUpload) => { + let isAPISuccess = true; try { - let postNewProjectDetails; + let response; if (projectData.form_ways === 'custom_form') { // TODO move form upload to a separate service / endpoint? const generateApiFormData = new FormData(); generateApiFormData.append('xls_form_upload', formUpload); - postNewProjectDetails = await axios.post(url, generateApiFormData, { + response = await axios.post(url, generateApiFormData, { headers: { 'Content-Type': 'multipart/form-data', }, }); } else { - postNewProjectDetails = await axios.post(url, {}); + response = await axios.post(url, {}); + } + isAPISuccess = isStatusSuccess(response.status); + if (!isAPISuccess) { + throw new Error(`Request failed with status ${response.status}`); } - const resp: string = postNewProjectDetails.data; - await dispatch(CreateProjectActions.GenerateProjectQRLoading(false)); + await dispatch(CreateProjectActions.GenerateProjectLoading(false)); dispatch(CommonActions.SetLoading(false)); - await dispatch(CreateProjectActions.GenerateProjectQRSuccess(resp)); + // Trigger the watcher and redirect after success + await dispatch(CreateProjectActions.GenerateProjectSuccess(true)); } catch (error: any) { + isAPISuccess = false; dispatch(CommonActions.SetLoading(false)); + await dispatch(CreateProjectActions.GenerateProjectError(true)); dispatch( CommonActions.SetSnackBar({ open: true, @@ -174,11 +212,12 @@ const GenerateProjectQRService: Function = (url: string, projectData: any, formU duration: 2000, }), ); - dispatch(CreateProjectActions.GenerateProjectQRLoading(false)); + dispatch(CreateProjectActions.GenerateProjectLoading(false)); } + return isAPISuccess; }; - await postUploadArea(url, projectData, formUpload); + return await postUploadArea(url, projectData, formUpload); }; }; @@ -333,6 +372,7 @@ const PatchProjectDetails: Function = (url: string, projectData: any) => { await patchProjectDetails(url, projectData); }; }; + const PostFormUpdate: Function = (url: string, projectData: any) => { return async (dispatch) => { dispatch(CreateProjectActions.SetPostFormUpdateLoading(true)); @@ -452,7 +492,7 @@ const ValidateCustomForm: Function = (url: string, formUpload: any) => { }; }; -const DeleteProjectService: Function = (url: string) => { +const DeleteProjectService: Function = (url: string, hasRedirect: boolean = true) => { return async (dispatch) => { const deleteProject = async (url: string) => { try { @@ -460,15 +500,17 @@ const DeleteProjectService: Function = (url: string) => { dispatch( CommonActions.SetSnackBar({ open: true, - message: 'Project deleted. Redirecting...', + message: `Project deleted. ${hasRedirect && 'Redirecting...'}`, variant: 'success', duration: 2000, }), ); // Redirect to homepage - setTimeout(() => { - window.location.href = '/'; - }, 2000); + if (hasRedirect) { + setTimeout(() => { + window.location.href = '/'; + }, 2000); + } } catch (error) { if (error.response.status === 404) { dispatch( @@ -492,7 +534,7 @@ export { UploadTaskAreasService, CreateProjectService, FormCategoryService, - GenerateProjectQRService, + GenerateProjectFilesService, OrganisationService, GetDividedTaskFromGeojson, TaskSplittingPreviewService, diff --git a/src/frontend/src/components/createnewproject/SplitTasks.tsx b/src/frontend/src/components/createnewproject/SplitTasks.tsx index 4a5875d96e..c6d7457dad 100644 --- a/src/frontend/src/components/createnewproject/SplitTasks.tsx +++ b/src/frontend/src/components/createnewproject/SplitTasks.tsx @@ -31,7 +31,8 @@ const SplitTasks = ({ flag, geojsonFile, setGeojsonFile, customDataExtractUpload const projectDetails = useAppSelector((state) => state.createproject.projectDetails); const dataExtractGeojson = useAppSelector((state) => state.createproject.dataExtractGeojson); - const generateQrSuccess = useAppSelector((state) => state.createproject.generateQrSuccess); + const generateProjectSuccess = useAppSelector((state) => state.createproject.generateProjectSuccess); + const generateProjectError = useAppSelector((state) => state.createproject.generateProjectError); const projectDetailsResponse = useAppSelector((state) => state.createproject.projectDetailsResponse); const dividedTaskGeojson = useAppSelector((state) => state.createproject.dividedTaskGeojson); const projectDetailsLoading = useAppSelector((state) => state.createproject.projectDetailsLoading); @@ -191,12 +192,12 @@ const SplitTasks = ({ flag, geojsonFile, setGeojsonFile, customDataExtractUpload const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const handleQRGeneration = async () => { - if (generateQrSuccess) { + if (!generateProjectError && generateProjectSuccess) { const projectId = projectDetailsResponse?.id; dispatch( CommonActions.SetSnackBar({ open: true, - message: 'QR Generation Completed. Redirecting...', + message: 'Project Generation Completed. Redirecting...', variant: 'success', duration: 2000, }), @@ -205,7 +206,6 @@ const SplitTasks = ({ flag, geojsonFile, setGeojsonFile, customDataExtractUpload // Add 5-second delay to allow backend Entity generation to catch up await delay(5000); dispatch(CreateProjectActions.CreateProjectLoading(false)); - dispatch(CreateProjectActions.SetGenerateProjectQRSuccess(null)); navigate(`/project/${projectId}`); dispatch(CreateProjectActions.ClearCreateProjectFormData()); dispatch(CreateProjectActions.SetCanSwitchCreateProjectSteps(false)); @@ -213,7 +213,7 @@ const SplitTasks = ({ flag, geojsonFile, setGeojsonFile, customDataExtractUpload }; handleQRGeneration(); - }, [generateQrSuccess]); + }, [generateProjectSuccess, generateProjectError]); const renderTraceback = (errorText: string) => { if (!errorText) { diff --git a/src/frontend/src/store/slices/CreateProjectSlice.ts b/src/frontend/src/store/slices/CreateProjectSlice.ts index 619be23fdc..004fc887cd 100755 --- a/src/frontend/src/store/slices/CreateProjectSlice.ts +++ b/src/frontend/src/store/slices/CreateProjectSlice.ts @@ -28,10 +28,11 @@ export const initialState: CreateProjectStateTypes = { projectAreaLoading: false, formCategoryList: [], formCategoryLoading: false, - generateQrLoading: false, + generateProjectLoading: false, + generateProjectSuccess: false, + generateProjectError: false, organisationList: [], organisationListLoading: false, - generateQrSuccess: null, createProjectStep: 1, dividedTaskLoading: false, dividedTaskGeojson: null, @@ -94,6 +95,9 @@ const CreateProject = createSlice({ state.uploadAreaSelection = ''; state.dividedTaskGeojson = null; state.dividedTaskLoading = false; + state.generateProjectSuccess = false; + state.generateProjectError = false; + state.drawToggle = false; }, UploadAreaLoading(state, action) { state.projectAreaLoading = action.payload; @@ -110,8 +114,14 @@ const CreateProject = createSlice({ SetIndividualProjectDetailsData(state, action) { state.projectDetails = action.payload; }, - GenerateProjectQRLoading(state, action) { - state.generateQrLoading = action.payload; + GenerateProjectLoading(state, action) { + state.generateProjectLoading = action.payload; + }, + GenerateProjectSuccess(state, action) { + state.generateProjectSuccess = action.payload; + }, + GenerateProjectError(state, action) { + state.generateProjectError = action.payload; }, GetOrganisationList(state, action) { state.organisationList = action.payload; @@ -119,16 +129,6 @@ const CreateProject = createSlice({ GetOrganisationListLoading(state, action) { state.organisationListLoading = action.payload; }, - GenerateProjectQRSuccess(state, action) { - if (action.payload.status === 'SUCCESS') { - state.generateQrSuccess = null; - } else { - state.generateQrSuccess = action.payload; - } - }, - SetGenerateProjectQRSuccess(state, action) { - state.generateQrSuccess = action.payload; - }, SetCreateProjectFormStep(state, action) { state.createProjectStep = action.payload; }, diff --git a/src/frontend/src/store/types/ICreateProject.ts b/src/frontend/src/store/types/ICreateProject.ts index 013b791c40..7ea894ce12 100644 --- a/src/frontend/src/store/types/ICreateProject.ts +++ b/src/frontend/src/store/types/ICreateProject.ts @@ -11,10 +11,11 @@ export type CreateProjectStateTypes = { projectAreaLoading: boolean; formCategoryList: FormCategoryListTypes[] | []; formCategoryLoading: boolean; - generateQrLoading: boolean; + generateProjectLoading: boolean; + generateProjectSuccess: boolean; + generateProjectError: boolean; organisationList: OrganisationListTypes[]; organisationListLoading: boolean; - generateQrSuccess: GenerateQrSuccessTypes | null; createProjectStep: number; dividedTaskLoading: boolean; dividedTaskGeojson: null | GeoJSONFeatureTypes; @@ -129,11 +130,6 @@ export type FormCategoryListTypes = { title: string; }; -export type GenerateQrSuccessTypes = { - Message: string; - task_id: string; -}; - export type OrganisationListTypes = { logo: string; id: number; diff --git a/src/frontend/src/utilfunctions/commonUtils.ts b/src/frontend/src/utilfunctions/commonUtils.ts index e019ef4b78..cf04d93932 100644 --- a/src/frontend/src/utilfunctions/commonUtils.ts +++ b/src/frontend/src/utilfunctions/commonUtils.ts @@ -9,3 +9,10 @@ export const isInputEmpty = (text: string): boolean => { export const camelToFlat = (word: string): string => ( (word = word.replace(/[A-Z]/g, ' $&')), word[0].toUpperCase() + word.slice(1) ); + +export const isStatusSuccess = (status: number) => { + if (status < 300) { + return true; + } + return false; +};