diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 81d9a998..85d7e40a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,7 +95,7 @@ repos: # Lint / autoformat: Python code - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: "v0.8.4" + rev: "v0.9.1" hooks: # Run the linter - id: ruff diff --git a/README.md b/README.md index ef4c2701..e4d3fe86 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ Our vision is to create a platform that is not only accessible and user-friendly 4. **Seamless Pipeline**: Ensure smooth processing and dissemination of imagery data. 5. **User-Friendly Application**: Designed for ease of use, allowing even non-professional pilots to contribute. +### Supported Drones + +To see a list of supported and unsupported drones, please visit +the [FAQ](https://hotosm.github.io/drone-tm/about/faq/#q-what-drones-are-supported) page. + ## Getting Started To get started with Drone TM: @@ -84,17 +89,18 @@ To get started with Drone TM: |✅| 🖥️ upload of drone imagery collected during flight | |✅| 📱 follow terrain during flight plan generation in hilly/mountainous regions | |✅| 🖥️ merging of drone imagery into a final combined image for the project | +|✅| 📱 flight plans working on DJI Mini 4 Pro, Air 3, and Mavic 3 | +|✅| 📱 upload flight plan to drone via mobile app (no laptop required) | +|✅| 🖥️ precise georeferencing of final imagery using Ground Control Points | |⚙️| 🖥️ automated Digital Elevation Model inclusion (no manual upload required) | |⚙️| 🖥️ automated uploading of final imagery to OpenAerialMap (+ credit to user that uploads) | |⚙️| 📱 allow adjustments to the flight plan orientation based on field conditions | -|⚙️| 🖥️ precise georeferencing of final imagery using Ground Control Points | +|⚙️| 🖥️ separate workflows for processing individual images vs batch processing in ODM | | | 📱 capture of imagery at multiple (configurable) angles from the drone camera | -| | 📱 support for more drone models (DJI first, other manufacturers next) | -| | 📱 removing laptop requirement for flight plan upload to drone (via mobile instead) | +| | 📱 continue to add support for additional drone models | | | 🖥️ user access management for each part of the UI | | | 📱 & 🖥️ real-time notifications for drone flight progress & task status | | | 📱 improved offline capabilities of Drone-TM, reducing reliance on stable internet in the field | -|⚙️| 🖥️ separate workflows for processing individual images vs batch processing in ODM | | | 🖥️ scaling of ODM imagery processing to hundreds of images in parallel | | | 🖥️ better usage of 3D model data collected by drones | | | 📱 HOT community mapping drone: cheap, mapping optimized, materials sourced locally | diff --git a/docs/about/faq.md b/docs/about/faq.md index df8e345c..ccc3555d 100644 --- a/docs/about/faq.md +++ b/docs/about/faq.md @@ -1,7 +1,5 @@ # ❓ Frequently Asked Questions ❓ -# Frequently Asked Questions (FAQ) - ## Q. What problem do we solve with DroneTM? **Enhancing Emergency Response** @@ -39,9 +37,8 @@ You can contribute to DroneTM in multiple ways: Currently, only processed data is available for download. The final outputs include: -- **2D Orthophoto** -- **Digital Terrain Model (DTM)** -- **Digital Surface Model (DSM)** +- **2D Orthophoto**: a birdseye view of your area (like a high resolution satellite image). +- **3D Point Cloud**: this can be used to generate Digital Terrain / Surface Models. --- @@ -65,7 +62,7 @@ no-fly zones. There is a feature to draw these zones on the project map, and the --- -## Q. Can I use any drones to contribute to DroneTM or are there any specifications of drones? +## Q. What Drones Are Supported? Currently, DroneTM is tested on **DJI Mini 4 Pro**, and flight plans are optimized for its camera specifications. However, the system is compatible with any DJI drones that support waypoint features, such as: @@ -76,3 +73,25 @@ However, the system is compatible with any DJI drones that support waypoint feat Users can also download the **GeoJSON** of the task area, load it into their drones, and create custom flight plans as per the specifications provided by the project creator. + +!!! note + + We have two angles to increase the number of supported drones: + + 1. Support for interfacing with flight plan software such as DroneDeploy, + Litchi, DroneLink, meaning any drone supported there is supported by + DroneTM. + + 2. Support for Open-Source flight plan software such as ArduPilot and iNAV, + widening our support signficantly to cheap custom-made drones, and in future + the HOT mapping drone. + +## Q. What Drones Are Not Supported? + +We don't have an exhaustive list of all unsupported drones, but in general, if +the drone is not listed as supported, then DroneTM will probably not work with +it. + +Our goal is to support affordable community mapping drones that ideally cost +less than 1000 USD, so support for expensive commercial drones will not be +a priority for now. diff --git a/docs/index.md b/docs/index.md index 4157dfe3..0e2b3b7f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,38 @@ # Drone Tasking Manager -Welcome to the docs! +**Drone TM** is an integrated digital public good solution designed to harness +the power of the crowd to generate high-resolution aerial maps of any location. + +This innovative platform provides drone pilots, particularly in developing +countries, with job opportunities while contributing to the creation of +high-resolution datasets crucial for disaster response and community resilience. + +## Problem Statement + +In low-income and disaster-prone areas, the accessibility to near real-time satellite datasets is severely restricted. High-resolution satellite imagery, when available, is often prohibitively expensive and outdated. Full-scale aircraft mapping is not a viable option due to its high costs and operational complexity. Traditional mapping solutions, relying on professional consultants with expensive equipment, often result in delays and lack of locally relevant data. Existing drone operation tools are mostly proprietary and not designed for large-scale collaborative efforts, limiting their effectiveness for community-driven projects. + +## Solution + +**Drone TM** offers a solution to these challenges by empowering communities to utilize drones for immediate and locally relevant mapping needs. Our platform: + +- Provides a user-friendly, inclusive application enabling anyone with a drone, including inexpensive consumer or DIY drones, to contribute to a global repository of free and open aerial imagery. +- Facilitates community-driven drone operations, ensuring immediate response and responsible mapping that considers local needs. +- Coordinates aerial survey activities among multiple pilots through an open-source tasking platform, incorporating tools and processes to ensure coordinated flight plans for effective imagery acquisition. +- Offers a seamless pipeline for processing and dissemination of the collected imagery. + +## Vision + +Our vision is to create a platform that is not only accessible and user-friendly but also inclusive, enabling widespread participation in creating high-resolution aerial maps. By leveraging the power of community-operated drones, we aim to build a resilient and responsive solution that addresses the needs of low-income and disaster-prone areas. + +## Features + +1. **Crowdsourced Mapping**: Empower drone pilots to contribute to a global imagery repository. +2. **Community-Driven Operations**: Enable communities to use drones for immediate and locally relevant mapping. +3. **Open-Source Platform**: Coordinate aerial surveys with an open-source tasking platform. +4. **Seamless Pipeline**: Ensure smooth processing and dissemination of imagery data. +5. **User-Friendly Application**: Designed for ease of use, allowing even non-professional pilots to contribute. + +### Supported Drones + +To see a list of supported and unsupported drones, please visit +the [FAQ](https://hotosm.github.io/drone-tm/about/faq/#q-what-drones-are-supported) page. diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index f6c43a21..c3ca6f25 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -26,6 +26,7 @@ UserRole, State, RegulatorApprovalStatus, + ImageProcessingStatus, ) from sqlalchemy.orm import ( object_session, @@ -155,6 +156,11 @@ class DbProject(Base): regulator_approval_status = cast( RegulatorApprovalStatus, Column(Enum(RegulatorApprovalStatus), nullable=True) ) + image_processing_status = cast( + ImageProcessingStatus, + Column(Enum(ImageProcessingStatus), default=ImageProcessingStatus.NOT_STARTED), + ) # status of image processing + regulator_comment = cast(str, Column(String, nullable=True)) commenting_regulator_id = cast( str, diff --git a/src/backend/app/gcp/gcp_routes.py b/src/backend/app/gcp/gcp_routes.py index bc68cdba..a2ff327e 100644 --- a/src/backend/app/gcp/gcp_routes.py +++ b/src/backend/app/gcp/gcp_routes.py @@ -30,7 +30,7 @@ async def find_images( fov_degree = 82.1 # For DJI Mini 4 Pro 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, result.altitude + project_id, task_id, point, fov_degree, result.altitude_from_ground ) @@ -48,5 +48,5 @@ async def find_images_for_a_project( 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, result.altitude + project_id, task_id_list, point, fov_degree, result.altitude_from_ground ) diff --git a/src/backend/app/migrations/versions/f78cde896334_image_processing_status.py b/src/backend/app/migrations/versions/f78cde896334_image_processing_status.py new file mode 100644 index 00000000..ecc260ff --- /dev/null +++ b/src/backend/app/migrations/versions/f78cde896334_image_processing_status.py @@ -0,0 +1,69 @@ +"""image_processing_status + +Revision ID: f78cde896334 +Revises: b18103ac4ab7 +Create Date: 2025-01-15 05:11:08.788485 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "f78cde896334" +down_revision: Union[str, None] = "b18103ac4ab7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create the enum type in the database + image_processing_status_enum = sa.Enum( + "NOT_STARTED", + "PROCESSING", + "SUCCESS", + "FAILED", + name="imageprocessingstatus", + ) + image_processing_status_enum.create( + op.get_bind() + ) # Bind the enum type to the database + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "projects", + sa.Column( + "image_processing_status", + sa.Enum( + "NOT_STARTED", + "PROCESSING", + "SUCCESS", + "FAILED", + name="imageprocessingstatus", + ), + nullable=False, + server_default=sa.text("'NOT_STARTED'"), + ), + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("projects", "image_processing_status") + + # Drop the enum type from the database + image_processing_status_enum = sa.Enum( + "NOT_STARTED", + "PROCESSING", + "SUCCESS", + "FAILED", + name="imageprocessingstatus", + ) + image_processing_status_enum.drop(op.get_bind()) # Drop the enum type + + # ### end Alembic commands ### diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index cb6cebad..9be30568 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -74,6 +74,15 @@ class RegulatorApprovalStatus(IntEnum, Enum): REJECTED = 2 +class ImageProcessingStatus(IntEnum, Enum): + """Enum to describe all possible statys of a Image Processing for a Project""" + + NOT_STARTED = 0 + PROCESSING = 1 + SUCCESS = 2 + FAILED = 3 + + class ProjectVisibility(IntEnum, Enum): """Enum describing task splitting type.""" @@ -200,11 +209,11 @@ class EventType(str, Enum): class FlightMode(str, Enum): - """The flight mode of the drone. - - The flight mode can be: - - ``waylines`` - - ``waypoints`` + """ + The flight mode of the drone. + The flight mode can be: + - ``waylines`` + - ``waypoints`` """ waylines = "waylines" diff --git a/src/backend/app/projects/image_processing.py b/src/backend/app/projects/image_processing.py index 4e5baea7..ec2a23df 100644 --- a/src/backend/app/projects/image_processing.py +++ b/src/backend/app/projects/image_processing.py @@ -5,7 +5,7 @@ import shutil from pathlib import Path from app.tasks import task_logic -from app.models.enums import State +from app.models.enums import State, ImageProcessingStatus from app.utils import timestamp from app.db import database from app.projects import project_logic @@ -183,7 +183,7 @@ async def _process_images( # Start a new processing task task = self.process_new_task( - images_list, + list(set(images_list)), name=name or ( f"DTM-Task-{self.task_id}" @@ -352,6 +352,7 @@ async def process_assets_from_odm( message=None, dtm_task_id=None, dtm_user_id=None, + odm_status_code: Optional[int] = None, ): """ Downloads results from ODM, reprojects the orthophoto, and uploads assets to S3. @@ -443,6 +444,21 @@ async def process_assets_from_odm( conn, dtm_project_id, dtm_task_id, "assets_url", s3_path_url ) + if not dtm_task_id: + # Update the image processing status + pool = await database.get_db_connection_pool() + async with pool as pool_instance: + async with pool_instance.connection() as conn: + await project_logic.update_processing_status( + conn, + dtm_project_id, + ( + ImageProcessingStatus.SUCCESS + if odm_status_code == 40 + else ImageProcessingStatus.FAILED + ), + ) + 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 c072ed1e..2a78e3c8 100644 --- a/src/backend/app/projects/project_logic.py +++ b/src/backend/app/projects/project_logic.py @@ -38,13 +38,14 @@ create_placemarks, terrain_following_waylines, ) -from app.models.enums import FlightMode +from app.models.enums import FlightMode, ImageProcessingStatus async def get_centroids(db: Connection): try: async with db.cursor(row_factory=dict_row) as cur: - await cur.execute(""" + await cur.execute( + """ SELECT p.id, p.slug, @@ -61,7 +62,8 @@ async def get_centroids(db: Connection): task_events te ON t.id = te.task_id GROUP BY p.id, p.slug, p.name, p.centroid; - """) + """ + ) centroids = await cur.fetchall() if not centroids: @@ -335,6 +337,25 @@ async def process_drone_images( ) +async def update_processing_status( + db: Connection, project_id: uuid.UUID, status: ImageProcessingStatus +): + print("status = ", status.name) + """ + Update the processing status to the specified status in the database. + """ + await db.execute( + """ + UPDATE projects + SET image_processing_status = %(status)s + WHERE id = %(project_id)s; + """, + {"status": status.name, "project_id": project_id}, + ) + await db.commit() + return + + async def process_all_drone_images( project_id: uuid.UUID, tasks: list, user_id: str, db: Connection ): @@ -363,6 +384,10 @@ async def process_all_drone_images( webhook=webhook_url, ) + # Update the processing status to 'IMAGE_PROCESSING_STARTED' in the database. + await update_processing_status(db, project_id, ImageProcessingStatus.PROCESSING) + return + def get_project_info_from_s3(project_id: uuid.UUID, task_id: uuid.UUID): """ diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index d0505f64..80d7873e 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -456,7 +456,6 @@ async def process_imagery( @router.post("/process_all_imagery/{project_id}/", tags=["Image Processing"]) async def process_all_imagery( - project_id: uuid.UUID, project: Annotated[ project_schemas.DbProject, Depends(project_deps.get_project_by_id) ], @@ -474,12 +473,12 @@ async def process_all_imagery( 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" + 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( - project_logic.process_all_drone_images, project_id, tasks, user_id, db + project_logic.process_all_drone_images, project.id, tasks, user_id, db ) return {"message": f"Processing started for {len(tasks)} tasks."} @@ -505,6 +504,7 @@ async def odm_webhook_for_processing_whole_project( node_odm_url=settings.NODE_ODM_URL, dtm_project_id=dtm_project_id, odm_task_id=odm_task_id, + odm_status=status["code"], ) return {"message": "Webhook received", "task_id": dtm_project_id} @@ -542,6 +542,7 @@ async def odm_webhook_for_processing_a_single_task( message="Task completed.", dtm_task_id=dtm_task_id, dtm_user_id=dtm_user_id, + odm_status=40, ) elif status["code"] == 30 and state_value != State.IMAGE_PROCESSING_FAILED: @@ -564,6 +565,7 @@ async def odm_webhook_for_processing_a_single_task( message="Image processing failed.", dtm_task_id=dtm_task_id, dtm_user_id=dtm_user_id, + odm_status_code=30, ) return {"message": "Webhook received", "task_id": odm_task_id} diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 73aaf67d..21506f73 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -1,6 +1,6 @@ import json import uuid -from typing import Annotated, Optional, List +from typing import Annotated, Optional, List, Union from datetime import datetime, date import geojson from loguru import logger as log @@ -73,9 +73,16 @@ def validate_geojson( return None -def enum_to_str(value: IntEnum) -> str: - """Get the string value of the enum for db insert.""" - return value.name +def enum_to_str(value: Union[IntEnum, str]) -> str: + """ + Get the string value of the enum for db insert. + Handles both IntEnum objects and string values. + """ + if isinstance(value, str): + return value + if isinstance(value, IntEnum): + return value.name + return value class ProjectIn(BaseModel): @@ -150,7 +157,8 @@ def slug(self) -> str: return slug_with_date except Exception as e: log.error(f"An error occurred while generating the slug: {e}") - return "" + + return "" @model_validator(mode="before") @classmethod @@ -210,6 +218,7 @@ class DbProject(BaseModel): requires_approval_from_regulator: Optional[bool] = False regulator_emails: Optional[List[EmailStr]] = None regulator_approval_status: Optional[str] = None + image_processing_status: Optional[str] = None regulator_comment: Optional[str] = None commenting_regulator_id: Optional[str] = None author_id: Optional[str] = None @@ -584,6 +593,7 @@ class ProjectInfo(BaseModel): requires_approval_from_regulator: Optional[bool] = False regulator_emails: Optional[List[EmailStr]] = None regulator_approval_status: Optional[str] = None + image_processing_status: Optional[str] = None regulator_comment: Optional[str] = None commenting_regulator_id: Optional[str] = None author_name: Optional[str] = None diff --git a/src/backend/app/s3.py b/src/backend/app/s3.py index dbcec789..2293e960 100644 --- a/src/backend/app/s3.py +++ b/src/backend/app/s3.py @@ -33,8 +33,7 @@ def is_connection_secure(minio_url: str): else: err = ( - "The S3_ENDPOINT is set incorrectly. " - "It must start with http:// or https://" + "The S3_ENDPOINT is set incorrectly. It must start with http:// or https://" ) log.error(err) raise ValueError(err) diff --git a/src/backend/app/users/user_schemas.py b/src/backend/app/users/user_schemas.py index a46eb24b..35fe068b 100644 --- a/src/backend/app/users/user_schemas.py +++ b/src/backend/app/users/user_schemas.py @@ -303,7 +303,7 @@ async def update(db: Connection, user_id: int, profile_update: UserProfileUpdate ) ON CONFLICT (user_id) DO UPDATE SET - {', '.join(f"{key} = EXCLUDED.{key}" for key in model_data.keys())}; + {", ".join(f"{key} = EXCLUDED.{key}" for key in model_data.keys())}; """ # Prepare password update query if a new password is provided diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index 5de711cb..c7a03a76 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -12,7 +12,7 @@ from psycopg import AsyncConnection from app.users.user_schemas import DbUser import pytest -from app.projects.project_schemas import ProjectIn, DbProject +from app.projects.project_schemas import DbProject, ProjectIn @pytest_asyncio.fixture(scope="function") @@ -28,7 +28,7 @@ async def db() -> AsyncConnection: @pytest_asyncio.fixture(scope="function") -async def user(db) -> AuthUser: +async def auth_user(db) -> AuthUser: """Create a test user.""" db_user = await DbUser.get_or_create_user( db, @@ -44,15 +44,11 @@ async def user(db) -> AuthUser: @pytest_asyncio.fixture(scope="function") -async def project_info(db, user): +async def project_info(): """ Fixture to create project metadata for testing. """ - print( - f"User passed to project_info fixture: {user}, ID: {getattr(user, 'id', 'No ID')}" - ) - project_metadata = ProjectIn( name="TEST 98982849249278787878778", description="", @@ -93,12 +89,30 @@ async def project_info(db, user): ) try: - await DbProject.create(db, project_metadata, getattr(user, "id", "")) return project_metadata except Exception as e: pytest.fail(f"Fixture setup failed with exception: {str(e)}") +@pytest_asyncio.fixture(scope="function") +async def create_test_project(db, auth_user, project_info): + """ + Fixture to create a test project and return its project_id. + """ + project_id = await DbProject.create(db, project_info, auth_user.id) + return str(project_id) + + +@pytest_asyncio.fixture(scope="function") +async def test_get_project(db, create_test_project): + """ + Fixture to create a test project and return its project_id. + """ + project_id = create_test_project + project_info = await DbProject.one(db, project_id) + return project_info + + @pytest_asyncio.fixture(autouse=True) async def app() -> AsyncGenerator[FastAPI, Any]: """Get the FastAPI test server.""" @@ -125,12 +139,11 @@ def drone_info(): @pytest_asyncio.fixture(scope="function") -async def client(app: FastAPI, db: AsyncConnection): +async def client(app: FastAPI, db: AsyncConnection, auth_user: AuthUser): """The FastAPI test server.""" # Override server db connection app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[login_required] = lambda: user - + app.dependency_overrides[login_required] = lambda: auth_user async with LifespanManager(app) as manager: async with AsyncClient( transport=ASGITransport(app=manager.app), diff --git a/src/backend/tests/test_projects_routes.py b/src/backend/tests/test_projects_routes.py index 2d4ce665..71391ae7 100644 --- a/src/backend/tests/test_projects_routes.py +++ b/src/backend/tests/test_projects_routes.py @@ -1,27 +1,71 @@ -# import pytest -# import json - - -# @pytest.mark.asyncio -# async def test_create_project_with_files(client, project_info,): -# """ -# Test to verify the project creation API with file upload (image as binary data). -# """ -# project_info_json = json.dumps(project_info.model_dump()) -# files = { -# "project_info": (None, project_info_json, "application/json"), -# "dem": None, -# "image": None -# } - -# files = {k: v for k, v in files.items() if v is not None} -# response = await client.post( -# "/api/projects/", -# files=files -# ) -# assert response.status_code == 201 -# return response.json() - -# if __name__ == "__main__": -# """Main func if file invoked directly.""" -# pytest.main() +import pytest +import json +from io import BytesIO +from loguru import logger as log + + +@pytest.mark.asyncio +async def test_create_project_with_files( + client, + project_info, +): + """ + Test to verify the project creation API with file upload (image as binary data). + """ + project_info_json = json.dumps(project_info.model_dump()) + files = { + "project_info": (None, project_info_json, "application/json"), + "dem": None, + "image": None, + } + + files = {k: v for k, v in files.items() if v is not None} + response = await client.post("/api/projects/", files=files) + assert response.status_code == 200 + return response.json() + + +@pytest.mark.asyncio +async def test_upload_project_task_boundaries(client, test_get_project): + """ + Test to verify the upload of task boundaries. + """ + project_id = str(test_get_project.id) + log.debug(f"Testing project ID: {project_id}") + task_geojson = json.dumps( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "coordinates": [ + [ + [85.32002733312942, 27.706336826417214], + [85.31945017091391, 27.705465823954995], + [85.32117509889912, 27.704809664174988], + [85.32135218276034, 27.70612197978899], + [85.32002733312942, 27.706336826417214], + ] + ], + "type": "Polygon", + }, + } + ], + } + ).encode("utf-8") + + geojosn_files = { + "geojson": ("file.geojson", BytesIO(task_geojson), "application/geo+json") + } + response = await client.post( + f"/api/projects/{project_id}/upload-task-boundaries/", files=geojosn_files + ) + assert response.status_code == 200 + return response.json() + + +if __name__ == "__main__": + """Main func if file invoked directly.""" + pytest.main() diff --git a/src/backend/tests/test_tasks_routes.py b/src/backend/tests/test_tasks_routes.py new file mode 100644 index 00000000..897f00ff --- /dev/null +++ b/src/backend/tests/test_tasks_routes.py @@ -0,0 +1,17 @@ +import pytest +import uuid + + +@pytest.mark.asyncio +async def test_read_task(client): + task_id = uuid.uuid4() + response = await client.get(f"/api/tasks/{task_id}") + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_task_states(client, create_test_project): + project_id = create_test_project + + response = await client.get(f"/api/tasks/states/{project_id}") + assert response.status_code == 200 diff --git a/src/backend/tests/test_users_routes.py b/src/backend/tests/test_users_routes.py index 4154cac4..2fef678a 100644 --- a/src/backend/tests/test_users_routes.py +++ b/src/backend/tests/test_users_routes.py @@ -7,12 +7,12 @@ @pytest_asyncio.fixture(scope="function") -def token(user): +def token(auth_user): """ Create a reset password token for a given user. """ payload = { - "sub": user.email_address, + "sub": auth_user.email_address, "exp": datetime.utcnow() + timedelta(minutes=settings.RESET_PASSWORD_TOKEN_EXPIRE_MINUTES), } diff --git a/src/frontend/package.json b/src/frontend/package.json index 9a881b36..1b89e5ec 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -2,6 +2,7 @@ "dependencies": { "@cyntler/react-doc-viewer": "^1.17.0", "@geomatico/maplibre-cog-protocol": "^0.3.1", + "@hotosm/gcp-editor": "^0.0.9", "@mapbox/mapbox-gl-draw": "^1.4.2", "@mapbox/mapbox-gl-draw-static-mode": "^1.0.1", "@radix-ui/react-popover": "^1.0.6", @@ -14,22 +15,22 @@ "@tanstack/react-query-devtools": "^4.32.6", "@tanstack/react-table": "^8.9.3", "@turf/area": "^7.0.0", - "@turf/transform-rotate" : "^7.1.0", "@turf/bbox": "^7.0.0", "@turf/centroid": "^7.0.0", "@turf/flatten": "^7.0.0", "@turf/helpers": "^7.0.0", "@turf/length": "^7.0.0", "@turf/meta": "^7.0.0", + "@turf/transform-rotate": "^7.1.0", "autoprefixer": "^10.4.14", "axios": "^1.3.4", "class-variance-authority": "^0.6.1", "clsx": "^2.0.0", "countries-list": "^3.1.1", - "exifreader": "^4.25.0", "date-fns": "^2.30.0", "dom-to-code": "^1.5.4", "dotenv": "^16.0.3", + "exifreader": "^4.25.0", "framer-motion": "^11.2.9", "geojson": "^0.5.0", "geojson-validation": "^1.0.2", diff --git a/src/frontend/src/components/IndividualProject/GcpEditor/index.tsx b/src/frontend/src/components/IndividualProject/GcpEditor/index.tsx new file mode 100644 index 00000000..3c47acce --- /dev/null +++ b/src/frontend/src/components/IndividualProject/GcpEditor/index.tsx @@ -0,0 +1,57 @@ +import { useEffect, createElement } from 'react'; +import '@hotosm/gcp-editor'; +import '@hotosm/gcp-editor/style.css'; +import { useDispatch } from 'react-redux'; +import { setProjectState } from '@Store/actions/project'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { processAllImagery } from '@Services/project'; +import { useParams } from 'react-router-dom'; + +const GcpEditor = ({ + cogUrl, + finalButtonText, + // handleProcessingStart, + rawImageUrl, +}: any) => { + const { id } = useParams(); + const dispatch = useDispatch(); + const CUSTOM_EVENT: any = 'start-processing-click'; + const queryClient = useQueryClient(); + + const { mutate: startImageProcessing } = useMutation({ + mutationFn: processAllImagery, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['project-detail'] }); + dispatch(setProjectState({ showGcpEditor: false })); + }, + }); + + const startProcessing = (data: any) => { + const gcpData = data.detail; + const blob = new Blob([gcpData], { type: 'text/plain;charset=utf-8;' }); + const gcpFile = new File([blob], 'gcp.txt'); + startImageProcessing({ projectId: id, gcp_file: gcpFile }); + }; + + useEffect(() => { + document.addEventListener( + CUSTOM_EVENT, + data => { + startProcessing(data); + }, + // When we use the {once: true} option when adding an event listener, the listener will be invoked at most once and immediately removed as soon as the event is invoked. + { once: true }, + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [CUSTOM_EVENT, dispatch]); + + return createElement('gcp-editor', { + cogUrl, + customEvent: CUSTOM_EVENT, + finalButtonText, + rawImageUrl, + }); +}; + +export default GcpEditor; diff --git a/src/frontend/src/components/RegulatorsApprovalPage/Description/DescriptionSection.tsx b/src/frontend/src/components/RegulatorsApprovalPage/Description/DescriptionSection.tsx index 596e8fe8..e0628f05 100644 --- a/src/frontend/src/components/RegulatorsApprovalPage/Description/DescriptionSection.tsx +++ b/src/frontend/src/components/RegulatorsApprovalPage/Description/DescriptionSection.tsx @@ -1,3 +1,6 @@ +import { useDispatch } from 'react-redux'; +import { Button } from '@Components/RadixComponents/Button'; +import { setProjectState } from '@Store/actions/project'; import ApprovalSection from './ApprovalSection'; const DescriptionSection = ({ @@ -7,6 +10,8 @@ const DescriptionSection = ({ projectData: Record; page?: 'project-description' | 'project-approval'; }) => { + const dispatch = useDispatch(); + return (
{page === 'project-approval' && ( @@ -62,6 +67,19 @@ const DescriptionSection = ({ )}
+ {page !== 'project-approval' && + projectData?.image_processing_status !== 1 && ( +
+ +
+ )} {page === 'project-approval' && projectData?.regulator_approval_status === 'PENDING' && ( diff --git a/src/frontend/src/services/project.ts b/src/frontend/src/services/project.ts index 2b4ffc45..badad294 100644 --- a/src/frontend/src/services/project.ts +++ b/src/frontend/src/services/project.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ /* eslint-disable import/prefer-default-export */ import { authenticated, api } from '.'; @@ -13,5 +14,13 @@ export const postTaskStatus = (payload: Record) => { export const getRequestedTasks = () => authenticated(api).get('/tasks/requested_tasks/pending'); +export const processAllImagery = (data: Record) => { + const { projectId, gcp_file } = data; + return authenticated(api).post( + `/projects/process_all_imagery/${projectId}/`, + { gcp_file }, + ); +}; + // export const getAllAssetsUrl = (projectId: string) => // authenticated(api).get(`/projects/assets/${projectId}/`); diff --git a/src/frontend/src/store/slices/project.ts b/src/frontend/src/store/slices/project.ts index f1471478..46df0586 100644 --- a/src/frontend/src/store/slices/project.ts +++ b/src/frontend/src/store/slices/project.ts @@ -8,6 +8,8 @@ export interface ProjectState { projectArea: Record | null; selectedTaskId: string; taskClickedOnTable: Record | null; + showGcpEditor: boolean; + gcpData: any; } const initialState: ProjectState = { @@ -16,6 +18,8 @@ const initialState: ProjectState = { projectArea: null, selectedTaskId: '', taskClickedOnTable: null, + showGcpEditor: false, + gcpData: null, }; const setProjectState: CaseReducer< diff --git a/src/frontend/src/views/IndividualProject/index.tsx b/src/frontend/src/views/IndividualProject/index.tsx index f91c8503..ac7b0b96 100644 --- a/src/frontend/src/views/IndividualProject/index.tsx +++ b/src/frontend/src/views/IndividualProject/index.tsx @@ -9,6 +9,7 @@ import { MapSection, Tasks, } from '@Components/IndividualProject'; +import GcpEditor from '@Components/IndividualProject/GcpEditor'; import Skeleton from '@Components/RadixComponents/Skeleton'; import DescriptionSection from '@Components/RegulatorsApprovalPage/Description/DescriptionSection'; import { projectOptions } from '@Constants/index'; @@ -16,8 +17,12 @@ import { setProjectState } from '@Store/actions/project'; import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; import centroid from '@turf/centroid'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; +import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; +// eslint-disable-next-line camelcase +const { BASE_URL } = process.env; + // function to render the content based on active tab const getActiveTabContent = ( activeTab: string, @@ -60,6 +65,7 @@ const IndividualProject = () => { state => state.project.individualProjectActiveTab, ); const tasksList = useTypedSelector(state => state.project.tasksData); + const showGcpEditor = useTypedSelector(state => state.project.showGcpEditor); const { data: projectData, @@ -106,6 +112,12 @@ const IndividualProject = () => { return {}; }; + useEffect(() => { + return () => { + dispatch(setProjectState({ showGcpEditor: false })); + }; + }, [dispatch]); + return (
{ { name: projectData?.name || '--', navLink: '' }, ]} /> -
-
- - dispatch( - setProjectState({ individualProjectActiveTab: String(val) }), - ) - } - tabOptions={projectOptions} - activeTab={individualProjectActiveTab} - clickable + {showGcpEditor ? ( +
+ + -
- {getActiveTabContent( - individualProjectActiveTab, - projectData as Record, - isProjectDataFetching, - handleTableRowClick, +
+ ) : ( +
+
+ + dispatch( + setProjectState({ individualProjectActiveTab: String(val) }), + ) + } + tabOptions={projectOptions} + activeTab={individualProjectActiveTab} + clickable + /> +
+ {getActiveTabContent( + individualProjectActiveTab, + projectData as Record, + isProjectDataFetching, + handleTableRowClick, + )} +
+
+
+ {isProjectDataFetching ? ( + + ) : ( + } /> )}
- -
- {isProjectDataFetching ? ( - - ) : ( - } /> - )} -
-
+ )}
); };