diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b6aa81..17bd1a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ These are the section headers that we use: - Added `GET /api/v1/me` endpoint to get the current user information. ([#140](https://github.com/argilla-io/argilla-server/pull/140)) - Added `GET /api/v1/users` endpoint to get a list of all users. ([#142](https://github.com/argilla-io/argilla-server/pull/142)) - Added `POST /api/v1/users` endpoint to create a new user. ([#146](https://github.com/argilla-io/argilla-server/pull/146)) +- Added `DELETE /api/v1/users` endpoint to delete a user. ([#148](https://github.com/argilla-io/argilla-server/pull/148)) ## [Unreleased]() diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index 36cac142..6cfce784 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -71,6 +71,27 @@ async def create_user( return user +@router.delete("/users/{user_id}", response_model=User) +async def delete_user( + *, + db: AsyncSession = Depends(get_async_db), + user_id: UUID, + current_user: models.User = Security(auth.get_current_user), +): + user = await accounts.get_user_by_id(db, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id `{user_id}` not found", + ) + + await authorize(current_user, UserPolicyV1.delete) + + await accounts.delete_user(db, user) + + return user + + @router.get("/users/{user_id}/workspaces", response_model=Workspaces) async def list_user_workspaces( *, diff --git a/src/argilla_server/policies.py b/src/argilla_server/policies.py index 60561530..49164288 100644 --- a/src/argilla_server/policies.py +++ b/src/argilla_server/policies.py @@ -137,6 +137,10 @@ async def list(cls, actor: User) -> bool: async def create(cls, actor: User) -> bool: return actor.is_owner + @classmethod + async def delete(cls, actor: User) -> bool: + return actor.is_owner + @classmethod async def list_workspaces(cls, actor: User) -> bool: return actor.is_owner diff --git a/tests/unit/api/v1/users/test_delete_user.py b/tests/unit/api/v1/users/test_delete_user.py new file mode 100644 index 00000000..0c3b6b58 --- /dev/null +++ b/tests/unit/api/v1/users/test_delete_user.py @@ -0,0 +1,82 @@ +# Copyright 2021-present, the Recognai S.L. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime +from uuid import UUID, uuid4 + +import pytest +from argilla_server.constants import API_KEY_HEADER_NAME +from argilla_server.enums import UserRole +from argilla_server.models import User +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.factories import UserFactory + + +@pytest.mark.asyncio +class TestDeleteUser: + def url(self, user_id: UUID) -> str: + return f"/api/v1/users/{user_id}" + + async def test_delete_user(self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict): + user = await UserFactory.create() + + response = await async_client.delete(self.url(user.id), headers=owner_auth_header) + + assert response.status_code == 200 + assert response.json() == { + "id": str(user.id), + "first_name": user.first_name, + "last_name": user.last_name, + "username": user.username, + "role": user.role, + "api_key": user.api_key, + "inserted_at": user.inserted_at.isoformat(), + "updated_at": user.updated_at.isoformat(), + } + + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + async def test_delete_user_without_authentication(self, db: AsyncSession, async_client: AsyncClient): + user = await UserFactory.create() + + response = await async_client.delete(self.url(user.id)) + + assert response.status_code == 401 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + @pytest.mark.parametrize("user_role", [UserRole.admin, UserRole.annotator]) + async def test_delete_user_with_unauthorized_role( + self, db: AsyncSession, async_client: AsyncClient, user_role: UserRole + ): + user = await UserFactory.create() + user_with_unauthorized_role = await UserFactory.create(role=user_role) + + response = await async_client.delete( + self.url(user.id), + headers={API_KEY_HEADER_NAME: user_with_unauthorized_role.api_key}, + ) + + assert response.status_code == 403 + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + + async def test_delete_user_with_nonexistent_user_id(self, async_client: AsyncClient, owner_auth_header: dict): + user_id = uuid4() + + response = await async_client.delete(self.url(user_id), headers=owner_auth_header) + + assert response.status_code == 404 + assert response.json() == {"detail": f"User with id `{user_id}` not found"}