diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 6a5b9bd0..63b29b80 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -1,3 +1,4 @@ +import json import os import uuid from typing import Annotated, Optional @@ -29,6 +30,7 @@ from app.users.user_deps import login_required from app.users.user_schemas import AuthUser from app.tasks import task_schemas +from app.utils import geojson_to_kml router = APIRouter( @@ -55,6 +57,10 @@ async def download_boundaries( default=False, description="Whether to split the area or not. Set to True to download task boundaries, otherwise AOI will be downloaded.", ), + export_type: str = Query( + default="geojson", + description="The format of the file to download. Options are 'geojson' or 'kml'.", + ), ): """Downloads the AOI or task boundaries for a project as a GeoJSON file. @@ -64,6 +70,7 @@ async def download_boundaries( user_data (AuthUser): The authenticated user data, checks if the user has permission. task_id (Optional[UUID]): The task ID in UUID format. If not provided and split_area is True, all tasks will be downloaded. split_area (bool): Whether to split the area or not. Set to True to download task boundaries, otherwise AOI will be downloaded. + export_type (str): The format of the file to download. Can be either 'geojson' or 'kml'. Returns: Response: The HTTP response object containing the downloaded file. @@ -76,17 +83,43 @@ async def download_boundaries( if out is None: raise HTTPException(status_code=404, detail="Geometry not found.") - filename = ( - (f"task_{task_id}.geojson" if task_id else "project_outline.geojson") - if split_area - else "project_aoi.geojson" - ) + if isinstance(out, str): + out = json.loads(out) + + # Convert the geometry to a FeatureCollection if it is a valid GeoJSON geometry + if isinstance(out, dict) and "type" in out and "coordinates" in out: + out = { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": out, "properties": {}}], + } + + # Determine filename and content-type based on export type + if export_type == "geojson": + filename = ( + f"task_{task_id}.geojson" if task_id else "project_outline.geojson" + ) + if not split_area: + filename = "project_aoi.geojson" + content_type = "application/geo+json" + content = json.dumps(out) + + elif export_type == "kml": + filename = f"task_{task_id}.kml" if task_id else "project_outline.kml" + if not split_area: + filename = "project_aoi.kml" + content_type = "application/vnd.google-earth.kml+xml" + content = geojson_to_kml(out) + + else: + raise HTTPException( + status_code=400, detail="Invalid export type specified." + ) headers = { "Content-Disposition": f"attachment; filename={filename}", - "Content-Type": "application/geo+json", + "Content-Type": content_type, } - return Response(content=out, headers=headers) + return Response(content=content.encode("utf-8"), headers=headers) except HTTPException as e: log.error(f"Error during boundaries download: {e.detail}") diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index 12cba359..596e75b0 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -149,6 +149,7 @@ class UserTasksStatsOut(BaseModel): state: str project_id: uuid.UUID project_task_index: int + project_name: str @staticmethod async def get_tasks_by_user( @@ -160,6 +161,7 @@ async def get_tasks_by_user( tasks.id AS task_id, tasks.project_task_index AS project_task_index, task_events.project_id AS project_id, + projects.name AS project_name, ST_Area(ST_Transform(tasks.outline, 3857)) / 1000000 AS task_area, task_events.created_at, CASE @@ -173,6 +175,8 @@ async def get_tasks_by_user( task_events LEFT JOIN tasks ON task_events.task_id = tasks.id + LEFT JOIN + projects ON task_events.project_id = projects.id WHERE ( %(role)s = 'DRONE_PILOT' AND task_events.user_id = %(user_id)s diff --git a/src/backend/app/utils.py b/src/backend/app/utils.py index ae30711f..cc347631 100644 --- a/src/backend/app/utils.py +++ b/src/backend/app/utils.py @@ -470,3 +470,60 @@ async def send_reset_password_email(email: str, token: str): ) except Exception as e: log.error(f"Error sending email: {e}") + + +def geojson_to_kml(geojson_data: dict) -> str: + """ + Converts GeoJSON data to KML format. + + Args: + geojson_data (dict): GeoJSON data as a dictionary. + + Returns: + str: KML formatted string. + """ + kml_output = [ + '', + '', + "", + ] + + # Iterate through each feature in the GeoJSON + for feature in geojson_data.get("features", []): + geometry_type = feature["geometry"]["type"] + coordinates = feature["geometry"]["coordinates"] + + # Create a KML Placemark for each feature + kml_output.append("") + + # Add properties as name or description if available + if "properties" in feature and feature["properties"]: + if "name" in feature["properties"]: + kml_output.append(f"{feature['properties']['name']}") + if "description" in feature["properties"]: + kml_output.append( + f"{feature['properties']['description']}" + ) + + # Handle different geometry types (Point, LineString, Polygon) + if geometry_type == "Point": + lon, lat = coordinates + kml_output.append(f"{lon},{lat}") + elif geometry_type == "LineString": + coord_string = " ".join([f"{lon},{lat}" for lon, lat in coordinates]) + kml_output.append( + f"{coord_string}" + ) + elif geometry_type == "Polygon": + for polygon in coordinates: + coord_string = " ".join([f"{lon},{lat}" for lon, lat in polygon]) + kml_output.append( + f"{coord_string}" + ) + + kml_output.append("") + + kml_output.append("") + kml_output.append("") + + return "\n".join(kml_output) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index ebb53b3a..cfef8088 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { getModalContent, getPromptDialogContent, } from '@Constants/modalContents'; +import ScrollToTop from '@Components/common/ScrollToTop'; export default function App() { const dispatch = useTypedDispatch(); @@ -81,13 +82,21 @@ export default function App() { > {getPromptDialogContent(promptDialogContent)?.content} - - {generateRoutes({ - routes: - process.env.NODE_ENV !== 'production' - ? [...testRoutes, ...appRoutes] - : appRoutes, - })} +
+ {generateRoutes({ + routes: + process.env.NODE_ENV !== 'production' + ? [...testRoutes, ...appRoutes] + : appRoutes, + })} +
+ ); diff --git a/src/frontend/src/api/projects.ts b/src/frontend/src/api/projects.ts index f3c613f1..7720efb1 100644 --- a/src/frontend/src/api/projects.ts +++ b/src/frontend/src/api/projects.ts @@ -5,11 +5,12 @@ import { getTaskStates } from '@Services/project'; import { getUserProfileInfo } from '@Services/common'; export const useGetProjectsListQuery = ( + projectsFilterByOwner: 'yes' | 'no', queryOptions?: Partial, ) => { return useQuery({ - queryKey: ['projects-list'], - queryFn: getProjectsList, + queryKey: ['projects-list', projectsFilterByOwner], + queryFn: () => getProjectsList(projectsFilterByOwner === 'yes'), select: (res: any) => res.data, ...queryOptions, }); diff --git a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx index 475a9a90..67129049 100644 --- a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx +++ b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx @@ -164,7 +164,7 @@ const CreateprojectLayout = () => { dispatch(resetUploadedAndDrawnAreas()); }, onError: err => { - toast.error(err.message); + toast.error(err?.response?.data?.detail || err?.message || ''); }, }); diff --git a/src/frontend/src/components/Dashboard/RequestLogs/index.tsx b/src/frontend/src/components/Dashboard/RequestLogs/index.tsx index 8f53573a..ccdde506 100644 --- a/src/frontend/src/components/Dashboard/RequestLogs/index.tsx +++ b/src/frontend/src/components/Dashboard/RequestLogs/index.tsx @@ -55,8 +55,9 @@ const RequestLogs = () => { className="naxatw-flex naxatw-h-fit naxatw-w-full naxatw-items-center naxatw-justify-between naxatw-rounded-xl naxatw-border naxatw-border-gray-300 naxatw-p-3" >
- The Task# {task.project_task_index} is requested - for Mapping + The Task# {task.project_task_index} from{' '} + {task?.project_name} project is requested for + Mapping.