diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index ab91f0db..03951131 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -203,7 +203,7 @@ class TaskEvent(Base): user_id = cast(str, Column(String(100), ForeignKey("users.id"), nullable=False)) comment = cast(str, Column(String)) - state = cast(State, Column(Enum(TaskStatus), nullable=False)) + state = cast(State, Column(Enum(State), nullable=False)) created_at = cast(datetime, Column(DateTime, default=timestamp)) __table_args__ = ( diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 79ce87ce..21ea4b58 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -9,6 +9,7 @@ from fastapi.responses import RedirectResponse, JSONResponse from app.users import oauth_routes from app.users import user_routes +from app.tasks import task_routes from loguru import logger as log from fastapi.templating import Jinja2Templates @@ -95,6 +96,7 @@ def get_application() -> FastAPI: _app.include_router(waypoint_routes.router) _app.include_router(user_routes.router) _app.include_router(oauth_routes.router) + _app.include_router(task_routes.router) return _app diff --git a/src/backend/app/migrations/versions/06668eb5d14a_.py b/src/backend/app/migrations/versions/06668eb5d14a_.py new file mode 100644 index 00000000..0af9412b --- /dev/null +++ b/src/backend/app/migrations/versions/06668eb5d14a_.py @@ -0,0 +1,56 @@ +""" + +Revision ID: 06668eb5d14a +Revises: fa5c74996273 +Create Date: 2024-07-09 04:17:49.816148 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "06668eb5d14a" +down_revision: Union[str, None] = "fa5c74996273" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# 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", + name="state", +) + +old_state_enum = sa.Enum( + "UNLOCKED_TO_MAP", + "LOCKED_FOR_MAPPING", + "UNLOCKED_TO_VALIDATE", + "LOCKED_FOR_VALIDATION", + "UNLOCKED_DONE", + name="state", +) + + +def upgrade(): + op.execute("ALTER TYPE state ADD VALUE 'REQUEST_FOR_MAPPING'") + + +def downgrade(): + # Downgrade the enum type by recreating it without the new value + 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 state USING state::text::state" + ) + ) + op.execute("DROP TYPE state_old") diff --git a/src/backend/app/migrations/versions/fa5c74996273_.py b/src/backend/app/migrations/versions/fa5c74996273_.py new file mode 100644 index 00000000..04ef8e56 --- /dev/null +++ b/src/backend/app/migrations/versions/fa5c74996273_.py @@ -0,0 +1,66 @@ +""" + +Revision ID: fa5c74996273 +Revises: ac09917990dc +Create Date: 2024-07-05 11:51:02.146671 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "fa5c74996273" +down_revision: Union[str, None] = "ac09917990dc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Define the existing enum type +existing_taskstatus_enum = sa.Enum( + "READY", + "LOCKED_FOR_MAPPING", + "MAPPED", + "LOCKED_FOR_VALIDATION", + "VALIDATED", + "INVALIDATED", + "BAD", + "SPLIT", + name="taskstatus", +) + +# Define the new enum type +new_state_enum = sa.Enum( + "UNLOCKED_TO_MAP", + "LOCKED_FOR_MAPPING", + "UNLOCKED_TO_VALIDATE", + "LOCKED_FOR_VALIDATION", + "UNLOCKED_DONE", + name="state", +) + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + # Create the new enum type in the database + new_state_enum.create(op.get_bind()) + + # Use the USING clause to convert existing column values to the new enum type + op.execute( + "ALTER TABLE task_events ALTER COLUMN state TYPE state USING state::text::state" + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + # Use the USING clause to convert back to the original enum type + op.execute( + "ALTER TABLE task_events ALTER COLUMN state TYPE taskstatus USING state::text::taskstatus" + ) + + # Drop the new enum type from the database + new_state_enum.drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index 8e9c8af5..41563192 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -120,6 +120,7 @@ class State(int, Enum): """The state of a task. The state can be: + - ``request for mapping`` - ``unlocked to map`` - ``locked for mapping`` - ``unlocked to validate`` @@ -127,6 +128,7 @@ class State(int, Enum): - ``unlocked done`` """ + REQUEST_FOR_MAPPING = -1 UNLOCKED_TO_MAP = 0 LOCKED_FOR_MAPPING = 1 UNLOCKED_TO_VALIDATE = 2 @@ -142,6 +144,7 @@ class EventType(str, Enum): Possible values are: + - ``request`` -- Request a task to be mapped. - ``map`` -- Set to *locked for mapping*, i.e. mapping in progress. - ``finish`` -- Set to *unlocked to validate*, i.e. is mapped. - ``validate`` -- Request recent task ready to be validate. @@ -154,6 +157,8 @@ class EventType(str, Enum): Note that ``task_id`` must be specified in the endpoint too. """ + REQUESTS = "request" + REJECTED = "reject" MAP = "map" FINISH = "finish" VALIDATE = "validate" diff --git a/src/backend/app/tasks/task_crud.py b/src/backend/app/tasks/task_crud.py new file mode 100644 index 00000000..ac42dd92 --- /dev/null +++ b/src/backend/app/tasks/task_crud.py @@ -0,0 +1,125 @@ +import uuid +from databases import Database +from app.models.enums import State + + +async def all_tasks_states(db: Database, project_id: uuid.UUID): + query = """ + SELECT DISTINCT ON (task_id) project_id, task_id, state + FROM task_events + WHERE project_id=:project_id + ORDER BY task_id, created_at DESC + """ + r = await db.fetch_all(query, {"project_id": str(project_id)}) + + return [dict(r) for r in r] + + +async def request_mapping( + db: Database, project_id: uuid.UUID, task_id: uuid.UUID, user_id: str, comment: str +): + 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 + ), + released AS ( + SELECT COUNT(*) = 0 AS no_record + FROM task_events + WHERE project_id= :project_id AND task_id= :task_id AND state = :unlocked_to_map_state + ) + INSERT INTO task_events (event_id, project_id, task_id, user_id, comment, state, created_at) + + SELECT + gen_random_uuid(), + :project_id, + :task_id, + :user_id, + :comment, + :request_for_map_state, + now() + FROM last + RIGHT JOIN released ON true + WHERE (last.state = :unlocked_to_map_state OR released.no_record = true); + """ + + values = { + "project_id": str(project_id), + "task_id": str(task_id), + "user_id": str(user_id), + "comment": comment, + "unlocked_to_map_state": State.UNLOCKED_TO_MAP.name, + "request_for_map_state": State.REQUEST_FOR_MAPPING.name, + } + + await db.fetch_one(query, values) + + 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 get_requested_user_id( + db: Database, project_id: uuid.UUID, task_id: uuid.UUID +): + query = """ + SELECT user_id + FROM task_events + WHERE project_id = :project_id AND task_id = :task_id and state = :request_for_map_state + ORDER BY created_at DESC + LIMIT 1 + """ + values = { + "project_id": str(project_id), + "task_id": str(task_id), + "request_for_map_state": State.REQUEST_FOR_MAPPING.name, + } + + result = await db.fetch_one(query, values) + if result is None: + raise ValueError("No user requested for mapping") + return result["user_id"] diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py new file mode 100644 index 00000000..06ca88de --- /dev/null +++ b/src/backend/app/tasks/task_routes.py @@ -0,0 +1,118 @@ +import uuid +from fastapi import APIRouter, Depends +from app.config import settings +from app.models.enums import EventType, State +from app.tasks import task_schemas, task_crud +from app.users.user_deps import login_required +from app.users.user_schemas import AuthUser +from databases import Database +from app.db import database + + +router = APIRouter( + prefix=f"{settings.API_PREFIX}/tasks", + tags=["tasks"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/states/{project_id}") +async def task_states( + project_id: uuid.UUID, db: Database = Depends(database.encode_db) +): + """Get all tasks states for a project.""" + + return await task_crud.all_tasks_states(db, project_id) + + +@router.post("/event/{project_id}/{task_id}") +async def new_event( + project_id: uuid.UUID, + task_id: uuid.UUID, + detail: task_schemas.NewEvent, + user_data: AuthUser = Depends(login_required), + db: Database = Depends(database.encode_db), +): + user_id = user_data.id + + match detail.event: + case EventType.REQUESTS: + # TODO: send notification here after this function + return await task_crud.request_mapping( + db, + project_id, + task_id, + user_id, + "Request for mapping", + ) + case EventType.MAP: + # TODO: send notification here after this function + requested_user_id = await task_crud.get_requested_user_id( + db, project_id, task_id + ) + return await task_crud.update_task_state( + db, + project_id, + task_id, + requested_user_id, + "Request accepted for mapping", + State.REQUEST_FOR_MAPPING, + State.LOCKED_FOR_MAPPING, + ) + case EventType.REJECTED: + # TODO: send notification here after this function + requested_user_id = await task_crud.get_requested_user_id( + db, project_id, task_id + ) + return await task_crud.update_task_state( + db, + project_id, + task_id, + requested_user_id, + "Request for mapping rejected", + State.REQUEST_FOR_MAPPING, + State.UNLOCKED_TO_MAP, + ) + case EventType.FINISH: + return await task_crud.update_task_state( + db, + project_id, + task_id, + user_id, + "Done: unlocked to validate", + State.LOCKED_FOR_MAPPING, + State.UNLOCKED_TO_VALIDATE, + ) + case EventType.VALIDATE: + return await task_crud.update_task_state( + db, + project_id, + task_id, + user_id, + "Done: locked for validation", + State.UNLOCKED_TO_VALIDATE, + State.LOCKED_FOR_VALIDATION, + ) + case EventType.GOOD: + return await task_crud.update_task_state( + db, + project_id, + task_id, + user_id, + "Done: Task is Good", + State.LOCKED_FOR_VALIDATION, + State.UNLOCKED_DONE, + ) + + case EventType.BAD: + return await task_crud.update_task_state( + db, + project_id, + task_id, + user_id, + "Done: needs to redo", + State.LOCKED_FOR_VALIDATION, + State.UNLOCKED_TO_MAP, + ) + + return True diff --git a/src/backend/app/tasks/task_schemas.py b/src/backend/app/tasks/task_schemas.py new file mode 100644 index 00000000..ef38f50e --- /dev/null +++ b/src/backend/app/tasks/task_schemas.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel +from app.models.enums import EventType + + +class NewEvent(BaseModel): + event: EventType