Skip to content

Commit

Permalink
Merge pull request #185 from hotosm/feat/download-task-boundaries
Browse files Browse the repository at this point in the history
Feat: add download boundaries endpoint and auto-approve task locking for project owner
  • Loading branch information
nrjadkry authored Sep 6, 2024
2 parents f5ae21e + 8bccf67 commit 29449d6
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 28 deletions.
76 changes: 74 additions & 2 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,13 +27,74 @@
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",
responses={404: {"description": "Not found"}},
)


@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[
Expand Down
45 changes: 25 additions & 20 deletions src/backend/app/tasks/task_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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"]
)
Expand All @@ -217,6 +221,7 @@ async def new_event(
"Request for mapping",
html_content,
)

return data

case EventType.MAP:
Expand Down
84 changes: 78 additions & 6 deletions src/backend/app/tasks/task_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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}

Expand Down

0 comments on commit 29449d6

Please sign in to comment.