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..c9ee001b --- /dev/null +++ b/src/backend/app/tasks/oam.py @@ -0,0 +1,47 @@ +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, + } 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 })); + }} + /> + + + )} + 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; diff --git a/src/frontend/src/constants/createProject.tsx b/src/frontend/src/constants/createProject.tsx index 80951f12..a11010ad 100644 --- a/src/frontend/src/constants/createProject.tsx +++ b/src/frontend/src/constants/createProject.tsx @@ -160,6 +160,15 @@ export const lockApprovalOptions = [ { name: 'Not Required', label: 'Not Required', value: 'not_required' }, ]; +export const regulatorApprovalOptions = [ + { name: 'regulator approval Required', label: 'Required', value: 'required' }, + { + name: 'regulator approval not Required', + label: 'Not Required', + value: 'not_required', + }, +]; + export const FinalOutputOptions = [ { label: '2D Orthophoto', value: 'ORTHOPHOTO_2D', icon: orthoPhotoIcon }, // { label: '3D Model', value: 'ORTHOPHOTO_3D', icon: _3DModal }, diff --git a/src/frontend/src/store/slices/createproject.ts b/src/frontend/src/store/slices/createproject.ts index 260f6ae1..b9be9093 100644 --- a/src/frontend/src/store/slices/createproject.ts +++ b/src/frontend/src/store/slices/createproject.ts @@ -24,6 +24,8 @@ export interface CreateProjectState { projectMapImage: any; imageMergeType: string; ProjectsFilterByOwner: 'yes' | 'no'; + requiresApprovalFromRegulator: 'required' | 'not_required'; + regulatorEmails: string[] | []; } const initialState: CreateProjectState = { @@ -47,6 +49,8 @@ const initialState: CreateProjectState = { projectMapImage: null, imageMergeType: 'overlap', ProjectsFilterByOwner: 'no', + requiresApprovalFromRegulator: 'not_required', + regulatorEmails: [], }; const setCreateProjectState: CaseReducer<