From 4d664d1d7279fce915edbc6d84d38475e9c05fd3 Mon Sep 17 00:00:00 2001 From: Pradip-p Date: Thu, 17 Oct 2024 10:54:33 +0545 Subject: [PATCH 01/22] feat: update role coumn in use profilem, now role will be store in array --- src/backend/app/db/db_models.py | 3 +- .../app/migrations/versions/b36a13183a83_.py | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/backend/app/migrations/versions/b36a13183a83_.py diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index 8a38d19f..e34d5424 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -277,7 +277,8 @@ class GroundControlPoint(Base): class DbUserProfile(Base): __tablename__ = "user_profile" user_id = cast(str, Column(String, ForeignKey("users.id"), primary_key=True)) - role = cast(UserRole, Column(Enum(UserRole), default=UserRole.DRONE_PILOT)) + role = cast(list, Column(ARRAY(Enum(UserRole)))) + # role = cast(UserRole, Column(Enum(UserRole), default=UserRole.DRONE_PILOT)) phone_number = cast(str, Column(String)) country = cast(str, Column(String)) city = cast(str, Column(String)) diff --git a/src/backend/app/migrations/versions/b36a13183a83_.py b/src/backend/app/migrations/versions/b36a13183a83_.py new file mode 100644 index 00000000..cc8fcb08 --- /dev/null +++ b/src/backend/app/migrations/versions/b36a13183a83_.py @@ -0,0 +1,47 @@ +from typing import Sequence, Union +from alembic import op +from app.models.enums import UserRole +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.sql import text + + +# revision identifiers, used by Alembic. +revision: str = "b36a13183a83" +down_revision: Union[str, None] = "5235ef4afa9c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +def upgrade() -> None: + # Check if the enum type 'userrole' already exists + conn = op.get_bind() + result = conn.execute(text("SELECT 1 FROM pg_type WHERE typname = 'userrole';")).scalar() + + if not result: + # Create a new enum type for user roles if it doesn't exist + userrole_enum = sa.Enum(UserRole, name="userrole") + userrole_enum.create(op.get_bind()) + + # Change the column from a single enum value to an array of enums + # We need to cast each enum value to text and back to an array of enums + op.alter_column("user_profile", "role", + existing_type=sa.Enum(UserRole, name="userrole"), + type_=sa.ARRAY(postgresql.ENUM("PROJECT_CREATOR", "DRONE_PILOT", name="userrole")), + postgresql_using="ARRAY[role]::userrole[]", # Convert the single enum to an array + nullable=True) + +def downgrade() -> None: + # Change the column back from an array to a single enum value + op.alter_column("user_profile", "role", + existing_type=sa.ARRAY(postgresql.ENUM("PROJECT_CREATOR", "DRONE_PILOT", name="userrole")), + type_=sa.Enum(UserRole, name="userrole"), + postgresql_using="role[1]", + nullable=True) + + # Drop the enum type only if it exists + conn = op.get_bind() + result = conn.execute(text("SELECT 1 FROM pg_type WHERE typname = 'userrole';")).scalar() + + if result: + userrole_enum = sa.Enum(UserRole, name="userrole") + userrole_enum.drop(op.get_bind()) From b6852f2bf0ebe8f033928e54cf1b0a9544d8b348 Mon Sep 17 00:00:00 2001 From: Pradip-p Date: Thu, 17 Oct 2024 12:20:54 +0545 Subject: [PATCH 02/22] fix: fix role issues of user in profile update endpoint --- .../app/migrations/versions/b36a13183a83_.py | 40 +++++--- src/backend/app/users/user_schemas.py | 91 ++++++++++++++++++- 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/backend/app/migrations/versions/b36a13183a83_.py b/src/backend/app/migrations/versions/b36a13183a83_.py index cc8fcb08..78ce2cee 100644 --- a/src/backend/app/migrations/versions/b36a13183a83_.py +++ b/src/backend/app/migrations/versions/b36a13183a83_.py @@ -12,10 +12,13 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None + def upgrade() -> None: # Check if the enum type 'userrole' already exists conn = op.get_bind() - result = conn.execute(text("SELECT 1 FROM pg_type WHERE typname = 'userrole';")).scalar() + result = conn.execute( + text("SELECT 1 FROM pg_type WHERE typname = 'userrole';") + ).scalar() if not result: # Create a new enum type for user roles if it doesn't exist @@ -24,23 +27,36 @@ def upgrade() -> None: # Change the column from a single enum value to an array of enums # We need to cast each enum value to text and back to an array of enums - op.alter_column("user_profile", "role", - existing_type=sa.Enum(UserRole, name="userrole"), - type_=sa.ARRAY(postgresql.ENUM("PROJECT_CREATOR", "DRONE_PILOT", name="userrole")), - postgresql_using="ARRAY[role]::userrole[]", # Convert the single enum to an array - nullable=True) + op.alter_column( + "user_profile", + "role", + existing_type=sa.Enum(UserRole, name="userrole"), + type_=sa.ARRAY( + postgresql.ENUM("PROJECT_CREATOR", "DRONE_PILOT", name="userrole") + ), + postgresql_using="ARRAY[role]::userrole[]", # Convert the single enum to an array + nullable=True, + ) + def downgrade() -> None: # Change the column back from an array to a single enum value - op.alter_column("user_profile", "role", - existing_type=sa.ARRAY(postgresql.ENUM("PROJECT_CREATOR", "DRONE_PILOT", name="userrole")), - type_=sa.Enum(UserRole, name="userrole"), - postgresql_using="role[1]", - nullable=True) + op.alter_column( + "user_profile", + "role", + existing_type=sa.ARRAY( + postgresql.ENUM("PROJECT_CREATOR", "DRONE_PILOT", name="userrole") + ), + type_=sa.Enum(UserRole, name="userrole"), + postgresql_using="role[1]", + nullable=True, + ) # Drop the enum type only if it exists conn = op.get_bind() - result = conn.execute(text("SELECT 1 FROM pg_type WHERE typname = 'userrole';")).scalar() + result = conn.execute( + text("SELECT 1 FROM pg_type WHERE typname = 'userrole';") + ).scalar() if result: userrole_enum = sa.Enum(UserRole, name="userrole") diff --git a/src/backend/app/users/user_schemas.py b/src/backend/app/users/user_schemas.py index 0ff79814..a55d0bfd 100644 --- a/src/backend/app/users/user_schemas.py +++ b/src/backend/app/users/user_schemas.py @@ -2,7 +2,7 @@ from app.models.enums import HTTPStatus, State, UserRole from pydantic import BaseModel, EmailStr, ValidationInfo, Field from pydantic.functional_validators import field_validator -from typing import Optional +from typing import List, Optional from psycopg import Connection from psycopg.rows import class_row import psycopg @@ -95,20 +95,31 @@ class BaseUserProfile(BaseModel): drone_you_own: Optional[str] = None experience_years: Optional[int] = None certified_drone_operator: Optional[bool] = False - role: Optional[UserRole] = None + role: Optional[List[UserRole]] = None @field_validator("role", mode="after") @classmethod def integer_role_to_string(cls, value: UserRole): + if isinstance(value, list): + value = [str(role.name) for role in value] + if isinstance(value, int): value = UserRole(value) - return str(value.name) + + unique_roles = set(value) + value = list(unique_roles) + return value @field_validator("role", mode="before") @classmethod def srting_role_to_integer(cls, value: UserRole) -> str: if isinstance(value, str): - value = UserRole[value].value + role_list = value.strip("{}").split(",") + value = [ + UserRole[role.strip()].value + for role in role_list + if role.strip() in UserRole.__members__ + ] return value @@ -130,6 +141,26 @@ async def update(db: Connection, user_id: int, profile_update: UserProfileIn): model_dump = profile_update.model_dump( exclude_none=True, exclude=["password", "old_password"] ) + + # If there are new roles, update the existing roles + if "role" in model_dump and model_dump["role"] is not None: + new_roles = model_dump["role"] + + # Create a query to update roles + role_update_query = """ + UPDATE user_profile + SET role = ( + SELECT ARRAY( + SELECT DISTINCT unnest(array_cat(role, %s)) + ) + ) + WHERE user_id = %s; + """ + + async with db.cursor() as cur: + await cur.execute(role_update_query, (new_roles, user_id)) + + # Prepare the columns and placeholders for the main update columns = ", ".join(model_dump.keys()) value_placeholders = ", ".join(f"%({key})s" for key in model_dump.keys()) sql = f""" @@ -169,6 +200,58 @@ async def update(db: Connection, user_id: int, profile_update: UserProfileIn): ) return True + # class DbUserProfile(BaseUserProfile): + # """UserProfile model for interacting with the user_profile table.""" + + # user_id: int + + # @staticmethod + # async def update(db: Connection, user_id: int, profile_update: UserProfileIn): + # """Update or insert a user profile.""" + + # # Prepare data for insert or update + # model_dump = profile_update.model_dump( + # exclude_none=True, exclude=["password", "old_password"] + # ) + # columns = ", ".join(model_dump.keys()) + # value_placeholders = ", ".join(f"%({key})s" for key in model_dump.keys()) + # sql = f""" + # INSERT INTO user_profile ( + # user_id, {columns} + # ) + # VALUES ( + # %(user_id)s, {value_placeholders} + # ) + # ON CONFLICT (user_id) + # DO UPDATE SET + # {', '.join(f"{key} = EXCLUDED.{key}" for key in model_dump.keys())}; + # """ + + # # Prepare password update query if a new password is provided + # password_update_query = """ + # UPDATE users + # SET password = %(password)s + # WHERE id = %(user_id)s; + # """ + + # model_dump["user_id"] = user_id + + # async with db.cursor() as cur: + # await cur.execute(sql, model_dump) + + # if profile_update.password: + # # Update password if provided + # await cur.execute( + # password_update_query, + # { + # "password": user_logic.get_password_hash( + # profile_update.password + # ), + # "user_id": user_id, + # }, + # ) + # return True + async def get_userprofile_by_userid(db: Connection, user_id: str): """Fetch the user profile by user ID.""" query = """ From 505aeb70616c56dedb02c6019921618f9f382206 Mon Sep 17 00:00:00 2001 From: Pradip-p Date: Thu, 17 Oct 2024 12:56:03 +0545 Subject: [PATCH 03/22] feat: added new endpoints for project centroids --- src/backend/app/projects/project_logic.py | 14 ++++++++++++++ src/backend/app/projects/project_routes.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/backend/app/projects/project_logic.py b/src/backend/app/projects/project_logic.py index f9a91bf7..1d8ba785 100644 --- a/src/backend/app/projects/project_logic.py +++ b/src/backend/app/projects/project_logic.py @@ -19,6 +19,20 @@ from app.projects.image_processing import DroneImageProcessor from app.projects import project_schemas from minio import S3Error +from psycopg.rows import dict_row + + +async def get_centroids(db: Connection): + try: + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """SELECT ST_AsGeoJSON(centroid)::jsonb AS centroid FROM projects""" + ) + centroids = await cur.fetchall() + + return centroids + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) async def upload_file_to_s3( diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 9bbbf841..2aab3752 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -42,6 +42,26 @@ ) +@router.get("/centroids", tags=["Projects"]) +async def read_project_centroids( + db: Annotated[Connection, Depends(database.get_db)], + user_data: Annotated[AuthUser, Depends(login_required)] +): + """ + Get all project centroids. + """ + try: + centroids = await project_logic.get_centroids( + db, + ) + if not centroids: + return [] + + return centroids + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/{project_id}/download-boundaries", tags=["Projects"]) async def download_boundaries( project_id: Annotated[ From 73c68b426cbacf9122785ca1ee4be207d6ef4bd7 Mon Sep 17 00:00:00 2001 From: Pradip-p Date: Thu, 17 Oct 2024 13:37:51 +0545 Subject: [PATCH 04/22] feat: update status & ongoing task on project endpoint --- src/backend/app/projects/project_logic.py | 24 +++++++++++++++--- src/backend/app/projects/project_routes.py | 6 +++-- src/backend/app/projects/project_schemas.py | 27 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/backend/app/projects/project_logic.py b/src/backend/app/projects/project_logic.py index 1d8ba785..30b74fdd 100644 --- a/src/backend/app/projects/project_logic.py +++ b/src/backend/app/projects/project_logic.py @@ -25,11 +25,29 @@ async def get_centroids(db: Connection): try: async with db.cursor(row_factory=dict_row) as cur: - await cur.execute( - """SELECT ST_AsGeoJSON(centroid)::jsonb AS centroid FROM projects""" - ) + await cur.execute(""" + SELECT + p.id, + p.slug, + p.name, + ST_AsGeoJSON(p.centroid)::jsonb AS centroid, + COUNT(t.id) AS total_task_count, + COUNT(CASE WHEN te.state IN ('LOCKED_FOR_MAPPING', 'REQUEST_FOR_MAPPING', 'IMAGE_UPLOADED', 'UNFLYABLE_TASK') THEN 1 END) AS ongoing_task_count, + COUNT(CASE WHEN te.state = 'IMAGE_PROCESSED' THEN 1 END) AS completed_task_count + FROM + projects p + LEFT JOIN + tasks t ON p.id = t.project_id + LEFT JOIN + task_events te ON t.id = te.task_id + GROUP BY + p.id, p.slug, p.name, p.centroid; + """) centroids = await cur.fetchall() + if not centroids: + raise HTTPException(status_code=404, detail="No centroids found.") + return centroids except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 2aab3752..f4ca8e06 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -42,10 +42,12 @@ ) -@router.get("/centroids", tags=["Projects"]) +@router.get( + "/centroids", tags=["Projects"], response_model=list[project_schemas.CentroidOut] +) async def read_project_centroids( db: Annotated[Connection, Depends(database.get_db)], - user_data: Annotated[AuthUser, Depends(login_required)] + # user_data: Annotated[AuthUser, Depends(login_required)], ): """ Get all project centroids. diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 42986800..ce2e51f7 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -26,6 +26,33 @@ from app.s3 import get_presigned_url +class CentroidOut(BaseModel): + id: uuid.UUID + slug: str + name: str + centroid: dict + total_task_count: int + ongoing_task_count: int + completed_task_count: int + status: str = None + + @model_validator(mode="after") + def calculate_status(cls, values): + """Set the project status based on task counts.""" + ongoing_task_count = values.ongoing_task_count + completed_task_count = values.completed_task_count + total_task_count = values.total_task_count + + if completed_task_count == 0 and ongoing_task_count == 0: + values.status = "not-started" + elif completed_task_count == total_task_count: + values.status = "completed" + else: + values.status = "ongoing" + + return values + + class AssetsInfo(BaseModel): project_id: str task_id: str From b8bef9f89af20a82e6a9c6db5471395e189873b1 Mon Sep 17 00:00:00 2001 From: Pradip-p Date: Thu, 17 Oct 2024 13:40:21 +0545 Subject: [PATCH 05/22] fix: remove unused code from update profile --- src/backend/app/db/db_models.py | 1 - src/backend/app/users/user_schemas.py | 52 --------------------------- 2 files changed, 53 deletions(-) diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index e34d5424..7704ceef 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -278,7 +278,6 @@ class DbUserProfile(Base): __tablename__ = "user_profile" user_id = cast(str, Column(String, ForeignKey("users.id"), primary_key=True)) role = cast(list, Column(ARRAY(Enum(UserRole)))) - # role = cast(UserRole, Column(Enum(UserRole), default=UserRole.DRONE_PILOT)) phone_number = cast(str, Column(String)) country = cast(str, Column(String)) city = cast(str, Column(String)) diff --git a/src/backend/app/users/user_schemas.py b/src/backend/app/users/user_schemas.py index a55d0bfd..87d4880c 100644 --- a/src/backend/app/users/user_schemas.py +++ b/src/backend/app/users/user_schemas.py @@ -200,58 +200,6 @@ async def update(db: Connection, user_id: int, profile_update: UserProfileIn): ) return True - # class DbUserProfile(BaseUserProfile): - # """UserProfile model for interacting with the user_profile table.""" - - # user_id: int - - # @staticmethod - # async def update(db: Connection, user_id: int, profile_update: UserProfileIn): - # """Update or insert a user profile.""" - - # # Prepare data for insert or update - # model_dump = profile_update.model_dump( - # exclude_none=True, exclude=["password", "old_password"] - # ) - # columns = ", ".join(model_dump.keys()) - # value_placeholders = ", ".join(f"%({key})s" for key in model_dump.keys()) - # sql = f""" - # INSERT INTO user_profile ( - # user_id, {columns} - # ) - # VALUES ( - # %(user_id)s, {value_placeholders} - # ) - # ON CONFLICT (user_id) - # DO UPDATE SET - # {', '.join(f"{key} = EXCLUDED.{key}" for key in model_dump.keys())}; - # """ - - # # Prepare password update query if a new password is provided - # password_update_query = """ - # UPDATE users - # SET password = %(password)s - # WHERE id = %(user_id)s; - # """ - - # model_dump["user_id"] = user_id - - # async with db.cursor() as cur: - # await cur.execute(sql, model_dump) - - # if profile_update.password: - # # Update password if provided - # await cur.execute( - # password_update_query, - # { - # "password": user_logic.get_password_hash( - # profile_update.password - # ), - # "user_id": user_id, - # }, - # ) - # return True - async def get_userprofile_by_userid(db: Connection, user_id: str): """Fetch the user profile by user ID.""" query = """ From dc5b9413bfe3c79e07472f56387e006bbe8908de Mon Sep 17 00:00:00 2001 From: Sujit Date: Thu, 17 Oct 2024 15:21:17 +0545 Subject: [PATCH 06/22] feat: update role text on signin/signup --- .../src/components/LandingPage/SignInOverlay/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/LandingPage/SignInOverlay/index.tsx b/src/frontend/src/components/LandingPage/SignInOverlay/index.tsx index 9bb4b529..2b22ce23 100644 --- a/src/frontend/src/components/LandingPage/SignInOverlay/index.tsx +++ b/src/frontend/src/components/LandingPage/SignInOverlay/index.tsx @@ -46,7 +46,7 @@ export default function SignInOverlay() { className="naxatw-whitespace-nowrap !naxatw-bg-landing-red" rightIcon="east" onClick={() => { - localStorage.setItem('signedInAs', 'Project Creator'); + localStorage.setItem('signedInAs', 'PROJECT_CREATOR'); if (isAuthenticated()) { navigate('/projects'); } else { @@ -67,7 +67,7 @@ export default function SignInOverlay() { className="naxatw-whitespace-nowrap !naxatw-bg-landing-red" rightIcon="east" onClick={() => { - localStorage.setItem('signedInAs', 'Drone Operator'); + localStorage.setItem('signedInAs', 'DRONE_OPERATOR'); if (isAuthenticated()) { navigate('/projects'); } else { From 5984a3c92f68996976d47c562cbfabaa570dee87 Mon Sep 17 00:00:00 2001 From: Sujit Date: Thu, 17 Oct 2024 15:25:38 +0545 Subject: [PATCH 07/22] feat: redirect to complete profile page if the current signin role is not includes in role list --- .../src/components/common/UserProfile/index.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/components/common/UserProfile/index.tsx b/src/frontend/src/components/common/UserProfile/index.tsx index b64f406b..a8da0ce5 100644 --- a/src/frontend/src/components/common/UserProfile/index.tsx +++ b/src/frontend/src/components/common/UserProfile/index.tsx @@ -15,12 +15,21 @@ export default function UserProfile() { const { data: userDetails, isFetching }: Record = useGetUserDetailsQuery(); + const userProfile = getLocalStorageValue('userprofile'); + const role = localStorage.getItem('signedInAs'); useEffect(() => { - if (userDetails?.has_user_profile || isFetching) return; - if (!userDetails?.has_user_profile) navigate('/complete-profile'); - }, [userDetails?.has_user_profile, navigate, isFetching]); + if (userDetails?.role?.includes(role) || isFetching) return; + if (!userDetails?.has_user_profile || !userDetails?.role?.includes(role)) + navigate('/complete-profile'); + }, [ + userDetails?.has_user_profile, + userDetails?.role, + role, + navigate, + isFetching, + ]); const settingOptions = [ { From d23227447b2e0b5e639292359e91c28445aa0126 Mon Sep 17 00:00:00 2001 From: Sujit Date: Thu, 17 Oct 2024 15:32:57 +0545 Subject: [PATCH 08/22] feat: update text `Drone Oprerator` to `DRONE_OPERATOR` and `Project Creator` to `PROJECT_CREATOR` --- .../src/components/Projects/ProjectsHeader/index.tsx | 4 ++-- .../src/components/Authentication/Login/index.tsx | 2 +- src/frontend/src/views/CompleteUserProfile/index.tsx | 6 +++--- src/frontend/src/views/Dashboard/index.tsx | 6 +++--- src/frontend/src/views/UpdateUserProfile/index.tsx | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/components/Projects/ProjectsHeader/index.tsx b/src/frontend/src/components/Projects/ProjectsHeader/index.tsx index c4761e15..c997b02c 100644 --- a/src/frontend/src/components/Projects/ProjectsHeader/index.tsx +++ b/src/frontend/src/components/Projects/ProjectsHeader/index.tsx @@ -13,7 +13,7 @@ import useDebounceListener from '@Hooks/useDebouncedListener'; export default function ProjectsHeader() { const dispatch = useTypedDispatch(); const navigate = useNavigate(); - const signedInAs = localStorage.getItem('signedInAs') || 'Project Creator'; + const signedInAs = localStorage.getItem('signedInAs') || 'PROJECT_CREATOR'; const showMap = useTypedSelector(state => state.common.showMap); const projectsFilterByOwner = useTypedSelector( state => state.createproject.ProjectsFilterByOwner, @@ -75,7 +75,7 @@ export default function ProjectsHeader() { /> - {signedInAs === 'Project Creator' && ( + {signedInAs === 'PROJECT_CREATOR' && (