diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index ecace923..c88bd22b 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -18,6 +18,7 @@ from sqlalchemy.dialects.postgresql import UUID from geoalchemy2 import Geometry, WKBElement from app.models.enums import ( + FinalOutput, TaskStatus, TaskSplitType, ProjectStatus, @@ -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( @@ -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, diff --git a/src/backend/app/migrations/versions/d862bfa31c36_.py b/src/backend/app/migrations/versions/d862bfa31c36_.py new file mode 100644 index 00000000..729043bf --- /dev/null +++ b/src/backend/app/migrations/versions/d862bfa31c36_.py @@ -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()) diff --git a/src/backend/app/models/enums.py b/src/backend/app/models/enums.py index a4943433..750ce60c 100644 --- a/src/backend/app/models/enums.py +++ b/src/backend/app/models/enums.py @@ -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.""" diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index ff43f4f9..7d070b0f 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -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, @@ -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 @@ -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 diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 757873c5..3796784f 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -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: @@ -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 """ diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 2811c5dd..0e737bc1 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -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 ( @@ -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 diff --git a/src/backend/app/tasks/task_crud.py b/src/backend/app/tasks/task_crud.py index 4044abc8..160ddcab 100644 --- a/src/backend/app/tasks/task_crud.py +++ b/src/backend/app/tasks/task_crud.py @@ -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, @@ -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), @@ -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( @@ -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 diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index 87caebcf..27b6a860 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -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: diff --git a/src/backend/app/users/user_deps.py b/src/backend/app/users/user_deps.py index 2d174765..0ba83475 100644 --- a/src/backend/app/users/user_deps.py +++ b/src/backend/app/users/user_deps.py @@ -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="",