From 5c6189245c3078c83da872d9a443645b4e09bd27 Mon Sep 17 00:00:00 2001 From: Sujit Date: Wed, 20 Nov 2024 10:07:15 +0545 Subject: [PATCH 1/5] feat: add `MultipleEmailInput` component --- .../common/MultipleEmailInput/index.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/frontend/src/components/common/MultipleEmailInput/index.tsx diff --git a/src/frontend/src/components/common/MultipleEmailInput/index.tsx b/src/frontend/src/components/common/MultipleEmailInput/index.tsx new file mode 100644 index 00000000..5e6ca6a2 --- /dev/null +++ b/src/frontend/src/components/common/MultipleEmailInput/index.tsx @@ -0,0 +1,99 @@ +import { FormEvent, KeyboardEvent, useState } from 'react'; +import { FormControl, Input } from '../FormUI'; +import ErrorMessage from '../FormUI/ErrorMessage'; +import { FlexRow } from '../Layouts'; + +interface IMultipleEmailInput { + emails: string[] | []; + // eslint-disable-next-line no-unused-vars + onEmailAdd: (emails: string[]) => void; +} + +const MultipleEmailInput = ({ emails, onEmailAdd }: IMultipleEmailInput) => { + const [inputEmail, setInputEmail] = useState(''); + const [emailList, setEmailList] = useState(emails || []); + const [error, setError] = useState(''); + + const handleChange = (e: FormEvent) => { + setInputEmail(e.currentTarget.value?.trim()); + setError(''); + }; + + const addInputEmailOnList = () => { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/?.test(inputEmail)) + return setError('Email is invalid'); + if (emailList?.find(email => email === inputEmail)) + return setError('Email already exists on list'); + setInputEmail(''); + const newEmailList = [...emailList, inputEmail]; + + setEmailList(prev => { + const newList = [...prev, inputEmail]; + onEmailAdd(newList); + return newList; + }); + onEmailAdd(newEmailList); + return () => {}; + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + addInputEmailOnList(); + } + return () => {}; + }; + + const handleDeleteEmail = (email: string) => { + setEmailList(prev => { + const newList = prev?.filter(prevEmail => prevEmail !== email); + onEmailAdd(newList); + return newList; + }); + }; + + return ( + + + + addInputEmailOnList()} + role="button" + tabIndex={0} + onKeyDown={() => {}} + > + add + + + + + {emailList?.map((email: string) => ( +
+
+ {email} +
+ {}} + onClick={() => handleDeleteEmail(email)} + > + close + +
+ ))} +
+
+ ); +}; + +export default MultipleEmailInput; From 077403d545d8f14fc34453a0832045b108c4d469 Mon Sep 17 00:00:00 2001 From: Sujit Date: Wed, 20 Nov 2024 10:10:35 +0545 Subject: [PATCH 2/5] feat(project-creation): post regulator emails with project details if the project requires regulators approval --- .../CreateprojectLayout/index.tsx | 26 +++++++++++ .../FormContents/Contributions/index.tsx | 43 ++++++++++++++++++- src/frontend/src/constants/createProject.tsx | 9 ++++ .../src/store/slices/createproject.ts | 4 ++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx index abc9460a..76694d4e 100644 --- a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx +++ b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx @@ -91,6 +91,12 @@ const CreateprojectLayout = () => { const imageMergeType = useTypedSelector( state => state.createproject.imageMergeType, ); + const requiresApprovalFromRegulator = useTypedSelector( + state => state.createproject.requiresApprovalFromRegulator, + ); + const regulatorEmails = useTypedSelector( + state => state.createproject.regulatorEmails, + ); const initialState: FieldValues = { name: '', @@ -108,6 +114,8 @@ const CreateprojectLayout = () => { dem: null, requires_approval_from_manager_for_locking: false, altitude_from_ground: 0, + requires_approval_from_regulator: false, + regulator_emails: [], }; const { @@ -214,6 +222,18 @@ const CreateprojectLayout = () => { if (activeStep === 4 && !splitGeojson) return; + if (activeStep === 5) { + if (requiresApprovalFromRegulator === 'required') { + if (regulatorEmails?.length) { + setValue('requires_approval_from_regulator', true); + setValue('regulator_emails', regulatorEmails); + } else { + toast.error("Please provide regulator's email"); + return; + } + } + } + if (activeStep !== 5) { dispatch(setCreateProjectState({ activeStep: activeStep + 1 })); return; @@ -239,6 +259,10 @@ const CreateprojectLayout = () => { imageMergeType === 'spacing' ? getSideOverlap(agl, data?.side_spacing) : data?.side_overlap, + + requires_approval_from_regulator: + requiresApprovalFromRegulator === 'required', + regulator_emails: regulatorEmails, }; delete refactoredData?.forward_spacing; delete refactoredData?.side_spacing; @@ -249,6 +273,8 @@ const CreateprojectLayout = () => { delete refactoredData?.dem; if (measurementType === 'gsd') delete refactoredData?.altitude_from_ground; else delete refactoredData?.gsd_cm_px; + if (requiresApprovalFromRegulator !== 'required') + delete refactoredData?.regulator_emails; // make form data with value JSON stringify to combine value on single json / form data with only 2 keys (backend didn't found project_info on non-stringified data) const formData = new FormData(); diff --git a/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx b/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx index 1de7864e..3d45169c 100644 --- a/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx @@ -4,8 +4,12 @@ import { FormControl, Label, Input } from '@Components/common/FormUI'; import ErrorMessage from '@Components/common/ErrorMessage'; import { UseFormPropsType } from '@Components/common/FormUI/types'; import RadioButton from '@Components/common/RadioButton'; -import { lockApprovalOptions } from '@Constants/createProject'; +import { + lockApprovalOptions, + regulatorApprovalOptions, +} from '@Constants/createProject'; import { setCreateProjectState } from '@Store/actions/createproject'; +import MultipleEmailInput from '@Components/common/MultipleEmailInput'; // import { contributionsOptions } from '@Constants/createProject'; export default function Conditions({ @@ -18,6 +22,12 @@ export default function Conditions({ const requireApprovalFromManagerForLocking = useTypedSelector( state => state.createproject.requireApprovalFromManagerForLocking, ); + const requiresApprovalFromRegulator = useTypedSelector( + state => state.createproject.requiresApprovalFromRegulator, + ); + const regulatorEmails = useTypedSelector( + state => state.createproject.regulatorEmails, + ); return ( @@ -58,6 +68,37 @@ export default function Conditions({ + + + { + dispatch( + setCreateProjectState({ + requiresApprovalFromRegulator: value, + }), + ); + }} + value={requiresApprovalFromRegulator} + /> + + + {requiresApprovalFromRegulator === 'required' && ( + + + { + dispatch(setCreateProjectState({ regulatorEmails: emails })); + }} + /> + + + )} + Date: Wed, 20 Nov 2024 17:27:51 +0545 Subject: [PATCH 3/5] feat: add endpoint to upload orthophoto to OpenAerialMap (OAM) --- .env.example | 3 ++ src/backend/app/config.py | 1 + src/backend/app/s3.py | 26 +++++++++++++++++ src/backend/app/tasks/oam.py | 40 ++++++++++++++++++++++++++ src/backend/app/tasks/task_routes.py | 43 ++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+) create mode 100644 src/backend/app/tasks/oam.py diff --git a/.env.example b/.env.example index 815265be..7f5416e7 100644 --- a/.env.example +++ b/.env.example @@ -68,3 +68,6 @@ WO_DEV=${WO_DEV:-NO} WO_BROKER=${WO_BROKER:-redis://odm-broker} WO_DEFAULT_NODES=${WO_DEFAULT_NODES:-1} WO_SETTINGS=${WO_SETTINGS} + +##OAM API TOKEN +OAM_API_TOKEN=${OAM_API_TOKEN} diff --git a/src/backend/app/config.py b/src/backend/app/config.py index 2b5ce89c..cb43f7fa 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -108,6 +108,7 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any: EMAILS_FROM_NAME: Optional[str] = "Drone Tasking Manager" NODE_ODM_URL: Optional[str] = "http://odm-api:3000" + OAM_API_TOKEN: Optional[str] = None @computed_field @property diff --git a/src/backend/app/s3.py b/src/backend/app/s3.py index 22d83113..ec2ebc36 100644 --- a/src/backend/app/s3.py +++ b/src/backend/app/s3.py @@ -1,5 +1,7 @@ +from fastapi import HTTPException from app.config import settings from loguru import logger as log +from app.models.enums import HTTPStatus from minio import Minio from io import BytesIO from typing import Any @@ -231,3 +233,27 @@ def get_cog_path(bucket_name: str, project_id: str, task_id: str): # Get the presigned URL return get_presigned_url(bucket_name, s3_path) + + +def get_orthophoto_url(bucket_name: str, project_id: str, task_id: str): + """Generate the presigned URL for a COG file in an S3 bucket if the file exists. + + Args: + bucket_name (str): The name of the S3 bucket. + project_id (str): The unique project identifier. + task_id (str): The unique task identifier. + + Returns: + str or None: The presigned URL to access the COG file if it exists, or None otherwise. + """ + s3_path = f"dtm-data/projects/{project_id}/{task_id}/orthophoto/odm_orthophoto.tif" + try: + get_object_metadata(bucket_name, s3_path) + return get_presigned_url(bucket_name, s3_path) + + except Exception as e: + log.warning(f"File not found or error checking metadata for {s3_path}: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Orthophoto file for project {project_id}, task {task_id} not found in the S3 bucket.", + ) diff --git a/src/backend/app/tasks/oam.py b/src/backend/app/tasks/oam.py new file mode 100644 index 00000000..d5b3536a --- /dev/null +++ b/src/backend/app/tasks/oam.py @@ -0,0 +1,40 @@ +from http import HTTPStatus +from fastapi import HTTPException +import requests +from urllib.parse import urlencode +import json +from app.models.enums import HTTPStatus +from loguru import logger as log + + +async def upload_orthophoto_to_oam(oam_params, download_url): + """ + Uploads an orthophoto to OpenAerialMap (OAM) using provided parameters. + + Args: + oam_params: A dictionary of OAM parameters. + download_url: The URL of the orthophoto file. + """ + try: + # Prepare the API URL with encoded parameters + api_url = f"https://api.openaerialmap.org/dronedeploy?{urlencode(oam_params)}" + + response = requests.post( + api_url, + json={"download_path": download_url}, + ) + + res = response.json() + if response.status_code == 200 and "results" in res and "upload" in res["results"]: + oam_upload_id = res["results"]["upload"] + log.info(f"Orthophoto successfully uploaded to OAM with ID: {oam_upload_id}") + return oam_upload_id + else: + err_msg = f"Failed to upload orthophoto. Response: {json.dumps(res)}" + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=err_msg) + + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Error: {e}", + ) diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index db96a2c5..c14bd642 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -1,3 +1,4 @@ +from datetime import datetime import uuid from typing import Annotated from app.projects import project_deps, project_schemas @@ -9,6 +10,8 @@ from psycopg import Connection from app.db import database from loguru import logger as log +from app.s3 import get_orthophoto_url +from app.tasks import oam router = APIRouter( prefix=f"{settings.API_PREFIX}/tasks", @@ -86,3 +89,43 @@ async def new_event( user_data, background_tasks, ) + + +@router.post("/upload/{project_id}/{task_id}") +async def upload_orthophoto_to_oam( + project_id: uuid.UUID, + task_id: uuid.UUID, + user_data: Annotated[AuthUser, Depends(login_required)], + project: Annotated[ + project_schemas.DbProject, Depends(project_deps.get_project_by_id) + ], +): + """ + Uploads an orthophoto (TIFF) file to OpenAerialMap (OAM). + + Args: + project_id: The UUID of the project. + task_id: The UUID of the task. + user_data: Authenticated user data. + project: Project details fetched by project ID. + """ + + s3_url = get_orthophoto_url(settings.S3_BUCKET_NAME, project_id, task_id) + oam_params = { + "acquisition_end": datetime.now().isoformat(), + "acquisition_start": datetime.now().isoformat(), + "provider": f"{user_data.name}", + "sensor": "DJI MINI4", + "tags": "", + "title": project.name, + "token": settings.OAM_API_TOKEN, + } + + oam_upload_id = await oam.upload_orthophoto_to_oam(oam_params, s3_url) + # NOTE: Status of the uploaded orthophoto can be checked on OpenAerialMap using the OAM upload ID.https://map.openaerialmap.org/#/upload/status/673dbb268ac1b1000173a51d? + return { + "message": "Upload initiated", + "project_id": project_id, + "task_id": task_id, + "oam_id": oam_upload_id, + } From dd2fa342f190f3237d6cf539329d2bdc77550744 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:47:57 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/backend/app/tasks/oam.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/backend/app/tasks/oam.py b/src/backend/app/tasks/oam.py index d5b3536a..1e9f095b 100644 --- a/src/backend/app/tasks/oam.py +++ b/src/backend/app/tasks/oam.py @@ -25,13 +25,21 @@ async def upload_orthophoto_to_oam(oam_params, download_url): ) res = response.json() - if response.status_code == 200 and "results" in res and "upload" in res["results"]: + if ( + response.status_code == 200 + and "results" in res + and "upload" in res["results"] + ): oam_upload_id = res["results"]["upload"] - log.info(f"Orthophoto successfully uploaded to OAM with ID: {oam_upload_id}") + log.info( + f"Orthophoto successfully uploaded to OAM with ID: {oam_upload_id}" + ) return oam_upload_id else: err_msg = f"Failed to upload orthophoto. Response: {json.dumps(res)}" - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=err_msg) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=err_msg + ) except Exception as e: raise HTTPException( From b56e89e99fc9605e774019452fa42fa0a56fb432 Mon Sep 17 00:00:00 2001 From: Pradip-p Date: Wed, 20 Nov 2024 17:37:11 +0545 Subject: [PATCH 5/5] fix(remove): remove HTTPStatus import from http in oam page --- src/backend/app/tasks/oam.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/backend/app/tasks/oam.py b/src/backend/app/tasks/oam.py index d5b3536a..c9ee001b 100644 --- a/src/backend/app/tasks/oam.py +++ b/src/backend/app/tasks/oam.py @@ -1,4 +1,3 @@ -from http import HTTPStatus from fastapi import HTTPException import requests from urllib.parse import urlencode @@ -25,13 +24,21 @@ async def upload_orthophoto_to_oam(oam_params, download_url): ) res = response.json() - if response.status_code == 200 and "results" in res and "upload" in res["results"]: + if ( + response.status_code == 200 + and "results" in res + and "upload" in res["results"] + ): oam_upload_id = res["results"]["upload"] - log.info(f"Orthophoto successfully uploaded to OAM with ID: {oam_upload_id}") + log.info( + f"Orthophoto successfully uploaded to OAM with ID: {oam_upload_id}" + ) return oam_upload_id else: err_msg = f"Failed to upload orthophoto. Response: {json.dumps(res)}" - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=err_msg) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=err_msg + ) except Exception as e: raise HTTPException(