diff --git a/src/conode/application/manage_profile/__init__.py b/src/conode/application/manage_profile/__init__.py index de96a01..a5e1c45 100644 --- a/src/conode/application/manage_profile/__init__.py +++ b/src/conode/application/manage_profile/__init__.py @@ -2,8 +2,14 @@ UpdateCurrentUserProfileInteractor, UpdateCurrentUserProfileRequestDTO, ) +from .update_user_profile import ( + UpdateUserProfileInteractor, + UpdateUserProfileRequestDTO, +) __all__ = ( "UpdateCurrentUserProfileInteractor", "UpdateCurrentUserProfileRequestDTO", + "UpdateUserProfileInteractor", + "UpdateUserProfileRequestDTO", ) diff --git a/src/conode/application/manage_profile/update_current_user_profile.py b/src/conode/application/manage_profile/update_current_user_profile.py index 9bec12b..ec8a3a8 100644 --- a/src/conode/application/manage_profile/update_current_user_profile.py +++ b/src/conode/application/manage_profile/update_current_user_profile.py @@ -29,3 +29,5 @@ async def execute(self, request: UpdateCurrentUserProfileRequestDTO) -> None: username=request.username, bio=request.bio, ) + + await self.user_repository.update(user) diff --git a/src/conode/application/manage_profile/update_user_profile.py b/src/conode/application/manage_profile/update_user_profile.py new file mode 100644 index 0000000..79c6a13 --- /dev/null +++ b/src/conode/application/manage_profile/update_user_profile.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass + +from conode.application.interfaces.repositories import UserRepository +from conode.application.interfaces.transaction_manager import TransactionManager +from conode.application.services.access_control import AccessControlService +from conode.domain.user import UserId + + +@dataclass(frozen=True, slots=True, kw_only=True) +class UpdateUserProfileRequestDTO: + first_name: str + last_name: str + username: str + bio: str + + +@dataclass +class UpdateUserProfileInteractor: + access_control_service: AccessControlService + transaction_manager: TransactionManager + user_repository: UserRepository + + async def execute( + self, user_id: UserId, request: UpdateUserProfileRequestDTO + ) -> None: + async with self.transaction_manager: + user = await self.access_control_service.get_authorized_user() + + if user.id == user_id: + target = user + else: + self.access_control_service.ensure_user_can_manipulate_user_profiles( + user + ) + target = await self.user_repository.get_by_id(user_id) + + target.update_profile( + username=request.username, + first_name=request.first_name, + last_name=request.last_name, + bio=request.bio, + ) + + await self.user_repository.update(target) diff --git a/src/conode/application/services/access_control.py b/src/conode/application/services/access_control.py index d2d31b4..0d18862 100644 --- a/src/conode/application/services/access_control.py +++ b/src/conode/application/services/access_control.py @@ -24,7 +24,7 @@ RolePermission, RolePermissionEntityId, ) -from conode.domain.user import Email, User, UserId +from conode.domain.user import Email, User, UserId, UserSystemRole type RolesPermissions = list[RolePermission] @@ -289,3 +289,7 @@ async def ensure_user_can_manipulate_groups( raise NotEnoughRightsError( "Not enough rights to perform operation", None ) + + def ensure_user_can_manipulate_user_profiles(self, user: User) -> None: + if user.system_role != UserSystemRole.ADMIN: + raise NotEnoughRightsError("Not enough rights to perform operation", None) diff --git a/src/conode/bootstrap/di/providers/application.py b/src/conode/bootstrap/di/providers/application.py index 36f4816..46ba0f6 100644 --- a/src/conode/bootstrap/di/providers/application.py +++ b/src/conode/bootstrap/di/providers/application.py @@ -13,7 +13,10 @@ from conode.application.manage_edge import CreateEdgeInteractor, DeleteEdgeInteractor from conode.application.manage_group import CreateGroupInteractor, DeleteGroupInteractor from conode.application.manage_node import CreateNodeInteractor, DeleteNodeInteractor -from conode.application.manage_profile import UpdateCurrentUserProfileInteractor +from conode.application.manage_profile import ( + UpdateCurrentUserProfileInteractor, + UpdateUserProfileInteractor, +) from conode.application.manage_role import ( CreateRoleInteractor, DeleteRoleInteractor, @@ -63,41 +66,42 @@ class ApplicationProvider(Provider): provides = provide_all( - UpdateNodeInteractor, - AccessControlService, - OfferAcceptanceService, - OfferSendingService, - DeleteEdgeInteractor, - UpdateEdgeWeightInteractor, - IncrementEdgeWeightInteractor, - GetGroupByIdInteractor, - GetContextByIdInteractor, UpdateCurrentUserProfileInteractor, - SendOfferToCompanyInteractor, + IncrementEdgeWeightInteractor, DecrementEdgeWeightInteractor, + SendOfferToCompanyInteractor, + RevokeRoleFromUserInteractor, + UpdateUserProfileInteractor, + GetNodeNeighboursInteractor, GetUserByUsernameInteractor, + UpdateEdgeWeightInteractor, FindShortestPathInteractor, GetNodesByGroupInteractor, RegisterCompanyInteractor, GetCurrentUserInteractor, - CreateContextInteractor, + GetContextByIdInteractor, + GiveRoleToUserInteractor, VerifyCompanyInteractor, DeleteContextInteractor, + CreateContextInteractor, + OfferAcceptanceService, + GetGroupByIdInteractor, + DeclineOfferInteractor, + AcceptOfferInteractor, CreateGroupInteractor, DeleteGroupInteractor, + UpdateNodeInteractor, + AccessControlService, + DeleteEdgeInteractor, CreateNodeInteractor, DeleteRoleInteractor, RoleManagmentService, UpdateRoleInteractor, DeleteNodeInteractor, - AcceptOfferInteractor, - DeclineOfferInteractor, DetachNodeInteractor, - GetNodeNeighboursInteractor, CreateRoleInteractor, AttachNodeInteractor, CreateEdgeInteractor, - GiveRoleToUserInteractor, - RevokeRoleFromUserInteractor, + OfferSendingService, scope=Scope.REQUEST, ) diff --git a/src/conode/presentation/schemas/user.py b/src/conode/presentation/schemas/user.py index 4590124..51cc019 100644 --- a/src/conode/presentation/schemas/user.py +++ b/src/conode/presentation/schemas/user.py @@ -37,7 +37,31 @@ class UpdateCurrentUserProfileRequest(BaseModel): max_length=MAX_ALLOWED_LAST_NAME_LENGTH, ), ] - password: Annotated[str, Field(min_length=7, max_length=100)] + bio: Annotated[str, Field(max_length=MAX_ALLOWED_BIO_LENGTH)] + + +class UpdateUserProfileRequest(BaseModel): + username: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_USERNAME_LENGTH, + max_length=MAX_ALLOWED_USERNAME_LENGTH, + ), + ] + first_name: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_FIRST_NAME_LENGTH, + max_length=MAX_ALLOWED_FIRST_NAME_LENGTH, + ), + ] + last_name: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_LAST_NAME_LENGTH, + max_length=MAX_ALLOWED_LAST_NAME_LENGTH, + ), + ] bio: Annotated[str, Field(max_length=MAX_ALLOWED_BIO_LENGTH)] diff --git a/src/conode/presentation/views/user.py b/src/conode/presentation/views/user.py index 4ea6216..c674088 100644 --- a/src/conode/presentation/views/user.py +++ b/src/conode/presentation/views/user.py @@ -6,6 +6,8 @@ from conode.application.manage_profile import ( UpdateCurrentUserProfileInteractor, UpdateCurrentUserProfileRequestDTO, + UpdateUserProfileInteractor, + UpdateUserProfileRequestDTO, ) from conode.application.manage_user_rights import ( GiveRoleToUserInteractor, @@ -19,6 +21,7 @@ from conode.domain.user import UserId from conode.presentation.schemas.user import ( UpdateCurrentUserProfileRequest, + UpdateUserProfileRequest, UserSchema, ) @@ -40,6 +43,23 @@ async def update_current_user_profile( ) +@router.put("/me/profile/{user_id}", status_code=HTTPStatus.NO_CONTENT) +async def update_user_profile( + user_id: UserId, + request: UpdateUserProfileRequest, + interactor: FromDishka[UpdateUserProfileInteractor], +) -> None: + await interactor.execute( + user_id, + UpdateUserProfileRequestDTO( + username=request.username, + first_name=request.first_name, + last_name=request.last_name, + bio=request.bio, + ), + ) + + @router.get("/me/profile") async def get_current_user_profile( interactor: FromDishka[GetCurrentUserInteractor], diff --git a/tests/e2e/user/test_user_profile.py b/tests/e2e/user/test_user_profile.py new file mode 100644 index 0000000..faaf245 --- /dev/null +++ b/tests/e2e/user/test_user_profile.py @@ -0,0 +1,94 @@ +from http import HTTPStatus + +import pytest +from dirty_equals import IsPartialDataclass, IsPartialDict +from httpx import AsyncClient + +from conode.application.interfaces.repositories import UserRepository +from conode.domain.user import Bio, FirstName, LastName, Username +from tests.factories.common import authorization_headers +from tests.factories.models import UserFactory +from tests.factories.schemas import ( + UpdateUserProfileRequestFactory, +) + + +@pytest.mark.asyncio +async def test_update_foreign_user_profile_ok( + transport: AsyncClient, + user_factory: UserFactory, + user_repository: UserRepository, +) -> None: + users = (await user_factory.build(admin=True), await user_factory.build()) + + request = UpdateUserProfileRequestFactory.build() + + response = await transport.put( + f"/users/me/profile/{users[1].user.id}", + json=request.model_dump(mode="json"), + headers=authorization_headers(users[0].access_token), + ) + result = await user_repository.get_by_id(users[1].user.id) + + assert response.status_code == HTTPStatus.NO_CONTENT + assert result == IsPartialDataclass( + first_name=FirstName(request.first_name), + last_name=LastName(request.last_name), + username=Username(request.username), + bio=Bio(request.bio), + ) + + +@pytest.mark.asyncio +async def test_update_self_user_profile_ok( + transport: AsyncClient, + user_factory: UserFactory, + user_repository: UserRepository, +) -> None: + user = await user_factory.build() + + request = UpdateUserProfileRequestFactory.build() + + response = await transport.put( + f"/users/me/profile/{user.user.id}", + json=request.model_dump(mode="json"), + headers=authorization_headers(user.access_token), + ) + result = await user_repository.get_by_id(user.user.id) + + assert response.status_code == HTTPStatus.NO_CONTENT + assert result == IsPartialDataclass( + first_name=FirstName(request.first_name), + last_name=LastName(request.last_name), + username=Username(request.username), + bio=Bio(request.bio), + ) + + +@pytest.mark.asyncio +async def test_update_foreign_user_profile_without_correct_rights( + transport: AsyncClient, + user_factory: UserFactory, + user_repository: UserRepository, +) -> None: + users = (await user_factory.build(), await user_factory.build()) + + request = UpdateUserProfileRequestFactory.build() + + response = await transport.put( + f"/users/me/profile/{users[1].user.id}", + json=request.model_dump(mode="json"), + headers=authorization_headers(users[0].access_token), + ) + result = await user_repository.get_by_id(users[1].user.id) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.json() == IsPartialDict( + detail="Not enough rights to perform operation", meta=None + ) + assert result == IsPartialDataclass( + first_name=users[1].user.first_name, + last_name=users[1].user.last_name, + username=users[1].user.username, + bio=users[1].user.bio, + ) diff --git a/tests/factories/schemas/__init__.py b/tests/factories/schemas/__init__.py index aa4078b..1f1a812 100644 --- a/tests/factories/schemas/__init__.py +++ b/tests/factories/schemas/__init__.py @@ -9,6 +9,7 @@ ) from .user import ( UpdateCurrentUserProfileRequestFactory, + UpdateUserProfileRequestFactory, ) __all__ = ( @@ -20,4 +21,5 @@ "RegisterCompanyRequestFactory", "UpdateCurrentUserProfileRequestFactory", "UpdateNodeRequestFactory", + "UpdateUserProfileRequestFactory", ) diff --git a/tests/factories/schemas/user.py b/tests/factories/schemas/user.py index 4f95e88..176e506 100644 --- a/tests/factories/schemas/user.py +++ b/tests/factories/schemas/user.py @@ -2,9 +2,13 @@ from conode.presentation.schemas.user import ( UpdateCurrentUserProfileRequest, + UpdateUserProfileRequest, ) class UpdateCurrentUserProfileRequestFactory( ModelFactory[UpdateCurrentUserProfileRequest] ): ... + + +class UpdateUserProfileRequestFactory(ModelFactory[UpdateUserProfileRequest]): ...