From b6b2f35d0d1fed3a9b451edfe66f48ce7fc4da6a Mon Sep 17 00:00:00 2001 From: Pradip Thapa Date: Wed, 1 Jan 2025 16:43:50 +0545 Subject: [PATCH] Production Release (#423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add function to convert exifData to GeoJson data * feat: add function to extract exif data from image file * feat: add function to sort array of objects using dateTime property * hotfix: orthophoto zoom to geotiff * refactor: handle cases of no selection * refactor: handle cases of undefined/ null data * refactor: rename item to exifData at map function * refactor: remove destructuring of state and use single var * refactor: update type for handle change event * feat: post drone registered certificate and drone pilot certificate on profile complete * refactor: update import of redux state * refactor: destructure url instead of accessing whole object and accessing url * feat: show default image on profile in case of failure * refactor: update message mutation for if the task is not flyable * refactor: reset exifData at redux state at initial render * feat(update-profile): post only the binary file on certificate * feat: add `@cyntler/react-doc-viewer` package * feat: add document preview modal * feat(dashboard): implement drone certificate preview on task lock request * fix(individual-project): task requested for lock although the project owner lockes the task * feat: set certificate & registration url in my-info endpoint * fix: document viewer details style * fix: UploadArea component * feat: show uploaded certificates on edit profile page * feat: clear selectedDocumentDetails on document preview component unmount * docs: update user roadmap with latest requirements * feat(document-preview): download certificate using blob and additional UI if the file is image * docs: tweak user roadmap based on feedback * feat: set the certificate & registration url in task request page * feat(dashboard): show drone certificate and pilot certificate on task request log * feat: ommit password section if the existing user is trying to login as another role * fix: remove comment code from task schemas * feat(regulators-approval-page): clear local storage on main page unmount * feat: implemented user certificate upload functionality, storing user certificates on S3. (#331) * feat: Implement S3 path structure & file upload for user-specific drone operator certificate uploads * refac: refactor user profile update: Add validation for empty data, handle certificate URL and role updates * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: update certificate URL in database for user profile * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: update registration registration of drone operator * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(profile-update): UI Issue * feat: add method parameter on `callApiSimultaneously` function * feat(update-profile): post add files `certificate` and `drone registration` file along with the other details * feat: post drone registered certificate and drone pilot certificate on profile complete * feat: show default image on profile in case of failure * feat(update-profile): post only the binary file on certificate * feat: add `@cyntler/react-doc-viewer` package * feat: add document preview modal * feat(dashboard): implement drone certificate preview on task lock request * fix(individual-project): task requested for lock although the project owner lockes the task * feat: set certificate & registration url in my-info endpoint * fix: document viewer details style * fix: UploadArea component * feat: show uploaded certificates on edit profile page * feat: clear selectedDocumentDetails on document preview component unmount * feat(document-preview): download certificate using blob and additional UI if the file is image * feat: set the certificate & registration url in task request page * feat(dashboard): show drone certificate and pilot certificate on task request log * feat: ommit password section if the existing user is trying to login as another role * fix: remove comment code from task schemas * feat(regulators-approval-page): clear local storage on main page unmount --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sujit * fix: set iscertifiedDroneUser initial value once * feat(user-profile): support pdf to upload certificates * feat: add zoom to extent function * refactor: remove unecessary states and handle session end of modal * refactor: rename title for imageMapBox modal * feat(project-approval-page): clear comment on approval comment success * feat: update type for coordinates * feat: make project `DescriptionSection` dynamic to render on both project description and project approval page * feat: add about section on project description page * feat: make regulator approval status optional to view on description section * feat(project-description): make map popup disabled if the regulator approval status is `REJECTED` * feat(project-description): disable popup open on table row click if requlator approval status is `REJECTED` * Adjust project description as per regulator's approval status (#361) * feat: Implement S3 path structure & file upload for user-specific drone operator certificate uploads * refac: refactor user profile update: Add validation for empty data, handle certificate URL and role updates * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: update certificate URL in database for user profile * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: update registration registration of drone operator * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(profile-update): UI Issue * feat: add method parameter on `callApiSimultaneously` function * feat(update-profile): post add files `certificate` and `drone registration` file along with the other details * feat: post drone registered certificate and drone pilot certificate on profile complete * feat: show default image on profile in case of failure * feat(update-profile): post only the binary file on certificate * feat: add `@cyntler/react-doc-viewer` package * feat: add document preview modal * feat(dashboard): implement drone certificate preview on task lock request * fix(individual-project): task requested for lock although the project owner lockes the task * feat: set certificate & registration url in my-info endpoint * fix: document viewer details style * fix: UploadArea component * feat: show uploaded certificates on edit profile page * feat: clear selectedDocumentDetails on document preview component unmount * feat(document-preview): download certificate using blob and additional UI if the file is image * feat: set the certificate & registration url in task request page * feat(dashboard): show drone certificate and pilot certificate on task request log * feat: ommit password section if the existing user is trying to login as another role * fix: remove comment code from task schemas * feat(regulators-approval-page): clear local storage on main page unmount * fix: set iscertifiedDroneUser initial value once * feat(user-profile): support pdf to upload certificates * feat(project-approval-page): clear comment on approval comment success * feat: make project `DescriptionSection` dynamic to render on both project description and project approval page * feat: add about section on project description page * feat: make regulator approval status optional to view on description section * feat(project-description): make map popup disabled if the regulator approval status is `REJECTED` * feat(project-description): disable popup open on table row click if requlator approval status is `REJECTED` --------- Co-authored-by: Pradip-p Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sujit * fix: hide approval section if the approval status has value * add trailing slash(/) in the regulator approval endpoint * style: about project * feat(project-description): show different popup message and hide task lock button if the `regulator_approval_status` is `PENDING` or `REJECTED` * fix: about typo and set default selected project tab to about * fix: capitalize tab options label * fix: update roles in existing user when auto local regulator creation * feat(regulators-approval-section): hide approval section on approval status updates * fix: remove commented code * fix: show popup on task click although the project is rejected * fix: update roles in existing user when auto local regulator creation (#362) * fix: upadate the regulator * Fix/regulator profile creation (#366) * fix: update roles in existing user when auto local regulator creation * fix: upadate the regulator * fix: reslove user roles access issues with regulator * feat: add drone altitude regulations model * feat: Add 'get all' and 'get one' endpoints for drone altitudes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reac: Rename DbDroneAltitude class to DbDroneFlightHeight and update table name * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: Implemented endpoint to retrieve all drone altitude regulations of country (#368) * feat: add drone altitude regulations model * feat: Add 'get all' and 'get one' endpoints for drone altitudes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reac: Rename DbDroneAltitude class to DbDroneFlightHeight and update table name * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * feat: add `OSM_NOMINATIM_URL` on viteconfig and env.example * feat(project-creation): get project country by project centroid using `OSM Nominatim API` find centroid with the help of `truf` Integrated Nominatim API to fetch the country based on the centroid coordinates * Replace `pdm` to `uv` for faster dependency management (#347) * feat: migrate from pdm to uv * build: replace pdm with uv, add pre-commit hook to lock deps * build: replace all usage of pdm with uv * build: replace pdm with uv for faster dependency managementreplace all usage of pdm with uv * feat: update entry point to run with uv * update: users deps login_required --------- Co-authored-by: Niraj Adhikari * feat: add className optional prop to InfoMessage component for dynamic styling * feat: add orthoPhoto task component * feat: add symbol type prop for icon at tooltip * refactor: remove delay * feat: add functions to handle visiblity of vector layers and orthophoto layers * refactor: remove button of show orthophoto from description section * refactor: update onSuccess function to navigate user from task page to project page * feat(create-project): Show warning if altitude exceeds country’s max limit * feat(individual-project): disable popup only if the user role regulator and has no other roles * feat(create-project): update fields description * feat: add validations for input feild * refactor: update onSuccess Function to navigate user to dashboard after password change * style: add z-index to user profile * feat: remove nominatim api from .env and vite config and save as a constant variable since it is constant on all environment * feat: update .env.example * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: Show warning if altitude exceeds country's max limit (#370) * feat: add drone altitude regulations model * feat: Add 'get all' and 'get one' endpoints for drone altitudes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reac: Rename DbDroneAltitude class to DbDroneFlightHeight and update table name * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: add `OSM_NOMINATIM_URL` on viteconfig and env.example * feat(project-creation): get project country by project centroid using `OSM Nominatim API` find centroid with the help of `truf` Integrated Nominatim API to fetch the country based on the centroid coordinates * feat: add className optional prop to InfoMessage component for dynamic styling * feat(create-project): Show warning if altitude exceeds country’s max limit * feat(individual-project): disable popup only if the user role regulator and has no other roles * feat(create-project): update fields description * feat: remove nominatim api from .env and vite config and save as a constant variable since it is constant on all environment * feat: update .env.example * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Pradip-p Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sujit * feat: overwrite props className using `cn` the utils function to merge class names using `clsc` and `twMerge` * fix: typo * feat: add validation to check if old and new password are same * refactor: update placeholder for file upload * feat: updated count waypoints within AOI * fix: added auth in endpoint * feat: update handleDrawEnd function to prevent duplicate draw * feat: add conditional rendering for orthophoto button at map * refactor: remove character length restriction from comment submission * refactor: update naming of filterDuplicateFeature parameters * fix: update waypoints count of aoi * feat: add service to get project waypoints * feat: implementation of project avg taskway points count api * feat: add logic to count number of waypoints in task split (#373) * feat: updated count waypoints within AOI * fix: added auth in endpoint * fix: update waypoints count of aoi * feat: add service to get project waypoints * feat: implementation of project avg taskway points count api --------- Co-authored-by: Bijay Rauniyar * fix: disable popup trigger if user signin as `ReGULATOR` * fix: added slash in waypoints count endpoint * [pre-commit.ci] pre-commit autoupdate (#376) updates: - [github.com/astral-sh/uv-pre-commit: 0.5.2 → 0.5.5](https://github.com/astral-sh/uv-pre-commit/compare/0.5.2...0.5.5) - [github.com/commitizen-tools/commitizen: v3.31.0 → v4.0.0](https://github.com/commitizen-tools/commitizen/compare/v3.31.0...v4.0.0) - [github.com/astral-sh/ruff-pre-commit: v0.8.0 → v0.8.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.0...v0.8.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * refctor: update params and add a loading state for avg way points * fix: ensure small areas merge with the nearest one in task splitting (#378) * fix: task locking and validation error in project creator (#382) * add images.json file into s3 * feat: add turf/transform-rotate package * feat: add onDrag event listner and corresponding props * feat: add type for functions to be call at on drag event * feat: add feature to rotate filght plan * feat: add function to calculate angle, centeroid * feat: add funciton to rotate geojson * refactor: update type for function params * Feat/flightplan rotation (#383) * feat: add turf/transform-rotate package * feat: add onDrag event listner and corresponding props * feat: add type for functions to be call at on drag event * feat: add feature to rotate filght plan * feat: add function to calculate angle, centeroid * feat: add funciton to rotate geojson * refactor: update type for function params * feat: gcp app with router setup * feat: function to calculate image footprints * fix: regulator tags updated in routes * functions to find images within a point * feat: endpoint to find all the images that contains the specific point * gcp router added in main.py * typing used for params * refactor: update type for calculate angle fn * hot-fix: ignore ts on modal component * feat: add needDragEvent Prop * refactor: update taskDataPolygon and pass needDragEvent at vector layer * bump drone_flightplan version * bump drone_flightplan version (#387) * bump drone_flightplan version to 0.3.3 * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/uv-pre-commit: 0.5.5 → 0.5.7](https://github.com/astral-sh/uv-pre-commit/compare/0.5.5...0.5.7) - [github.com/commitizen-tools/commitizen: v4.0.0 → v4.1.0](https://github.com/commitizen-tools/commitizen/compare/v4.0.0...v4.1.0) - [github.com/astral-sh/ruff-pre-commit: v0.8.1 → v0.8.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.1...v0.8.2) - [github.com/pycontribs/mirrors-prettier: v3.3.3 → v3.4.2](https://github.com/pycontribs/mirrors-prettier/compare/v3.3.3...v3.4.2) * feat: update rotation funcitons * feat: optimization of flightplan rotation * style: add dropshadow style * feat: add newAsync Popup for taskpage * feat: add rotattionCue component * feat: optimize flightplan rotation * feat: add layer ids constants * refactor: remove unused vars * feat: add function to swap first and last coordinate and fucntion to get last coordinate * style: add responsive attributes to rotation cue * refactor: update swapFirstAndLastCoordinate * refactor: remove swapFirstAndLast coordinate function * fix: update the flight plan time & updated waypoints conuts if user terrian follow (#390) * feat: update api for taskDataPolygon * refactor: update popup close fn * refactor: update type for mouse Events * fix: calculate bbox for the drone image (#393) * refactor: update taskWayPointsData data * feat: add est flight time * refactor: update type for coordinates * feat: generate and download KMZ files with placemarks (#392) * Added API to generate and download KMZ files * feat: added centroids on task details * fix: update authentication read task * feat: update rotation cue to handle touch events * feat: add button to save rotated taskpoints * Gcp calculation (#394) * fix: calculate bbox for the drone image * fix: calculate image footprints * refactor: extract centroid from api * feat: remove conditional color of points vector layer * refactor: update get exif data function to check N and S and provide negative or positive coordinates * style: decrease height of save rotated plan button * feat: add rotation degree inside rotation cue * docs: add quick summary page about GCP workflow * style: add bgcolor for toggled buttons * feat: update events for rotation handle * Get a list of images that contains a GCP Point from a whole Project (#396) * fix: calculate bbox for the drone image * fix: calculate image footprints * feat: get a list of images that contains a point * Style/responsive (#397) * refactor: remove useEffect and items to show state * style: add css to hide spinner on input:number * style: decrease width of select and input feilds * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Fix/image procssing status (#386) * fix: add state image processing started to already image uploaded state * fix: change updated time to be same as created at * fix: add state for image processing to already present url * refactor: add funcitonality to start image processing manually * refactor: remove upload button * style: add bg color for IMAGE_UPLOADED state * fix: resolved image processing issues, state management for color, and dashboard listing --------- Co-authored-by: Bijay Rauniyar Co-authored-by: Pradip-p * feat: Download dem file from JAXA if not provided by the user (#380) * feat: auto add dem file if not provided by the user for more information, see https://pre-commit.ci * fix: scrapy signals issue with signals for more information, see https://pre-commit.ci * feat: add constants for dem radio data * feat: add reducer and action ro set demTYpe * feat: add dem type * feat: auto add dem file if not provided by the user for more information, see https://pre-commit.ci * fix: scrapy signals issue with signals for more information, see https://pre-commit.ci * refactor: remove title from dem type switch * fix: pre commit fix --------- Co-authored-by: Bijay Rauniyar * Update README.md * update: label for dem file download from jaxa * Feat/flightplan rotation (#400) * feat: implement api to save rotated flight plan * refactor: remove type from flightPlan mutation * refactor: update condition to show update image button * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * [pre-commit.ci] pre-commit autoupdate (#403) updates: - [github.com/astral-sh/uv-pre-commit: 0.5.7 → 0.5.9](https://github.com/astral-sh/uv-pre-commit/compare/0.5.7...0.5.9) - [github.com/astral-sh/ruff-pre-commit: v0.8.2 → v0.8.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.2...v0.8.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * feat: update drone image processor to handle single and multiple tasks (#399) * feat: add endpoints to process all task images of AOI * reac: refactor process_assets_from_odm function to reduce redundancy * fix: remove double s3_path from process_assets_from_odm * fix: added all tasks images in odm processing * fix: remove local setup of config & docker-compose * fix: remove multple class for drone images processing & now handle from single class * fix: refine drone image processing * added tags in the odm webhook router endpoint --------- Co-authored-by: Niraj Adhikari * Update faq section to add basic frequently asked questions.md (#402) * Update faq.md * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * feat: initial py-test setup & update Dockerfile (#404) * feat: setup the pytest * feat: initial pytest setup for drone create * feat: updated dependency overrides for login required * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: updated test case for read drone * feat: update user test routes & make dummy project data as fixtures * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix: update response for flight plan * [pre-commit.ci] pre-commit autoupdate (#412) updates: - [github.com/astral-sh/uv-pre-commit: 0.5.9 → 0.5.11](https://github.com/astral-sh/uv-pre-commit/compare/0.5.9...0.5.11) - [github.com/astral-sh/ruff-pre-commit: v0.8.3 → v0.8.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.3...v0.8.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Implement Waylines flight mode in flights with terrain following enabled. (#411) * flight mode enums * updated drone_flightplan package * implement waylines in terrain following as well * upgrade drone_flightplan package * remove: generate each points in waypoints generation * feat: update waypint service to accept waypoint mode * feat: add reducer state and action for waypoint mode value update * feat: add constant waypoint options * feat: fetch waypoint data as per selected waypoint mode * style: download waypoints/waylines as per waypoint mode --------- Co-authored-by: Sujit Co-authored-by: Sujit * heading angle added in popup * fix: waypoints count * remove unwanted params in waypoint generation * change buffer distance for take off point * fix: waypoints count in project creation * fix: kmz download in android chrome * [pre-commit.ci] pre-commit autoupdate (#418) updates: - [github.com/astral-sh/uv-pre-commit: 0.5.11 → 0.5.13](https://github.com/astral-sh/uv-pre-commit/compare/0.5.11...0.5.13) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * fix: Show re-upload section if image upload is failed (#417) * feat: show re-upload section if the image count is >0 and task status is still `LOCKED_FOR_MAPPING` if image count is >0 and status is same it indicates that all images upload is failed so it couldnt trigger the status update api so display re-upload section if image count is >0 and status is `LOCKED_FOR_MAPPING` * fix: download menu hidden on mobile view reduce waypoint mode switcher z-index * Implement all image processing with gcp files & updated task table to maintain task areas and so on. (#416) * feat: update the gcp files for all project images * feat: update the new fields in tasks tables * feat: update task areas from db to instead of postgis * feat: update task flight time, flight distance & task areas * fix: import errors in project routes * fix: waypoints & waylines counts * fix: only get unique task id based on task events when all image processing.. * fix: issues resolved in user task out lists in dashboard * fixup! fix: issues resolved in user task out lists in dashboard * feat: update assests_url in task tables instead of searching in s3 * fix: run pre-commit for format migartions file * fix: process assests from odm, download issues * feat: update the gcp files for all project images * feat: update the new fields in tasks tables * feat: update task areas from db to instead of postgis * feat: update task flight time, flight distance & task areas * fix: import errors in project routes * fix: waypoints & waylines counts * fix: only get unique task id based on task events when all image processing.. * fix: issues resolved in user task out lists in dashboard * fixup! fix: issues resolved in user task out lists in dashboard * feat: update assests_url in task tables instead of searching in s3 * fix: run pre-commit for format migartions file * fix: process assests from odm, download issues * feat: add dem file on task split api payload * fix: dem data upload section is on view althoiugh the terrian follow option is false * fix: projection creation fail if no fly is [] remove no flyzone key if np fly zone data is not available * feat: remove unused api service `getAllAssetsUrl` * feat: add remove project assets api fetch and display data from project description api * feat: add action and slice for storing assets information of task * feat(task-description-map-section): remove extra call for task information and use data from redux state * feat: remove task-assets-information api and display data from task description api store asests info on redux state on api call success and remove on component unmount update keys as per data information * refactor: comment task assets information services * refactor: remove comment * feat: implement dummy api for upload the task table * fix: remove flight data from waypoints routes --------- Co-authored-by: Sujit * fix: update all image processing without gcp files issues solve on task table update * hotfix: fixes on flight plan time & distance * fix:Ensure password is saved during user creation if provided (#421) - Updated logic to handle cases where a password is provided during profile creation. Improved user creation flow to avoid incomplete records. Added checks to hash and save the password if it's present. * feat: invalidate task description storing to refetch the updated data on image upload and start processing (#422) --------- Co-authored-by: Bijay Rauniyar Co-authored-by: Niraj Adhikari Co-authored-by: Sujit Co-authored-by: Sovas Tiwari <40485930+subashtiwari1010@users.noreply.github.com> Co-authored-by: spwoodcock Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sujit <90745363+suzit-10@users.noreply.github.com> Co-authored-by: Bijay Rauniyar <155698689+bijayrauniyar0@users.noreply.github.com> Co-authored-by: Niraj Adhikari <41701707+nrjadkry@users.noreply.github.com> Co-authored-by: Saurav Aryal <64722587+aryalsaurav@users.noreply.github.com> Co-authored-by: Manjita Pandey <97273021+manjitapandey@users.noreply.github.com> Co-authored-by: Sujit --- .pre-commit-config.yaml | 4 +- src/backend/app/config.py | 4 +- src/backend/app/db/db_models.py | 7 + src/backend/app/gcp/gcp_routes.py | 12 +- .../app/migrations/versions/b18103ac4ab7_.py | 79 +++++ src/backend/app/models/enums.py | 14 +- src/backend/app/projects/image_processing.py | 59 +++- src/backend/app/projects/project_logic.py | 291 ++++++++++++++++-- src/backend/app/projects/project_routes.py | 90 ++---- src/backend/app/projects/project_schemas.py | 31 +- src/backend/app/s3.py | 36 +-- src/backend/app/tasks/task_logic.py | 9 + src/backend/app/tasks/task_routes.py | 242 ++++++++++++++- src/backend/app/tasks/task_schemas.py | 36 ++- src/backend/app/users/user_schemas.py | 12 + src/backend/app/utils.py | 23 +- src/backend/app/waypoints/waypoint_routes.py | 55 ++-- src/backend/pyproject.toml | 2 +- src/backend/uv.lock | 8 +- src/frontend/src/api/projects.ts | 24 +- src/frontend/src/api/tasks.ts | 33 +- .../CreateprojectLayout/index.tsx | 3 +- .../FormContents/GenerateTask/index.tsx | 36 ++- .../FormContents/KeyParameters/index.tsx | 2 +- .../Dashboard/TaskLogs/TaskLogsTable.tsx | 2 +- .../DescriptionBox/index.tsx | 165 +++++----- .../PopoverBox/LoadingBox/index.tsx | 2 +- .../DescriptionSection/index.tsx | 16 +- .../DroneOperatorTask/MapSection/index.tsx | 86 ++++-- .../Contributions/TableSection/index.tsx | 24 +- .../Tasks/TableSection/index.tsx | 12 +- src/frontend/src/constants/taskDescription.ts | 5 + src/frontend/src/services/project.ts | 4 +- src/frontend/src/services/tasks.ts | 12 +- .../src/store/actions/droneOperatorTask.ts | 2 + .../src/store/slices/droneOperartorTask.ts | 14 + 36 files changed, 1083 insertions(+), 373 deletions(-) create mode 100644 src/backend/app/migrations/versions/b18103ac4ab7_.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa74f5e4..81d9a998 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -79,7 +79,7 @@ repos: # Deps: ensure Python uv lockfile is up to date - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.9 + rev: 0.5.13 hooks: - id: uv-lock files: src/backend/pyproject.toml @@ -95,7 +95,7 @@ repos: # Lint / autoformat: Python code - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: "v0.8.3" + rev: "v0.8.4" hooks: # Run the linter - id: ruff diff --git a/src/backend/app/config.py b/src/backend/app/config.py index b97bb7fa..d0479458 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -11,7 +11,7 @@ from pydantic_settings import BaseSettings from typing import Annotated, Optional, Union, Any from pydantic.networks import HttpUrl, PostgresDsn - +from loguru import logger as log HttpUrlStr = Annotated[ str, @@ -122,7 +122,7 @@ def get_settings(): """Cache settings when accessed throughout app.""" _settings = Settings() if _settings.DEBUG: - print(f"Loaded settings: {_settings.model_dump()}") + log.info(f"Loaded settings: {_settings.model_dump()}") return _settings diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index 8b4a2ac7..f6c43a21 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -78,6 +78,13 @@ class DbTask(Base): take_off_point = cast( WKBElement, Column(Geometry("POINT", srid=4326), nullable=True) ) + total_area_sqkm = cast(float, Column(Float, nullable=True)) + flight_time_minutes = cast(int, Column(Float, nullable=True)) + flight_distance_km = cast(float, Column(Float, nullable=True)) + total_image_uploaded = cast(int, Column(SmallInteger, nullable=True)) + assets_url = cast( + str, Column(String, nullable=True) + ) # download link for assets of images(orthophoto) class DbProject(Base): diff --git a/src/backend/app/gcp/gcp_routes.py b/src/backend/app/gcp/gcp_routes.py index 31799e50..bc68cdba 100644 --- a/src/backend/app/gcp/gcp_routes.py +++ b/src/backend/app/gcp/gcp_routes.py @@ -1,5 +1,6 @@ import uuid from app.config import settings +from app.projects import project_schemas from fastapi import APIRouter, Depends from app.waypoints import waypoint_schemas from app.gcp import gcp_crud @@ -21,15 +22,15 @@ async def find_images( project_id: uuid.UUID, task_id: uuid.UUID, + db: Annotated[Connection, Depends(database.get_db)], point: waypoint_schemas.PointField = None, ) -> List[str]: """Find images that contain a specified point.""" fov_degree = 82.1 # For DJI Mini 4 Pro - altitude = 100 # TODO: Get this from db - + result = await project_schemas.DbProject.one(db, project_id) return await gcp_crud.find_images_in_a_task_for_point( - project_id, task_id, point, fov_degree, altitude + project_id, task_id, point, fov_degree, result.altitude ) @@ -42,11 +43,10 @@ async def find_images_for_a_project( """Find images that contain a specified point in a project.""" fov_degree = 82.1 # For DJI Mini 4 Pro - altitude = 100 # TODO: Get this from db - + result = await project_schemas.DbProject.one(db, project_id) # Get all task IDs for the project from database task_id_list = await list_task_id_for_project(db, project_id) return await gcp_crud.find_images_in_a_project_for_point( - project_id, task_id_list, point, fov_degree, altitude + project_id, task_id_list, point, fov_degree, result.altitude ) diff --git a/src/backend/app/migrations/versions/b18103ac4ab7_.py b/src/backend/app/migrations/versions/b18103ac4ab7_.py new file mode 100644 index 00000000..54ddb74c --- /dev/null +++ b/src/backend/app/migrations/versions/b18103ac4ab7_.py @@ -0,0 +1,79 @@ +""" + +Revision ID: b18103ac4ab7 +Revises: e23c05f21542 +Create Date: 2024-12-30 11:36:29.762485 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "b18103ac4ab7" +down_revision: Union[str, None] = "e23c05f21542" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "task_events", + "state", + existing_type=postgresql.ENUM( + "REQUEST_FOR_MAPPING", + "UNLOCKED_TO_MAP", + "LOCKED_FOR_MAPPING", + "UNLOCKED_TO_VALIDATE", + "LOCKED_FOR_VALIDATION", + "UNLOCKED_DONE", + "UNFLYABLE_TASK", + "IMAGE_UPLOADED", + "IMAGE_PROCESSING_FAILED", + "IMAGE_PROCESSING_STARTED", + "IMAGE_PROCESSING_FINISHED", + name="state", + ), + nullable=False, + ) + op.add_column("tasks", sa.Column("total_area_sqkm", sa.Float(), nullable=True)) + op.add_column("tasks", sa.Column("flight_time_minutes", sa.Float(), nullable=True)) + op.add_column("tasks", sa.Column("flight_distance_km", sa.Float(), nullable=True)) + op.add_column( + "tasks", sa.Column("total_image_uploaded", sa.SmallInteger(), nullable=True) + ) + op.add_column("tasks", sa.Column("assets_url", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("tasks", "assets_url") + op.drop_column("tasks", "total_image_uploaded") + op.drop_column("tasks", "flight_distance_km") + op.drop_column("tasks", "flight_time_minutes") + op.drop_column("tasks", "total_area_sqkm") + op.alter_column( + "task_events", + "state", + existing_type=postgresql.ENUM( + "REQUEST_FOR_MAPPING", + "UNLOCKED_TO_MAP", + "LOCKED_FOR_MAPPING", + "UNLOCKED_TO_VALIDATE", + "LOCKED_FOR_VALIDATION", + "UNLOCKED_DONE", + "UNFLYABLE_TASK", + "IMAGE_UPLOADED", + "IMAGE_PROCESSING_FAILED", + "IMAGE_PROCESSING_STARTED", + "IMAGE_PROCESSING_FINISHED", + name="state", + ), + nullable=True, + ) + # ### end Alembic commands ### diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index e2b79093..cb6cebad 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -125,7 +125,7 @@ class DroneType(IntEnum): DJI_MINI_4_PRO = 1 -class UserRole(IntEnum, Enum): +class UserRole(int, Enum): PROJECT_CREATOR = 1 DRONE_PILOT = 2 REGULATOR = 3 @@ -197,3 +197,15 @@ class EventType(str, Enum): UNLOCK = "unlock" IMAGE_UPLOAD = "image_upload" IMAGE_PROCESSING_START = "image_processing_start" + + +class FlightMode(str, Enum): + """The flight mode of the drone. + + The flight mode can be: + - ``waylines`` + - ``waypoints`` + """ + + waylines = "waylines" + waypoints = "waypoints" diff --git a/src/backend/app/projects/image_processing.py b/src/backend/app/projects/image_processing.py index ec0a2de7..4e5baea7 100644 --- a/src/backend/app/projects/image_processing.py +++ b/src/backend/app/projects/image_processing.py @@ -8,6 +8,7 @@ from app.models.enums import State from app.utils import timestamp from app.db import database +from app.projects import project_logic 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 @@ -165,6 +166,17 @@ async def _process_images( self.download_images_from_s3(bucket_name, temp_dir, self.task_id) images_list = self.list_images(temp_dir) else: + gcp_list_file = f"dtm-data/projects/{self.project_id}/gcp/gcp_list.txt" + gcp_file_path = os.path.join(temp_dir, "gcp_list.txt") + + # Check and add the GCP file to the images list if it exists + if get_file_from_bucket(bucket_name, gcp_list_file, gcp_file_path): + images_list.append(gcp_file_path) + else: + log.info( + f"GCP file not available for project ID {self.project_id}." + ) + for task_id in self.task_ids: self.download_images_from_s3(bucket_name, temp_dir, task_id) images_list.extend(self.list_images(temp_dir)) @@ -355,16 +367,22 @@ async def process_assets_from_odm( """ log.info(f"Starting processing for project {dtm_project_id}") node = Node.from_url(node_odm_url) - output_file_path = f"/tmp/{dtm_project_id}" + output_file_path = f"/tmp/{uuid.uuid4()}" try: + os.makedirs(output_file_path, exist_ok=True) task = node.get_task(odm_task_id) - log.info(f"Downloading results for task {dtm_project_id} to {output_file_path}") + log.info(f"Downloading results for task {odm_task_id} to {output_file_path}") assets_path = task.download_zip(output_file_path) - s3_path = f"dtm-data/projects/{dtm_project_id}/{dtm_task_id if dtm_task_id else ''}/assets.zip".strip( - "/" - ) + if not os.path.exists(assets_path): + log.error(f"Downloaded file not found: {assets_path}") + raise + log.info(f"Successfully downloaded ZIP to {assets_path}") + + # Construct the S3 path dynamically to avoid empty segments + task_segment = f"{dtm_task_id}/" if dtm_task_id else "" + s3_path = f"dtm-data/projects/{dtm_project_id}/{task_segment}assets.zip" log.info(f"Uploading {assets_path} to S3 path: {s3_path}") add_file_to_bucket(settings.S3_BUCKET_NAME, assets_path, s3_path) @@ -379,22 +397,21 @@ async def process_assets_from_odm( raise FileNotFoundError("Orthophoto file is missing") reproject_to_web_mercator(orthophoto_path, orthophoto_path) - s3_ortho_path = f"dtm-data/projects/{dtm_project_id}/{dtm_task_id if dtm_task_id else ''}/orthophoto/odm_orthophoto.tif".strip( - "/" - ) - + s3_ortho_path = f"dtm-data/projects/{dtm_project_id}/{task_segment}orthophoto/odm_orthophoto.tif" log.info(f"Uploading reprojected orthophoto to S3 path: {s3_ortho_path}") add_file_to_bucket(settings.S3_BUCKET_NAME, orthophoto_path, s3_ortho_path) images_json_path = os.path.join(output_file_path, "images.json") - s3_images_json_path = f"dtm-data/projects/{dtm_project_id}/{dtm_task_id if dtm_task_id else ''}/images.json".strip( - "/" - ) - - log.info(f"Uploading images.json to S3 path: {s3_images_json_path}") - add_file_to_bucket( - settings.S3_BUCKET_NAME, images_json_path, s3_images_json_path - ) + if os.path.exists(images_json_path): + s3_images_json_path = ( + f"dtm-data/projects/{dtm_project_id}/{task_segment}images.json" + ) + log.info(f"Uploading images.json to S3 path: {s3_images_json_path}") + add_file_to_bucket( + settings.S3_BUCKET_NAME, images_json_path, s3_images_json_path + ) + else: + log.warning(f"images.json not found in {output_file_path}") log.info(f"Processing complete for project {dtm_project_id}") @@ -418,6 +435,14 @@ async def process_assets_from_odm( f"Task {dtm_task_id} state updated to IMAGE_PROCESSING_FINISHED in the database." ) + s3_path_url = ( + f"dtm-data/projects/{dtm_project_id}/{dtm_task_id}/assets.zip" + ) + # update the task table + await project_logic.update_task_field( + conn, dtm_project_id, dtm_task_id, "assets_url", s3_path_url + ) + except Exception as e: log.error(f"Error during processing for project {dtm_project_id}: {e}") diff --git a/src/backend/app/projects/project_logic.py b/src/backend/app/projects/project_logic.py index af1769ad..c072ed1e 100644 --- a/src/backend/app/projects/project_logic.py +++ b/src/backend/app/projects/project_logic.py @@ -1,8 +1,10 @@ import json +import os +import shutil +from typing import Any import uuid from loguru import logger as log from fastapi import HTTPException, UploadFile -from pyproj import Transformer from app.tasks.task_splitter import split_by_square from fastapi.concurrency import run_in_threadpool from psycopg import Connection @@ -21,6 +23,22 @@ from app.projects import project_schemas from minio import S3Error from psycopg.rows import dict_row +from shapely.ops import transform +import pyproj +from geojson import Feature, FeatureCollection, Polygon +from app.s3 import get_file_from_bucket +from app.utils import ( + calculate_flight_time_from_placemarks, +) +import geojson +from drone_flightplan import ( + waypoints, + add_elevation_from_dem, + calculate_parameters, + create_placemarks, + terrain_following_waylines, +) +from app.models.enums import FlightMode async def get_centroids(db: Connection): @@ -33,7 +51,7 @@ async def get_centroids(db: Connection): p.name, ST_AsGeoJSON(p.centroid)::jsonb AS centroid, COUNT(t.id) AS total_task_count, - COUNT(CASE WHEN te.state IN ('LOCKED_FOR_MAPPING', 'REQUEST_FOR_MAPPING', 'IMAGE_UPLOADED', 'UNFLYABLE_TASK') THEN 1 END) AS ongoing_task_count, + COUNT(CASE WHEN te.state IN ('LOCKED_FOR_MAPPING', 'REQUEST_FOR_MAPPING', 'IMAGE_UPLOADED', 'UNFLYABLE_TASK', 'IMAGE_PROCESSING_STARTED') THEN 1 END) AS ongoing_task_count, COUNT(CASE WHEN te.state = 'IMAGE_PROCESSING_FINISHED' THEN 1 END) AS completed_task_count FROM projects p @@ -120,40 +138,126 @@ async def update_url(db: Connection, project_id: uuid.UUID, url: str): async def create_tasks_from_geojson( db: Connection, project_id: uuid.UUID, - boundaries: str, + boundaries: Any, + project: project_schemas.DbProject, ): """Create tasks for a project, from provided task boundaries.""" try: if isinstance(boundaries, str): boundaries = json.loads(boundaries) - # Update the boundary polyon on the database. if boundaries["type"] == "Feature": polygons = [boundaries] else: polygons = boundaries["features"] + log.debug(f"Processing {len(polygons)} task geometries") + + # Set up the projection transform for EPSG:3857 (Web Mercator) + proj_wgs84 = pyproj.CRS("EPSG:4326") + proj_mercator = pyproj.CRS("EPSG:3857") + project_transformer = pyproj.Transformer.from_crs( + proj_wgs84, proj_mercator, always_xy=True + ) + for index, polygon in enumerate(polygons): + forward_overlap = project.front_overlap if project.front_overlap else 70 + side_overlap = project.side_overlap if project.side_overlap else 70 + generate_3d = False # TODO: For 3d imageries drone_flightplan package needs to be updated. + + gsd = project.gsd_cm_px + altitude = project.altitude_from_ground + + parameters = calculate_parameters( + forward_overlap, + side_overlap, + altitude, + gsd, + 2, # Image Interval is set to 2 + ) + + # Wrap polygon into GeoJSON Feature + if not polygon["geometry"]: + continue + # If the polygon is a MultiPolygon, convert it to a Polygon + if polygon["geometry"]["type"] == "MultiPolygon": + log.debug("Converting MultiPolygon to Polygon") + polygon["geometry"]["type"] = "Polygon" + polygon["geometry"]["coordinates"] = polygon["geometry"]["coordinates"][ + 0 + ] + + geom = shape(polygon["geometry"]) + + coordinates = polygon["geometry"]["coordinates"] + if polygon["geometry"]["type"] == "Polygon": + coordinates = polygon["geometry"]["coordinates"] + feature = Feature(geometry=Polygon(coordinates), properties={}) + feature_collection = FeatureCollection([feature]) + + # Common parameters for create_waypoint + waypoint_params = { + "project_area": feature_collection, + "agl": altitude, + "gsd": gsd, + "forward_overlap": forward_overlap, + "side_overlap": side_overlap, + "rotation_angle": 0, + "generate_3d": generate_3d, + } + waypoint_params["mode"] = FlightMode.waypoints + if project.is_terrain_follow: + dem_path = f"/tmp/{uuid.uuid4()}/dem.tif" + + # Terrain follow uses waypoints mode, waylines are generated later + points = waypoints.create_waypoint(**waypoint_params) + + try: + get_file_from_bucket( + settings.S3_BUCKET_NAME, + f"dtm-data/projects/{project.id}/dem.tif", + dem_path, + ) + # TODO: Do this with inmemory data + outfile_with_elevation = "/tmp/output_file_with_elevation.geojson" + add_elevation_from_dem(dem_path, points, outfile_with_elevation) + + inpointsfile = open(outfile_with_elevation, "r") + points_with_elevation = inpointsfile.read() + + except Exception: + points_with_elevation = points + + placemarks = create_placemarks( + geojson.loads(points_with_elevation), parameters + ) + + else: + points = waypoints.create_waypoint(**waypoint_params) + placemarks = create_placemarks(geojson.loads(points), parameters) + + flight_time_minutes = calculate_flight_time_from_placemarks(placemarks).get( + "total_flight_time" + ) + flight_distance_km = calculate_flight_time_from_placemarks(placemarks).get( + "flight_distance_km" + ) try: - if not polygon["geometry"]: - continue - # If the polygon is a MultiPolygon, convert it to a Polygon - if polygon["geometry"]["type"] == "MultiPolygon": - log.debug("Converting MultiPolygon to Polygon") - polygon["geometry"]["type"] = "Polygon" + # Transform the geometry to EPSG:3857 and calculate the area in square meters + transformed_geom = transform(project_transformer.transform, geom) + area_sq_m = transformed_geom.area # Area in square meters - polygon["geometry"]["coordinates"] = polygon["geometry"][ - "coordinates" - ][0] + # Convert area to square kilometers + total_area_sqkm = area_sq_m / 1_000_000 task_id = str(uuid.uuid4()) async with db.cursor() as cur: await cur.execute( """ - INSERT INTO tasks (id, project_id, outline, project_task_index) - VALUES (%(id)s, %(project_id)s, %(outline)s, %(project_task_index)s) - RETURNING id; - """, + INSERT INTO tasks (id, project_id, outline, project_task_index, total_area_sqkm, flight_time_minutes, flight_distance_km) + VALUES (%(id)s, %(project_id)s, %(outline)s, %(project_task_index)s, %(total_area_sqkm)s, %(flight_time_minutes)s, %(flight_distance_km)s) + RETURNING id; + """, { "id": task_id, "project_id": project_id, @@ -161,9 +265,13 @@ async def create_tasks_from_geojson( shape(polygon["geometry"]), hex=True ), "project_task_index": index + 1, + "total_area_sqkm": total_area_sqkm, + "flight_time_minutes": flight_time_minutes, + "flight_distance_km": flight_distance_km, }, ) result = await cur.fetchone() + if result: log.debug( "Created database task | " @@ -320,8 +428,10 @@ async def check_regulator_project(db: Connection, project_id: str, email: str): def generate_square_geojson(center_lat, center_lon, side_length_meters): - transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) - transformer_back = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True) + transformer = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) + transformer_back = pyproj.Transformer.from_crs( + "EPSG:3857", "EPSG:4326", always_xy=True + ) center_x, center_y = transformer.transform(center_lon, center_lat) half_side = side_length_meters / 2 @@ -350,15 +460,152 @@ def generate_square_geojson(center_lat, center_lon, side_length_meters): async def get_all_tasks_for_project(project_id, db): - "Get all tasks associated with the project ID that are in state IMAGE_UPLOADED." + """ + Get all unique tasks associated with the project ID + that are in state IMAGE_UPLOADED. + """ async with db.cursor() as cur: query = """ - SELECT t.id + SELECT DISTINCT ON (t.id) t.id FROM tasks t JOIN task_events te ON t.id = te.task_id - WHERE t.project_id = %s AND te.state = 'IMAGE_UPLOADED'; + WHERE t.project_id = %s AND te.state = 'IMAGE_UPLOADED' + ORDER BY t.id, te.created_at DESC; """ await cur.execute(query, (project_id,)) results = await cur.fetchall() # Convert UUIDs to string return [str(result[0]) for result in results] + + +async def update_task_field( + db: Connection, project_id: uuid.UUID, task_id: uuid.UUID, column: Any, value: str +): + """ + Generic function to update a field(assets_url and total_image_count) in the tasks table. + """ + async with db.cursor() as cur: + await cur.execute( + f""" + UPDATE tasks + SET {column} = %(value)s + WHERE project_id = %(project_id)s AND id = %(task_id)s; + """, + { + "value": value, + "project_id": str(project_id), + "task_id": str(task_id), + }, + ) + return True + + +async def process_waypoints_and_waylines( + side_overlap: float, + front_overlap: float, + altitude_from_ground: float, + gsd_cm_px: float, + meters: float, + project_geojson: UploadFile, + is_terrain_follow: bool, + dem: UploadFile, +): + """ + Processes and returns counts of waypoints and waylines. + """ + # Validate the input GeoJSON file + file_name = os.path.splitext(project_geojson.filename) + file_ext = file_name[1] + allowed_extensions = [".geojson", ".json"] + if file_ext not in allowed_extensions: + raise HTTPException(status_code=400, detail="Provide a valid .geojson file") + + # Generate square boundary GeoJSON + content = project_geojson.file.read() + boundary = geojson.loads(content) + geometry = shape(boundary["features"][0]["geometry"]) + centroid = geometry.centroid + center_lon = centroid.x + center_lat = centroid.y + square_geojson = generate_square_geojson(center_lat, center_lon, meters) + + # Prepare common parameters for waypoint creation + forward_overlap = front_overlap if front_overlap else 70 + side_overlap = side_overlap if side_overlap else 70 + parameters = calculate_parameters( + forward_overlap, + side_overlap, + altitude_from_ground, + gsd_cm_px, + 2, + ) + waypoint_params = { + "project_area": square_geojson, + "agl": altitude_from_ground, + "gsd": gsd_cm_px, + "forward_overlap": forward_overlap, + "side_overlap": side_overlap, + "rotation_angle": 0, + "generate_3d": False, # TODO: For 3d imageries drone_flightplan package needs to be updated. + "take_off_point": None, + } + count_data = {"waypoints": 0, "waylines": 0} + + if is_terrain_follow and dem: + temp_dir = f"/tmp/{uuid.uuid4()}" + dem_path = os.path.join(temp_dir, "dem.tif") + + try: + os.makedirs(temp_dir, exist_ok=True) + # Read DEM content into memory and write to the file + file_content = await dem.read() + with open(dem_path, "wb") as file: + file.write(file_content) + + # Process waypoints with terrain-follow elevation + waypoint_params["mode"] = FlightMode.waypoints + points = waypoints.create_waypoint(**waypoint_params) + + # Add elevation data to waypoints + outfile_with_elevation = os.path.join( + temp_dir, "output_file_with_elevation.geojson" + ) + add_elevation_from_dem(dem_path, points, outfile_with_elevation) + + # Read the updated waypoints with elevation + with open(outfile_with_elevation, "r") as inpointsfile: + points_with_elevation = inpointsfile.read() + count_data["waypoints"] = len( + json.loads(points_with_elevation)["features"] + ) + + # Generate waylines from waypoints with elevation + wayline_placemarks = create_placemarks( + geojson.loads(points_with_elevation), parameters + ) + + placemarks = terrain_following_waylines.waypoints2waylines( + wayline_placemarks, 5 + ) + count_data["waylines"] = len(placemarks["features"]) + + except Exception as e: + log.error(f"Error processing DEM: {e}") + + finally: + # Cleanup temporary files and directory + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + return count_data + + else: + # Generate waypoints and waylines + waypoint_params["mode"] = FlightMode.waypoints + points = waypoints.create_waypoint(**waypoint_params) + count_data["waypoints"] = len(json.loads(points)["features"]) + + waypoint_params["mode"] = FlightMode.waylines + lines = waypoints.create_waypoint(**waypoint_params) + count_data["waylines"] = len(json.loads(lines)["features"]) + + return count_data diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index e2ff6813..d0505f64 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -1,6 +1,5 @@ import json import os -import shutil import uuid from typing import Annotated, Optional from uuid import UUID @@ -28,7 +27,7 @@ from app.projects import project_schemas, project_deps, project_logic, image_processing from app.db import database from app.models.enums import HTTPStatus, State -from app.s3 import s3_client +from app.s3 import add_file_to_bucket, s3_client from app.config import settings from app.users.user_deps import login_required from app.users.user_schemas import AuthUser @@ -41,7 +40,6 @@ from app.users import user_schemas from app.jaxa.upload_dem import upload_dem_file from minio.deleteobjects import DeleteObject -from drone_flightplan import waypoints, add_elevation_from_dem router = APIRouter( prefix=f"{settings.API_PREFIX}/projects", @@ -254,7 +252,7 @@ async def upload_project_task_boundaries( dict: JSON containing success message, project ID, and number of tasks. """ log.debug("Creating tasks for each polygon in project") - await project_logic.create_tasks_from_geojson(db, project.id, task_featcol) + await project_logic.create_tasks_from_geojson(db, project.id, task_featcol, project) return {"message": "Project Boundary Uploaded", "project_id": f"{project.id}"} @@ -302,6 +300,7 @@ async def preview_split_by_square( @router.post("/generate-presigned-url/", tags=["Image Upload"]) async def generate_presigned_url( + db: Annotated[Connection, Depends(database.get_db)], user: Annotated[AuthUser, Depends(login_required)], data: project_schemas.PresignedUrlRequest, replace_existing: bool = False, @@ -464,11 +463,19 @@ async def process_all_imagery( user_data: Annotated[AuthUser, Depends(login_required)], background_tasks: BackgroundTasks, db: Annotated[Connection, Depends(database.get_db)], + gcp_file: UploadFile = File(None), ): """ API endpoint to process all tasks associated with a project. """ user_id = user_data.id + if gcp_file: + gcp_file_path = f"/tmp/{uuid.uuid4()}" + with open(gcp_file_path, "wb") as f: + f.write(await gcp_file.read()) + + s3_path = f"dtm-data/projects/{project_id}/gcp/gcp_list.txt" + add_file_to_bucket(settings.S3_BUCKET_NAME, gcp_file_path, s3_path) tasks = await project_logic.get_all_tasks_for_project(project.id, db) background_tasks.add_task( @@ -642,73 +649,19 @@ async def get_project_waypoints_counts( user_data: AuthUser = Depends(login_required), ): """ - Count waypoints within AOI. + Count waypoints and waylines within AOI. """ - # Validating for .geojson File. - file_name = os.path.splitext(project_geojson.filename) - file_ext = file_name[1] - allowed_extensions = [".geojson", ".json"] - if file_ext not in allowed_extensions: - raise HTTPException(status_code=400, detail="Provide a valid .geojson file") - - # read entire file - content = await project_geojson.read() - boundary = geojson.loads(content) - geometry = shape(boundary["features"][0]["geometry"]) - centroid = geometry.centroid - center_lon = centroid.x - center_lat = centroid.y - square_geojson = project_logic.generate_square_geojson( - center_lat, center_lon, meters - ) - generate_each_points = True if is_terrain_follow else False - generate_3d = ( - False # TODO: For 3d imageries drone_flightplan package needs to be updated. - ) - forward_overlap = front_overlap if front_overlap else 70 - side_overlap = side_overlap if side_overlap else 70 - - points = waypoints.create_waypoint( - project_area=square_geojson, - agl=altitude_from_ground, - gsd=gsd_cm_px, - forward_overlap=forward_overlap, - side_overlap=side_overlap, - rotation_angle=0, - generate_each_points=generate_each_points, - generate_3d=generate_3d, - take_off_point=None, + return await project_logic.process_waypoints_and_waylines( + side_overlap, + front_overlap, + altitude_from_ground, + gsd_cm_px, + meters, + project_geojson, + is_terrain_follow, + dem, ) - # Handle terrain-following logic if a DEM is provided - points_with_elevation = points - if is_terrain_follow and dem: - temp_dir = f"/tmp/{uuid.uuid4()}" - try: - os.makedirs(temp_dir, exist_ok=True) - dem_path = os.path.join(temp_dir, "dem.tif") - outfile_with_elevation = os.path.join( - temp_dir, "output_file_with_elevation.geojson" - ) - - with open(dem_path, "wb") as dem_file: - dem_file.write(await dem.read()) - - add_elevation_from_dem(dem_path, points, outfile_with_elevation) - - with open(outfile_with_elevation, "r") as inpointsfile: - points_with_elevation = inpointsfile.read() - except Exception as e: - log.error(f"Error processing DEM: {e}") - - finally: - if os.path.exists(temp_dir): - shutil.rmtree(temp_dir) - - return { - "avg_no_of_waypoints": len(json.loads(points_with_elevation)["features"]), - } - @router.get( "/assets/{project_id}/", @@ -730,7 +683,6 @@ async def get_assets_info( if task_id is None: # Fetch all tasks associated with the project tasks = await project_deps.get_tasks_by_project_id(project.id, db) - results = [] for task in tasks: diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 962b2f4c..73aaf67d 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -25,7 +25,7 @@ ) from psycopg.rows import dict_row from app.config import settings -from app.s3 import get_presigned_url +from app.s3 import generate_static_url, get_presigned_url class CentroidOut(BaseModel): @@ -173,10 +173,22 @@ class TaskOut(BaseModel): outline: Optional[Polygon | Feature | FeatureCollection] = None state: Optional[str] = None user_id: Optional[str] = None - task_area: Optional[float] = None name: Optional[str] = None image_count: Optional[int] = None assets_url: Optional[str] = None + total_area_sqkm: Optional[float] = None + flight_time_minutes: Optional[float] = None + flight_distance_km: Optional[float] = None + total_image_uploaded: Optional[int] = None + + @model_validator(mode="after") + def set_assets_url(cls, values): + """Set image_url before rendering the model.""" + assets_url = values.assets_url + if assets_url: + values.assets_url = generate_static_url(settings.S3_BUCKET_NAME, assets_url) + + return values class DbProject(BaseModel): @@ -210,7 +222,6 @@ class DbProject(BaseModel): is_terrain_follow: bool = False image_url: Optional[str] = None created_at: datetime - author_id: str async def one(db: Connection, project_id: uuid.UUID): """Get a single project & all associated tasks by ID.""" @@ -291,6 +302,11 @@ async def one(db: Connection, project_id: uuid.UUID): t.id, t.project_task_index, t.project_id, + t.total_area_sqkm, + t.flight_time_minutes, + t.flight_distance_km, + t.assets_url, + t.total_image_uploaded, ST_AsGeoJSON(t.outline)::jsonb -> 'coordinates' AS coordinates, ST_AsGeoJSON(t.outline)::jsonb -> 'type' AS type, ST_XMin(ST_Envelope(t.outline)) AS xmin, @@ -299,8 +315,7 @@ async def one(db: Connection, project_id: uuid.UUID): ST_YMax(ST_Envelope(t.outline)) AS ymax, tsc.state AS state, tsc.user_id, - u.name, - ST_Area(ST_Transform(t.outline, 3857)) / 1000000 AS task_area + u.name FROM tasks t LEFT JOIN @@ -316,8 +331,12 @@ async def one(db: Connection, project_id: uuid.UUID): state, user_id, name, - task_area, project_id, + total_area_sqkm, + flight_distance_km, + flight_time_minutes, + total_image_uploaded, + assets_url, jsonb_build_object( 'type', 'Feature', 'geometry', jsonb_build_object( diff --git a/src/backend/app/s3.py b/src/backend/app/s3.py index 22d83113..dbcec789 100644 --- a/src/backend/app/s3.py +++ b/src/backend/app/s3.py @@ -4,6 +4,8 @@ from io import BytesIO from typing import Any from datetime import timedelta +from urllib.parse import urljoin +from minio.error import S3Error def s3_client(): @@ -103,9 +105,15 @@ def get_file_from_bucket(bucket_name: str, s3_path: str, file_path: str): # Ensure s3_path starts with a forward slash # if not s3_path.startswith("/"): # s3_path = f"/{s3_path}" - - client = s3_client() - client.fget_object(bucket_name, s3_path, file_path) + try: + client = s3_client() + client.fget_object(bucket_name, s3_path, file_path) + except S3Error as e: + if e.code == "NoSuchKey": + log.warning(f"File not found in bucket: {s3_path}") + else: + log.error(f"Error occurred while downloading file: {e}") + return False def get_obj_from_bucket(bucket_name: str, s3_path: str) -> BytesIO: @@ -215,19 +223,9 @@ def get_object_metadata(bucket_name: str, object_name: str): return client.stat_object(bucket_name, object_name) -def get_cog_path(bucket_name: str, project_id: str, task_id: str): - """Generate the presigned URL for a COG file in an S3 bucket. - - Args: - bucket_name (str): The name of the S3 bucket. - project_id (str): The unique project identifier. - orthophoto_name (str): The name of the COG file. - - Returns: - str: The presigned URL to access the COG file. - """ - # Construct the S3 path for the COG file - s3_path = f"dtm-data/projects/{project_id}/{task_id}/orthophoto/odm_orthophoto.tif" - - # Get the presigned URL - return get_presigned_url(bucket_name, s3_path) +def generate_static_url(bucket_name: str, s3_path: str): + """Generate a static URL for an S3 object.""" + minio_url, is_secure = is_connection_secure(settings.S3_ENDPOINT) + protocol = "https" if is_secure else "http" + base_url = f"{protocol}://{minio_url}/{bucket_name}/" + return urljoin(base_url, s3_path) diff --git a/src/backend/app/tasks/task_logic.py b/src/backend/app/tasks/task_logic.py index e7277dec..d23777da 100644 --- a/src/backend/app/tasks/task_logic.py +++ b/src/backend/app/tasks/task_logic.py @@ -4,6 +4,7 @@ from app.tasks.task_schemas import NewEvent, TaskStats from app.users import user_schemas from app.utils import render_email_template, send_notification_email +from app.projects import project_logic from psycopg import Connection from app.models.enums import EventType, HTTPStatus, State, UserRole from fastapi import HTTPException, BackgroundTasks @@ -602,6 +603,14 @@ async def handle_event( status_code=403, detail="You cannot upload an image for this task as it is locked by another user.", ) + # update the count of the task to image uploaded. + toatl_image_count = project_logic.get_project_info_from_s3( + project_id, task_id + ).image_count + + await project_logic.update_task_field( + db, project_id, task_id, "total_image_uploaded", toatl_image_count + ) return await update_task_state( db, diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index cc37f152..7e265aa1 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -1,6 +1,7 @@ import uuid from typing import Annotated from app.projects import project_deps, project_schemas +from app.projects import project_logic from fastapi import APIRouter, BackgroundTasks, Depends from app.config import settings from app.tasks import task_schemas, task_logic @@ -9,6 +10,7 @@ from psycopg import Connection from app.db import database from loguru import logger as log +from psycopg.rows import dict_row router = APIRouter( prefix=f"{settings.API_PREFIX}/tasks", @@ -47,7 +49,7 @@ async def list_tasks( user_id = user_data.id role = user_data.role log.info(f"Fetching tasks for user {user_id} with role: {role}") - return await task_schemas.UserTasksStatsOut.get_tasks_by_user( + return await task_schemas.UserTasksOut.get_tasks_by_user( db, user_id, role, skip, limit ) @@ -86,3 +88,241 @@ async def new_event( user_data, background_tasks, ) + + +# TODO: We will remove this endpoint after production release. +@router.post("/dummy/") +async def update_task_table( + db: Annotated[Connection, Depends(database.get_db)], +): + async with db.cursor(row_factory=dict_row) as cur: + # Fetch all projects + await cur.execute( + """ + SELECT * + FROM projects + """ + ) + db_projects = await cur.fetchall() + + for project in db_projects: + project_id = project["id"] + + # Fetch tasks for the current project + await cur.execute( + """ + SELECT * + FROM tasks + WHERE project_id = %s + """, + (project_id,), + ) + tasks = await cur.fetchall() + + for task in tasks: + if task["total_area_sqkm"] is None: + # Calculate the area + await cur.execute( + """ + SELECT ST_Area(ST_Transform(%s, 3857)) / 1000000 AS task_area + """, + (task["outline"],), + ) + area_result = await cur.fetchone() + + task_area = area_result["task_area"] if area_result else 0 + + # Update the total_area_sqkm in the tasks table + await cur.execute( + """ + UPDATE tasks + SET total_area_sqkm = %s + WHERE id = %s + """, + (task_area, task["id"]), + ) + task_id = task["id"] + + if task["assets_url"] is None: + await cur.execute( + """ + SELECT state + FROM task_events + WHERE task_id = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (task_id,), + ) + task_event = await cur.fetchone() + if ( + task_event + and task_event["state"] == "IMAGE_PROCESSING_FINISHED" + ): + s3_path_url = ( + f"dtm-data/projects/{project_id}/{task_id}/assets.zip" + ) + # Update the task table with the assets_url + await project_logic.update_task_field( + db, project_id, task_id, "assets_url", s3_path_url + ) + + if task["total_image_uploaded"] is None: + await cur.execute( + """ + SELECT state + FROM task_events + WHERE task_id = %s AND state = 'IMAGE_UPLOADED' + ORDER BY created_at DESC + """, + (task_id,), + ) + task_event = await cur.fetchone() + if task_event: + # update the count of the task to image uploaded. + toatl_image_count = project_logic.get_project_info_from_s3( + project_id, task_id + ).image_count + + await project_logic.update_task_field( + db, + project_id, + task_id, + "total_image_uploaded", + toatl_image_count, + ) + # If both is None + if ( + task["flight_time_minutes"] is None + and task["flight_distance_km"] is None + ): + import geojson + from drone_flightplan import ( + waypoints, + add_elevation_from_dem, + calculate_parameters, + create_placemarks, + ) + from app.s3 import get_file_from_bucket + from geojson import Feature, FeatureCollection, Polygon + from app.models.enums import FlightMode + from app.utils import calculate_flight_time_from_placemarks + + # Fetch the task outline + await cur.execute( + """ + SELECT jsonb_build_object( + 'type', 'Feature', + 'geometry', ST_AsGeoJSON(tasks.outline)::jsonb, + 'properties', jsonb_build_object( + 'id', tasks.id, + 'bbox', jsonb_build_array( + ST_XMin(ST_Envelope(tasks.outline)), + ST_YMin(ST_Envelope(tasks.outline)), + ST_XMax(ST_Envelope(tasks.outline)), + ST_YMax(ST_Envelope(tasks.outline)) + ) + ), + 'id', tasks.id + ) AS outline + FROM tasks + WHERE id = %s; + """, + (task["id"],), + ) + polygon = await cur.fetchone() + polygon = polygon["outline"] + forward_overlap = ( + project["front_overlap"] if project["front_overlap"] else 70 + ) + side_overlap = ( + project["side_overlap"] if project["side_overlap"] else 70 + ) + generate_3d = False + + gsd = project["gsd_cm_px"] + altitude = project["altitude_from_ground"] + + parameters = calculate_parameters( + forward_overlap, + side_overlap, + altitude, + gsd, + 2, + ) + + # Wrap polygon into GeoJSON Feature + coordinates = polygon["geometry"]["coordinates"] + + if polygon["geometry"]["type"] == "Polygon": + coordinates = polygon["geometry"]["coordinates"] + + feature = Feature(geometry=Polygon(coordinates), properties={}) + feature_collection = FeatureCollection([feature]) + + # Common parameters for create_waypoint + waypoint_params = { + "project_area": feature_collection, + "agl": altitude, + "gsd": gsd, + "forward_overlap": forward_overlap, + "side_overlap": side_overlap, + "rotation_angle": 0, + "generate_3d": generate_3d, + } + waypoint_params["mode"] = FlightMode.waypoints + if project["is_terrain_follow"]: + dem_path = f"/tmp/{uuid.uuid4()}/dem.tif" + + # Terrain follow uses waypoints mode, waylines are generated later + points = waypoints.create_waypoint(**waypoint_params) + + try: + get_file_from_bucket( + settings.S3_BUCKET_NAME, + f"dtm-data/projects/{project.id}/dem.tif", + dem_path, + ) + # TODO: Do this with inmemory data + outfile_with_elevation = ( + "/tmp/output_file_with_elevation.geojson" + ) + add_elevation_from_dem( + dem_path, points, outfile_with_elevation + ) + + inpointsfile = open(outfile_with_elevation, "r") + points_with_elevation = inpointsfile.read() + + except Exception: + points_with_elevation = points + + placemarks = create_placemarks( + geojson.loads(points_with_elevation), parameters + ) + + else: + points = waypoints.create_waypoint(**waypoint_params) + placemarks = create_placemarks( + geojson.loads(points), parameters + ) + + flight_time_minutes = calculate_flight_time_from_placemarks( + placemarks + ).get("total_flight_time") + flight_distance_km = calculate_flight_time_from_placemarks( + placemarks + ).get("flight_distance_km") + + # Update the total_area_sqkm in the tasks table + await cur.execute( + """ + UPDATE tasks + SET flight_time_minutes = %s, + flight_distance_km = %s + WHERE id = %s + """, + (flight_time_minutes, flight_distance_km, task["id"]), + ) + + return {"message": "Task table updated successfully."} diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index 31715c2e..8dc57708 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -9,7 +9,7 @@ from psycopg.rows import class_row, dict_row from typing import List, Literal, Optional from pydantic.functional_validators import field_validator -from app.s3 import is_connection_secure +from app.s3 import generate_static_url, is_connection_secure class Geometry(BaseModel): @@ -163,9 +163,11 @@ async def all(db: Connection, project_id: uuid.UUID): return combined_tasks -class UserTasksStatsOut(BaseModel): +class UserTasksOut(BaseModel): task_id: uuid.UUID - task_area: float + total_area_sqkm: Optional[float] = None + flight_time_minutes: Optional[float] = None + flight_distance_km: Optional[float] = None created_at: datetime state: str project_id: uuid.UUID @@ -199,7 +201,7 @@ def format_url(url): async def get_tasks_by_user( db: Connection, user_id: str, role: str, skip: int = 0, limit: int = 50 ): - async with db.cursor(row_factory=class_row(UserTasksStatsOut)) as cur: + async with db.cursor(row_factory=class_row(UserTasksOut)) as cur: await cur.execute( """ SELECT DISTINCT ON (tasks.id) @@ -207,7 +209,9 @@ async def get_tasks_by_user( 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, + tasks.total_area_sqkm, + tasks.flight_time_minutes, + tasks.flight_distance_km, task_events.created_at, task_events.updated_at, task_events.state, @@ -263,7 +267,11 @@ async def get_tasks_by_user( class TaskDetailsOut(BaseModel): - task_area: float + total_area_sqkm: Optional[float] = None + flight_time_minutes: Optional[float] = None + flight_distance_km: Optional[float] = None + total_image_uploaded: Optional[int] = None + assets_url: Optional[str] = None outline: Outline created_at: datetime updated_at: Optional[datetime] = None @@ -276,6 +284,15 @@ class TaskDetailsOut(BaseModel): gimble_angles_degrees: Optional[int] = None centroid: dict + @model_validator(mode="after") + def set_assets_url(cls, values): + """Set image_url before rendering the model.""" + assets_url = values.assets_url + if assets_url: + values.assets_url = generate_static_url(settings.S3_BUCKET_NAME, assets_url) + + return values + @field_validator("state", mode="after") @classmethod def integer_state_to_string(cls, value: State): @@ -300,8 +317,11 @@ async def get_task_details(db: Connection, task_id: uuid.UUID): await cur.execute( """ SELECT - ST_Area(ST_Transform(tasks.outline, 3857)) / 1000000 AS task_area, - + tasks.total_area_sqkm, + tasks.flight_time_minutes, + tasks.flight_distance_km, + tasks.total_image_uploaded, + tasks.assets_url, -- Construct the outline as a GeoJSON Feature jsonb_build_object( 'type', 'Feature', diff --git a/src/backend/app/users/user_schemas.py b/src/backend/app/users/user_schemas.py index 058a8590..a46eb24b 100644 --- a/src/backend/app/users/user_schemas.py +++ b/src/backend/app/users/user_schemas.py @@ -251,6 +251,18 @@ async def create(db: Connection, user_id: int, profile_create: UserProfileCreate async with db.cursor() as cur: await cur.execute(sql, model_data) + if profile_create.password: + password_update_query = """ + UPDATE users + SET password = %(password)s + WHERE id = %(user_id)s; + """ + hashed_password = user_logic.get_password_hash(profile_create.password) + await cur.execute( + password_update_query, + {"password": hashed_password, "user_id": user_id}, + ) + for file_type, url_key in field_mapping.items(): if results.get(file_type): model_data[url_key] = results[file_type].get("presigned_url") diff --git a/src/backend/app/utils.py b/src/backend/app/utils.py index 41212919..f3512275 100644 --- a/src/backend/app/utils.py +++ b/src/backend/app/utils.py @@ -558,17 +558,19 @@ async def send_project_approval_email_to_regulator( def calculate_flight_time_from_placemarks(placemarks: Dict) -> Dict: """ - Calculate the total and average flight time based on placemarks and dynamically format the output. + Calculate the total and average flight time and total flight distance based on placemarks. Args: placemarks (Dict): GeoJSON-like data structure with flight plan. Returns: - Dict: Contains formatted total flight time and segment times. + Dict: Contains formatted total flight time, segment times, and total distance. """ total_time = 0 + total_distance = 0 features = placemarks["features"] transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) + for i in range(1, len(features)): # Extract current and previous coordinates prev_coords = features[i - 1]["geometry"]["coordinates"][:2] @@ -581,22 +583,15 @@ def calculate_flight_time_from_placemarks(placemarks: Dict) -> Dict: # Calculate distance (meters) and time (seconds) distance = prev_point.distance(curr_point) + total_distance += distance # Accumulate total distance segment_time = distance / speed total_time += segment_time - # Dynamically format the total flight time - hours = int(total_time // 3600) - minutes = int((total_time % 3600) // 60) - seconds = round(total_time % 60, 2) - - if total_time < 60: - formatted_time = f"{seconds} seconds" - elif total_time < 3600: - formatted_time = f"{minutes} minutes {seconds:.2f} seconds" - else: - formatted_time = f"{hours} hours {minutes} minutes {seconds:.2f} seconds" + flight_distance_km = total_distance / 1000 # Convert to kilometers + flight_time_minutes = total_time / 60 # Convert to minutes return { - "total_flight_time": formatted_time, + "total_flight_time": f"{flight_time_minutes:.2f}", "total_flight_time_seconds": round(total_time, 2), + "flight_distance_km": round(flight_distance_km, 2), } diff --git a/src/backend/app/waypoints/waypoint_routes.py b/src/backend/app/waypoints/waypoint_routes.py index f038f8cc..e5e3e8c3 100644 --- a/src/backend/app/waypoints/waypoint_routes.py +++ b/src/backend/app/waypoints/waypoint_routes.py @@ -10,10 +10,11 @@ create_placemarks, calculate_parameters, add_elevation_from_dem, + terrain_following_waylines, wpml, waypoints, ) -from app.models.enums import HTTPStatus +from app.models.enums import HTTPStatus, FlightMode from app.tasks.task_logic import ( get_task_geojson, get_take_off_point_from_db, @@ -23,7 +24,7 @@ check_point_within_buffer, ) from app.db import database -from app.utils import calculate_flight_time_from_placemarks, merge_multipolygon +from app.utils import merge_multipolygon from app.s3 import get_file_from_bucket from typing import Annotated from psycopg import Connection @@ -48,6 +49,7 @@ async def get_task_waypoint( project_id: uuid.UUID, task_id: uuid.UUID, download: bool = True, + mode: FlightMode = FlightMode.waylines, take_off_point: waypoint_schemas.PointField = None, ): """ @@ -70,11 +72,11 @@ async def get_task_waypoint( if take_off_point: take_off_point = [take_off_point.longitude, take_off_point.latitude] - # Validate that the take-off point is within a 350 buffer of the task boundary - if not check_point_within_buffer(take_off_point, task_geojson, 350): + # Validate that the take-off point is within a 1000 buffer of the task boundary + if not check_point_within_buffer(take_off_point, task_geojson, 1000): raise HTTPException( status_code=400, - detail="Take off point should be within 350m of the boundary", + detail="Take off point should be within 1km of the boundary", ) # Update take_off_point in tasks table @@ -95,7 +97,6 @@ async def get_task_waypoint( forward_overlap = project.front_overlap if project.front_overlap else 70 side_overlap = project.side_overlap if project.side_overlap else 70 - generate_each_points = True if project.is_terrain_follow else False generate_3d = ( False # TODO: For 3d imageries drone_flightplan package needs to be updated. ) @@ -103,18 +104,6 @@ async def get_task_waypoint( gsd = project.gsd_cm_px altitude = project.altitude_from_ground - points = waypoints.create_waypoint( - project_area=task_geojson, - agl=altitude, - gsd=gsd, - forward_overlap=forward_overlap, - side_overlap=side_overlap, - rotation_angle=0, - generate_each_points=generate_each_points, - generate_3d=generate_3d, - take_off_point=take_off_point, - ) - parameters = calculate_parameters( forward_overlap, side_overlap, @@ -123,8 +112,25 @@ async def get_task_waypoint( 2, # Image Interval is set to 2 ) + # Common parameters for create_waypoint + waypoint_params = { + "project_area": task_geojson, + "agl": altitude, + "gsd": gsd, + "forward_overlap": forward_overlap, + "side_overlap": side_overlap, + "rotation_angle": 0, + "generate_3d": generate_3d, + "take_off_point": take_off_point, + } + if project.is_terrain_follow: dem_path = f"/tmp/{uuid.uuid4()}/dem.tif" + + # Terrain follow uses waypoints mode, waylines are generated later + waypoint_params["mode"] = FlightMode.waypoints + points = waypoints.create_waypoint(**waypoint_params) + try: get_file_from_bucket( settings.S3_BUCKET_NAME, @@ -142,18 +148,25 @@ async def get_task_waypoint( points_with_elevation = points placemarks = create_placemarks(geojson.loads(points_with_elevation), parameters) + + # Create a flight plan with terrain follow in waylines mode + if mode == FlightMode.waylines: + placemarks = terrain_following_waylines.waypoints2waylines(placemarks, 5) + else: + waypoint_params["mode"] = mode + points = waypoints.create_waypoint(**waypoint_params) placemarks = create_placemarks(geojson.loads(points), parameters) + if download: outfile = outfile = f"/tmp/{uuid.uuid4()}" kmz_file = wpml.create_wpml(placemarks, outfile) return FileResponse( kmz_file, - media_type="application/zip", + media_type="application/vnd.google-earth.kmz", filename=f"{task_id}_flight_plan.kmz", ) - flight_data = calculate_flight_time_from_placemarks(placemarks) - return {"results": placemarks, "flight_data": flight_data} + return {"results": placemarks} @router.post("/") diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index a4b08bc2..15594f1f 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "pyodm>=1.5.11", "asgiref>=3.8.1", "bcrypt>=4.2.1", - "drone-flightplan>=0.3.2", + "drone-flightplan>=0.3.4rc2", "Scrapy==2.12.0", "asgi-lifespan>=2.1.0", ] diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 6b2ab52b..de038958 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -507,16 +507,16 @@ wheels = [ [[package]] name = "drone-flightplan" -version = "0.3.3" +version = "0.3.4rc2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "geojson" }, { name = "pyproj" }, { name = "shapely" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/d1/f11d3e68db62471e4c28517d18e2c79796532ba6f112c563840d1502ed33/drone_flightplan-0.3.3.tar.gz", hash = "sha256:d694aaee7b5646421fc24ef6030de554610a0aef83430127bfae5accaab57905", size = 34935 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/ab/a2803a255f51014f25104e5ca2430e3e78a8a1ab8cc6acdf862378518bc7/drone_flightplan-0.3.4rc2.tar.gz", hash = "sha256:f1137e1217d2d62abd5e2a77d379fa550e8058d566426b66b482bf9fbd0a1f16", size = 35073 } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/6a/acc658f1e87ef2e2e2e57eb05c26a8b97149327353f57d9288ca3f66fc7f/drone_flightplan-0.3.3-py3-none-any.whl", hash = "sha256:2b4ed0c2dac444f0cbd975846f3444bdde3c793a36efccf5e7d1885c927e99bd", size = 39406 }, + { url = "https://files.pythonhosted.org/packages/82/3e/db5251247d0f5303a325025b523a8d14dee19d473596ae13ee8d71e06f5c/drone_flightplan-0.3.4rc2-py3-none-any.whl", hash = "sha256:f3d80da9f86437313a6a3f2acb3f156ced4c3cef78fb9cf9a3100de55402f1a0", size = 39615 }, ] [[package]] @@ -596,7 +596,7 @@ requires-dist = [ { name = "asgi-lifespan", specifier = ">=2.1.0" }, { name = "asgiref", specifier = ">=3.8.1" }, { name = "bcrypt", specifier = ">=4.2.1" }, - { name = "drone-flightplan", specifier = ">=0.3.2" }, + { name = "drone-flightplan", specifier = ">=0.3.4rc2" }, { name = "fastapi", specifier = "==0.112.0" }, { name = "gdal", specifier = "==3.6.2" }, { name = "geoalchemy2", specifier = "==0.14.2" }, diff --git a/src/frontend/src/api/projects.ts b/src/frontend/src/api/projects.ts index 12fced39..28b15d38 100644 --- a/src/frontend/src/api/projects.ts +++ b/src/frontend/src/api/projects.ts @@ -5,7 +5,7 @@ import { getProjectDetail, getProjectCentroid, } from '@Services/createproject'; -import { getAllAssetsUrl, getTaskStates } from '@Services/project'; +import { getTaskStates } from '@Services/project'; import { getUserProfileInfo } from '@Services/common'; export const useGetProjectsListQuery = ( @@ -66,17 +66,17 @@ export const useGetUserDetailsQuery = ( }); }; -export const useGetAllAssetsUrlQuery = ( - projectId: string, - queryOptions?: Partial, -) => { - return useQuery({ - queryKey: ['all-assets-url'], - queryFn: () => getAllAssetsUrl(projectId), - select: (data: any) => data.data, - ...queryOptions, - }); -}; +// export const useGetAllAssetsUrlQuery = ( +// projectId: string, +// queryOptions?: Partial, +// ) => { +// return useQuery({ +// queryKey: ['all-assets-url'], +// queryFn: () => getAllAssetsUrl(projectId), +// select: (data: any) => data.data, +// ...queryOptions, +// }); +// }; export const useGetProjectCentroidQuery = ( queryOptions?: Partial, diff --git a/src/frontend/src/api/tasks.ts b/src/frontend/src/api/tasks.ts index 46a31e45..dc8cc2da 100644 --- a/src/frontend/src/api/tasks.ts +++ b/src/frontend/src/api/tasks.ts @@ -1,7 +1,7 @@ /* eslint-disable import/prefer-default-export */ import { getIndividualTask, - getTaskAssetsInfo, + // getTaskAssetsInfo, getTaskWaypoint, } from '@Services/tasks'; import { useQuery, UseQueryOptions } from '@tanstack/react-query'; @@ -9,12 +9,13 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; export const useGetTaskWaypointQuery = ( projectId: string, taskId: string, + mode: string, queryOptions?: Partial, ) => { return useQuery({ - queryKey: ['task-waypoints'], + queryKey: ['task-waypoints', mode], enabled: !!(projectId && taskId), - queryFn: () => getTaskWaypoint(projectId, taskId), + queryFn: () => getTaskWaypoint(projectId, taskId, mode), select: (res: any) => res.data, ...queryOptions, }); @@ -33,16 +34,16 @@ export const useGetIndividualTaskQuery = ( }); }; -export const useGetTaskAssetsInfo = ( - projectId: string, - taskId: string, - queryOptions?: Partial, -) => { - return useQuery({ - queryKey: ['task-assets-info'], - enabled: !!taskId, - queryFn: () => getTaskAssetsInfo(projectId, taskId), - select: (res: any) => res.data, - ...queryOptions, - }); -}; +// export const useGetTaskAssetsInfo = ( +// projectId: string, +// taskId: string, +// queryOptions?: Partial, +// ) => { +// return useQuery({ +// queryKey: ['task-assets-info'], +// enabled: !!taskId, +// queryFn: () => getTaskAssetsInfo(projectId, taskId), +// 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 21b84133..6d4eba59 100644 --- a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx +++ b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx @@ -293,8 +293,7 @@ const CreateprojectLayout = () => { delete refactoredData?.side_spacing; // remove key - if (isNoflyzonePresent === 'no') - delete refactoredData?.outline_no_fly_zones; + if (isNoflyzonePresent === 'no') delete refactoredData?.no_fly_zones; delete refactoredData?.dem; if (measurementType === 'gsd') delete refactoredData?.altitude_from_ground; else delete refactoredData?.gsd_cm_px; diff --git a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx index 88f70daf..07adf16d 100644 --- a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/index.tsx @@ -18,6 +18,10 @@ import MapSection from './MapSection'; export default function GenerateTask({ formProps }: { formProps: any }) { const dispatch = useTypedDispatch(); const [error, setError] = useState(''); + const isTerrainFollow = useTypedSelector( + state => state.createproject.isTerrainFollow, + ); + const demType = useTypedSelector(state => state.createproject.demType); const { register, watch } = formProps; const { @@ -27,6 +31,7 @@ export default function GenerateTask({ formProps }: { formProps: any }) { gsd_cm_px, outline, task_split_dimension, + dem: demFile, } = watch(); const dimension = watch('task_split_dimension'); @@ -53,9 +58,10 @@ export default function GenerateTask({ formProps }: { formProps: any }) { isLoading: projectWaypointCountIsLoading, } = useMutation({ mutationFn: (projectGeoJsonPayload: Record) => { - const { project_geojson, ...params } = projectGeoJsonPayload; + const { project_geojson, dem, ...params } = projectGeoJsonPayload; return getProjectWayPoints(params, { project_geojson, + dem, }); }, }); @@ -71,6 +77,8 @@ export default function GenerateTask({ formProps }: { formProps: any }) { }, }); + // console.log(dem, isTerrainFollow, demType, 'types'); + return (
@@ -115,6 +123,11 @@ export default function GenerateTask({ formProps }: { formProps: any }) { gsd_cm_px: gsd_cm_px || 0, meters: task_split_dimension, project_geojson: convertGeojsonToFile(outline), + is_terrain_follow: isTerrainFollow, + dem: + isTerrainFollow && demType === 'manual' + ? demFile[0]?.file + : null, }; mutateProjectWayPoints(projectWayPointsPayload); return mutate(payload); @@ -124,10 +137,23 @@ export default function GenerateTask({ formProps }: { formProps: any }) { {!projectWaypointCountIsLoading && projectWayPoints && (

- The average number of way points is{' '} - - {projectWayPoints?.data?.avg_no_of_waypoints} - + The average number of waypoints is: +

    + {projectWayPoints?.data?.waypoints && ( +
  • + + In waypoints mode: {projectWayPoints?.data?.waypoints} + +
  • + )} + {projectWayPoints?.data?.waylines && ( +
  • + + In waylines mode: {projectWayPoints.data.waylines} + +
  • + )} +

)}
diff --git a/src/frontend/src/components/CreateProject/FormContents/KeyParameters/index.tsx b/src/frontend/src/components/CreateProject/FormContents/KeyParameters/index.tsx index 5f0c4a7e..1d9df04d 100644 --- a/src/frontend/src/components/CreateProject/FormContents/KeyParameters/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/KeyParameters/index.tsx @@ -384,7 +384,7 @@ const KeyParameters = ({ formProps }: { formProps: UseFormPropsType }) => { )} - {demType === 'manual' && ( + {demType === 'manual' && isTerrainFollow && ( { {task?.project_name} - {Number(task?.task_area)?.toFixed(3)} + {Number(task?.total_area_sqkm)?.toFixed(3)} {/* - */} diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx index f87c5cdc..3844a058 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DescriptionBox/index.tsx @@ -1,12 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, 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 { useGetIndividualTaskQuery, useGetTaskWaypointQuery } from '@Api/tasks'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { postProcessImagery } from '@Services/tasks'; import { formatString } from '@Utils/index'; @@ -16,12 +12,12 @@ import SwitchTab from '@Components/common/SwitchTab'; import { resetFilesExifData, setSelectedTaskDetailToViewOrthophoto, + setTaskAssetsInformation, setUploadedImagesType, } from '@Store/actions/droneOperatorTask'; import { useTypedSelector } from '@Store/hooks'; // import { toggleModal } from '@Store/actions/common'; import { postTaskStatus } from '@Services/project'; -import Skeleton from '@Components/RadixComponents/Skeleton'; import DescriptionBoxComponent from './DescriptionComponent'; import QuestionBox from '../QuestionBox'; import UploadsInformation from '../UploadsInformation'; @@ -35,23 +31,20 @@ const DescriptionBox = () => { const uploadedImageType = useTypedSelector( state => state.droneOperatorTask.uploadedImagesType, ); + const waypointMode = useTypedSelector( + state => state.droneOperatorTask.waypointMode, + ); const { data: taskWayPoints }: any = useGetTaskWaypointQuery( projectId as string, taskId as string, + waypointMode as string, { select: (data: any) => { - return data.data.features; + return data.data.results.features; }, }, ); - const { - data: taskAssetsInformation, - isFetching: taskAssetsInfoLoading, - }: Record = useGetTaskAssetsInfo( - projectId as string, - taskId as string, - ); const { mutate: updateStatus, isLoading: statusUpdating } = useMutation< any, @@ -61,7 +54,7 @@ const DescriptionBox = () => { >({ mutationFn: postTaskStatus, onSuccess: () => { - queryClient.invalidateQueries(['task-assets-info']); + queryClient.invalidateQueries(['task-description']); }, onError: (err: any) => { toast.error(err.message); @@ -89,26 +82,16 @@ const DescriptionBox = () => { }, }); - const { data: flightTimeData }: any = useGetTaskWaypointQuery( - projectId as string, - taskId as string, - { - select: ({ data }: any) => data.flight_data, - }, - ); - const { data: taskDescription }: Record = useGetIndividualTaskQuery(taskId as string, { - enabled: !!taskWayPoints, + // enabled: !!taskWayPoints, select: (data: any) => { const { data: taskData } = data; dispatch( - dispatch( - setSelectedTaskDetailToViewOrthophoto({ - outline: taskData?.outline, - }), - ), + setSelectedTaskDetailToViewOrthophoto({ + outline: taskData?.outline, + }), ); return [ @@ -130,18 +113,19 @@ const DescriptionBox = () => { }, { name: 'Total task area', - value: taskData?.task_area - ? `${Number(taskData?.task_area)?.toFixed(3)} km²` + value: taskData?.total_area_sqkm + ? `${Number(taskData?.total_area_sqkm)?.toFixed(3)} km²` : null, }, - { name: 'Total points', value: taskWayPoints?.length }, { - name: 'Est. flight time', - value: flightTimeData?.total_flight_time || null, + name: 'Total waypoints count', + value: taskWayPoints?.length, }, { - name: 'Est. flight time in seconds', - value: flightTimeData?.total_flight_time_seconds || null, + name: 'Est. flight time', + value: taskData?.flight_time_minutes + ? `${Number(taskData?.flight_time_minutes)?.toFixed(3)} minutes` + : null, }, ], }, @@ -180,17 +164,27 @@ const DescriptionBox = () => { }, ], }, + { + total_image_uploaded: taskData?.total_image_uploaded || 0, + assets_url: taskData?.assets_url, + state: taskData?.state, + }, ]; }, }); + const taskAssetsInformation = useMemo(() => { + if (!taskDescription) return {}; + dispatch(setTaskAssetsInformation(taskDescription?.[2])); + return taskDescription?.[2]; + }, [taskDescription, dispatch]); + const handleDownloadResult = () => { if (!taskAssetsInformation?.assets_url) return; - try { const link = document.createElement('a'); link.href = taskAssetsInformation?.assets_url; - link.download = 'assets.zip'; + link.download = `${projectId}-${taskId}.tif`; document.body.appendChild(link); link.click(); link.remove(); @@ -210,21 +204,21 @@ const DescriptionBox = () => { /> ))}
- {taskAssetsInformation?.image_count === 0 && ( + + {taskAssetsInformation?.total_image_uploaded === 0 && ( )} - - {taskAssetsInformation?.image_count > 0 && ( + {taskAssetsInformation?.total_image_uploaded > 0 && (
{ }, { name: 'Image Status', - value: formatString(taskAssetsInformation?.state), + value: + // if the state is LOCKED_FOR_MAPPING and has a image count it means the images are not fully uploaded + taskAssetsInformation?.state === 'LOCKED_FOR_MAPPING' && + taskAssetsInformation?.image_count > 0 + ? 'Image Uploading Failed' + : formatString(taskAssetsInformation?.state), }, ]} /> - {taskAssetsInfoLoading && } - {taskAssetsInformation?.assets_url && (
{/*
)} - {taskAssetsInformation?.state === 'IMAGE_PROCESSING_FAILED' || - (taskAssetsInformation?.state === 'IMAGE_UPLOADED' && ( -
- - ) => { - 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.'} + {(taskAssetsInformation?.state === 'IMAGE_PROCESSING_FAILED' || + // if the state is LOCKED_FOR_MAPPING and has a image count it means all selected images are not uploaded and the status updating api call is interrupted so need to give user to upload the remaining images + taskAssetsInformation?.state === 'LOCKED_FOR_MAPPING' || + taskAssetsInformation?.state === 'IMAGE_UPLOADED') && ( +

+
- ))} + + ) => { + 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/LoadingBox/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/LoadingBox/index.tsx index 3911014a..9949e9b3 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/LoadingBox/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/PopoverBox/LoadingBox/index.tsx @@ -21,7 +21,7 @@ const FilesUploadingPopOver = ({ // function to close modal and refetch task assets to update the UI function closeModal() { - queryClient.invalidateQueries(['task-assets-info']); + queryClient.invalidateQueries(['task-description']); setTimeout(() => { dispatch(toggleModal()); }, 2000); diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx index 36c12a65..34c566dd 100644 --- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/index.tsx @@ -1,11 +1,12 @@ /* eslint-disable no-nested-ternary */ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useTypedSelector } from '@Store/hooks'; +import { toast } from 'react-toastify'; import { useGetIndividualTaskQuery, useGetTaskWaypointQuery } from '@Api/tasks'; import { Button } from '@Components/RadixComponents/Button'; import useWindowDimensions from '@Hooks/useWindowDimensions'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; -import { useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { toast } from 'react-toastify'; import MapSection from '../MapSection'; import DescriptionBox from './DescriptionBox'; @@ -17,6 +18,9 @@ const DroneOperatorDescriptionBox = () => { useState(false); const { width } = useWindowDimensions(); const Token = localStorage.getItem('token'); + const waypointMode = useTypedSelector( + state => state.droneOperatorTask.waypointMode, + ); const { data: taskDescription }: Record = useGetIndividualTaskQuery(taskId as string); @@ -24,6 +28,7 @@ const DroneOperatorDescriptionBox = () => { const { data: taskWayPoints }: any = useGetTaskWaypointQuery( projectId as string, taskId as string, + waypointMode as string, { select: (data: any) => { return data.data?.results.features; @@ -33,7 +38,7 @@ const DroneOperatorDescriptionBox = () => { const downloadFlightPlanKmz = () => { fetch( - `${BASE_URL}/waypoint/task/${taskId}/?project_id=${projectId}&download=true`, + `${BASE_URL}/waypoint/task/${taskId}/?project_id=${projectId}&download=true&mode=${waypointMode}`, { method: 'POST' }, ) .then(response => { @@ -60,6 +65,7 @@ const DroneOperatorDescriptionBox = () => { const downloadFlightPlanGeojson = () => { if (!taskWayPoints) return; + const waypointGeojson = { type: 'FeatureCollection', features: taskWayPoints, @@ -70,7 +76,7 @@ const DroneOperatorDescriptionBox = () => { const url = window.URL.createObjectURL(fileBlob); const link = document.createElement('a'); link.href = url; - link.download = 'waypoint.geojson'; + link.download = `${waypointMode}.geojson`; document.body.appendChild(link); link.click(); link.remove(); diff --git a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx index 81ef5326..325e3995 100644 --- a/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx +++ b/src/frontend/src/components/DroneOperatorTask/MapSection/index.tsx @@ -4,7 +4,7 @@ /* eslint-disable react/no-array-index-key */ import { useGetIndividualTaskQuery, - useGetTaskAssetsInfo, + // useGetTaskAssetsInfo, useGetTaskWaypointQuery, } from '@Api/tasks'; import marker from '@Assets/images/marker.png'; @@ -23,6 +23,8 @@ import { setSelectedTakeOffPoint, setSelectedTakeOffPointOption, setSelectedTaskDetailToViewOrthophoto, + setTaskAssetsInformation, + setWaypointMode, } from '@Store/actions/droneOperatorTask'; import { useTypedSelector } from '@Store/hooks'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -41,14 +43,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import ToolTip from '@Components/RadixComponents/ToolTip'; -import Skeleton from '@Components/RadixComponents/Skeleton'; +// import Skeleton from '@Components/RadixComponents/Skeleton'; import rotateGeoJSON from '@Utils/rotateGeojsonData'; import COGOrthophotoViewer from '@Components/common/MapLibreComponents/COGOrthophotoViewer'; import { toast } from 'react-toastify'; import { FlexColumn } from '@Components/common/Layouts'; import RotatingCircle from '@Components/common/RotationCue'; import { mapLayerIDs } from '@Constants/droneOperator'; +import SwitchTab from '@Components/common/SwitchTab'; import { findNearestCoordinate, swapFirstAndLast } from '@Utils/index'; +import { waypointModeOptions } from '@Constants/taskDescription'; import GetCoordinatesOnClick from './GetCoordinatesOnClick'; import ShowInfo from './ShowInfo'; @@ -83,6 +87,12 @@ const MapSection = ({ className }: { className?: string }) => { const newTakeOffPoint = useTypedSelector( state => state.droneOperatorTask.selectedTakeOffPoint, ); + const waypointMode = useTypedSelector( + state => state.droneOperatorTask.waypointMode, + ); + const taskAssetsInformation = useTypedSelector( + state => state.droneOperatorTask.taskAssetsInformation, + ); function setVisibilityOfLayers(layerIds: string[], visibility: string) { layerIds.forEach(layerId => { @@ -125,6 +135,7 @@ const MapSection = ({ className }: { className?: string }) => { const { data: taskWayPointsData }: any = useGetTaskWaypointQuery( projectId as string, taskId as string, + waypointMode as string, { select: ({ data }: any) => { const modifiedTaskWayPointsData = { @@ -222,6 +233,7 @@ const MapSection = ({ className }: { className?: string }) => {

Gimble angle: {popupData?.gimbal_angle} degree

+

Heading: {popupData?.heading}

{popupData?.altitude && (

Altitude: {popupData?.altitude} meter @@ -262,6 +274,13 @@ const MapSection = ({ className }: { className?: string }) => { dispatch(setSelectedTaskDetailToViewOrthophoto(null)); dispatch(setSelectedTakeOffPoint(null)); dispatch(setSelectedTakeOffPointOption('current_location')); + dispatch( + setTaskAssetsInformation({ + total_image_uploaded: 0, + assets_url: '', + state: '', + }), + ); }, [dispatch], ); @@ -351,13 +370,13 @@ const MapSection = ({ className }: { className?: string }) => { }); }; - const { - data: taskAssetsInformation, - isFetching: taskAssetsInfoLoading, - }: Record = useGetTaskAssetsInfo( - projectId as string, - taskId as string, - ); + // const { + // data: taskAssetsInformation, + // isFetching: taskAssetsInfoLoading, + // }: Record = useGetTaskAssetsInfo( + // projectId as string, + // taskId as string, + // ); useEffect(() => { setTaskWayPoints(taskWayPointsData); @@ -706,26 +725,22 @@ const MapSection = ({ className }: { className?: string }) => { - {taskAssetsInfoLoading ? ( - - ) : ( - taskAssetsInformation?.assets_url && ( -

- -
- ) + {taskAssetsInformation?.assets_url && ( +
+ +
)} {!isRotationEnabled && (
@@ -830,6 +845,19 @@ const MapSection = ({ className }: { className?: string }) => { hideButton getCoordOnProperties /> + +
+ ) => { + dispatch(setWaypointMode(value.value)); + }} + /> +
diff --git a/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx b/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx index 9b6656b3..6164f599 100644 --- a/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx +++ b/src/frontend/src/components/IndividualProject/Contributions/TableSection/index.tsx @@ -1,4 +1,3 @@ -import { useGetAllAssetsUrlQuery } from '@Api/projects'; import DataTable from '@Components/common/DataTable'; import Icon from '@Components/common/Icon'; import { toggleModal } from '@Store/actions/common'; @@ -7,7 +6,6 @@ import { useTypedSelector } from '@Store/hooks'; import { formatString } from '@Utils/index'; import { useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; const contributionsDataColumns = [ @@ -93,37 +91,27 @@ export default function TableSection({ isFetching, handleTableRowClick, }: ITableSectionProps) { - const { id } = useParams(); const tasksData = useTypedSelector(state => state.project.tasksData); - const { data: allUrls, isFetching: isUrlFetching } = useGetAllAssetsUrlQuery( - id as string, - ); - - const getTasksAssets = (taskID: string, assetsList: any[]) => { - if (!assetsList || !taskID) return null; - return assetsList.find((assets: any) => assets?.task_id === taskID); - }; - const taskDataForTable = useMemo(() => { - if (!tasksData || isUrlFetching) return []; + if (!tasksData) return []; return tasksData?.reduce((acc: any, curr: any) => { if (!curr?.state || curr?.state === 'UNLOCKED_TO_MAP') return acc; - const selectedAssetsDetails = getTasksAssets(curr?.id, allUrls as any[]); + return [ ...acc, { user: curr?.name || '-', task_mapped: `Task# ${curr?.project_task_index}`, task_state: formatString(curr?.state), - assets_url: selectedAssetsDetails?.assets_url, - image_count: selectedAssetsDetails?.image_count, + assets_url: curr?.assets_url, + image_count: curr?.total_image_uploaded, task_id: curr?.id, outline: curr?.outline, }, ]; }, []); - }, [tasksData, allUrls, isUrlFetching]); + }, [tasksData]); return ( []} withPagination={false} - loading={isFetching || isUrlFetching} + loading={isFetching} handleTableRowClick={handleTableRowClick} /> ); diff --git a/src/frontend/src/components/IndividualProject/Tasks/TableSection/index.tsx b/src/frontend/src/components/IndividualProject/Tasks/TableSection/index.tsx index 340ab791..156d90a2 100644 --- a/src/frontend/src/components/IndividualProject/Tasks/TableSection/index.tsx +++ b/src/frontend/src/components/IndividualProject/Tasks/TableSection/index.tsx @@ -11,6 +11,14 @@ const tasksDataColumns = [ header: 'Task Area in km²', accessorKey: 'task_area', }, + { + header: 'Flight Time in Minutes', + accessorKey: 'flight_time_minutes', + }, + { + header: 'Flight Distance in km', + accessorKey: 'flight_distance_km', + } ]; interface ITableSectionProps { @@ -34,7 +42,9 @@ export default function TableSection({ { id: `Task# ${curr?.project_task_index}`, flight_time: curr?.flight_time || '-', - task_area: Number(curr?.task_area)?.toFixed(3), + task_area: Number(curr?.total_area_sqkm)?.toFixed(3), + flight_time_minutes: Number(curr?.flight_time_minutes)?.toFixed(3), + flight_distance_km: Number(curr?.flight_distance_km)?.toFixed(3), task_id: curr?.id, // status: curr?.state, }, diff --git a/src/frontend/src/constants/taskDescription.ts b/src/frontend/src/constants/taskDescription.ts index afeebfb4..0a992761 100644 --- a/src/frontend/src/constants/taskDescription.ts +++ b/src/frontend/src/constants/taskDescription.ts @@ -12,3 +12,8 @@ export const takeOffPointOptions = [ name: 'take_off_point', }, ]; + +export const waypointModeOptions = [ + { label: 'Waypoints', value: 'waypoints' }, + { label: 'Waylines', value: 'waylines' }, +]; diff --git a/src/frontend/src/services/project.ts b/src/frontend/src/services/project.ts index c5db4348..2b4ffc45 100644 --- a/src/frontend/src/services/project.ts +++ b/src/frontend/src/services/project.ts @@ -13,5 +13,5 @@ export const postTaskStatus = (payload: Record) => { export const getRequestedTasks = () => authenticated(api).get('/tasks/requested_tasks/pending'); -export const getAllAssetsUrl = (projectId: string) => - authenticated(api).get(`/projects/assets/${projectId}/`); +// export const getAllAssetsUrl = (projectId: string) => +// authenticated(api).get(`/projects/assets/${projectId}/`); diff --git a/src/frontend/src/services/tasks.ts b/src/frontend/src/services/tasks.ts index 135e62c4..0b38afc5 100644 --- a/src/frontend/src/services/tasks.ts +++ b/src/frontend/src/services/tasks.ts @@ -1,8 +1,12 @@ import { api, authenticated } from '.'; -export const getTaskWaypoint = (projectId: string, taskId: string) => +export const getTaskWaypoint = ( + projectId: string, + taskId: string, + mode: string, +) => authenticated(api).post( - `/waypoint/task/${taskId}/?project_id=${projectId}&download=false`, + `/waypoint/task/${taskId}/?project_id=${projectId}&download=false&mode=${mode}`, ); export const getIndividualTask = (taskId: string) => @@ -18,8 +22,8 @@ export const postTaskWaypoint = (payload: Record) => { }, ); }; -export const getTaskAssetsInfo = (projectId: string, taskId: string) => - authenticated(api).get(`/projects/assets/${projectId}/?task_id=${taskId}`); +// export const getTaskAssetsInfo = (projectId: string, taskId: string) => +// authenticated(api).get(`/projects/assets/${projectId}/?task_id=${taskId}`); export const postProcessImagery = (projectId: string, taskId: string) => authenticated(api).post(`/projects/process_imagery/${projectId}/${taskId}/`); diff --git a/src/frontend/src/store/actions/droneOperatorTask.ts b/src/frontend/src/store/actions/droneOperatorTask.ts index f2444783..38efe121 100644 --- a/src/frontend/src/store/actions/droneOperatorTask.ts +++ b/src/frontend/src/store/actions/droneOperatorTask.ts @@ -16,4 +16,6 @@ export const { setSelectedTaskDetailToViewOrthophoto, setFilesExifData, resetFilesExifData, + setWaypointMode, + setTaskAssetsInformation, } = droneOperatorTaskSlice.actions; diff --git a/src/frontend/src/store/slices/droneOperartorTask.ts b/src/frontend/src/store/slices/droneOperartorTask.ts index e73a9bee..23ce64e3 100644 --- a/src/frontend/src/store/slices/droneOperartorTask.ts +++ b/src/frontend/src/store/slices/droneOperartorTask.ts @@ -18,6 +18,8 @@ export interface IDroneOperatorTaskState { uploadedImagesType: 'add' | 'replace'; selectedTaskDetailToViewOrthophoto: any; filesExifData: IFilesExifData[]; + waypointMode: 'waypoints' | 'waylines'; + taskAssetsInformation: Record; } const initialState: IDroneOperatorTaskState = { @@ -32,6 +34,12 @@ const initialState: IDroneOperatorTaskState = { uploadedImagesType: 'add', selectedTaskDetailToViewOrthophoto: null, filesExifData: [], + waypointMode: 'waypoints', + taskAssetsInformation: { + total_image_uploaded: 0, + assets_url: '', + state: '', + }, }; export const droneOperatorTaskSlice = createSlice({ @@ -90,6 +98,12 @@ export const droneOperatorTaskSlice = createSlice({ resetFilesExifData: state => { state.filesExifData = []; }, + setWaypointMode: (state, action) => { + state.waypointMode = action.payload; + }, + setTaskAssetsInformation: (state, action) => { + state.taskAssetsInformation = action.payload; + }, }, });