Skip to content

Commit

Permalink
Merge pull request #243 from hotosm/develop
Browse files Browse the repository at this point in the history
Production Release
  • Loading branch information
nrjadkry authored Sep 27, 2024
2 parents 2cc9274 + 4c2cba9 commit da22514
Show file tree
Hide file tree
Showing 31 changed files with 542 additions and 155 deletions.
47 changes: 40 additions & 7 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import uuid
from typing import Annotated, Optional
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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}")
Expand Down
4 changes: 4 additions & 0 deletions src/backend/app/tasks/task_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/backend/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<kml xmlns="http://www.opengis.net/kml/2.2">',
"<Document>",
]

# 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("<Placemark>")

# Add properties as name or description if available
if "properties" in feature and feature["properties"]:
if "name" in feature["properties"]:
kml_output.append(f"<name>{feature['properties']['name']}</name>")
if "description" in feature["properties"]:
kml_output.append(
f"<description>{feature['properties']['description']}</description>"
)

# Handle different geometry types (Point, LineString, Polygon)
if geometry_type == "Point":
lon, lat = coordinates
kml_output.append(f"<Point><coordinates>{lon},{lat}</coordinates></Point>")
elif geometry_type == "LineString":
coord_string = " ".join([f"{lon},{lat}" for lon, lat in coordinates])
kml_output.append(
f"<LineString><coordinates>{coord_string}</coordinates></LineString>"
)
elif geometry_type == "Polygon":
for polygon in coordinates:
coord_string = " ".join([f"{lon},{lat}" for lon, lat in polygon])
kml_output.append(
f"<Polygon><outerBoundaryIs><LinearRing><coordinates>{coord_string}</coordinates></LinearRing></outerBoundaryIs></Polygon>"
)

kml_output.append("</Placemark>")

kml_output.append("</Document>")
kml_output.append("</kml>")

return "\n".join(kml_output)
23 changes: 16 additions & 7 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getModalContent,
getPromptDialogContent,
} from '@Constants/modalContents';
import ScrollToTop from '@Components/common/ScrollToTop';

export default function App() {
const dispatch = useTypedDispatch();
Expand Down Expand Up @@ -81,13 +82,21 @@ export default function App() {
>
{getPromptDialogContent(promptDialogContent)?.content}
</PromptDialog>

{generateRoutes({
routes:
process.env.NODE_ENV !== 'production'
? [...testRoutes, ...appRoutes]
: appRoutes,
})}
<div
id="app_playground"
className="app_playground scrollbar naxatw-overflow-y-auto naxatw-px-3 md:naxatw-px-0"
style={{
height: 'calc(100vh-3.5rem)',
}}
>
{generateRoutes({
routes:
process.env.NODE_ENV !== 'production'
? [...testRoutes, ...appRoutes]
: appRoutes,
})}
</div>
<ScrollToTop />
</div>
</>
);
Expand Down
5 changes: 3 additions & 2 deletions src/frontend/src/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { getTaskStates } from '@Services/project';
import { getUserProfileInfo } from '@Services/common';

export const useGetProjectsListQuery = (
projectsFilterByOwner: 'yes' | 'no',
queryOptions?: Partial<UseQueryOptions>,
) => {
return useQuery({
queryKey: ['projects-list'],
queryFn: getProjectsList,
queryKey: ['projects-list', projectsFilterByOwner],
queryFn: () => getProjectsList(projectsFilterByOwner === 'yes'),
select: (res: any) => res.data,
...queryOptions,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const CreateprojectLayout = () => {
dispatch(resetUploadedAndDrawnAreas());
},
onError: err => {
toast.error(err.message);
toast.error(err?.response?.data?.detail || err?.message || '');
},
});

Expand Down
5 changes: 3 additions & 2 deletions src/frontend/src/components/Dashboard/RequestLogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div>
The <strong>Task# {task.project_task_index}</strong> is requested
for Mapping
The <strong>Task# {task.project_task_index}</strong> from{' '}
<strong>{task?.project_name}</strong> project is requested for
Mapping.
</div>
<div className="naxatw-flex naxatw-w-[172px] naxatw-gap-3">
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ const TaskLogsTable = ({ data: taskList }: ITaskLogsTableProps) => {
<table className="naxatw-w-full naxatw-overflow-hidden naxatw-rounded-lg">
<thead>
<tr className="naxatw-bg-red naxatw-text-left naxatw-font-normal naxatw-text-white">
<td className="naxatw-w-80 naxatw-border-r-2 naxatw-px-2 naxatw-py-1">
<td className="naxatw-w-20 naxatw-border-r-2 naxatw-px-2 naxatw-py-1">
ID
</td>
<td className="naxatw-min-w-30 naxatw-border-r-2 naxatw-px-2 naxatw-py-1">
Project Name
</td>
<td className="naxatw-border-r-2 naxatw-px-2 naxatw-py-1">
Total task area
</td>
Expand All @@ -33,9 +36,10 @@ const TaskLogsTable = ({ data: taskList }: ITaskLogsTableProps) => {
<tbody>
{taskList?.map(task => (
<tr key={task.task_id}>
<td className="naxatw-px-2 naxatw-py-1">
<td className="naxatw-line-clamp-1 naxatw-px-2 naxatw-py-1">
Task# {task?.project_task_index}
</td>
<td className="naxatw-px-2 naxatw-py-1">{task?.project_name}</td>
<td className="naxatw-px-2 naxatw-py-1">
{Number(task?.task_area)?.toFixed(3)}
</td>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import {
useGetIndividualTaskQuery,
useGetTaskAssetsInfo,
useGetTaskWaypointQuery,
} from '@Api/tasks';
import { useState } from 'react';
// import { useTypedSelector } from '@Store/hooks';
import { format } from 'date-fns';
import { Button } from '@Components/RadixComponents/Button';
import DescriptionBoxComponent from './DescriptionComponent';
import QuestionBox from '../QuestionBox';
import UploadsInformation from '../UploadsInformation';

const DescriptionBox = () => {
// const secondPageStates = useTypedSelector(state => state.droneOperatorTask);
const [flyable, setFlyable] = useState('yes');
// const { secondPage } = secondPageStates;
const { taskId, projectId } = useParams();

const { data: taskWayPoints }: any = useGetTaskWaypointQuery(
Expand Down Expand Up @@ -98,6 +97,32 @@ const DescriptionBox = () => {
},
});

const handleDownloadResult = () => {
if (!taskAssetsInformation?.assets_url) return;

fetch(`${taskAssetsInformation?.assets_url}`, { method: 'GET' })
.then(response => {
if (!response.ok) {
throw new Error(`Network response was ${response.statusText}`);
}
return response.blob();
})
.then(blob => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'assets.zip';
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
})
.catch(error =>
toast.error(`There wan an error while downloading file
${error}`),
);
};

return (
<>
<div className="naxatw-flex naxatw-flex-col naxatw-gap-5">
Expand All @@ -109,12 +134,13 @@ const DescriptionBox = () => {
/>
))}
</div>
{/* {!secondPage && <QuestionBox />} */}
<QuestionBox
setFlyable={setFlyable}
flyable={flyable}
haveNoImages={taskAssetsInformation?.image_count === 0}
/>
{taskAssetsInformation?.image_count === 0 && (
<QuestionBox
setFlyable={setFlyable}
flyable={flyable}
haveNoImages={taskAssetsInformation?.image_count === 0}
/>
)}

{taskAssetsInformation?.image_count > 0 && (
<div className="naxatw-flex naxatw-flex-col naxatw-gap-5">
Expand All @@ -130,6 +156,19 @@ const DescriptionBox = () => {
},
]}
/>
{taskAssetsInformation?.assets_url && (
<div className="">
<Button
variant="ghost"
className="naxatw-bg-red naxatw-text-white disabled:!naxatw-cursor-not-allowed disabled:naxatw-bg-gray-500 disabled:naxatw-text-white"
leftIcon="download"
iconClassname="naxatw-text-[1.125rem]"
onClick={() => handleDownloadResult()}
>
Download Result
</Button>
</div>
)}
</div>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Icon from '@Components/common/Icon';
import { toggleModal } from '@Store/actions/common';
import { useQueryClient } from '@tanstack/react-query';
import { useDispatch } from 'react-redux';

interface IFilesUploadingPopOverProps {
Expand All @@ -16,12 +17,12 @@ const FilesUploadingPopOver = ({
uploadedFiles,
}: IFilesUploadingPopOverProps) => {
const dispatch = useDispatch();
// const navigate = useNavigate();
const queryClient = useQueryClient();

// function to close modal
// function to close modal and refetch task assets to update the UI
function closeModal() {
queryClient.invalidateQueries(['task-assets-info']);
setTimeout(() => {
// navigate('/dashboard');
dispatch(toggleModal());
}, 2000);
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
const UploadsInformation = ({ data }: { data: Record<string, any>[] }) => {
return (
<>
<div className="naxatw-flex naxatw-w-full naxatw-flex-col naxatw-gap-5">
<div className="naxatw-flex naxatw-flex-col naxatw-gap-3">
<div className="naxatw-flex naxatw-w-full naxatw-flex-col naxatw-gap-2">
<div className="naxatw-flex naxatw-flex-col naxatw-gap-2">
<p className="naxatw-text-[0.875rem] naxatw-font-semibold naxatw-leading-normal naxatw-tracking-[0.0175rem] naxatw-text-[#D73F3F]">
Upload Information
</p>
Expand Down
Loading

0 comments on commit da22514

Please sign in to comment.