Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/task stats endpoint #132

Merged
merged 9 commits into from
Aug 8, 2024
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