diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index 409af322..efa7e5cc 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -168,6 +168,7 @@ class EventType(str, Enum): - ``assign`` -- For a requester user to assign a task to another user. Set the state *locked for mapping* passing in the required user id. - ``comment`` -- Keep the state the same, but simply add a comment. - ``unlock`` -- Unlock a task state by unlocking it if it's locked. + - ``image_upload`` -- Set the state to *image uploaded* when the task image is uploaded. Note that ``task_id`` must be specified in the endpoint too. """ @@ -183,3 +184,4 @@ class EventType(str, Enum): ASSIGN = "assign" COMMENT = "comment" UNLOCK = "unlock" + IMAGE_UPLOAD = "image_upload" diff --git a/src/backend/app/projects/image_processing.py b/src/backend/app/projects/image_processing.py index 0bffdef4..acaed224 100644 --- a/src/backend/app/projects/image_processing.py +++ b/src/backend/app/projects/image_processing.py @@ -6,6 +6,7 @@ from app.tasks import task_logic from app.models.enums import State from app.utils import timestamp +from app.db import database from pyodm import Node from app.s3 import get_file_from_bucket, list_objects_from_bucket, add_file_to_bucket from loguru import logger as log @@ -13,7 +14,6 @@ from psycopg import Connection from asgiref.sync import async_to_sync from app.config import settings -from app.db import database class DroneImageProcessor: @@ -199,6 +199,8 @@ async def download_and_upload_assets_from_odm_to_s3( dtm_project_id: uuid.UUID, dtm_task_id: uuid.UUID, user_id: str, + current_state: State, + comment: str, ): """ Downloads results from ODM and uploads them to S3 (Minio). @@ -210,6 +212,7 @@ async def download_and_upload_assets_from_odm_to_s3( """ log.info(f"Starting download for task {task_id}") + # Replace with actual ODM node details and URL node = Node.from_url(node_odm_url) @@ -241,8 +244,8 @@ async def download_and_upload_assets_from_odm_to_s3( project_id=dtm_project_id, task_id=dtm_task_id, user_id=user_id, - comment="Task completed.", - initial_state=State.IMAGE_UPLOADED, + comment=comment, + initial_state=current_state, final_state=State.IMAGE_PROCESSED, updated_at=timestamp(), ) diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index de6ea197..eddc5c22 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -34,6 +34,7 @@ from app.tasks import task_schemas from app.utils import geojson_to_kml, timestamp from app.users import user_schemas +from minio.deleteobjects import DeleteObject router = APIRouter( @@ -287,8 +288,9 @@ async def preview_split_by_square( @router.post("/generate-presigned-url/", tags=["Image Upload"]) async def generate_presigned_url( - data: project_schemas.PresignedUrlRequest, user: Annotated[AuthUser, Depends(login_required)], + data: project_schemas.PresignedUrlRequest, + replace_existing: bool = False, ): """ Generate a pre-signed URL for uploading an image to S3 Bucket. @@ -297,21 +299,54 @@ async def generate_presigned_url( an S3 bucket. The URL expires after a specified duration. Args: - - image_name: The name of the image you want to upload - expiry : Expiry time in hours + image_name: The name of the image(s) you want to upload. + expiry : Expiry time in hours. + replace_existing: A boolean flag to indicate if the image should be replaced. Returns: - - str: The pre-signed URL to upload the image + list: A list of dictionaries with the image name and the pre-signed URL to upload. """ try: - # Generate a pre-signed URL for an object + # Initialize the S3 client client = s3_client() urls = [] + + # Process each image in the request for image in data.image_name: image_path = f"projects/{data.project_id}/{data.task_id}/images/{image}" + # If replace_existing is True, delete the image first + if replace_existing: + image_dir = f"projects/{data.project_id}/{data.task_id}/images/" + try: + # Prepare the list of objects to delete (recursively if necessary) + delete_object_list = map( + lambda x: DeleteObject(x.object_name), + client.list_objects( + settings.S3_BUCKET_NAME, image_dir, recursive=True + ), + ) + + # Remove the objects (images) + errors = client.remove_objects( + settings.S3_BUCKET_NAME, delete_object_list + ) + + # Handle deletion errors, if any + for error in errors: + log.error("Error occurred when deleting object", error) + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to delete existing image: {error}", + ) + + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to delete existing image. {e}", + ) + + # Generate a new pre-signed URL for the image upload url = client.get_presigned_url( "PUT", settings.S3_BUCKET_NAME, @@ -321,6 +356,7 @@ async def generate_presigned_url( urls.append({"image_name": image, "url": url}) return urls + except Exception as e: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -395,17 +431,6 @@ async def process_imagery( db: Annotated[Connection, Depends(database.get_db)], ): user_id = user_data.id - # TODO: Update task state to reflect completion of image uploads. - await task_logic.update_task_state( - db, - project.id, - task_id, - user_id, - "Task images upload completed.", - State.LOCKED_FOR_MAPPING, - State.IMAGE_UPLOADED, - timestamp(), - ) background_tasks.add_task( project_logic.process_drone_images, project.id, task_id, user_id, db ) @@ -443,7 +468,10 @@ async def get_assets_info( return results else: - return project_logic.get_project_info_from_s3(project.id, task_id) + current_state = await task_logic.get_task_state(db, project.id, task_id) + project_info = project_logic.get_project_info_from_s3(project.id, task_id) + project_info.state = current_state.get("state") + return project_info @router.post( @@ -479,16 +507,74 @@ async def odm_webhook( # If status is 'success', download and upload assets to S3. # 40 is the status code for success in odm if status["code"] == 40: - # Call function to download assets from ODM and upload to S3 - background_tasks.add_task( - image_processing.download_and_upload_assets_from_odm_to_s3, - settings.NODE_ODM_URL, - task_id, - dtm_project_id, - dtm_task_id, - dtm_user_id, - ) + log.info(f"Task ID: {task_id}, Status: going for download......") + + current_state = await task_logic.get_task_state(db, dtm_project_id, dtm_task_id) + current_state_value = State[current_state.get("state")] + match current_state_value: + case State.IMAGE_UPLOADED: + log.info( + f"Task ID: {task_id}, Status: already IMAGE_UPLOADED - no update needed." + ) + # Call function to download assets from ODM and upload to S3 + background_tasks.add_task( + image_processing.download_and_upload_assets_from_odm_to_s3, + settings.NODE_ODM_URL, + task_id, + dtm_project_id, + dtm_task_id, + dtm_user_id, + State.IMAGE_UPLOADED, + "Task completed.", + ) + + case State.IMAGE_PROCESSING_FAILED: + log.warning( + f"Task ID: {task_id}, Status: previously failed, updating to IMAGE_UPLOADED" + ) + # Call function to download assets from ODM and upload to S3 + background_tasks.add_task( + image_processing.download_and_upload_assets_from_odm_to_s3, + settings.NODE_ODM_URL, + task_id, + dtm_project_id, + dtm_task_id, + dtm_user_id, + State.IMAGE_UPLOADED, + "Task completed.", + ) + + case _: + log.info( + f"Task ID: {task_id}, Status: updating to IMAGE_UPLOADED from {current_state}" + ) + elif status["code"] == 30: - # failed task - log.error(f'ODM task {task_id} failed: {status["errorMessage"]}') + current_state = await task_logic.get_task_state(db, dtm_project_id, dtm_task_id) + # If the current state is not already IMAGE_PROCESSING_FAILED, update it + if current_state != State.IMAGE_PROCESSING_FAILED: + await task_logic.update_task_state( + db, + dtm_project_id, + dtm_task_id, + dtm_user_id, + "Image processing failed.", + State.IMAGE_UPLOADED, + State.IMAGE_PROCESSING_FAILED, + timestamp(), + ) + + background_tasks.add_task( + image_processing.download_and_upload_assets_from_odm_to_s3, + settings.NODE_ODM_URL, + task_id, + dtm_project_id, + dtm_task_id, + dtm_user_id, + State.IMAGE_PROCESSING_FAILED, + "Image processing failed.", + ) + + log.info(f"Task ID: {task_id}, Status: Webhook received") + return {"message": "Webhook received", "task_id": task_id} diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index d05ee799..bd394db6 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -12,7 +12,7 @@ from psycopg import Connection from psycopg.rows import class_row from slugify import slugify -from app.models.enums import FinalOutput, ProjectVisibility +from app.models.enums import FinalOutput, ProjectVisibility, UserRole from app.models.enums import ( IntEnum, ProjectStatus, @@ -58,6 +58,7 @@ class AssetsInfo(BaseModel): task_id: str image_count: int assets_url: Optional[str] + state: Optional[UserRole] = None def validate_geojson( @@ -197,6 +198,7 @@ class DbProject(BaseModel): altitude_from_ground: Optional[float] = None is_terrain_follow: bool = False image_url: Optional[str] = None + created_at: datetime async def one(db: Connection, project_id: uuid.UUID): """Get a single project & all associated tasks by ID.""" @@ -258,15 +260,7 @@ async def one(db: Connection, project_id: uuid.UUID): SELECT DISTINCT ON (te.task_id) te.task_id, te.user_id, - CASE - WHEN te.state = 'REQUEST_FOR_MAPPING' THEN 'request logs' - WHEN te.state = 'LOCKED_FOR_MAPPING' OR te.state = 'IMAGE_UPLOADED' THEN 'ongoing' - WHEN te.state = 'IMAGE_PROCESSED' THEN 'completed' - WHEN te.state = 'UNFLYABLE_TASK' THEN 'unflyable task' - WHEN te.state = 'IMAGE_PROCESSING_FAILED' THEN 'task failed' - - ELSE '' - END AS calculated_state + te.state FROM task_events te ORDER BY @@ -283,7 +277,7 @@ async def one(db: Connection, project_id: uuid.UUID): ST_YMin(ST_Envelope(t.outline)) AS ymin, ST_XMax(ST_Envelope(t.outline)) AS xmax, ST_YMax(ST_Envelope(t.outline)) AS ymax, - COALESCE(tsc.calculated_state) AS state, + tsc.state AS state, tsc.user_id, u.name, ST_Area(ST_Transform(t.outline, 3857)) / 1000000 AS task_area @@ -343,7 +337,7 @@ async def all( await cur.execute( """ SELECT - p.id, p.slug, p.name, p.description, p.per_task_instructions, + p.id, p.slug, p.name, p.description, p.per_task_instructions, p.created_at, ST_AsGeoJSON(p.outline)::jsonb AS outline, p.requires_approval_from_manager_for_locking, @@ -541,6 +535,7 @@ class ProjectInfo(BaseModel): ongoing_task_count: Optional[int] = 0 completed_task_count: Optional[int] = 0 status: Optional[str] = "not-started" + created_at: datetime @model_validator(mode="after") def set_image_url(cls, values): diff --git a/src/backend/app/tasks/task_logic.py b/src/backend/app/tasks/task_logic.py index dff7d597..398a67b2 100644 --- a/src/backend/app/tasks/task_logic.py +++ b/src/backend/app/tasks/task_logic.py @@ -7,26 +7,6 @@ from datetime import datetime -async def get_current_state(db, project_id, task_id): - try: - async with db.cursor() as cur: - await cur.execute( - """ - SELECT DISTINCT ON (state) state - FROM task_events - WHERE task_id = %(task_id)s AND project_id = %(project_id)s - ORDER BY state, created_at DESC - """, - {"task_id": task_id, "project_id": project_id}, - ) - return await cur.fetchone() - - except Exception as err: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(err) - ) - - async def update_take_off_point_in_db( db: Connection, task_id: uuid.UUID, take_off_point: str ): diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index f5a05a4a..c4cdd2b4 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -401,4 +401,43 @@ async def new_event( detail.updated_at, ) + case EventType.IMAGE_UPLOAD: + current_task_state = await task_logic.get_task_state( + db, project_id, task_id + ) + if not current_task_state: + raise HTTPException( + status_code=400, detail="Task is not ready for image upload." + ) + state = current_task_state.get("state") + locked_user_id = current_task_state.get("user_id") + + # Determine error conditions: Current State must be IMAGE_UPLOADED or IMAGE_PROCESSING_FAILED or lokec for mapping. + if state not in ( + State.IMAGE_UPLOADED.name, + State.IMAGE_PROCESSING_FAILED.name, + State.LOCKED_FOR_MAPPING.name, + ): + raise HTTPException( + status_code=400, + detail="Task state does not match expected state for image upload.", + ) + + if user_id != locked_user_id: + raise HTTPException( + status_code=403, + detail="You cannot upload an image for this task as it is locked by another user.", + ) + + return await task_logic.update_task_state( + db, + project_id, + task_id, + user_id, + f"Task image uploaded by user {user_data.name}.", + State[state], + State.IMAGE_UPLOADED, + detail.updated_at, + ) + return True diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index 14155290..28b2fbfd 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -168,14 +168,7 @@ async def get_tasks_by_user( ST_Area(ST_Transform(tasks.outline, 3857)) / 1000000 AS task_area, task_events.created_at, task_events.updated_at, - CASE - WHEN task_events.state = 'REQUEST_FOR_MAPPING' THEN 'request logs' - WHEN task_events.state IN ('LOCKED_FOR_MAPPING', 'IMAGE_UPLOADED') THEN 'ongoing' - WHEN task_events.state = 'IMAGE_PROCESSED' THEN 'completed' - WHEN task_events.state = 'UNFLYABLE_TASK' THEN 'unflyable task' - WHEN task_events.state = 'IMAGE_PROCESSING_FAILED' THEN 'task failed' - ELSE '' - END AS state + task_events.state FROM task_events LEFT JOIN diff --git a/src/frontend/src/components/Dashboard/RequestLogs/index.tsx b/src/frontend/src/components/Dashboard/RequestLogs/index.tsx index ccdde506..4322acac 100644 --- a/src/frontend/src/components/Dashboard/RequestLogs/index.tsx +++ b/src/frontend/src/components/Dashboard/RequestLogs/index.tsx @@ -1,19 +1,20 @@ import { useGetTaskListQuery } from '@Api/dashboard'; import { FlexColumn } from '@Components/common/Layouts'; import { Button } from '@Components/RadixComponents/Button'; +import { taskStatusObj } from '@Constants/index'; import { postTaskStatus } from '@Services/project'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; import { toast } from 'react-toastify'; const RequestLogs = () => { + const queryClient = useQueryClient(); const { data: requestedTasks }: any = useGetTaskListQuery({ select: (data: any) => - data?.data?.filter( - (task: Record) => task?.state === 'request logs', + data?.data?.filter((task: Record) => + taskStatusObj.request_logs.includes(task?.state), ), }); - const queryClient = useQueryClient(); const { mutate: respondToRequest } = useMutation({ mutationFn: postTaskStatus, diff --git a/src/frontend/src/components/Dashboard/TaskLogs/TaskLogsTable.tsx b/src/frontend/src/components/Dashboard/TaskLogs/TaskLogsTable.tsx index 0095b9d6..a46a31f2 100644 --- a/src/frontend/src/components/Dashboard/TaskLogs/TaskLogsTable.tsx +++ b/src/frontend/src/components/Dashboard/TaskLogs/TaskLogsTable.tsx @@ -1,3 +1,4 @@ +import { formatString } from '@Utils/index'; import { format } from 'date-fns'; import { useNavigate } from 'react-router-dom'; @@ -19,7 +20,7 @@ const TaskLogsTable = ({ data: taskList }: ITaskLogsTableProps) => { Project Name - Total task area + Total task area in kmĀ² {/* Est.flight time @@ -47,7 +48,7 @@ const TaskLogsTable = ({ data: taskList }: ITaskLogsTableProps) => { {format(new Date(task.created_at), 'yyyy-MM-dd')} - {task.state} + {formatString(task.state)}
{ - if (title === 'Ongoing Tasks') return 'ongoing'; - if (title === 'Request Logs') return 'request logs'; - if (title === 'Unflyable Tasks') return 'unflyable task'; - if (title === 'Completed Tasks') return 'completed'; - - return ''; +const getStatusListByTitle = (title: string): string[] => { + if (title === 'Ongoing Tasks') return taskStatusObj.ongoing; + if (title === 'Request Logs') return taskStatusObj.request_logs; + if (title === 'Unflyable Tasks') return taskStatusObj.unflyable; + if (title === 'Completed Tasks') return taskStatusObj.completed; + return []; }; const TaskLogs = ({ title }: TaskLogsProps) => { @@ -21,8 +21,8 @@ const TaskLogs = ({ title }: TaskLogsProps) => { const filteredData = useMemo( () => - taskList?.filter( - (task: Record) => task?.state === getStatusByTitle(title), + taskList?.filter((task: Record) => + getStatusListByTitle(title)?.includes(task?.state), ), [title, taskList], ); diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx index c6c20130..f2dc2eba 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx @@ -1,19 +1,32 @@ import { useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; import { toast } from 'react-toastify'; import { useGetIndividualTaskQuery, useGetTaskAssetsInfo, useGetTaskWaypointQuery, } from '@Api/tasks'; +import { useMutation } from '@tanstack/react-query'; +import { postProcessImagery } from '@Services/tasks'; +import { formatString } from '@Utils/index'; import { Button } from '@Components/RadixComponents/Button'; +import { Label } from '@Components/common/FormUI'; +import SwitchTab from '@Components/common/SwitchTab'; +import { setUploadedImagesType } from '@Store/actions/droneOperatorTask'; +import { useTypedSelector } from '@Store/hooks'; import DescriptionBoxComponent from './DescriptionComponent'; import QuestionBox from '../QuestionBox'; import UploadsInformation from '../UploadsInformation'; +import UploadsBox from '../UploadsBox'; const DescriptionBox = () => { + const dispatch = useDispatch(); const [flyable, setFlyable] = useState('yes'); const { taskId, projectId } = useParams(); + const uploadedImageType = useTypedSelector( + state => state.droneOperatorTask.uploadedImagesType, + ); const { data: taskWayPoints }: any = useGetTaskWaypointQuery( projectId as string, @@ -27,6 +40,13 @@ const DescriptionBox = () => { const { data: taskAssetsInformation }: Record = useGetTaskAssetsInfo(projectId as string, taskId as string); + const { mutate: reStartImageryProcess } = useMutation({ + mutationFn: () => postProcessImagery(projectId as string, taskId as string), + onSuccess: () => { + toast.success('Image processing re-started'); + }, + }); + const { data: taskDescription }: Record = useGetIndividualTaskQuery(taskId as string, { enabled: !!taskWayPoints, @@ -170,8 +190,13 @@ const DescriptionBox = () => { name: 'Orthophoto available', value: taskAssetsInformation?.assets_url ? 'Yes' : 'No', }, + { + name: 'Image Status', + value: formatString(taskAssetsInformation?.state), + }, ]} /> + {taskAssetsInformation?.assets_url && (
)} + {taskAssetsInformation?.state === 'IMAGE_PROCESSING_FAILED' && ( +
+ +
+ )} + {taskAssetsInformation?.state === 'IMAGE_PROCESSING_FAILED' && ( +
+ + ) => { + dispatch(setUploadedImagesType(selected.value)); + }} + /> +

+ Note:{' '} + {uploadedImageType === 'add' + ? 'Uploaded images will be added with the existing images.' + : 'Uploaded images will be replaced with all the existing images and starts processing.'} +

+ +
+ )}
)} diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx index fc625d98..789b3f58 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/ImageBox/index.tsx @@ -1,8 +1,4 @@ -/* eslint-disable jsx-a11y/interactive-supports-focus */ -/* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable no-await-in-loop */ -/* eslint-disable no-console */ -/* eslint-disable no-unused-vars */ import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; import { toast } from 'react-toastify'; @@ -20,19 +16,13 @@ import chunkArray from '@Utils/createChunksOfArray'; import delay from '@Utils/createDelay'; import widthCalulator from '@Utils/percentageCalculator'; import { postProcessImagery } from '@Services/tasks'; - +import { postTaskStatus } from '@Services/project'; import FilesUploadingPopOver from '../LoadingBox'; import ImageCard from './ImageCard'; import PreviewImage from './PreviewImage'; -// interface IImageBoxPopOverProps { -// show: boolean; -// imageFiles: any[]; -// } - const ImageBoxPopOver = () => { const dispatch = useTypedDispatch(); - const pathname = window.location.pathname?.split('/'); const projectId = pathname?.[2]; const taskId = pathname?.[4]; @@ -50,18 +40,36 @@ const ImageBoxPopOver = () => { const checkedImages = useTypedSelector( state => state.droneOperatorTask.checkedImages, ); + const uploadedImageType = useTypedSelector( + state => state.droneOperatorTask.uploadedImagesType, + ); + + const { mutate: updateStatus } = useMutation({ + mutationFn: postTaskStatus, + onError: (err: any) => { + toast.error(err.message); + }, + }); const { mutate: startImageryProcess } = useMutation({ mutationFn: () => postProcessImagery(projectId, taskId), - onSuccess: () => toast.success('Image processing started'), - // retry: (failureCount: any, error: any) => - // error.status === 307 && failureCount < 5, + onSuccess: () => { + updateStatus({ + projectId, + taskId, + data: { event: 'image_upload', updated_at: new Date().toISOString() }, + }); + toast.success('Image processing started'); + }, }); // function that gets the signed urls for the images and again puts them in chunks of 4 const { mutate } = useMutation({ mutationFn: async (data: any) => { - const urlsData = await getImageUploadLink(data); + const urlsData = await getImageUploadLink( + uploadedImageType === 'replace', + data, + ); // urls fromm array of objects is retrieved and stored in value const urls = urlsData.data.map((url: any) => url.url); @@ -184,7 +192,7 @@ const ImageBoxPopOver = () => {
@@ -197,7 +205,7 @@ const ImageBoxPopOver = () => {

diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx index 4df0ea89..c88c0ae0 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/UploadsBox/index.tsx @@ -6,7 +6,7 @@ import { toggleModal } from '@Store/actions/common'; import { setFiles } from '@Store/actions/droneOperatorTask'; import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; -const UploadsBox = () => { +const UploadsBox = ({ label = 'Upload Raw Image' }: { label?: string }) => { const dispatch = useTypedDispatch(); const files = useTypedSelector(state => state.droneOperatorTask.files); const handleFileChange = (event: any) => { @@ -22,17 +22,38 @@ const UploadsBox = () => {

- Upload Raw Image + {label}

- + + {files.length > 0 && (
{width < 640 && } - {renderComponent(secondPageState)} +
+ +
); diff --git a/src/frontend/src/components/DroneOperatorTask/Header/index.tsx b/src/frontend/src/components/DroneOperatorTask/Header/index.tsx index 648d91cf..01bf47fb 100644 --- a/src/frontend/src/components/DroneOperatorTask/Header/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/Header/index.tsx @@ -1,32 +1,30 @@ import { useGetIndividualTaskQuery } from '@Api/tasks'; -import { useNavigate, useParams } from 'react-router-dom'; +import BreadCrumb from '@Components/common/Breadcrumb'; +import { useParams } from 'react-router-dom'; const DroneOperatorTaskHeader = () => { - const navigate = useNavigate(); - const { taskId } = useParams(); + const { taskId, projectId } = useParams(); const { data: taskDescription }: Record = useGetIndividualTaskQuery(taskId as string); return ( <> -
-
-

navigate('/projects')} - > - Projects -

-

- / -

-

- {taskDescription?.project_name || '-'} -

-
-
+ 8 ? '...' : ''}` || + '--', + navLink: `/projects/${projectId}`, + }, + { + name: `#${taskDescription?.project_task_index}` || '--', + navLink: '', + }, + ]} + /> ); }; diff --git a/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx b/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx index 4b3b3b8c..47f2b34c 100644 --- a/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx +++ b/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx @@ -2,6 +2,7 @@ import { useGetAllAssetsUrlQuery } from '@Api/projects'; import DataTable from '@Components/common/DataTable'; import Icon from '@Components/common/Icon'; import { useTypedSelector } from '@Store/hooks'; +import { formatString } from '@Utils/index'; import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -88,7 +89,7 @@ export default function TableSection({ { user: curr?.name || '-', task_mapped: `Task# ${curr?.project_task_index}`, - task_state: curr?.state, + task_state: formatString(curr?.state), assets_url: selectedAssetsDetails?.assets_url, image_count: selectedAssetsDetails?.image_count, task_id: curr?.id, diff --git a/src/frontend/src/components/IndividualProject/MapSection/Legend.tsx b/src/frontend/src/components/IndividualProject/MapSection/Legend.tsx index 21cc42fb..f3b3417e 100644 --- a/src/frontend/src/components/IndividualProject/MapSection/Legend.tsx +++ b/src/frontend/src/components/IndividualProject/MapSection/Legend.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; const Legend = () => { const [showLegendItems, setShowLegendItems] = useState(true); return ( -
+
Legend
{
Image Processing
+
+
+
Image Processing Failed
+
Requested Tasks
diff --git a/src/frontend/src/components/IndividualProject/MapSection/index.tsx b/src/frontend/src/components/IndividualProject/MapSection/index.tsx index d421937e..6cedff46 100644 --- a/src/frontend/src/components/IndividualProject/MapSection/index.tsx +++ b/src/frontend/src/components/IndividualProject/MapSection/index.tsx @@ -141,7 +141,8 @@ const MapSection = ({ projectData }: { projectData: Record }) => { return `This task's Images has been uploaded ${properties.locked_user_name ? `by ${userDetails?.id === properties?.locked_user_id ? 'you' : properties?.locked_user_name}` : ''}`; case 'IMAGE_PROCESSED': return `This task is completed ${properties.locked_user_name ? `by ${userDetails?.id === properties?.locked_user_id ? 'you' : properties?.locked_user_name}` : ''}`; - + case 'IMAGE_PROCESSING_FAILED': + return `This task's image processing is failed started ${properties.locked_user_name ? `by ${userDetails?.id === properties?.locked_user_id ? 'you' : properties?.locked_user_name}` : ''}`; default: return ''; } @@ -260,32 +261,52 @@ const MapSection = ({ projectData }: { projectData: Record }) => { 'fill-opacity': 0.5, }, } - : taskStatusObj?.[`${task?.id}`] === 'IMAGE_UPLOADED' + : taskStatusObj?.[`${task?.id}`] === 'IMAGE_PROCESSED' ? { type: 'fill', paint: { - 'fill-color': '#9C77B2', + 'fill-color': '#ACD2C4', 'fill-outline-color': '#484848', - 'fill-opacity': 0.5, + 'fill-opacity': 0.7, }, } - : taskStatusObj?.[`${task?.id}`] === 'IMAGE_PROCESSED' + : taskStatusObj?.[`${task?.id}`] === 'IMAGE_UPLOADED' ? { type: 'fill', paint: { - 'fill-color': '#ACD2C4', - 'fill-outline-color': '#484848', - 'fill-opacity': 0.7, - }, - } - : { - type: 'fill', - paint: { - 'fill-color': '#ffffff', + 'fill-color': '#9C77B2', 'fill-outline-color': '#484848', 'fill-opacity': 0.5, }, } + : taskStatusObj?.[`${task?.id}`] === + 'IMAGE_PROCESSING_FAILED' + ? { + type: 'fill', + paint: { + 'fill-color': '#f00000', + 'fill-outline-color': '#484848', + 'fill-opacity': 0.5, + }, + } + : taskStatusObj?.[`${task?.id}`] === + 'UNFLYABLE_TASK' + ? { + type: 'fill', + paint: { + 'fill-color': '#9EA5AD', + 'fill-outline-color': '#484848', + 'fill-opacity': 0.7, + }, + } + : { + type: 'fill', + paint: { + 'fill-color': '#ffffff', + 'fill-outline-color': '#484848', + 'fill-opacity': 0.5, + }, + } } hasImage={ taskStatusObj?.[`${task?.id}`] === 'LOCKED_FOR_MAPPING' || false @@ -321,7 +342,8 @@ const MapSection = ({ projectData }: { projectData: Record }) => { (taskStatusObj?.[selectedTaskId] === 'LOCKED_FOR_MAPPING' && lockedUser?.id === userDetails?.id) || taskStatusObj?.[selectedTaskId] === 'IMAGE_UPLOADED' || - taskStatusObj?.[selectedTaskId] === 'IMAGE_PROCESSED' + taskStatusObj?.[selectedTaskId] === 'IMAGE_PROCESSED' || + taskStatusObj?.[selectedTaskId] === 'IMAGE_PROCESSING_FAILED' ) } buttonText={ diff --git a/src/frontend/src/constants/index.ts b/src/frontend/src/constants/index.ts index 561da55c..4d236725 100644 --- a/src/frontend/src/constants/index.ts +++ b/src/frontend/src/constants/index.ts @@ -86,3 +86,10 @@ export const rowsPerPageOptions = [ { label: '24', value: 24 }, { label: '30', value: 30 }, ]; + +export const taskStatusObj = { + request_logs: ['REQUEST_FOR_MAPPING'], + ongoing: ['LOCKED_FOR_MAPPING', 'IMAGE_UPLOADED', 'IMAGE_PROCESSING_FAILED'], + completed: ['IMAGE_PROCESSED'], + unflyable: ['UNFLYABLE_TASK'], +}; diff --git a/src/frontend/src/services/droneOperator.ts b/src/frontend/src/services/droneOperator.ts index fb4f0a32..fae23836 100644 --- a/src/frontend/src/services/droneOperator.ts +++ b/src/frontend/src/services/droneOperator.ts @@ -16,9 +16,13 @@ export const postUnflyableComment = ({ }, }); -export const getImageUploadLink = (data: any) => - authenticated(api).post(`/projects/generate-presigned-url/`, data, { - headers: { - 'Content-Type': 'application/json', +export const getImageUploadLink = (replaceExistingImages: boolean, data: any) => + authenticated(api).post( + `/projects/generate-presigned-url/?replace_existing=${replaceExistingImages}`, + data, + { + headers: { + 'Content-Type': 'application/json', + }, }, - }); + ); diff --git a/src/frontend/src/store/actions/droneOperatorTask.ts b/src/frontend/src/store/actions/droneOperatorTask.ts index 19e24a36..eb6d031b 100644 --- a/src/frontend/src/store/actions/droneOperatorTask.ts +++ b/src/frontend/src/store/actions/droneOperatorTask.ts @@ -12,4 +12,5 @@ export const { setFiles, setSelectedTakeOffPointOption, setSelectedTakeOffPoint, + setUploadedImagesType, } = droneOperatorTaskSlice.actions; diff --git a/src/frontend/src/store/slices/droneOperartorTask.ts b/src/frontend/src/store/slices/droneOperartorTask.ts index 85f33d4a..5f942271 100644 --- a/src/frontend/src/store/slices/droneOperartorTask.ts +++ b/src/frontend/src/store/slices/droneOperartorTask.ts @@ -10,6 +10,7 @@ export interface IDroneOperatorTaskState { files: any[]; selectedTakeOffPointOption: string; selectedTakeOffPoint: any[] | string | null; + uploadedImagesType: 'add' | 'replace'; } const initialState: IDroneOperatorTaskState = { @@ -21,6 +22,7 @@ const initialState: IDroneOperatorTaskState = { files: [], selectedTakeOffPointOption: 'current_location', selectedTakeOffPoint: null, + uploadedImagesType: 'add', }; export const droneOperatorTaskSlice = createSlice({ @@ -65,6 +67,10 @@ export const droneOperatorTaskSlice = createSlice({ setSelectedTakeOffPoint: (state, action) => { state.selectedTakeOffPoint = action.payload; }, + + setUploadedImagesType: (state, action) => { + state.uploadedImagesType = action.payload; + }, }, }); diff --git a/src/frontend/src/utils/index.ts b/src/frontend/src/utils/index.ts index a413c1a8..82620b4a 100644 --- a/src/frontend/src/utils/index.ts +++ b/src/frontend/src/utils/index.ts @@ -76,3 +76,13 @@ export const getFrontOverlap = (agl: number, forwardSpacing: number) => { const frontOverlap = (frontOverlapDistance * 100) / frontPhotoHeight; return frontOverlap.toFixed(2); }; + +// remove underscore and capitalize the word +export const formatString = (value: string) => { + if (!value) return ''; + if (value === 'IMAGE_PROCESSED') return 'Completed'; + return value + .replace(/_/g, ' ') + .toLowerCase() + .replace(/^\w/, char => char.toUpperCase()); +}; diff --git a/src/frontend/src/views/IndividualProject/index.tsx b/src/frontend/src/views/IndividualProject/index.tsx index ccd2a046..38efef96 100644 --- a/src/frontend/src/views/IndividualProject/index.tsx +++ b/src/frontend/src/views/IndividualProject/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable jsx-a11y/interactive-supports-focus */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import { useGetProjectsDetailQuery } from '@Api/projects'; +import BreadCrumb from '@Components/common/Breadcrumb'; import Tab from '@Components/common/Tabs'; import { Contributions, @@ -14,7 +15,7 @@ import { setProjectState } from '@Store/actions/project'; import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; import centroid from '@turf/centroid'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; // function to render the content based on active tab const getActiveTabContent = ( @@ -51,7 +52,6 @@ const getActiveTabContent = ( const IndividualProject = () => { const { id } = useParams(); const dispatch = useTypedDispatch(); - const navigate = useNavigate(); const individualProjectActiveTab = useTypedSelector( state => state.project.individualProjectActiveTab, @@ -103,25 +103,12 @@ const IndividualProject = () => { return (
- {/* <----------- temporary breadcrumb -----------> */} -
- { - navigate('/projects'); - }} - > - Project / - - - { - // @ts-ignore - projectData?.name || '--' - } - - {/* <----------- temporary breadcrumb -----------> */} -
+
{ const { width } = useWindowDimensions(); return ( <> -
-
-
+
+
+