diff --git a/src/backend/app/db/db_models.py b/src/backend/app/db/db_models.py index 1737b12e1e..41440b9dfc 100644 --- a/src/backend/app/db/db_models.py +++ b/src/backend/app/db/db_models.py @@ -18,7 +18,7 @@ """SQLAlchemy database models for interacting with Postgresql.""" from datetime import datetime -from typing import cast +from typing import Optional, cast from geoalchemy2 import Geometry, WKBElement from sqlalchemy import ( @@ -222,14 +222,15 @@ class DbTaskHistory(Base): action_text = cast(str, Column(String)) action_date = cast(datetime, Column(DateTime, nullable=False, default=timestamp)) user_id = cast( - int, + Optional[int], Column( BigInteger, ForeignKey("users.id", name="fk_users"), index=True, - nullable=False, + nullable=True, ), ) + username = cast(str, Column(String)) # Define relationships user = relationship(DbUser, uselist=False, backref="task_history_user") @@ -288,17 +289,30 @@ class DbTask(Base): feature_count = cast(int, Column(Integer)) task_status = cast(TaskStatus, Column(Enum(TaskStatus), default=TaskStatus.READY)) locked_by = cast( - int, - Column(BigInteger, ForeignKey("users.id", name="fk_users_locked"), index=True), + Optional[int], + Column( + BigInteger, + ForeignKey("users.id", name="fk_users_locked"), + index=True, + nullable=True, + ), ) mapped_by = cast( - int, - Column(BigInteger, ForeignKey("users.id", name="fk_users_mapper"), index=True), + Optional[int], + Column( + BigInteger, + ForeignKey("users.id", name="fk_users_mapper"), + index=True, + nullable=True, + ), ) validated_by = cast( - int, + Optional[int], Column( - BigInteger, ForeignKey("users.id", name="fk_users_validator"), index=True + BigInteger, + ForeignKey("users.id", name="fk_users_validator"), + index=True, + nullable=True, ), ) @@ -344,7 +358,7 @@ class DbProject(Base): Column( BigInteger, ForeignKey("users.id", name="fk_users"), - nullable=False, + nullable=True, server_default="20386219", ), ) diff --git a/src/backend/app/tasks/tasks_crud.py b/src/backend/app/tasks/tasks_crud.py index 67a02bc06a..04be2bc573 100644 --- a/src/backend/app/tasks/tasks_crud.py +++ b/src/backend/app/tasks/tasks_crud.py @@ -135,9 +135,8 @@ async def update_task_status( # update history prior to updating task update_history = await create_task_history_for_status_change( - db_task, new_status, db_user + db_task, new_status, db_user, db ) - db.add(update_history) db_task.task_status = new_status @@ -176,7 +175,7 @@ async def update_task_status( async def create_task_history_for_status_change( - db_task: db_models.DbTask, new_status: TaskStatus, db_user: db_models.DbUser + db_task: db_models.DbTask, new_status: TaskStatus, db_user: db_models.DbUser, db ): """Append task status change to task history.""" msg = ( @@ -185,15 +184,34 @@ async def create_task_history_for_status_change( ) log.info(msg) - new_task_history = db_models.DbTaskHistory( - project_id=db_task.project_id, - task_id=db_task.id, - action=get_action_for_status_change(new_status), - action_text=msg, - actioned_by=db_user, - user_id=db_user.id, + query = text( + """ + INSERT INTO task_history ( + project_id, task_id, action, action_text, + action_date, user_id + ) + VALUES ( + :project_id, :task_id, :action, :action_text, + :action_date, :user_id + ) + RETURNING * + """ ) + params = { + "project_id": db_task.project_id, + "task_id": db_task.id, + "action": get_action_for_status_change(new_status).name, + "action_text": msg, + "action_date": datetime.now(), + "user_id": db_user.id, + } + + result = db.execute(query, params) + db.commit() + + row = result.fetchone() + # TODO add invalidation history # if new_status == TaskStatus.INVALIDATED: # new_invalidation_history = db_models.DbTaskInvalidationHistory( @@ -204,6 +222,17 @@ async def create_task_history_for_status_change( # TODO add mapping issue # if new_status == TaskStatus.BAD: + new_task_history = db_models.DbTaskHistory( + id=row[0], + project_id=row[1], + task_id=row[2], + action=row[3], + action_text=row[4], + action_date=row[5], + user_id=row[6], + username=row[7], + ) + return new_task_history @@ -348,7 +377,7 @@ async def get_project_task_history( """ query = """ SELECT task_history.id, task_history.task_id, task_history.action_text, - task_history.action_date, users.username, + task_history.action_date, task_history.username, users.profile_img FROM task_history LEFT JOIN users on users.id = task_history.user_id diff --git a/src/backend/app/tasks/tasks_schemas.py b/src/backend/app/tasks/tasks_schemas.py index 5faf6d1912..2cf35ff19e 100644 --- a/src/backend/app/tasks/tasks_schemas.py +++ b/src/backend/app/tasks/tasks_schemas.py @@ -39,8 +39,8 @@ class TaskHistoryBase(BaseModel): class TaskHistoryOut(TaskHistoryBase): """Task mapping history display.""" - username: str - profile_img: Optional[str] + username: Optional[str] = "" + profile_img: Optional[str] = "" status: Optional[str] = None diff --git a/src/backend/app/users/user_routes.py b/src/backend/app/users/user_routes.py index 059b82a90a..d8ce07efda 100644 --- a/src/backend/app/users/user_routes.py +++ b/src/backend/app/users/user_routes.py @@ -20,6 +20,8 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException +from loguru import logger as log +from sqlalchemy import text from sqlalchemy.orm import Session from app.db import database @@ -83,3 +85,72 @@ async def get_user_roles(): for role in UserRoleEnum: user_roles[role.name] = role.value return user_roles + + +@router.delete("/{user_id}", response_model=dict) +async def delete_user_account(user_id: int, db: Session = Depends(database.get_db)): + """Delete a user account while preserving task history references.""" + try: + transaction_sql = text( + """ + BEGIN; + + -- Update task history to remove user references + UPDATE task_history + SET user_id = NULL + WHERE user_id = :user_id; + + -- Update projects authored by the user + UPDATE projects + SET author_id = NULL + WHERE author_id = :user_id; + + -- Update tasks associated with the user + UPDATE tasks + SET + mapped_by = CASE + WHEN mapped_by = :user_id THEN NULL + ELSE mapped_by + END, + locked_by = CASE + WHEN locked_by = :user_id THEN NULL + ELSE locked_by + END, + validated_by = CASE + WHEN validated_by = :user_id THEN NULL + ELSE validated_by + END + WHERE + mapped_by = :user_id + OR locked_by = :user_id + OR validated_by = :user_id; + + -- Delete user roles + DELETE FROM user_roles + WHERE user_id = :user_id; + + -- Delete organisation managers + DELETE FROM organisation_managers + WHERE user_id = :user_id; + + -- Delete user + DELETE FROM users + WHERE id = :user_id; + + COMMIT; + """ + ) + + result = db.execute(transaction_sql, {"user_id": user_id}) + + if result.rowcount == 0: + raise HTTPException(status_code=404, detail=f"User ID {user_id} not found") + + db.commit() + + except Exception as e: + db.rollback() + log.error(f"Error occurred while deleting user {user_id}: {e}") + raise HTTPException(status_code=400, detail="Failed to delete user") from e + + return {"message": "User deleted successfully"} diff --git a/src/backend/migrations/009-delete-user.sql b/src/backend/migrations/009-delete-user.sql new file mode 100644 index 0000000000..af78a5e435 --- /dev/null +++ b/src/backend/migrations/009-delete-user.sql @@ -0,0 +1,25 @@ +-- ## Migration to: +-- * Enable user data deletion + +-- Start a transaction +BEGIN; + +-- Allow user_id in task_history to be NULL +ALTER TABLE task_history +ALTER COLUMN user_id DROP NOT NULL; + +-- Add a new column username to task_history +ALTER TABLE task_history +ADD COLUMN username VARCHAR; + +-- Allow author_id in projects to be NULL +ALTER TABLE projects ALTER COLUMN author_id DROP NOT NULL; + +-- Allow locked_by, mapped_by, and validated_by in tasks to be NULL +ALTER TABLE tasks +ALTER COLUMN locked_by DROP NOT NULL, +ALTER COLUMN mapped_by DROP NOT NULL, +ALTER COLUMN validated_by DROP NOT NULL; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/revert/009-delete-user.sql b/src/backend/migrations/revert/009-delete-user.sql new file mode 100644 index 0000000000..67bcc5d210 --- /dev/null +++ b/src/backend/migrations/revert/009-delete-user.sql @@ -0,0 +1,25 @@ +-- ## Migration to: +-- * Revert user data deletion changes + +-- Start a transaction +BEGIN; + +-- Disallow user_id in task_history to be NULL +ALTER TABLE task_history +ALTER COLUMN user_id SET NOT NULL; + +-- Remove the column username from task_history +ALTER TABLE task_history +DROP COLUMN username; + +-- Disallow author_id in projects to be NULL +ALTER TABLE projects ALTER COLUMN author_id SET NOT NULL; + +-- Disallow locked_by, mapped_by, and validated_by in tasks to be NULL +ALTER TABLE tasks +ALTER COLUMN locked_by SET NOT NULL, +ALTER COLUMN mapped_by SET NOT NULL, +ALTER COLUMN validated_by SET NOT NULL; + +-- Commit the transaction +COMMIT;