From 42790e18c2ddd2a4769f8751e554eca2db126af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 6 May 2024 16:32:54 +0200 Subject: [PATCH 01/12] feat: migrate endpoint /me to /api/v1/me --- src/argilla_server/apis/v1/handlers/users.py | 8 ++++ src/argilla_server/schemas/v1/users.py | 34 +++++++++++++++ tests/unit/api/v1/users/__init__.py | 13 ++++++ .../api/v1/users/test_get_current_user.py | 43 +++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 src/argilla_server/schemas/v1/users.py create mode 100644 tests/unit/api/v1/users/__init__.py create mode 100644 tests/unit/api/v1/users/test_get_current_user.py diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index f0578f65..daf0c1bb 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -21,12 +21,20 @@ from argilla_server.database import get_async_db from argilla_server.models import User from argilla_server.policies import UserPolicyV1, authorize +from argilla_server.schemas.v1.users import User as UserSchema from argilla_server.schemas.v1.workspaces import Workspaces from argilla_server.security import auth router = APIRouter(tags=["users"]) +@router.get("/me", response_model=UserSchema) +async def get_current_user(current_user: User = Security(auth.get_current_user)): + # TODO: Should we add telemetry.track_login? + + return current_user + + @router.get("/users/{user_id}/workspaces", response_model=Workspaces) async def list_user_workspaces( *, db: AsyncSession = Depends(get_async_db), user_id: UUID, current_user: User = Security(auth.get_current_user) diff --git a/src/argilla_server/schemas/v1/users.py b/src/argilla_server/schemas/v1/users.py new file mode 100644 index 00000000..f728d7c1 --- /dev/null +++ b/src/argilla_server/schemas/v1/users.py @@ -0,0 +1,34 @@ +# 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 typing import Optional +from uuid import UUID + +from argilla_server.enums import UserRole +from argilla_server.pydantic_v1 import BaseModel + + +class User(BaseModel): + id: UUID + first_name: str + last_name: Optional[str] + username: str + role: UserRole + api_key: str + inserted_at: datetime + updated_at: datetime + + class Config: + orm_mode = True diff --git a/tests/unit/api/v1/users/__init__.py b/tests/unit/api/v1/users/__init__.py new file mode 100644 index 00000000..55be4179 --- /dev/null +++ b/tests/unit/api/v1/users/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/tests/unit/api/v1/users/test_get_current_user.py b/tests/unit/api/v1/users/test_get_current_user.py new file mode 100644 index 00000000..e0d29f93 --- /dev/null +++ b/tests/unit/api/v1/users/test_get_current_user.py @@ -0,0 +1,43 @@ +# 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. + +import pytest +from argilla_server.models import User +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestGetCurrentUser: + def url(self) -> str: + return "/api/v1/me" + + async def test_get_current_user(self, async_client: AsyncClient, owner: User, owner_auth_header: dict): + response = await async_client.get(self.url(), headers=owner_auth_header) + + assert response.status_code == 200 + assert response.json() == { + "id": str(owner.id), + "first_name": owner.first_name, + "last_name": owner.last_name, + "username": owner.username, + "role": owner.role, + "api_key": owner.api_key, + "inserted_at": owner.inserted_at.isoformat(), + "updated_at": owner.updated_at.isoformat(), + } + + async def test_get_current_user_without_authentication(self, async_client: AsyncClient): + response = await async_client.get(self.url()) + + assert response.status_code == 401 From 6e4b910adb7f6d0eccb7fbafbe329edb5ca96806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 6 May 2024 16:40:55 +0200 Subject: [PATCH 02/12] chore: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04cdcff4..647a8713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ These are the section headers that we use: ## [Unreleased]() - Added `POST /api/v1/token` endpoint to generate a new API token for a user. ([#138](https://github.com/argilla-io/argilla-server/pull/138)) +- Added `GET /api/v1/me` endpoint to get the current user information. ([#140](https://github.com/argilla-io/argilla-server/pull/140)) ## [Unreleased]() From 06bd5201c684744550007583f1d325b44f733277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 6 May 2024 17:00:02 +0200 Subject: [PATCH 03/12] feat: add telemetry.track_login call --- src/argilla_server/apis/v1/handlers/users.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index daf0c1bb..5860631f 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -14,30 +14,34 @@ from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Security, status +from fastapi import APIRouter, Depends, HTTPException, Request, Security, status from sqlalchemy.ext.asyncio import AsyncSession +from argilla_server import models, telemetry from argilla_server.contexts import accounts from argilla_server.database import get_async_db from argilla_server.models import User from argilla_server.policies import UserPolicyV1, authorize -from argilla_server.schemas.v1.users import User as UserSchema +from argilla_server.schemas.v1.users import User from argilla_server.schemas.v1.workspaces import Workspaces from argilla_server.security import auth router = APIRouter(tags=["users"]) -@router.get("/me", response_model=UserSchema) -async def get_current_user(current_user: User = Security(auth.get_current_user)): - # TODO: Should we add telemetry.track_login? +@router.get("/me", response_model=User) +async def get_current_user(request: Request, current_user: models.User = Security(auth.get_current_user)): + await telemetry.track_login(request, current_user) return current_user @router.get("/users/{user_id}/workspaces", response_model=Workspaces) async def list_user_workspaces( - *, db: AsyncSession = Depends(get_async_db), user_id: UUID, current_user: User = Security(auth.get_current_user) + *, + db: AsyncSession = Depends(get_async_db), + user_id: UUID, + current_user: models.User = Security(auth.get_current_user), ): await authorize(current_user, UserPolicyV1.list_workspaces) From 4fb7e7361f11b8f9d86bcbf0243d2b7d0ad1c3c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 6 May 2024 18:04:27 +0200 Subject: [PATCH 04/12] feat: migrate /users endpoint to /api/v1/users --- src/argilla_server/apis/v1/handlers/users.py | 17 +++- src/argilla_server/contexts/accounts.py | 2 + src/argilla_server/policies.py | 4 + src/argilla_server/schemas/v1/users.py | 6 +- tests/unit/api/v1/users/test_list_users.py | 81 ++++++++++++++++++++ 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/unit/api/v1/users/test_list_users.py diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index 5860631f..94c79808 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import List from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request, Security, status @@ -20,9 +21,8 @@ from argilla_server import models, telemetry from argilla_server.contexts import accounts from argilla_server.database import get_async_db -from argilla_server.models import User from argilla_server.policies import UserPolicyV1, authorize -from argilla_server.schemas.v1.users import User +from argilla_server.schemas.v1.users import User, Users from argilla_server.schemas.v1.workspaces import Workspaces from argilla_server.security import auth @@ -36,6 +36,19 @@ async def get_current_user(request: Request, current_user: models.User = Securit return current_user +@router.get("/users", response_model=Users) +async def list_users( + *, + db: AsyncSession = Depends(get_async_db), + current_user: models.User = Security(auth.get_current_user), +): + await authorize(current_user, UserPolicyV1.list) + + users = await accounts.list_users(db) + + return Users(items=users) + + @router.get("/users/{user_id}/workspaces", response_model=Workspaces) async def list_user_workspaces( *, diff --git a/src/argilla_server/contexts/accounts.py b/src/argilla_server/contexts/accounts.py index b128f07b..7e0ef28b 100644 --- a/src/argilla_server/contexts/accounts.py +++ b/src/argilla_server/contexts/accounts.py @@ -110,6 +110,8 @@ async def get_user_by_api_key(db: AsyncSession, api_key: str) -> Union[User, Non async def list_users(db: "AsyncSession") -> Sequence[User]: + # TODO: After removing API v0 implementation we can remove the workspaces eager loading + # because is not used in the new API v1 endpoints. result = await db.execute(select(User).order_by(User.inserted_at.asc()).options(selectinload(User.workspaces))) return result.scalars().all() diff --git a/src/argilla_server/policies.py b/src/argilla_server/policies.py index 196ef159..97dbf183 100644 --- a/src/argilla_server/policies.py +++ b/src/argilla_server/policies.py @@ -129,6 +129,10 @@ async def is_allowed(actor: User) -> bool: class UserPolicyV1: + @classmethod + async def list(cls, actor: User) -> bool: + return actor.is_owner + @classmethod async def list_workspaces(cls, actor: User) -> bool: return actor.is_owner diff --git a/src/argilla_server/schemas/v1/users.py b/src/argilla_server/schemas/v1/users.py index f728d7c1..4ad4b133 100644 --- a/src/argilla_server/schemas/v1/users.py +++ b/src/argilla_server/schemas/v1/users.py @@ -13,7 +13,7 @@ # limitations under the License. from datetime import datetime -from typing import Optional +from typing import List, Optional from uuid import UUID from argilla_server.enums import UserRole @@ -32,3 +32,7 @@ class User(BaseModel): class Config: orm_mode = True + + +class Users(BaseModel): + items: List[User] diff --git a/tests/unit/api/v1/users/test_list_users.py b/tests/unit/api/v1/users/test_list_users.py new file mode 100644 index 00000000..43a09bfe --- /dev/null +++ b/tests/unit/api/v1/users/test_list_users.py @@ -0,0 +1,81 @@ +# 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. + +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 tests.factories import UserFactory + + +@pytest.mark.asyncio +class TestListUsers: + def url(self) -> str: + return "/api/v1/users" + + async def test_list_users(self, async_client: AsyncClient, owner: User, owner_auth_header: dict): + user_a, user_b = await UserFactory.create_batch(2) + + response = await async_client.get(self.url(), headers=owner_auth_header) + + assert response.status_code == 200 + assert response.json() == { + "items": [ + { + "id": str(owner.id), + "first_name": owner.first_name, + "last_name": owner.last_name, + "username": owner.username, + "role": owner.role, + "api_key": owner.api_key, + "inserted_at": owner.inserted_at.isoformat(), + "updated_at": owner.updated_at.isoformat(), + }, + { + "id": str(user_a.id), + "first_name": user_a.first_name, + "last_name": user_a.last_name, + "username": user_a.username, + "role": user_a.role, + "api_key": user_a.api_key, + "inserted_at": user_a.inserted_at.isoformat(), + "updated_at": user_a.updated_at.isoformat(), + }, + { + "id": str(user_b.id), + "first_name": user_b.first_name, + "last_name": user_b.last_name, + "username": user_b.username, + "role": user_b.role, + "api_key": user_b.api_key, + "inserted_at": user_b.inserted_at.isoformat(), + "updated_at": user_b.updated_at.isoformat(), + }, + ] + } + + @pytest.mark.parametrize("user_role", [UserRole.admin, UserRole.annotator]) + async def test_list_users_with_invalid_role(self, async_client: AsyncClient, user_role: UserRole): + user = await UserFactory.create(role=user_role) + + response = await async_client.get(self.url(), headers={API_KEY_HEADER_NAME: user.api_key}) + + assert response.status_code == 403 + + async def test_list_users_without_authentication(self, async_client: AsyncClient): + response = await async_client.get(self.url()) + + assert response.status_code == 401 From 045342099fe2cc7dce566587ca32f4b06541f841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 6 May 2024 18:16:16 +0200 Subject: [PATCH 05/12] chore: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 647a8713..96c7d4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ These are the section headers that we use: - Added `POST /api/v1/token` endpoint to generate a new API token for a user. ([#138](https://github.com/argilla-io/argilla-server/pull/138)) - 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)) ## [Unreleased]() From 06c32d1823d0c43e7bd702eadd67556e398f8dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 7 May 2024 13:27:17 +0200 Subject: [PATCH 06/12] feat: migrate POST /users endpoint to POST /api/v1/users --- src/argilla_server/apis/v0/handlers/users.py | 2 +- src/argilla_server/apis/v1/handlers/users.py | 26 +- src/argilla_server/contexts/accounts.py | 36 ++- src/argilla_server/policies.py | 4 + src/argilla_server/schemas/v1/users.py | 14 +- tests/unit/api/v0/test_users.py | 47 +++ tests/unit/api/v1/users/test_create_user.py | 305 +++++++++++++++++++ tests/unit/api/v1/users/test_list_users.py | 12 +- 8 files changed, 422 insertions(+), 24 deletions(-) create mode 100644 tests/unit/api/v1/users/test_create_user.py diff --git a/src/argilla_server/apis/v0/handlers/users.py b/src/argilla_server/apis/v0/handlers/users.py index 1fa3b45a..165523c9 100644 --- a/src/argilla_server/apis/v0/handlers/users.py +++ b/src/argilla_server/apis/v0/handlers/users.py @@ -95,7 +95,7 @@ async def create_user( raise EntityAlreadyExistsError(name=user_create.username, type=User) try: - user = await accounts.create_user(db, user_create) + user = await accounts.create_user(db, user_create.dict(), user_create.workspaces) telemetry.track_user_created(user) except Exception as e: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index 94c79808..b6ae591d 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -21,8 +21,9 @@ from argilla_server import models, telemetry from argilla_server.contexts import accounts from argilla_server.database import get_async_db +from argilla_server.errors import EntityAlreadyExistsError from argilla_server.policies import UserPolicyV1, authorize -from argilla_server.schemas.v1.users import User, Users +from argilla_server.schemas.v1.users import User, UserCreate, Users from argilla_server.schemas.v1.workspaces import Workspaces from argilla_server.security import auth @@ -49,6 +50,29 @@ async def list_users( return Users(items=users) +@router.post("/users", response_model=User) +async def create_user( + *, + db: AsyncSession = Depends(get_async_db), + user_create: UserCreate, + current_user: models.User = Security(auth.get_current_user), +): + await authorize(current_user, UserPolicyV1.create) + + user = await accounts.get_user_by_username(db, user_create.username) + if user is not None: + raise EntityAlreadyExistsError(name=user_create.username, type=User) + + try: + user = await accounts.create_user(db, user_create.dict()) + + telemetry.track_user_created(user) + except Exception as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) + + return user + + @router.get("/users/{user_id}/workspaces", response_model=Workspaces) async def list_user_workspaces( *, diff --git a/src/argilla_server/contexts/accounts.py b/src/argilla_server/contexts/accounts.py index 7e0ef28b..98c8dd22 100644 --- a/src/argilla_server/contexts/accounts.py +++ b/src/argilla_server/contexts/accounts.py @@ -121,23 +121,26 @@ async def list_users_by_ids(db: AsyncSession, ids: Iterable[UUID]) -> Sequence[U return result.scalars().all() -async def create_user(db: "AsyncSession", user_create: UserCreate) -> User: +# TODO: After removing API v0 implementation we can remove the workspaces attribute. +# With API v1 the workspaces will be created doing additional requests to other endpoints for it. +async def create_user(db: AsyncSession, user_attrs: dict, workspaces: Union[List[str], None] = None) -> User: async with db.begin_nested(): user = await User.create( db, - first_name=user_create.first_name, - last_name=user_create.last_name, - username=user_create.username, - role=user_create.role, - password_hash=hash_password(user_create.password), + first_name=user_attrs["first_name"], + last_name=user_attrs["last_name"], + username=user_attrs["username"], + role=user_attrs["role"], + password_hash=hash_password(user_attrs["password"]), autocommit=False, ) - if user_create.workspaces: - for workspace_name in user_create.workspaces: + if workspaces is not None: + for workspace_name in workspaces: workspace = await get_workspace_by_name(db, workspace_name) if not workspace: raise ValueError(f"Workspace '{workspace_name}' does not exist") + await WorkspaceUser.create( db, workspace_id=workspace.id, @@ -154,15 +157,18 @@ async def create_user_with_random_password( db, username: str, first_name: str, - workspaces: List[str] = None, role: UserRole = UserRole.annotator, + workspaces: Union[List[str], None] = None, ) -> User: - password = _generate_random_password() - - user_create = UserCreate( - first_name=first_name, username=username, role=role, password=password, workspaces=workspaces - ) - return await create_user(db, user_create) + user_attrs = { + "first_name": first_name, + "last_name": None, + "username": username, + "role": role, + "password": _generate_random_password(), + } + + return await create_user(db, user_attrs, workspaces) async def delete_user(db: AsyncSession, user: User) -> User: diff --git a/src/argilla_server/policies.py b/src/argilla_server/policies.py index 97dbf183..60561530 100644 --- a/src/argilla_server/policies.py +++ b/src/argilla_server/policies.py @@ -133,6 +133,10 @@ class UserPolicyV1: async def list(cls, actor: User) -> bool: return actor.is_owner + @classmethod + async def create(cls, actor: User) -> bool: + return actor.is_owner + @classmethod async def list_workspaces(cls, actor: User) -> bool: return actor.is_owner diff --git a/src/argilla_server/schemas/v1/users.py b/src/argilla_server/schemas/v1/users.py index 4ad4b133..b04b7be5 100644 --- a/src/argilla_server/schemas/v1/users.py +++ b/src/argilla_server/schemas/v1/users.py @@ -17,7 +17,11 @@ from uuid import UUID from argilla_server.enums import UserRole -from argilla_server.pydantic_v1 import BaseModel +from argilla_server.pydantic_v1 import BaseModel, Field, constr + +USER_USERNAME_REGEX = "^(?!-|_)[A-za-z0-9-_]+$" +USER_PASSWORD_MIN_LENGTH = 8 +USER_PASSWORD_MAX_LENGTH = 100 class User(BaseModel): @@ -34,5 +38,13 @@ class Config: orm_mode = True +class UserCreate(BaseModel): + first_name: constr(min_length=1, strip_whitespace=True) + last_name: Optional[constr(min_length=1, strip_whitespace=True)] + username: str = Field(regex=USER_USERNAME_REGEX, min_length=1) + role: Optional[UserRole] + password: str = Field(min_length=USER_PASSWORD_MIN_LENGTH, max_length=USER_PASSWORD_MAX_LENGTH) + + class Users(BaseModel): items: List[User] diff --git a/tests/unit/api/v0/test_users.py b/tests/unit/api/v0/test_users.py index 2c1bd6c6..5a196b3f 100644 --- a/tests/unit/api/v0/test_users.py +++ b/tests/unit/api/v0/test_users.py @@ -217,6 +217,53 @@ async def test_create_user_with_non_default_role( assert response_body["role"] == UserRole.owner.value +@pytest.mark.asyncio +async def test_create_user_with_first_name_including_leading_and_trailing_spaces( + async_client: "AsyncClient", db: "AsyncSession", owner_auth_header: dict +): + response = await async_client.post( + "/api/users", + headers=owner_auth_header, + json={ + "first_name": " First name ", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 200 + + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + user = (await db.execute(select(User).filter_by(username="username"))).scalar_one() + + assert response.json()["first_name"] == "First name" + assert user.first_name == "First name" + + +@pytest.mark.asyncio +async def test_create_user_with_last_name_including_leading_and_trailing_spaces( + async_client: "AsyncClient", db: "AsyncSession", owner_auth_header: dict +): + response = await async_client.post( + "/api/users", + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": " Last name ", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 200 + + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + user = (await db.execute(select(User).filter_by(username="username"))).scalar_one() + + assert response.json()["last_name"] == "Last name" + assert user.last_name == "Last name" + + @pytest.mark.asyncio async def test_create_user_without_authentication(async_client: "AsyncClient", db: "AsyncSession"): user = {"first_name": "first-name", "username": "username", "password": "12345678"} diff --git a/tests/unit/api/v1/users/test_create_user.py b/tests/unit/api/v1/users/test_create_user.py new file mode 100644 index 00000000..b3dfd805 --- /dev/null +++ b/tests/unit/api/v1/users/test_create_user.py @@ -0,0 +1,305 @@ +# 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 + +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 TestCreateUser: + def url(self) -> str: + return "/api/v1/users" + + async def test_create_user(self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 200 + + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + user = (await db.execute(select(User).filter_by(username="username"))).scalar_one() + + response_json = response.json() + assert response_json == { + "id": str(UUID(response_json["id"])), + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "role": UserRole.annotator, + "api_key": user.api_key, + "inserted_at": datetime.fromisoformat(response_json["inserted_at"]).isoformat(), + "updated_at": datetime.fromisoformat(response_json["updated_at"]).isoformat(), + } + + async def test_create_user_with_first_name_including_leading_and_trailing_spaces( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": " First name ", + "last_name": "Last name", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 200 + + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + user = (await db.execute(select(User).filter_by(username="username"))).scalar_one() + + assert response.json()["first_name"] == "First name" + assert user.first_name == "First name" + + async def test_create_user_with_last_name_including_leading_and_trailing_spaces( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": " Last name ", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 200 + + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + user = (await db.execute(select(User).filter_by(username="username"))).scalar_one() + + assert response.json()["last_name"] == "Last name" + assert user.last_name == "Last name" + + async def test_create_user_without_last_name( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 200 + + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + user = (await db.execute(select(User).filter_by(username="username"))).scalar_one() + + assert response.json()["last_name"] == None + assert user.last_name == None + + async def test_create_user_with_non_default_role( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "12345678", + "role": UserRole.owner, + }, + ) + + assert response.status_code == 200 + + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + user = (await db.execute(select(User).filter_by(username="username"))).scalar_one() + + assert response.json()["role"] == UserRole.owner + assert user.role == UserRole.owner + + async def test_create_user_without_authentication(self, db: AsyncSession, async_client: AsyncClient): + response = await async_client.post( + self.url(), + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 401 + assert (await db.execute(select(func.count(User.id)))).scalar() == 0 + + @pytest.mark.parametrize("user_role", [UserRole.admin, UserRole.annotator]) + async def test_create_user_with_unauthorized_role( + self, db: AsyncSession, async_client: AsyncClient, user_role: UserRole + ): + user = await UserFactory.create(role=user_role) + + response = await async_client.post( + self.url(), + headers={API_KEY_HEADER_NAME: user.api_key}, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 403 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + async def test_create_user_with_existent_username( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + await UserFactory.create(username="username") + + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 409 + assert (await db.execute(select(func.count(User.id)))).scalar() == 2 + + async def test_create_user_with_invalid_username( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "invalid username", + "password": "12345678", + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + async def test_create_user_with_invalid_min_length_first_name( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "", + "last_name": "Last name", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + async def test_create_user_with_invalid_min_length_last_name( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "", + "username": "username", + "password": "12345678", + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + async def test_create_user_with_invalid_min_length_password( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "1234", + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + async def test_create_user_with_invalid_max_length_password( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "p" * 101, + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 + + async def test_create_user_with_invalid_role( + self, db: AsyncSession, async_client: AsyncClient, owner_auth_header: dict + ): + response = await async_client.post( + self.url(), + headers=owner_auth_header, + json={ + "first_name": "First name", + "last_name": "Last name", + "username": "username", + "password": "12345678", + "role": "invalid role", + }, + ) + + assert response.status_code == 422 + assert (await db.execute(select(func.count(User.id)))).scalar() == 1 diff --git a/tests/unit/api/v1/users/test_list_users.py b/tests/unit/api/v1/users/test_list_users.py index 43a09bfe..358d710d 100644 --- a/tests/unit/api/v1/users/test_list_users.py +++ b/tests/unit/api/v1/users/test_list_users.py @@ -67,15 +67,15 @@ async def test_list_users(self, async_client: AsyncClient, owner: User, owner_au ] } + async def test_list_users_without_authentication(self, async_client: AsyncClient): + response = await async_client.get(self.url()) + + assert response.status_code == 401 + @pytest.mark.parametrize("user_role", [UserRole.admin, UserRole.annotator]) - async def test_list_users_with_invalid_role(self, async_client: AsyncClient, user_role: UserRole): + async def test_list_users_with_unauthorized_role(self, async_client: AsyncClient, user_role: UserRole): user = await UserFactory.create(role=user_role) response = await async_client.get(self.url(), headers={API_KEY_HEADER_NAME: user.api_key}) assert response.status_code == 403 - - async def test_list_users_without_authentication(self, async_client: AsyncClient): - response = await async_client.get(self.url()) - - assert response.status_code == 401 From f3bed16c84cef403c38931b415e6602798228a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 7 May 2024 13:37:35 +0200 Subject: [PATCH 07/12] chore: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c7d4bd..11b6aa81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ These are the section headers that we use: - Added `POST /api/v1/token` endpoint to generate a new API token for a user. ([#138](https://github.com/argilla-io/argilla-server/pull/138)) - 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)) ## [Unreleased]() From 5ee6b3160028ce620011c19139d2ee0a0eda2edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 7 May 2024 15:59:55 +0200 Subject: [PATCH 08/12] feat: migrate DELETE /users/:user_id to DELETE /api/v1/users/:user_id --- src/argilla_server/apis/v1/handlers/users.py | 24 +++++- src/argilla_server/policies.py | 4 + tests/unit/api/v1/users/test_delete_user.py | 79 ++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/unit/api/v1/users/test_delete_user.py diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index b6ae591d..c02231f2 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -21,7 +21,7 @@ from argilla_server import models, telemetry from argilla_server.contexts import accounts from argilla_server.database import get_async_db -from argilla_server.errors import EntityAlreadyExistsError +from argilla_server.errors import EntityAlreadyExistsError, EntityNotFoundError from argilla_server.policies import UserPolicyV1, authorize from argilla_server.schemas.v1.users import User, UserCreate, Users from argilla_server.schemas.v1.workspaces import Workspaces @@ -73,6 +73,28 @@ 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: + # TODO: Forcing here user_id to be an string. + # Not casting it is causing a `Object of type UUID is not JSON serializable`. + # Possible solution redefining JSONEncoder.default here: + # https://github.com/jazzband/django-push-notifications/issues/586 + raise EntityNotFoundError(name=str(user_id), type=User) + + 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..e1731691 --- /dev/null +++ b/tests/unit/api/v1/users/test_delete_user.py @@ -0,0 +1,79 @@ +# 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): + response = await async_client.delete(self.url(uuid4()), headers=owner_auth_header) + + assert response.status_code == 404 From de515283ff7305ca613df5c6d44b0e0aae58bd4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Tue, 7 May 2024 16:07:58 +0200 Subject: [PATCH 09/12] chore: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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]() From 8544a1c0c13fbf978651a79b089a5acb50a66097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Fri, 10 May 2024 12:42:33 +0200 Subject: [PATCH 10/12] feat: add new API v1 endpoint to get a user using a user id --- src/argilla_server/apis/v1/handlers/users.py | 19 ++++++ src/argilla_server/policies.py | 4 ++ src/argilla_server/schemas/v1/users.py | 2 + tests/unit/api/v1/users/test_get_user.py | 71 ++++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 tests/unit/api/v1/users/test_get_user.py diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index c02231f2..c57901b1 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -37,6 +37,25 @@ async def get_current_user(request: Request, current_user: models.User = Securit return current_user +@router.get("/users/{user_id}", response_model=User) +async def get_user( + *, + db: AsyncSession = Depends(get_async_db), + user_id: UUID, + current_user: models.User = Security(auth.get_current_user), +): + await authorize(current_user, UserPolicyV1.get) + + 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", + ) + + return user + + @router.get("/users", response_model=Users) async def list_users( *, diff --git a/src/argilla_server/policies.py b/src/argilla_server/policies.py index 49164288..142f6c55 100644 --- a/src/argilla_server/policies.py +++ b/src/argilla_server/policies.py @@ -129,6 +129,10 @@ async def is_allowed(actor: User) -> bool: class UserPolicyV1: + @classmethod + async def get(cls, actor: User) -> bool: + return actor.is_owner + @classmethod async def list(cls, actor: User) -> bool: return actor.is_owner diff --git a/src/argilla_server/schemas/v1/users.py b/src/argilla_server/schemas/v1/users.py index b04b7be5..1b93d7f6 100644 --- a/src/argilla_server/schemas/v1/users.py +++ b/src/argilla_server/schemas/v1/users.py @@ -30,6 +30,8 @@ class User(BaseModel): last_name: Optional[str] username: str role: UserRole + # TODO: We need to move `api_key` outside of this schema and think about a more + # secure way to expose it, along with ways to expire it and create new API keys. api_key: str inserted_at: datetime updated_at: datetime diff --git a/tests/unit/api/v1/users/test_get_user.py b/tests/unit/api/v1/users/test_get_user.py new file mode 100644 index 00000000..da509891 --- /dev/null +++ b/tests/unit/api/v1/users/test_get_user.py @@ -0,0 +1,71 @@ +# 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 uuid import UUID, uuid4 + +import pytest +from argilla_server.constants import API_KEY_HEADER_NAME +from argilla_server.enums import UserRole +from httpx import AsyncClient + +from tests.factories import UserFactory + + +@pytest.mark.asyncio +class TestGetUser: + def url(self, user_id: UUID) -> str: + return f"/api/v1/users/{user_id}" + + async def test_get_user(self, async_client: AsyncClient, owner_auth_header: dict): + user = await UserFactory.create() + + response = await async_client.get(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": UserRole.annotator, + "api_key": user.api_key, + "inserted_at": user.inserted_at.isoformat(), + "updated_at": user.updated_at.isoformat(), + } + + async def test_get_user_without_authentication(self, async_client: AsyncClient): + user = await UserFactory.create() + + response = await async_client.get(self.url(user.id)) + + assert response.status_code == 401 + + @pytest.mark.parametrize("user_role", [UserRole.admin, UserRole.annotator]) + async def test_get_user_with_unauthorized_role(self, async_client: AsyncClient, user_role: UserRole): + user = await UserFactory.create(role=user_role) + + response = await async_client.get( + self.url(user.id), + headers={API_KEY_HEADER_NAME: user.api_key}, + ) + + assert response.status_code == 403 + + async def test_get_user_with_nonexistent_user_id(self, async_client: AsyncClient, owner_auth_header: dict): + user_id = uuid4() + + response = await async_client.get(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"} From 79367aeb5cdb45083216e94e5960614457f75a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Fri, 10 May 2024 12:48:41 +0200 Subject: [PATCH 11/12] chore: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17bd1a42..6f7d46f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ These are the section headers that we use: - 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)) +- Added `GET /api/v1/users/:user_id` endpoint to get a specific user. ([#166](https://github.com/argilla-io/argilla-server/pull/166)) ## [Unreleased]() From 9fa09e5b39643399e7cb822b3eb4b084507ea013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Francisco=20Calvo?= Date: Mon, 13 May 2024 15:08:11 +0200 Subject: [PATCH 12/12] feat: remove unused imports --- src/argilla_server/apis/v1/handlers/users.py | 1 - tests/unit/api/v1/users/test_create_user.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/argilla_server/apis/v1/handlers/users.py b/src/argilla_server/apis/v1/handlers/users.py index 81b342b4..23c53b52 100644 --- a/src/argilla_server/apis/v1/handlers/users.py +++ b/src/argilla_server/apis/v1/handlers/users.py @@ -21,7 +21,6 @@ from argilla_server import models, telemetry from argilla_server.contexts import accounts from argilla_server.database import get_async_db -from argilla_server.errors import EntityAlreadyExistsError, EntityNotFoundError from argilla_server.errors.future import NotUniqueError from argilla_server.policies import UserPolicyV1, authorize from argilla_server.schemas.v1.users import User, UserCreate, Users diff --git a/tests/unit/api/v1/users/test_create_user.py b/tests/unit/api/v1/users/test_create_user.py index 9fee2f1d..62a32d95 100644 --- a/tests/unit/api/v1/users/test_create_user.py +++ b/tests/unit/api/v1/users/test_create_user.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from datetime import datetime from uuid import UUID import pytest