Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add endpoint to upload task orthophoto to OpenAerialMap (OAM) #346

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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}
1 change: 1 addition & 0 deletions src/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/backend/app/s3.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.",
)
47 changes: 47 additions & 0 deletions src/backend/app/tasks/oam.py
Original file line number Diff line number Diff line change
@@ -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}",
)
43 changes: 43 additions & 0 deletions src/backend/app/tasks/task_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
import uuid
from typing import Annotated
from app.projects import project_deps, project_schemas
Expand All @@ -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",
Expand Down Expand Up @@ -86,3 +89,43 @@ async def new_event(
user_data,
background_tasks,
)


@router.post("/upload/{project_id}/{task_id}")
Copy link
Member

@spwoodcock spwoodcock Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably isn't a comment specific to this PR, but the project as a whole.

The endpoint structure for FMTM doesn't necessarily conform to the REST spec / best practices, but we are working on it & hopefully that's changing over time 🙏

So typically it would be set up like this:

If the task id is a unique identifier on it's own.
/projects/{project_id}
/projects/{project_id}/do-something
/tasks/{task_id}
/tasks/{task_id}/do-something

If task ids are only unique in relation to the project they are within:
/projects/{project_id}
/projects/{project_id}/tasks/{task_id}
/projects/{project_id}/tasks/{task_id}/do-something

So for this endpoint, as task_id is a globally unique UUID, it would make sense to create:

/tasks/{task_id}/upload-ortho

or something similar.

Just for info going forward! I'm not saying things have to change immediately, or even at all, but it could be a nice to have into the future 😄

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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You only really need to add docstrings with Args and Returns on crud functions. For routes, this is automatically documented using type hints and OpenAPI spec 👌

Plus docstrings are useful for devs to consume functions, but the route is the top level of the logic and nobody is importing and using the routes elsewhere

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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 (
<FlexColumn gap={5} className="">
Expand Down Expand Up @@ -58,6 +68,37 @@ export default function Conditions({
</div>
<ErrorMessage message={errors?.deadline_at?.message as string} />
</FormControl>

<FormControl>
<RadioButton
required
topic="Approval from Regulator"
options={regulatorApprovalOptions}
direction="column"
onChangeData={value => {
dispatch(
setCreateProjectState({
requiresApprovalFromRegulator: value,
}),
);
}}
value={requiresApprovalFromRegulator}
/>
</FormControl>

{requiresApprovalFromRegulator === 'required' && (
<FormControl className="naxatw-gap-2">
<Label required>Approval Email</Label>
<MultipleEmailInput
emails={regulatorEmails}
onEmailAdd={emails => {
dispatch(setCreateProjectState({ regulatorEmails: emails }));
}}
/>
<ErrorMessage message={errors?.regulator_emails?.message as string} />
</FormControl>
)}

<FormControl>
<RadioButton
required
Expand Down
99 changes: 99 additions & 0 deletions src/frontend/src/components/common/MultipleEmailInput/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
if (e.key === 'Enter') {
addInputEmailOnList();
}
return () => {};
};

const handleDeleteEmail = (email: string) => {
setEmailList(prev => {
const newList = prev?.filter(prevEmail => prevEmail !== email);
onEmailAdd(newList);
return newList;
});
};

return (
<FormControl className="naxatw-relative">
<Input
placeholder="Enter email and press enter or click '+' icon to add"
onChange={handleChange}
value={inputEmail}
onKeyDown={handleKeyDown}
/>

<i
className="material-icons naxatw-absolute naxatw-right-2 naxatw-top-[6px] naxatw-z-30 naxatw-cursor-pointer naxatw-rounded-full naxatw-text-red hover:naxatw-bg-redlight"
onClick={() => addInputEmailOnList()}
role="button"
tabIndex={0}
onKeyDown={() => {}}
>
add
</i>

<ErrorMessage message={error} />
<FlexRow gap={2} className="naxatw-flex-wrap">
{emailList?.map((email: string) => (
<div
key={email}
className="naxatw-flex naxatw-w-fit naxatw-items-center naxatw-gap-1 naxatw-rounded-xl naxatw-border naxatw-border-black naxatw-bg-gray-50 naxatw-px-2 naxatw-py-0.5"
>
<div className="naxatw-flex naxatw-items-center naxatw-text-sm naxatw-leading-4">
{email}
</div>
<i
className="material-icons naxatw-cursor-pointer naxatw-rounded-full naxatw-text-center naxatw-text-base hover:naxatw-bg-redlight"
tabIndex={0}
role="button"
onKeyDown={() => {}}
onClick={() => handleDeleteEmail(email)}
>
close
</i>
</div>
))}
</FlexRow>
</FormControl>
);
};

export default MultipleEmailInput;
9 changes: 9 additions & 0 deletions src/frontend/src/constants/createProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading