Skip to content

Commit

Permalink
Merge pull request #125 from hotosm/feat/auto-lock-tasks
Browse files Browse the repository at this point in the history
Introduce Final Output and Auto Lock Tasks Fields to Improve Task Management
  • Loading branch information
nrjadkry authored Aug 7, 2024
2 parents 73ea3cd + cc6585d commit 47fe9d9
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 58 deletions.
10 changes: 8 additions & 2 deletions 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 @@ -105,7 +106,9 @@ class DbProject(Base):
organisation = relationship(DbOrganisation, backref="projects")

# flight params
overlap_percent = cast(float, Column(Float, nullable=True))
# overlap_percent = cast(float, Column(Float, nullable=True))
front_overlap = cast(float, Column(Float, nullable=True))
side_overlap = cast(float, Column(Float, nullable=True))
gsd_cm_px = cast(float, Column(Float, nullable=True)) # in cm_px
camera_bearings = cast(list[int], Column(ARRAY(SmallInteger), nullable=True))
gimble_angles_degrees = cast(
Expand All @@ -129,7 +132,10 @@ class DbProject(Base):
),
)
author = relationship(DbUser, uselist=False, backref="user")

final_output = cast(list, Column(ARRAY(Enum(FinalOutput))))
requires_approval_from_manager_for_locking = cast(
bool, Column(Boolean, default=False)
)
# PROJECT STATUS
status = cast(
ProjectStatus,
Expand Down
84 changes: 84 additions & 0 deletions src/backend/app/migrations/versions/d862bfa31c36_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""
Revision ID: d862bfa31c36
Revises: 87b6f9d734e8
Create Date: 2024-08-07 07:39:34.816982
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


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


def upgrade() -> None:
finaloutput_enum = sa.Enum(
"ORTHOPHOTO_2D",
"ORTHOPHOTO_3D",
"DIGITAL_TERRAIN_MODEL",
"DIGITAL_SURFACE_MODEL",
name="finaloutput",
)
finaloutput_enum.create(op.get_bind())

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


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"projects",
sa.Column(
"overlap_percent",
sa.DOUBLE_PRECISION(precision=53),
autoincrement=False,
nullable=True,
),
)
op.drop_column("projects", "requires_approval_from_manager_for_locking")
op.drop_column("projects", "final_output")
op.drop_column("projects", "side_overlap")
op.drop_column("projects", "front_overlap")
# ### end Alembic commands ###
finaloutput_enum = sa.Enum(
"ORTHOPHOTO_2D",
"ORTHOPHOTO_3D",
"DIGITAL_TERRAIN_MODEL",
"DIGITAL_SURFACE_MODEL",
name="finaloutput",
)
finaloutput_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(Enum):
ORTHOPHOTO_2D = "ORTHOPHOTO_2D"
ORTHOPHOTO_3D = "ORTHOPHOTO_3D"
DIGITAL_TERRAIN_MODEL = "DIGITAL_TERRAIN_MODEL"
DIGITAL_SURFACE_MODEL = "DIGITAL_SURFACE_MODEL"


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

Expand Down
10 changes: 9 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, requires_approval_from_manager_for_locking, front_overlap, side_overlap, created_at)
VALUES (
:id,
:slug,
Expand All @@ -38,6 +38,10 @@ async def create_project_with_project_info(
:output_raw_url,
:task_split_dimension,
:deadline_at,
:final_output,
:requires_approval_from_manager_for_locking,
:front_overlap,
:side_overlap,
CURRENT_TIMESTAMP
)
RETURNING id
Expand All @@ -64,6 +68,10 @@ 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": [item.value for item in project_metadata.final_output],
"requires_approval_from_manager_for_locking": project_metadata.requires_approval_from_manager_for_locking,
"front_overlap": project_metadata.front_overlap,
"side_overlap": project_metadata.side_overlap,
},
)
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
14 changes: 13 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,18 @@ class ProjectIn(BaseModel):
output_raw_url: Optional[str] = None
deadline_at: Optional[date] = None
visibility: Optional[ProjectVisibility] = ProjectVisibility.PUBLIC
final_output: List[FinalOutput] = Field(
...,
example=[
"ORTHOPHOTO_2D",
"ORTHOPHOTO_3D",
"DIGITAL_TERRAIN_MODEL",
"DIGITAL_SURFACE_MODEL",
],
)
requires_approval_from_manager_for_locking: Optional[bool] = False
front_overlap: Optional[float] = None
side_overlap: Optional[float] = None

@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["requires_approval_from_manager_for_locking"] == "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
2 changes: 1 addition & 1 deletion src/backend/app/users/user_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ async def login_required(
"""Dependency to inject into endpoints requiring login."""
if settings.DEBUG:
return AuthUser(
id="0",
id="6da91a51-5efd-40c9-a9c4-b66465a75fbe",
email="admin@hotosm.org",
name="admin",
img_url="",
Expand Down

0 comments on commit 47fe9d9

Please sign in to comment.