diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index 4c64afa5f..df7b50e88 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -221,6 +221,41 @@ async def all( ) return await cur.fetchall() + @classmethod + async def delete(cls, db: Connection, user_id: int) -> bool: + """Delete a user and their related data.""" + async with db.cursor() as cur: + await cur.execute( + """ + UPDATE task_events SET user_id = NULL WHERE user_id = %(user_id)s; + """, + {"user_id": user_id}, + ) + await cur.execute( + """ + UPDATE projects SET author_id = NULL WHERE author_id = %(user_id)s; + """, + {"user_id": user_id}, + ) + await cur.execute( + """ + DELETE FROM organisation_managers WHERE user_id = %(user_id)s; + """, + {"user_id": user_id}, + ) + await cur.execute( + """ + DELETE FROM user_roles WHERE user_id = %(user_id)s; + """, + {"user_id": user_id}, + ) + await cur.execute( + """ + DELETE FROM users WHERE id = %(user_id)s; + """, + {"user_id": user_id}, + ) + @classmethod async def create( cls, @@ -605,11 +640,11 @@ class DbTaskEvent(BaseModel): project_id: Annotated[Optional[int], Field(gt=0)] = None user_id: Annotated[Optional[int], Field(gt=0)] = None + username: Optional[str] = None comment: Optional[str] = None created_at: Optional[AwareDatetime] = None # Computed - username: Optional[str] = None profile_img: Optional[str] = None # Computed via database trigger state: Optional[MappingState] = None @@ -662,13 +697,12 @@ async def all( sql = f""" SELECT - *, - u.username, + the.*, u.profile_img FROM - public.task_events - JOIN - users u ON u.id = task_events.user_id + public.task_events the + LEFT JOIN + users u ON u.id = the.user_id WHERE {filters_joined} ORDER BY created_at DESC; """ @@ -696,18 +730,19 @@ async def create( INSERT INTO public.task_events ( event_id, project_id, + username, {columns} ) VALUES ( gen_random_uuid(), (SELECT project_id FROM tasks WHERE id = %(task_id)s), + (SELECT username FROM users WHERE id = %(user_id)s), {value_placeholders} ) RETURNING * ) SELECT inserted.*, - u.username, u.profile_img FROM inserted JOIN users u ON u.id = inserted.user_id; diff --git a/src/backend/app/users/user_routes.py b/src/backend/app/users/user_routes.py index 634f449eb..051b25bba 100644 --- a/src/backend/app/users/user_routes.py +++ b/src/backend/app/users/user_routes.py @@ -19,11 +19,13 @@ from typing import Annotated, List -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Response +from loguru import logger as log from psycopg import Connection from app.auth.roles import mapper, super_admin from app.db.database import db_conn +from app.db.enums import HTTPStatus from app.db.enums import UserRole as UserRoleEnum from app.db.models import DbUser from app.users import user_schemas @@ -66,3 +68,18 @@ async def get_user_roles(current_user: Annotated[DbUser, Depends(mapper)]): for role in UserRoleEnum: user_roles[role.name] = role.value return user_roles + + +@router.delete("/{id}") +async def delete_user_by_identifier( + user: Annotated[DbUser, Depends(get_user)], + current_user: Annotated[DbUser, Depends(super_admin)], + db: Annotated[Connection, Depends(db_conn)], +): + """Delete a single user.""" + log.info( + f"User {current_user.username} attempting deletion of user {user.username}" + ) + await DbUser.delete(db, user.id) + log.info(f"User {user.id} deleted successfully.") + return Response(status_code=HTTPStatus.NO_CONTENT) diff --git a/src/backend/migrations/010-delete-user.sql b/src/backend/migrations/010-delete-user.sql new file mode 100644 index 000000000..7004f704c --- /dev/null +++ b/src/backend/migrations/010-delete-user.sql @@ -0,0 +1,47 @@ +-- ## Migration to: +-- * Enable user data deletion + +-- Start a transaction +BEGIN; + +-- Add column 'username' to 'task_events' table +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name='task_events' AND column_name='username' + ) THEN + ALTER TABLE public.task_events + ADD COLUMN username VARCHAR; + END IF; +END $$; + +-- Make 'user_id' nullable in 'task_events' table +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name='task_events' AND column_name='user_id' AND is_nullable = 'NO' + ) THEN + ALTER TABLE public.task_events + ALTER COLUMN user_id DROP NOT NULL; + END IF; +END $$; + +-- Make 'author_id' nullable in 'projects' table +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name='projects' AND column_name='author_id' AND is_nullable = 'NO' + ) THEN + ALTER TABLE public.projects + ALTER COLUMN author_id DROP NOT NULL; + END IF; +END $$; + +-- Commit the transaction +COMMIT;