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

Introduce Final Output and Auto Lock Tasks Fields to Improve Task Management #125

Merged
merged 12 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/backend/app/db/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from sqlalchemy.dialects.postgresql import UUID
from geoalchemy2 import Geometry, WKBElement
from app.models.enums import (
FinalOutput,
TaskStatus,
TaskSplitType,
ProjectStatus,
Expand Down Expand Up @@ -129,7 +130,10 @@ class DbProject(Base):
),
)
author = relationship(DbUser, uselist=False, backref="user")

final_output = cast(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the multiselect option. We might need an array field ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve updated the schema & field to use an array field to better handle multiple selections.

FinalOutput, Column(Enum(FinalOutput), default=FinalOutput.ORTHOPHOTO_2D)
)
auto_lock_tasks = cast(bool, Column(Boolean, default=False))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is better to rename this auto_lock_tasks to something like requires_approval_from_manager_for_locking.
Fields should be named clearly.

# PROJECT STATUS
status = cast(
ProjectStatus,
Expand Down
57 changes: 57 additions & 0 deletions src/backend/app/migrations/versions/b574d3a13e62_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""

Revision ID: b574d3a13e62
Revises: 87b6f9d734e8
Create Date: 2024-08-05 15:02:26.464109

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "b574d3a13e62"
down_revision: Union[str, None] = "87b6f9d734e8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

final_output_enum = sa.Enum(
"ORTHOPHOTO_2D",
"ORTHOPHOTO_3D",
"DIGITAL_TERRAIN_MODEL",
"DIGITAL_SURFACE_MODEL",
name="finaloutput",
)


def upgrade() -> None:
final_output_enum.create(op.get_bind())

# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"projects",
sa.Column(
"final_output",
sa.Enum(
"ORTHOPHOTO_2D",
"ORTHOPHOTO_3D",
"DIGITAL_TERRAIN_MODEL",
"DIGITAL_SURFACE_MODEL",
name="finaloutput",
),
nullable=True,
),
)
op.add_column("projects", sa.Column("auto_lock_tasks", sa.Boolean(), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("projects", "auto_lock_tasks")
op.drop_column("projects", "final_output")
# ### end Alembic commands ###
final_output_enum.drop(op.get_bind())
7 changes: 7 additions & 0 deletions src/backend/app/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ class IntEnum(int, Enum):
pass


class FinalOutput(IntEnum, Enum):
ORTHOPHOTO_2D = 0
ORTHOPHOTO_3D = 1
DIGITAL_TERRAIN_MODEL = 2
DIGITAL_SURFACE_MODEL = 3


class TaskStatus(IntEnum, Enum):
"""Enum describing available Task Statuses."""

Expand Down
6 changes: 5 additions & 1 deletion src/backend/app/projects/project_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async def create_project_with_project_info(
_id = uuid.uuid4()
query = """
INSERT INTO projects (
id, slug, author_id, name, description, per_task_instructions, status, visibility, outline, no_fly_zones, dem_url, output_orthophoto_url, output_pointcloud_url, output_raw_url, task_split_dimension, deadline_at, created_at)
id, slug, author_id, name, description, per_task_instructions, status, visibility, outline, no_fly_zones, dem_url, output_orthophoto_url, output_pointcloud_url, output_raw_url, task_split_dimension, deadline_at, final_output, auto_lock_tasks, created_at)
VALUES (
:id,
:slug,
Expand All @@ -38,6 +38,8 @@ async def create_project_with_project_info(
:output_raw_url,
:task_split_dimension,
:deadline_at,
:final_output,
:auto_lock_tasks,
CURRENT_TIMESTAMP
)
RETURNING id
Expand All @@ -64,6 +66,8 @@ async def create_project_with_project_info(
"output_raw_url": project_metadata.output_raw_url,
"task_split_dimension": project_metadata.task_split_dimension,
"deadline_at": project_metadata.deadline_at,
"final_output": project_metadata.final_output.name,
"auto_lock_tasks": project_metadata.auto_lock_tasks,
},
)
return project_id
Expand Down
4 changes: 2 additions & 2 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async def delete_project_by_id(
Delete a project by its ID, along with all associated tasks.

Args:
project_id (int): The ID of the project to delete.
project_id (uuid.UUID): The ID of the project to delete.
db (Database): The database session dependency.

Returns:
Expand All @@ -54,7 +54,7 @@ async def delete_project_by_id(
), deleted_task_events AS (
DELETE FROM task_events
WHERE project_id = :project_id
RETURNING id
RETURNING event_id
)
SELECT id FROM deleted_project
"""
Expand Down
4 changes: 3 additions & 1 deletion src/backend/app/projects/project_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pydantic import BaseModel, computed_field, Field, validator
from typing import Any, Optional, Union, List
from geojson_pydantic import Feature, FeatureCollection, Polygon
from app.models.enums import ProjectVisibility, State
from app.models.enums import FinalOutput, ProjectVisibility, State
from shapely import wkb
from datetime import date
from app.utils import (
Expand Down Expand Up @@ -41,6 +41,8 @@ class ProjectIn(BaseModel):
output_raw_url: Optional[str] = None
deadline_at: Optional[date] = None
visibility: Optional[ProjectVisibility] = ProjectVisibility.PUBLIC
final_output: Optional[FinalOutput] = FinalOutput.ORTHOPHOTO_2D
auto_lock_tasks: Optional[bool] = False

@computed_field
@property
Expand Down
72 changes: 48 additions & 24 deletions src/backend/app/tasks/task_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ async def request_mapping(
return {"project_id": project_id, "task_id": task_id, "comment": comment}


async def update_task_state(
async def update_or_create_task_state(
db: Database,
project_id: uuid.UUID,
task_id: uuid.UUID,
Expand All @@ -148,25 +148,33 @@ async def update_task_state(
initial_state: State,
final_state: State,
):
# Update or insert task event
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()
WITH last AS (
SELECT *
FROM task_events
WHERE project_id = :project_id AND task_id = :task_id
ORDER BY created_at DESC
LIMIT 1
),
updated AS (
UPDATE task_events
SET state = :final_state, comment = :comment, created_at = now()
WHERE EXISTS (
SELECT 1
FROM last
WHERE user_id = :user_id
RETURNING project_id, task_id, user_id, state;
"""
WHERE user_id = :user_id AND state = :initial_state
)
RETURNING project_id, task_id, user_id, 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()
WHERE NOT EXISTS (
SELECT 1
FROM updated
)
RETURNING project_id, task_id, user_id, state;
"""

values = {
"project_id": str(project_id),
Expand All @@ -177,9 +185,13 @@ async def update_task_state(
"final_state": final_state.name,
}

await db.fetch_one(query, values)
result = await db.fetch_one(query, values)

return {"project_id": project_id, "task_id": task_id, "comment": comment}
return {
"project_id": result["project_id"],
"task_id": result["task_id"],
"comment": comment,
}


async def get_requested_user_id(
Expand All @@ -206,14 +218,26 @@ async def get_requested_user_id(

async def get_project_task_by_id(db: Database, user_id: str):
"""Get a list of pending tasks created by a specific user (project creator)."""
_sql = """
SELECT id FROM projects WHERE author_id = :user_id
"""
project_ids_result = await db.fetch_all(query=_sql, values={"user_id": user_id})
project_ids = [row["id"] for row in project_ids_result]
raw_sql = """
SELECT t.id AS task_id, te.event_id, te.user_id, te.project_id, te.comment, te.state, te.created_at
FROM tasks t
LEFT JOIN task_events te ON t.id = te.task_id
LEFT JOIN projects p ON te.project_id = p.id
WHERE p.author_id = :user_id
AND te.state = 'REQUEST_FOR_MAPPING'
WHERE t.project_id = ANY(:project_ids)
AND te.state = :state
ORDER BY t.project_task_index;
"""
db_tasks = await db.fetch_all(query=raw_sql, values={"user_id": user_id})
values = {"project_ids": project_ids, "state": "REQUEST_FOR_MAPPING"}
try:
db_tasks = await db.fetch_all(query=raw_sql, values=values)
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch project tasks. {e}",
)

return db_tasks
65 changes: 38 additions & 27 deletions src/backend/app/tasks/task_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,34 +50,45 @@ async def new_event(

match detail.event:
case EventType.REQUESTS:
data = await task_crud.request_mapping(
db,
project_id,
task_id,
user_id,
"Request for mapping",
)

# email notification
# 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)
author = await get_user_by_id(db, project.author_id)

html_content = render_email_template(
template_name="mapping_requests.html",
context={
"name": author.name,
"drone_operator_name": user_data.name,
"task_id": task_id,
"project_name": project.name,
"description": project.description,
},
)
background_tasks.add_task(
send_notification_email,
user_data.email,
"Request for mapping",
html_content,
)
if project["auto_lock_tasks"] == "true":
data = await task_crud.update_or_create_task_state(
db,
project_id,
task_id,
user_id,
"Request accepted automatically",
State.REQUEST_FOR_MAPPING,
State.LOCKED_FOR_MAPPING,
)
else:
data = await task_crud.request_mapping(
db,
project_id,
task_id,
user_id,
"Request for mapping",
)
# email notification
author = await get_user_by_id(db, project.author_id)

html_content = render_email_template(
template_name="mapping_requests.html",
context={
"name": author.name,
"drone_operator_name": user_data.name,
"task_id": task_id,
"project_name": project.name,
"description": project.description,
},
)
background_tasks.add_task(
send_notification_email,
user_data.email,
"Request for mapping",
html_content,
)
return data

case EventType.MAP:
Expand Down