diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index 2a3d3ddd..2282f5ca 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -161,6 +161,7 @@ class EventType(str, Enum): - ``split`` -- Set the state *unlocked done* then generate additional subdivided task areas. - ``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. Note that ``task_id`` must be specified in the endpoint too. """ @@ -175,3 +176,4 @@ class EventType(str, Enum): SPLIT = "split" ASSIGN = "assign" COMMENT = "comment" + UNLOCK = "unlock" diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index a292278f..c49f746d 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -283,18 +283,38 @@ async def all( limit: int = 100, ): """ - Get all projects. Optionally filter by the project creator (user). + Get all projects, count total tasks and task states (ongoing, completed, etc.). + Optionally filter by the project creator (user). """ async with db.cursor(row_factory=dict_row) as cur: await cur.execute( """ SELECT - id, slug, name, description, per_task_instructions, - ST_AsGeoJSON(outline)::jsonb AS outline, - requires_approval_from_manager_for_locking - FROM projects - WHERE (author_id = COALESCE(%(user_id)s, author_id)) - ORDER BY created_at DESC + p.id, p.slug, p.name, p.description, p.per_task_instructions, + ST_AsGeoJSON(p.outline)::jsonb AS outline, + p.requires_approval_from_manager_for_locking, + + -- Count total tasks for each project + COUNT(t.id) AS total_task_count, + + -- Count based on the latest state of tasks + COUNT(CASE WHEN te.state = 'LOCKED_FOR_MAPPING' THEN 1 END) AS ongoing_task_count + + FROM projects p + LEFT JOIN tasks t ON t.project_id = p.id + LEFT JOIN ( + -- Get the latest event per task + SELECT DISTINCT ON (te.task_id) + te.task_id, + te.state, + te.created_at + FROM task_events te + ORDER BY te.task_id, te.created_at DESC + ) AS te ON te.task_id = t.id + + WHERE (p.author_id = COALESCE(%(user_id)s, p.author_id)) + GROUP BY p.id + ORDER BY p.created_at DESC OFFSET %(skip)s LIMIT %(limit)s """, @@ -417,9 +437,10 @@ class ProjectOut(BaseModel): outline: Optional[Polygon | Feature | FeatureCollection] no_fly_zones: Optional[Polygon | Feature | FeatureCollection | MultiPolygon] = None requires_approval_from_manager_for_locking: bool - task_count: int = 0 + total_task_count: int = 0 tasks: Optional[list[TaskOut]] = [] image_url: Optional[str] = None + ongoing_task_count: Optional[int] = 0 @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 68ec2210..c44c11b0 100644 --- a/src/backend/app/tasks/task_logic.py +++ b/src/backend/app/tasks/task_logic.py @@ -128,3 +128,41 @@ async def request_mapping( ) result = await cur.fetchone() return result + + +async def get_task_state( + db: Connection, project_id: uuid.UUID, task_id: uuid.UUID +) -> dict: + """ + Retrieve the latest state of a task by querying the task_events table. + + Args: + db (Connection): The database connection. + project_id (uuid.UUID): The project ID. + task_id (uuid.UUID): The task ID. + + Returns: + dict: A dictionary containing the task's state and associated metadata. + """ + try: + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT state, user_id, created_at, comment + FROM task_events + WHERE project_id = %(project_id)s AND task_id = %(task_id)s + ORDER BY created_at DESC + LIMIT 1; + """, + { + "project_id": str(project_id), + "task_id": str(task_id), + }, + ) + result = await cur.fetchone() + return result + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"An error occurred while retrieving the task state: {str(e)}", + ) diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index c7323d46..890ad7fa 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -368,4 +368,36 @@ async def new_event( State.UNFLYABLE_TASK, ) + case EventType.UNLOCK: + # Fetch the task state + current_task_state = await task_logic.get_task_state( + db, project_id, task_id + ) + + state = current_task_state.get("state") + locked_user_id = current_task_state.get("user_id") + + # Determine error conditions + if state != State.LOCKED_FOR_MAPPING.name: + raise HTTPException( + status_code=400, + detail="Task state does not match expected state for unlock operation.", + ) + if user_id != locked_user_id: + raise HTTPException( + status_code=403, + detail="You cannot unlock this task as it is locked by another user.", + ) + + # Proceed with unlocking the task + return await task_logic.update_task_state( + db, + project_id, + task_id, + user_id, + f"Task has been unlock by user {user_data.name}.", + State.LOCKED_FOR_MAPPING, + State.UNLOCKED_TO_MAP, + ) + return True diff --git a/src/backend/app/waypoints/waypoint_routes.py b/src/backend/app/waypoints/waypoint_routes.py index 8fe23bb7..af795fb2 100644 --- a/src/backend/app/waypoints/waypoint_routes.py +++ b/src/backend/app/waypoints/waypoint_routes.py @@ -122,7 +122,6 @@ async def get_task_waypoint( if download: outfile = outfile = f"/tmp/{uuid.uuid4()}" kmz_file = wpml.create_wpml(placemarks, outfile) - return FileResponse( kmz_file, media_type="application/zip", filename="flight_plan.kmz" ) diff --git a/src/frontend/src/assets/images/LandingPage/JamaicaFlyingLabs_Logo.png b/src/frontend/src/assets/images/LandingPage/JamaicaFlyingLabs_Logo.png new file mode 100644 index 00000000..b4fd78e3 Binary files /dev/null and b/src/frontend/src/assets/images/LandingPage/JamaicaFlyingLabs_Logo.png differ diff --git a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx index 00852b06..475a9a90 100644 --- a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx +++ b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx @@ -25,6 +25,7 @@ import { import { convertGeojsonToFile } from '@Utils/convertLayerUtils'; import prepareFormData from '@Utils/prepareFormData'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; +import { getFrontOverlap, getSideOverlap, gsdToAltitude } from '@Utils/index'; /** * This function looks up the provided map of components to find and return @@ -86,6 +87,9 @@ const CreateprojectLayout = () => { const capturedProjectMap = useTypedSelector( state => state.createproject.capturedProjectMap, ); + const imageMergeType = useTypedSelector( + state => state.createproject.imageMergeType, + ); const initialState: FieldValues = { name: '', @@ -214,13 +218,29 @@ const CreateprojectLayout = () => { return; } + // get altitude + const agl = + measurementType === 'gsd' + ? gsdToAltitude(data?.gsd_cm_px) + : data?.altitude_from_ground; + const refactoredData = { ...data, is_terrain_follow: isTerrainFollow, requires_approval_from_manager_for_locking: requireApprovalFromManagerForLocking === 'required', deadline_at: data?.deadline_at ? data?.deadline_at : null, + front_overlap: + imageMergeType === 'spacing' + ? getFrontOverlap(agl, data?.forward_spacing) + : data?.front_overlap, + side_overlap: + imageMergeType === 'spacing' + ? getSideOverlap(agl, data?.side_spacing) + : data?.side_overlap, }; + delete refactoredData?.forward_spacing; + delete refactoredData?.side_spacing; // remove key if (isNoflyzonePresent === 'no') diff --git a/src/frontend/src/components/CreateProject/DescriptionContents/GenerateTask/index.tsx b/src/frontend/src/components/CreateProject/DescriptionContents/GenerateTask/index.tsx index becbab36..b336b4ed 100644 --- a/src/frontend/src/components/CreateProject/DescriptionContents/GenerateTask/index.tsx +++ b/src/frontend/src/components/CreateProject/DescriptionContents/GenerateTask/index.tsx @@ -1,3 +1,5 @@ +import { taskGenerationGuidelines } from '@Constants/createProject'; + export default function GenerateTask() { return (
@@ -6,6 +8,19 @@ export default function GenerateTask() { Split the task into smaller chunks based on the given dimensions to ensure more efficient and precise data collection and analysis.

+
+
+

+ {taskGenerationGuidelines?.title} +

+
    + {taskGenerationGuidelines?.guidelines?.map(item => ( +
  1. + {item} +
  2. + ))} +
+
); } diff --git a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx index 6f1a015c..36cf3f57 100644 --- a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx @@ -1,4 +1,6 @@ import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import ErrorMessage from '@Components/common/ErrorMessage'; import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; import { FormControl, Label, Input } from '@Components/common/FormUI'; import { Button } from '@Components/RadixComponents/Button'; @@ -11,6 +13,7 @@ import MapSection from './MapSection'; export default function GenerateTask({ formProps }: { formProps: any }) { const dispatch = useTypedDispatch(); + const [error, setError] = useState(''); const { register, watch } = formProps; const dimension = watch('task_split_dimension'); @@ -53,11 +56,15 @@ export default function GenerateTask({ formProps }: { formProps: any }) { type="number" className="naxatw-mt-1" value={dimension} + min={50} + max={700} {...register('task_split_dimension', { required: 'Required', valueAsNumber: true, })} + onFocus={() => setError('')} /> + {error && }