diff --git a/src/conode/application/interfaces/repositories/role.py b/src/conode/application/interfaces/repositories/role.py index ffe2111..9d8a2fe 100644 --- a/src/conode/application/interfaces/repositories/role.py +++ b/src/conode/application/interfaces/repositories/role.py @@ -2,6 +2,7 @@ from conode.domain.company import CompanyId from conode.domain.role import Role, RoleId, RoleName +from conode.domain.user import UserId class RoleRepository(Protocol): @@ -10,6 +11,7 @@ async def delete(self, role: Role) -> None: ... async def create(self, role: Role) -> None: ... async def get_by_id(self, role_id: RoleId) -> Role: ... async def get_all_by_ids(self, roles_ids: list[RoleId]) -> list[Role]: ... + async def get_all_by_user_id(self, user_id: UserId) -> list[Role]: ... async def get_by_name_and_company_id( self, name: RoleName, company_id: CompanyId ) -> Role | None: ... diff --git a/src/conode/application/manage_company/__init__.py b/src/conode/application/manage_company/__init__.py new file mode 100644 index 0000000..5c2a208 --- /dev/null +++ b/src/conode/application/manage_company/__init__.py @@ -0,0 +1,3 @@ +from .update_company import UpdateCompanyInteractor, UpdateCompanyRequestDTO + +__all__ = ("UpdateCompanyInteractor", "UpdateCompanyRequestDTO") diff --git a/src/conode/application/manage_company/update_company.py b/src/conode/application/manage_company/update_company.py new file mode 100644 index 0000000..8b0d31b --- /dev/null +++ b/src/conode/application/manage_company/update_company.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass + +from conode.application.interfaces.repositories import CompanyRepository +from conode.application.interfaces.transaction_manager import TransactionManager +from conode.application.services import AccessControlService +from conode.domain.company import CompanyId + + +@dataclass(slots=True, kw_only=True, frozen=True) +class UpdateCompanyRequestDTO: + description: str + name: str + + +@dataclass +class UpdateCompanyInteractor: + transaction_manager: TransactionManager + access_control_service: AccessControlService + company_repository: CompanyRepository + + async def execute( + self, company_id: CompanyId, request: UpdateCompanyRequestDTO + ) -> None: + async with self.transaction_manager: + user = await self.access_control_service.get_authorized_user() + + company = await self.company_repository.get_by_id(company_id) + + await self.access_control_service.ensure_user_can_manipulate_company( + user, company + ) + + company.update( + name=request.name, + description=request.description, + ) + + await self.company_repository.update(company) diff --git a/src/conode/application/services/access_control.py b/src/conode/application/services/access_control.py index 3d57e5d..e3f2c22 100644 --- a/src/conode/application/services/access_control.py +++ b/src/conode/application/services/access_control.py @@ -18,6 +18,7 @@ from conode.domain.context import Context from conode.domain.group import Group from conode.domain.role import ( + OWNER_COMPANY_ROLE_NAME, EntityType, PermissionType, Role, @@ -277,6 +278,20 @@ async def ensure_user_can_manipulate_groups( company=company, ) + async def ensure_user_can_manipulate_company( + self, user: User, company: Company + ) -> None: + roles = await self.role_repository.get_all_by_user_id(user.id) + + for role in roles: + if ( + role.name == OWNER_COMPANY_ROLE_NAME + and role.owner_company_id == company.id + ): + return + + raise NotEnoughRightsError("Not enough rights to perform operation", None) + def _ensure_user_admin(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 46ba0f6..06d7721 100644 --- a/src/conode/bootstrap/di/providers/application.py +++ b/src/conode/bootstrap/di/providers/application.py @@ -6,6 +6,9 @@ from conode.application.detach_node_from_group import ( DetachNodeInteractor, ) +from conode.application.manage_company import ( + UpdateCompanyInteractor, +) from conode.application.manage_context import ( CreateContextInteractor, DeleteContextInteractor, @@ -81,6 +84,7 @@ class ApplicationProvider(Provider): GetCurrentUserInteractor, GetContextByIdInteractor, GiveRoleToUserInteractor, + UpdateCompanyInteractor, VerifyCompanyInteractor, DeleteContextInteractor, CreateContextInteractor, diff --git a/src/conode/domain/company/model.py b/src/conode/domain/company/model.py index 57306fb..18f1ca4 100644 --- a/src/conode/domain/company/model.py +++ b/src/conode/domain/company/model.py @@ -80,6 +80,9 @@ def new( updated_at=now, ) + def update(self, *, name: str, description: str) -> None: + self.name = CompanyName(name) + self.description = CompanyDescription(description) + def verify(self) -> None: self.verified = True - self.touch() diff --git a/src/conode/domain/role/__init__.py b/src/conode/domain/role/__init__.py index 288d145..742bd46 100644 --- a/src/conode/domain/role/__init__.py +++ b/src/conode/domain/role/__init__.py @@ -1,4 +1,5 @@ from .model import ( + OWNER_COMPANY_ROLE_NAME, EntityType, PermissionType, Role, @@ -10,6 +11,7 @@ ) __all__ = ( + "OWNER_COMPANY_ROLE_NAME", "EntityType", "PermissionType", "Role", diff --git a/src/conode/domain/role/errors.py b/src/conode/domain/role/errors.py index 398935e..ac47df3 100644 --- a/src/conode/domain/role/errors.py +++ b/src/conode/domain/role/errors.py @@ -5,3 +5,6 @@ class RoleDomainValidationError(ApplicationError): ... class InvalidRoleNameFormatError(RoleDomainValidationError): ... + + +class CannotCreateRoleWithThisNameError(RoleDomainValidationError): ... diff --git a/src/conode/domain/role/model.py b/src/conode/domain/role/model.py index 7305c3d..f3813ee 100644 --- a/src/conode/domain/role/model.py +++ b/src/conode/domain/role/model.py @@ -7,7 +7,10 @@ from conode.domain.company import Company, CompanyId from conode.domain.context import ContextId from conode.domain.group import GroupId -from conode.domain.role.errors import InvalidRoleNameFormatError +from conode.domain.role.errors import ( + CannotCreateRoleWithThisNameError, + InvalidRoleNameFormatError, +) from conode.domain.shared import Entity, ValueObject RoleId = NewType("RoleId", UUID) @@ -15,6 +18,7 @@ MIN_ALLOWED_ROLE_NAME_LENGTH: Final = 1 MAX_ALLOWED_ROLE_NAME_LENGTH: Final = 50 +OWNER_COMPANY_ROLE_NAME: Final = "owner" type RolePermissionEntityId = ContextId | CompanyId | GroupId @@ -62,6 +66,11 @@ def new(cls, role_id: RoleId, name: str, company: Company) -> Self: ) def change_name(self, name: str) -> None: + if name == OWNER_COMPANY_ROLE_NAME: + raise CannotCreateRoleWithThisNameError( + "Cannot create role with this name", + [{"key": "name", "value": name}], + ) self.name = RoleName(name) self.touch() diff --git a/src/conode/infrastructure/repositories/role.py b/src/conode/infrastructure/repositories/role.py index 8efb168..1a7524f 100644 --- a/src/conode/infrastructure/repositories/role.py +++ b/src/conode/infrastructure/repositories/role.py @@ -7,8 +7,9 @@ from conode.application.errors import RoleNotFoundError from conode.application.interfaces.repositories import RoleRepository from conode.domain.company import CompanyId -from conode.domain.role import Role -from conode.domain.role.model import RoleId, RoleName +from conode.domain.grant import UserGrant +from conode.domain.role import Role, RoleId, RoleName +from conode.domain.user import UserId logger = structlog.get_logger() @@ -46,6 +47,22 @@ async def get_all_by_ids(self, roles_ids: list[RoleId]) -> list[Role]: return roles + async def get_all_by_user_id(self, user_id: UserId) -> list[Role]: + logger.debug("Repository get roles by user id", user_id=user_id) + result = await self.session.execute( + select(Role) + .join(UserGrant, UserGrant.role_id == Role.id) # type: ignore + .where(UserGrant.user_id == user_id) # type: ignore + ) + + result_roles = list(result.scalars().all()) + logger.debug( + "Repository fetched roles by user id", + found_count=len(result_roles), + ) + + return result_roles + async def update(self, role: Role) -> None: logger.debug("Repository update role", role_id=role.id) await self.session.execute( diff --git a/src/conode/presentation/schemas/company.py b/src/conode/presentation/schemas/company.py index 5470060..ee6fb03 100644 --- a/src/conode/presentation/schemas/company.py +++ b/src/conode/presentation/schemas/company.py @@ -35,3 +35,20 @@ class RegisterCompanyRequest(BaseModel): max_length=MAX_ALLOWED_COMPANY_DESCRIPTION_LENGTH, ), ] + + +class UpdateCompanyRequest(BaseModel): + name: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_COMPANY_NAME_LENGTH, + max_length=MAX_ALLOWED_COMPANY_NAME_LENGTH, + ), + ] + description: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_COMPANY_DESCRIPTION_LENGTH, + max_length=MAX_ALLOWED_COMPANY_DESCRIPTION_LENGTH, + ), + ] diff --git a/src/conode/presentation/views/company.py b/src/conode/presentation/views/company.py index d2194b0..cf0b4cd 100644 --- a/src/conode/presentation/views/company.py +++ b/src/conode/presentation/views/company.py @@ -3,13 +3,21 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter +from conode.application.manage_company import ( + UpdateCompanyInteractor, + UpdateCompanyRequestDTO, +) from conode.application.register_company import ( RegisterCompanyInteractor, RegisterCompanyRequestDTO, ) from conode.application.verify_company import VerifyCompanyInteractor from conode.domain.company import CompanyId -from conode.presentation.schemas.company import CompanySchema, RegisterCompanyRequest +from conode.presentation.schemas.company import ( + CompanySchema, + RegisterCompanyRequest, + UpdateCompanyRequest, +) router = APIRouter(tags=["companies"], prefix="/companies", route_class=DishkaRoute) @@ -35,6 +43,18 @@ async def register_company( ) +@router.put("/{company_id}", status_code=HTTPStatus.NO_CONTENT) +async def update_company( + company_id: CompanyId, + request: UpdateCompanyRequest, + interactor: FromDishka[UpdateCompanyInteractor], +) -> None: + await interactor.execute( + company_id, + UpdateCompanyRequestDTO(name=request.name, description=request.description), + ) + + @router.patch("/{company_id}/verify", status_code=HTTPStatus.NO_CONTENT) async def verify_company( company_id: CompanyId, diff --git a/tests/e2e/company/test_update_company.py b/tests/e2e/company/test_update_company.py new file mode 100644 index 0000000..b1dfb37 --- /dev/null +++ b/tests/e2e/company/test_update_company.py @@ -0,0 +1,44 @@ +from http import HTTPStatus + +import pytest +from httpx import AsyncClient + +from tests.factories.common import authorization_headers +from tests.factories.models import CompanyFactory, UserFactory +from tests.factories.schemas import UpdateCompanyRequestFactory + + +@pytest.mark.asyncio +async def test_update_company_ok( + transport: AsyncClient, user_factory: UserFactory, company_factory: CompanyFactory +) -> None: + user = await user_factory.build() + company = await company_factory.build(owner=user.user) + + request = UpdateCompanyRequestFactory.build() + + response = await transport.put( + f"/companies/{company.id}", + json=request.model_dump(), + headers=authorization_headers(user.access_token), + ) + + assert response.status_code == HTTPStatus.NO_CONTENT + + +@pytest.mark.asyncio +async def test_update_company_without_corrent_rights( + transport: AsyncClient, user_factory: UserFactory, company_factory: CompanyFactory +) -> None: + users = (await user_factory.build(), await user_factory.build()) + company = await company_factory.build(owner=users[0].user) + + request = UpdateCompanyRequestFactory.build() + + response = await transport.put( + f"/companies/{company.id}", + json=request.model_dump(), + headers=authorization_headers(users[1].access_token), + ) + + assert response.status_code == HTTPStatus.FORBIDDEN diff --git a/tests/factories/schemas/__init__.py b/tests/factories/schemas/__init__.py index 1f1a812..f47886c 100644 --- a/tests/factories/schemas/__init__.py +++ b/tests/factories/schemas/__init__.py @@ -1,4 +1,4 @@ -from .company import RegisterCompanyRequestFactory +from .company import RegisterCompanyRequestFactory, UpdateCompanyRequestFactory from .context import CreateContextRequestFactory from .edge import CreateEdgeRequestFactory from .group import CreateGroupRequestFactory @@ -19,6 +19,7 @@ "CreateGroupRequestFactory", "CreateNodeRequestFactory", "RegisterCompanyRequestFactory", + "UpdateCompanyRequestFactory", "UpdateCurrentUserProfileRequestFactory", "UpdateNodeRequestFactory", "UpdateUserProfileRequestFactory", diff --git a/tests/factories/schemas/company.py b/tests/factories/schemas/company.py index 269d2d6..0d2e7d1 100644 --- a/tests/factories/schemas/company.py +++ b/tests/factories/schemas/company.py @@ -1,7 +1,14 @@ from polyfactory.factories.pydantic_factory import ModelFactory -from conode.presentation.schemas.company import RegisterCompanyRequest +from conode.presentation.schemas.company import ( + RegisterCompanyRequest, + UpdateCompanyRequest, +) class RegisterCompanyRequestFactory(ModelFactory[RegisterCompanyRequest]): __use_examples__ = True + + +class UpdateCompanyRequestFactory(ModelFactory[UpdateCompanyRequest]): + __use_examples__ = True