diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index a2da7722..151b872c 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -1,8 +1,19 @@ import os -from typing import Annotated +from typing import Annotated, Optional +from uuid import UUID import geojson from datetime import timedelta -from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form +from fastapi import ( + APIRouter, + HTTPException, + Depends, + Path, + Query, + UploadFile, + File, + Form, + Response, +) from geojson_pydantic import FeatureCollection from loguru import logger as log from psycopg import Connection @@ -16,6 +27,7 @@ from app.config import settings from app.users.user_deps import login_required from app.users.user_schemas import AuthUser +from app.tasks import task_schemas router = APIRouter( prefix=f"{settings.API_PREFIX}/projects", @@ -23,6 +35,66 @@ ) +@router.get("/{project_id}/download-boundaries", tags=["Projects"]) +async def download_boundaries( + project_id: Annotated[ + UUID, + Path( + description="The project ID in UUID format.", + ), + ], + db: Annotated[Connection, Depends(database.get_db)], + user_data: Annotated[AuthUser, Depends(login_required)], + task_id: Optional[UUID] = Query( + default=None, + description="The task ID in UUID format. If not provided, all tasks will be downloaded.", + ), + split_area: bool = Query( + default=False, + description="Whether to split the area or not. Set to True to download task boundaries, otherwise AOI will be downloaded.", + ), +): + """Downloads the AOI or task boundaries for a project as a GeoJSON file. + + Args: + project_id (UUID): The ID of the project in UUID format. + db (Connection): The database connection, provided automatically. + user_data (AuthUser): The authenticated user data, checks if the user has permission. + task_id (Optional[UUID]): The task ID in UUID format. If not provided and split_area is True, all tasks will be downloaded. + split_area (bool): Whether to split the area or not. Set to True to download task boundaries, otherwise AOI will be downloaded. + + Returns: + Response: The HTTP response object containing the downloaded file. + """ + try: + out = await task_schemas.Task.get_task_geometry( + db, project_id, task_id, split_area + ) + + if out is None: + raise HTTPException(status_code=404, detail="Geometry not found.") + + filename = ( + (f"task_{task_id}.geojson" if task_id else "project_outline.geojson") + if split_area + else "project_aoi.geojson" + ) + + headers = { + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "application/geo+json", + } + return Response(content=out, headers=headers) + + except HTTPException as e: + log.error(f"Error during boundaries download: {e.detail}") + raise e + + except Exception as e: + log.error(f"Unexpected error during boundaries download: {e}") + raise HTTPException(status_code=500, detail="Internal server error.") + + @router.delete("/{project_id}", tags=["Projects"]) async def delete_project_by_id( project: Annotated[ diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index 9687f2f9..95d96d67 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -158,7 +158,7 @@ async def task_states( db: Annotated[Connection, Depends(database.get_db)], project_id: uuid.UUID ): """Get all tasks states for a project.""" - return await task_schemas.TaskState.all(db, project_id) + return await task_schemas.Task.all(db, project_id) @router.post("/event/{project_id}/{task_id}") @@ -177,27 +177,31 @@ async def new_event( project = project.model_dump() match detail.event: case EventType.REQUESTS: - if project["requires_approval_from_manager_for_locking"] is False: - data = await task_logic.request_mapping( - db, - project_id, - task_id, - user_id, - "Request accepted automatically", - State.UNLOCKED_TO_MAP, - State.LOCKED_FOR_MAPPING, + # Determine the appropriate state and message + is_author = project["author_id"] == user_id + requires_approval = project["requires_approval_from_manager_for_locking"] + + if is_author or not requires_approval: + state_after = State.LOCKED_FOR_MAPPING + message = "Request accepted automatically" + ( + " as the author" if is_author else "" ) else: - data = await task_logic.request_mapping( - db, - project_id, - task_id, - user_id, - "Request for mapping", - State.UNLOCKED_TO_MAP, - State.REQUEST_FOR_MAPPING, - ) - # email notification + state_after = State.REQUEST_FOR_MAPPING + message = "Request for mapping" + + # Perform the mapping request + data = await task_logic.request_mapping( + db, + project_id, + task_id, + user_id, + message, + State.UNLOCKED_TO_MAP, + state_after, + ) + # Send email notification if approval is required + if state_after == State.REQUEST_FOR_MAPPING: author = await user_schemas.DbUser.get_user_by_id( db, project["author_id"] ) @@ -217,6 +221,7 @@ async def new_event( "Request for mapping", html_content, ) + return data case EventType.MAP: diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py index 13edf3ff..12cba359 100644 --- a/src/backend/app/tasks/task_schemas.py +++ b/src/backend/app/tasks/task_schemas.py @@ -14,10 +14,82 @@ class NewEvent(BaseModel): comment: Optional[str] = None -class TaskState(BaseModel): - task_id: uuid.UUID - state: str - project_id: uuid.UUID +class Task(BaseModel): + task_id: Optional[uuid.UUID] = None + state: Optional[str] = None + project_id: Optional[uuid.UUID] = None + outline: Optional[str] = None + + @staticmethod + async def get_task_geometry( + db: Connection, + project_id: uuid.UUID, + task_id: Optional[uuid.UUID] = None, + split_area: Optional[bool] = False, + ) -> str: + """Fetches the geometry of a single task or all tasks in a project. + + Args: + db (Connection): The database connection. + project_id (UUID): The ID of the project. + task_id (UUID, optional): The ID of a specific task. Defaults to None. + + Returns: + str: The GeoJSON representation of the task or project geometry. + """ + try: + async with db.cursor(row_factory=class_row(Task)) as cur: + if task_id: + await cur.execute( + """ + SELECT ST_AsGeoJSON(outline) AS outline + FROM tasks + WHERE project_id = %(project_id)s AND id = %(task_id)s + """, + {"project_id": project_id, "task_id": task_id}, + ) + row = await cur.fetchone() + if row: + return row.outline + else: + raise HTTPException(status_code=404, detail="Task not found.") + else: + if split_area: + await cur.execute( + """ + SELECT ST_AsGeoJSON(outline) AS outline + FROM tasks + WHERE project_id = %(project_id)s + """, + {"project_id": project_id}, + ) + else: + await cur.execute( + """ + SELECT ST_AsGeoJSON(ST_Union(outline)) AS outline + FROM tasks + WHERE project_id = %(project_id)s + """, + {"project_id": project_id}, + ) + + # Fetch the result + rows = await cur.fetchall() + if rows: + # Create a FeatureCollection with empty properties for each feature + features = [ + f'{{"type": "Feature", "geometry": {row.outline}, "properties": {{}}}}' + for row in rows + ] + feature_collection = f'{{"type": "FeatureCollection", "features": [{",".join(features)}]}}' + return feature_collection + else: + raise HTTPException( + status_code=404, detail="No tasks found for this project." + ) + except Exception as e: + log.error(f"Error fetching task geometry: {e}") + raise HTTPException(status_code=500, detail="Internal server error.") @staticmethod async def get_all_tasks(db: Connection, project_id: uuid.UUID): @@ -36,7 +108,7 @@ async def get_all_tasks(db: Connection, project_id: uuid.UUID): @staticmethod async def all(db: Connection, project_id: uuid.UUID): - async with db.cursor(row_factory=class_row(TaskState)) as cur: + async with db.cursor(row_factory=class_row(Task)) as cur: await cur.execute( """SELECT DISTINCT ON (task_id) project_id, task_id, state FROM task_events @@ -48,7 +120,7 @@ async def all(db: Connection, project_id: uuid.UUID): existing_tasks = await cur.fetchall() # Get all task_ids from the tasks table - task_ids = await TaskState.get_all_tasks(db, project_id) + task_ids = await Task.get_all_tasks(db, project_id) # Create a set of existing task_ids for quick lookup existing_task_ids = {task.task_id for task in existing_tasks}