Skip to content

Commit

Permalink
Merge pull request #132 from hotosm/feat/task-stats-endpoint
Browse files Browse the repository at this point in the history
Feat/task stats endpoint
  • Loading branch information
nrjadkry authored Aug 8, 2024
2 parents 0135523 + 95133cd commit e685551
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 27 deletions.
55 changes: 55 additions & 0 deletions src/backend/app/migrations/versions/2b92f8a9bbec_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Revision ID: 2b92f8a9bbec
Revises: d862bfa31c36
Create Date: 2024-08-08 08:10:11.056119
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# Define the new enum type
new_state_enum = sa.Enum(
"UNLOCKED_TO_MAP",
"LOCKED_FOR_MAPPING",
"UNLOCKED_TO_VALIDATE",
"LOCKED_FOR_VALIDATION",
"UNLOCKED_DONE",
"REQUEST_FOR_MAPPING",
"UNFLYABLE_TASK",
name="state",
)

old_state_enum = sa.Enum(
"UNLOCKED_TO_MAP",
"LOCKED_FOR_MAPPING",
"UNLOCKED_TO_VALIDATE",
"LOCKED_FOR_VALIDATION",
"UNLOCKED_DONE",
"REQUEST_FOR_MAPPING",
name="state",
)

# revision identifiers, used by Alembic.
revision: str = "2b92f8a9bbec"
down_revision: Union[str, None] = "5d38e368b3d2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("ALTER TYPE state ADD VALUE 'UNFLYABLE_TASK'")


def downgrade() -> None:
op.execute("ALTER TYPE state RENAME TO state_old")

old_state_enum.create(op.get_bind(), checkfirst=False)
op.execute("ALTER TABLE task_events ALTER COLUMN state TYPE text USING state::text")
# op.execute(
# "ALTER TABLE task_events ALTER COLUMN state TYPE state USING state::text::state"
# )
op.execute("DROP TYPE state_old")
2 changes: 2 additions & 0 deletions src/backend/app/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ class State(int, Enum):
- ``unlocked to validate``
- ``locked for validation``
- ``unlocked done``
- ``Unflyable task``
"""

REQUEST_FOR_MAPPING = -1
Expand All @@ -140,6 +141,7 @@ class State(int, Enum):
UNLOCKED_TO_VALIDATE = 2
LOCKED_FOR_VALIDATION = 3
UNLOCKED_DONE = 4
UNFLYABLE_TASK = 5


class EventType(str, Enum):
Expand Down
97 changes: 72 additions & 25 deletions src/backend/app/tasks/task_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,31 +36,35 @@ async def get_task_geojson(db: Database, task_id: uuid.UUID):

async def get_tasks_by_user(user_id: str, db: Database):
try:
query = """WITH task_details AS (
SELECT
tasks.id AS task_id,
ST_Area(ST_Transform(tasks.outline, 4326)) / 1000000 AS task_area,
task_events.created_at,
task_events.state
FROM
task_events
JOIN
tasks ON task_events.task_id = tasks.id
WHERE
task_events.user_id = :user_id
)
SELECT
task_details.task_id,
task_details.task_area,
task_details.created_at,
CASE
WHEN task_details.state = 'REQUEST_FOR_MAPPING' THEN 'ongoing'
WHEN task_details.state = 'UNLOCKED_DONE' THEN 'completed'
WHEN task_details.state IN ('UNLOCKED_TO_VALIDATE', 'LOCKED_FOR_VALIDATION') THEN 'mapped'
ELSE 'unknown'
END AS state
FROM task_details
"""
query = """
WITH task_details AS (
SELECT
tasks.id AS task_id,
task_events.project_id AS project_id,
ST_Area(ST_Transform(tasks.outline, 4326)) / 1000000 AS task_area,
task_events.created_at,
task_events.state
FROM
task_events
JOIN
tasks ON task_events.task_id = tasks.id
WHERE
task_events.user_id = :user_id
)
SELECT
task_details.task_id,
task_details.project_id,
task_details.task_area,
task_details.created_at,
CASE
WHEN task_details.state = 'REQUEST_FOR_MAPPING' THEN 'ongoing'
WHEN task_details.state = 'UNLOCKED_DONE' THEN 'completed'
WHEN task_details.state IN ('UNLOCKED_TO_VALIDATE', 'LOCKED_FOR_VALIDATION') THEN 'mapped'
ELSE 'unknown'
END AS state
FROM task_details
"""

records = await db.fetch_all(query, values={"user_id": user_id})
return records

Expand Down Expand Up @@ -168,6 +172,49 @@ async def request_mapping(
return {"project_id": project_id, "task_id": task_id, "comment": comment}


async def update_task_state(
db: Database,
project_id: uuid.UUID,
task_id: uuid.UUID,
user_id: str,
comment: str,
initial_state: State,
final_state: State,
):
query = """
WITH last AS (
SELECT *
FROM task_events
WHERE project_id = :project_id AND task_id = :task_id
ORDER BY created_at DESC
LIMIT 1
),
locked AS (
SELECT *
FROM last
WHERE user_id = :user_id AND state = :initial_state
)
INSERT INTO task_events(event_id, project_id, task_id, user_id, state, comment, created_at)
SELECT gen_random_uuid(), project_id, task_id, user_id, :final_state, :comment, now()
FROM last
WHERE user_id = :user_id
RETURNING project_id, task_id, user_id, state;
"""

values = {
"project_id": str(project_id),
"task_id": str(task_id),
"user_id": str(user_id),
"comment": comment,
"initial_state": initial_state.name,
"final_state": final_state.name,
}

await db.fetch_one(query, values)

return {"project_id": project_id, "task_id": task_id, "comment": comment}


async def update_or_create_task_state(
db: Database,
project_id: uuid.UUID,
Expand Down
83 changes: 81 additions & 2 deletions src/backend/app/tasks/task_routes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from app.config import settings
from app.models.enums import EventType, State, UserRole
from app.models.enums import EventType, HTTPStatus, State, UserRole
from app.tasks import task_schemas, task_crud
from app.users.user_deps import login_required
from app.users.user_schemas import AuthUser
Expand All @@ -19,6 +19,75 @@
)


@router.get("/{task_id}")
async def read_task(
task_id: uuid.UUID,
db: Database = Depends(database.get_db),
user_data: AuthUser = Depends(login_required),
):
"Retrieve details of a specific task by its ID."
try:
query = """
SELECT
ST_Area(ST_Transform(tasks.outline, 4326)) / 1000000 AS task_area,
task_events.created_at,
projects.name AS project_name,
project_task_index,
projects.front_overlap AS front_overlap,
projects.side_overlap AS side_overlap,
projects.gsd_cm_px AS gsd_cm_px,
projects.gimble_angles_degrees AS gimble_angles_degrees
FROM
task_events
JOIN
tasks ON task_events.task_id = tasks.id
JOIN
projects ON task_events.project_id = projects.id
WHERE
task_events.task_id = :task_id
"""
records = await db.fetch_one(query, values={"task_id": task_id})
return records
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch tasks. {e}",
)


@router.get("/statistics/")
async def get_task_stats(
db: Database = Depends(database.get_db),
user_data: AuthUser = Depends(login_required),
):
"Retrieve statistics related to tasks for the authenticated user."
user_id = user_data.id
query = """SELECT role FROM user_profile WHERE user_id = :user_id"""
records = await db.fetch_all(query, {"user_id": user_id})

if not records:
raise HTTPException(status_code=404, detail="User profile not found")
raw_sql = """
SELECT
COUNT(CASE WHEN te.state = 'LOCKED_FOR_MAPPING' THEN 1 END) AS ongoing_tasks,
COUNT(CASE WHEN te.state = 'REQUEST_FOR_MAPPING' THEN 1 END) AS request_logs,
COUNT(CASE WHEN te.state = 'UNLOCKED_DONE' THEN 1 END) AS completed_tasks,
COUNT(CASE WHEN te.state = 'UNFLYABLE_TASK' THEN 1 END) AS unflyable_tasks
FROM tasks t
LEFT JOIN task_events te ON t.id = te.task_id
WHERE t.project_id IN (SELECT id FROM projects WHERE author_id = :user_id);
"""

try:
db_counts = await db.fetch_one(query=raw_sql, values={"user_id": user_id})
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch task counts. {e}",
)
return db_counts


@router.get("/", response_model=list[task_schemas.UserTasksStatsOut])
async def list_tasks(
db: Database = Depends(database.get_db),
Expand Down Expand Up @@ -52,7 +121,7 @@ async def new_event(
case EventType.REQUESTS:
# TODO: Combine the logic of `update_or_create_task_state` and `request_mapping` functions into a single function if possible. Will do later.
project = await get_project_by_id(db, project_id)
if project["requires_approval_from_manager_for_locking"] == "true":
if project["requires_approval_from_manager_for_locking"] is False:
data = await task_crud.update_or_create_task_state(
db,
project_id,
Expand Down Expand Up @@ -217,6 +286,16 @@ async def new_event(
State.LOCKED_FOR_VALIDATION,
State.UNLOCKED_TO_MAP,
)
case EventType.COMMENT:
return await task_crud.update_task_state(
db,
project_id,
task_id,
user_id,
detail.comment,
State.LOCKED_FOR_MAPPING,
State.UNFLYABLE_TASK,
)

return True

Expand Down
3 changes: 3 additions & 0 deletions src/backend/app/tasks/task_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
from app.models.enums import EventType
import uuid
from datetime import datetime
from typing import Optional


class NewEvent(BaseModel):
event: EventType
comment: Optional[str] = None


class UserTasksStatsOut(BaseModel):
task_id: uuid.UUID
task_area: float
created_at: datetime
state: str
project_id: uuid.UUID

0 comments on commit e685551

Please sign in to comment.