From 64e80b992f26d88a70d532a26ca392430a097f05 Mon Sep 17 00:00:00 2001 From: Damirkhon Aloev Date: Tue, 18 Jun 2024 20:02:49 +0500 Subject: [PATCH 1/5] M2-7040:api for enabling and disabling integration --- src/apps/integrations/api/__init__.py | 1 + src/apps/integrations/api/integrations.py | 29 ++++++++++ src/apps/integrations/domain.py | 11 ++++ src/apps/integrations/errors.py | 7 +++ src/apps/integrations/router.py | 30 +++++++++++ src/apps/integrations/service/__init__.py | 1 + src/apps/integrations/service/integrations.py | 31 +++++++++++ src/apps/integrations/tests/__init__.py | 0 src/apps/integrations/tests/test_domain.py | 19 +++++++ src/apps/integrations/tests/test_routes.py | 53 +++++++++++++++++++ .../workspaces/db/schemas/user_workspace.py | 5 +- src/apps/workspaces/domain/workspace.py | 5 +- src/apps/workspaces/service/user_access.py | 20 ++++++- src/infrastructure/app.py | 2 + ...5_46-change_integrations_field_type_in_.py | 39 ++++++++++++++ 15 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 src/apps/integrations/api/__init__.py create mode 100644 src/apps/integrations/api/integrations.py create mode 100644 src/apps/integrations/domain.py create mode 100644 src/apps/integrations/errors.py create mode 100644 src/apps/integrations/router.py create mode 100644 src/apps/integrations/service/__init__.py create mode 100644 src/apps/integrations/service/integrations.py create mode 100644 src/apps/integrations/tests/__init__.py create mode 100644 src/apps/integrations/tests/test_domain.py create mode 100644 src/apps/integrations/tests/test_routes.py create mode 100644 src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py diff --git a/src/apps/integrations/api/__init__.py b/src/apps/integrations/api/__init__.py new file mode 100644 index 00000000000..2c20724f8db --- /dev/null +++ b/src/apps/integrations/api/__init__.py @@ -0,0 +1 @@ +from apps.integrations.api.integrations import * # noqa: F401, F403 diff --git a/src/apps/integrations/api/integrations.py b/src/apps/integrations/api/integrations.py new file mode 100644 index 00000000000..16ca4932d6a --- /dev/null +++ b/src/apps/integrations/api/integrations.py @@ -0,0 +1,29 @@ +from fastapi import Body, Depends + +from apps.authentication.deps import get_current_user +from apps.integrations.domain import Integration +from apps.integrations.service import IntegrationService +from apps.shared.domain import ResponseMulti +from apps.users.domain import User +from infrastructure.database import atomic +from infrastructure.database.deps import get_session + +__all__ = ["enable_integration", "disable_integration"] + + +async def enable_integration( + user: User = Depends(get_current_user), + session=Depends(get_session), + integrations: list[Integration] = Body(...), +) -> ResponseMulti[Integration]: + async with atomic(session): + integrations = await IntegrationService(session, user).enable_integration(integrations) + return ResponseMulti(result=integrations) + + +async def disable_integration( + user: User = Depends(get_current_user), + session=Depends(get_session), +): + async with atomic(session): + await IntegrationService(session, user).disable_integration() diff --git a/src/apps/integrations/domain.py b/src/apps/integrations/domain.py new file mode 100644 index 00000000000..5a4be4e6651 --- /dev/null +++ b/src/apps/integrations/domain.py @@ -0,0 +1,11 @@ +from enum import Enum + +from apps.shared.domain import InternalModel + + +class AvailableIntegrations(str, Enum): + LORIS = "LORIS" + + +class Integration(InternalModel): + integration_type: AvailableIntegrations diff --git a/src/apps/integrations/errors.py b/src/apps/integrations/errors.py new file mode 100644 index 00000000000..7b3b505cc15 --- /dev/null +++ b/src/apps/integrations/errors.py @@ -0,0 +1,7 @@ +from gettext import gettext as _ + +from apps.shared.exception import ValidationError + + +class UniqueIntegrationError(ValidationError): + message = _("Integrations must be unique.") diff --git a/src/apps/integrations/router.py b/src/apps/integrations/router.py new file mode 100644 index 00000000000..6cb517506fe --- /dev/null +++ b/src/apps/integrations/router.py @@ -0,0 +1,30 @@ +from fastapi.routing import APIRouter +from starlette import status + +from apps.integrations.api import disable_integration, enable_integration +from apps.shared.domain.response import AUTHENTICATION_ERROR_RESPONSES, DEFAULT_OPENAPI_RESPONSE + +router = APIRouter(prefix="/integrations", tags=["Integration"]) + + +router.post( + "/", + description="This endpoint is used to enable integration\ + options for a workspace", + status_code=status.HTTP_200_OK, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(enable_integration) + +router.delete( + "/", + description="This endpoint is used to remove integrations\ + from a workspace", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(disable_integration) diff --git a/src/apps/integrations/service/__init__.py b/src/apps/integrations/service/__init__.py new file mode 100644 index 00000000000..41d6b90261d --- /dev/null +++ b/src/apps/integrations/service/__init__.py @@ -0,0 +1 @@ +from apps.integrations.service.integrations import * # noqa: F401, F403 diff --git a/src/apps/integrations/service/integrations.py b/src/apps/integrations/service/integrations.py new file mode 100644 index 00000000000..4d614c15122 --- /dev/null +++ b/src/apps/integrations/service/integrations.py @@ -0,0 +1,31 @@ +import json + +from apps.integrations.domain import AvailableIntegrations, Integration +from apps.integrations.errors import UniqueIntegrationError +from apps.users.domain import User +from apps.workspaces.crud.workspaces import UserWorkspaceCRUD + +__all__ = [ + "IntegrationService", +] + + +class IntegrationService: + def __init__(self, session, user: User) -> None: + self.session = session + self.user = user + + async def enable_integration(self, integrations: list[Integration]): + workspace = await UserWorkspaceCRUD(self.session).get_by_user_id(user_id_=self.user.id) + integration_names: list[AvailableIntegrations] = [integration.integration_type for integration in integrations] + if len(set(integration_names)) != len(integration_names): + raise UniqueIntegrationError() + + workspace.integrations = json.dumps([integration.dict() for integration in integrations]) + workspace = await UserWorkspaceCRUD(self.session).save(workspace) + return [Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)] + + async def disable_integration(self): + workspace = await UserWorkspaceCRUD(self.session).get_by_user_id(user_id_=self.user.id) + workspace.integrations = None + await UserWorkspaceCRUD(self.session).save(workspace) diff --git a/src/apps/integrations/tests/__init__.py b/src/apps/integrations/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/apps/integrations/tests/test_domain.py b/src/apps/integrations/tests/test_domain.py new file mode 100644 index 00000000000..d3755e6448a --- /dev/null +++ b/src/apps/integrations/tests/test_domain.py @@ -0,0 +1,19 @@ +import pytest + +from apps.integrations.domain import Integration + + +def test_integration_model(): + integration_data = { + "integration_type": "LORIS", + } + item = Integration(**integration_data) + assert item.integration_type == integration_data["integration_type"] + + +def test_integration_model_error(): + integration_data = { + "integration_type": "MORRIS", + } + with pytest.raises(Exception): + Integration(**integration_data) diff --git a/src/apps/integrations/tests/test_routes.py b/src/apps/integrations/tests/test_routes.py new file mode 100644 index 00000000000..ddca1aa4851 --- /dev/null +++ b/src/apps/integrations/tests/test_routes.py @@ -0,0 +1,53 @@ +from apps.integrations.errors import UniqueIntegrationError +from apps.integrations.router import router as integration_router +from apps.shared.test import BaseTest +from apps.shared.test.client import TestClient +from apps.users.domain import User + + +class TestIntegrationRouter(BaseTest): + fixtures = [ + "workspaces/fixtures/workspaces.json", + ] + enable_integration_url = integration_router.url_path_for("enable_integration") + disable_integration_url = integration_router.url_path_for("disable_integration") + + async def test_enable_integration( + self, + client: TestClient, + tom: User, + ): + integration_data = [ + {"integrationType": "LORIS"}, + ] + client.login(tom) + response = await client.post(self.enable_integration_url, data=integration_data) + assert response.status_code == 200 + + async def test_disable_integration( + self, + client: TestClient, + tom: User, + ): + client.login(tom) + response = await client.delete( + self.disable_integration_url, + ) + assert response.status_code == 204 + + async def test_enable_integration_unique_error( + self, + client: TestClient, + tom: User, + ): + integration_data = [ + {"integrationType": "LORIS"}, + {"integrationType": "LORIS"}, + ] + client.login(tom) + response = await client.post(self.enable_integration_url, data=integration_data) + assert response.status_code == 400 + + result = response.json()["result"] + assert len(result) == 1 + assert result[0]["message"] == UniqueIntegrationError.message diff --git a/src/apps/workspaces/db/schemas/user_workspace.py b/src/apps/workspaces/db/schemas/user_workspace.py index bc81fac4992..79c267d2d0e 100644 --- a/src/apps/workspaces/db/schemas/user_workspace.py +++ b/src/apps/workspaces/db/schemas/user_workspace.py @@ -1,4 +1,5 @@ -from sqlalchemy import ARRAY, Boolean, Column, ForeignKey, String, Unicode +from sqlalchemy import Boolean, Column, ForeignKey, Unicode +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy_utils import StringEncryptedType from apps.shared.encryption import get_key @@ -24,4 +25,4 @@ class UserWorkspaceSchema(Base): storage_url = Column(StringEncryptedType(Unicode, get_key)) storage_bucket = Column(StringEncryptedType(Unicode, get_key)) use_arbitrary = Column(Boolean(), default=False) - integrations = Column(ARRAY(String(32))) + integrations = Column(JSONB(), default=dict()) diff --git a/src/apps/workspaces/domain/workspace.py b/src/apps/workspaces/domain/workspace.py index 381e510c953..086202e0334 100644 --- a/src/apps/workspaces/domain/workspace.py +++ b/src/apps/workspaces/domain/workspace.py @@ -7,6 +7,7 @@ from sqlalchemy_utils import StringEncryptedType from apps.applets.domain.base import Encryption +from apps.integrations.domain import Integration from apps.shared.domain import InternalModel, PublicModel from apps.shared.encryption import get_key from apps.workspaces.constants import StorageType @@ -39,7 +40,7 @@ class PublicWorkspace(PublicModel): "which is consists of 'first name', 'last name' of user " "which is applet owner and prefix", ) - integrations: list[str] | None = Field( + integrations: list[Integration] | None = Field( description="This field represents the list of integrations in which the workspace participates" ) @@ -57,7 +58,7 @@ class UserWorkspace(InternalModel): "which is consists of 'first name', 'last name' of user " "which is applet owner and prefix", ) - integrations: list[str] | None = Field( + integrations: list[Integration] | None = Field( description="This field represents the list of integrations in which the workspace participates" ) diff --git a/src/apps/workspaces/service/user_access.py b/src/apps/workspaces/service/user_access.py index b54d3379286..c52c4ecb84c 100644 --- a/src/apps/workspaces/service/user_access.py +++ b/src/apps/workspaces/service/user_access.py @@ -1,9 +1,11 @@ +import json import uuid from gettext import gettext as _ import config from apps.answers.crud.answers import AnswersCRUD from apps.applets.crud import UserAppletAccessCRUD +from apps.integrations.domain import Integration from apps.invitations.domain import ReviewerMeta from apps.invitations.errors import RespondentsNotSet from apps.shared.exception import AccessDeniedError, ValidationError @@ -49,7 +51,14 @@ async def get_user_workspaces(self) -> list[UserWorkspace]: user_ids.append(self._user_id) workspaces = await UserWorkspaceCRUD(self.session).get_by_ids(user_ids) - return [UserWorkspace.from_orm(workspace) for workspace in workspaces] + return [ + UserWorkspace( + user_id=workspace.user_id, + workspace_name=workspace.workspace_name, + integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)], + ) + for workspace in workspaces + ] async def get_super_admin_workspaces(self) -> list[UserWorkspace]: """ @@ -57,7 +66,14 @@ async def get_super_admin_workspaces(self) -> list[UserWorkspace]: """ workspaces = await UserWorkspaceCRUD(self.session).get_all() - return [UserWorkspace.from_orm(workspace) for workspace in workspaces] + return [ + UserWorkspace( + user_id=workspace.user_id, + workspace_name=workspace.workspace_name, + integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)], + ) + for workspace in workspaces + ] async def remove_manager_access(self, schema: RemoveManagerAccess): """Remove manager access from a specific user.""" diff --git a/src/infrastructure/app.py b/src/infrastructure/app.py index d39d468ccfa..e97ef98968b 100644 --- a/src/infrastructure/app.py +++ b/src/infrastructure/app.py @@ -15,6 +15,7 @@ import apps.folders.router as folders import apps.healthcheck.router as healthcheck import apps.integrations.loris.router as loris +import apps.integrations.router as integrations import apps.invitations.router as invitations import apps.library.router as library import apps.logs.router as logs @@ -63,6 +64,7 @@ ws_alerts.router, subject_router.router, loris.router, + integrations.router, ) # Declare your middlewares here diff --git a/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py b/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py new file mode 100644 index 00000000000..e5b0f2e5de6 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py @@ -0,0 +1,39 @@ +"""Change integrations field type in workspaces table + +Revision ID: d877f29be2f0 +Revises: 70c50aba13b7 +Create Date: 2024-06-17 15:46:33.965375 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d877f29be2f0" +down_revision = "70c50aba13b7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("users_workspaces", "integrations", type_=postgresql.JSONB(astext_type=sa.Text()),postgresql_using="array_to_json(integrations);") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users_workspaces", "integrations") + + op.add_column( + "users_workspaces", + sa.Column( + "integrations", + sa.ARRAY(sa.String(length=32)), + + nullable=True, + ), + ) + # op.alter_column("users_workspaces", "integrations", type_=sa.ARRAY(sa.String(length=32)), postgresql_using=" json_to_array(integrations);") + # ### end Alembic commands ### From ba8abc36d55f3fbb03ab01185b3f2c8b9bc0b074 Mon Sep 17 00:00:00 2001 From: Damirkhon Aloev Date: Tue, 18 Jun 2024 20:09:43 +0500 Subject: [PATCH 2/5] M2-7040: code quality --- .../2024_06_17_15_46-change_integrations_field_type_in_.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py b/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py index e5b0f2e5de6..1d4d7d996c1 100644 --- a/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py +++ b/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py @@ -35,5 +35,4 @@ def downgrade() -> None: nullable=True, ), ) - # op.alter_column("users_workspaces", "integrations", type_=sa.ARRAY(sa.String(length=32)), postgresql_using=" json_to_array(integrations);") # ### end Alembic commands ### From 4f623ce1c83444071ba8bc6603c37e14865e2764 Mon Sep 17 00:00:00 2001 From: Damirkhon Aloev Date: Tue, 18 Jun 2024 23:21:56 +0500 Subject: [PATCH 3/5] M2-7040: fix integrations missing in workspace --- src/apps/workspaces/service/user_access.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/apps/workspaces/service/user_access.py b/src/apps/workspaces/service/user_access.py index c52c4ecb84c..83b8d766035 100644 --- a/src/apps/workspaces/service/user_access.py +++ b/src/apps/workspaces/service/user_access.py @@ -55,7 +55,9 @@ async def get_user_workspaces(self) -> list[UserWorkspace]: UserWorkspace( user_id=workspace.user_id, workspace_name=workspace.workspace_name, - integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)], + integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)] + if workspace.integrations + else None, ) for workspace in workspaces ] @@ -70,7 +72,9 @@ async def get_super_admin_workspaces(self) -> list[UserWorkspace]: UserWorkspace( user_id=workspace.user_id, workspace_name=workspace.workspace_name, - integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)], + integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)] + if workspace.integrations + else None, ) for workspace in workspaces ] From e68928b54953fcdc677049ae7e5de1ec6bcff593 Mon Sep 17 00:00:00 2001 From: Damirkhon Aloev Date: Wed, 19 Jun 2024 14:08:06 +0500 Subject: [PATCH 4/5] M2-7040: add count into Response Integrations --- src/apps/integrations/api/integrations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/integrations/api/integrations.py b/src/apps/integrations/api/integrations.py index 16ca4932d6a..b172586e872 100644 --- a/src/apps/integrations/api/integrations.py +++ b/src/apps/integrations/api/integrations.py @@ -18,7 +18,7 @@ async def enable_integration( ) -> ResponseMulti[Integration]: async with atomic(session): integrations = await IntegrationService(session, user).enable_integration(integrations) - return ResponseMulti(result=integrations) + return ResponseMulti(result=integrations, count=len(integrations)) async def disable_integration( From 3129908887125758343ed7a7817acf75aaa9bef8 Mon Sep 17 00:00:00 2001 From: Damirkhon Aloev Date: Thu, 20 Jun 2024 18:45:14 +0500 Subject: [PATCH 5/5] M2-7040: delete filter, tests change --- src/apps/integrations/api/integrations.py | 6 +++-- src/apps/integrations/domain.py | 4 ++++ src/apps/integrations/service/integrations.py | 23 ++++++++++++++----- .../{test_routes.py => test_integrations.py} | 0 .../domain/test_integration.py} | 0 src/apps/workspaces/service/user_access.py | 8 ++++--- 6 files changed, 30 insertions(+), 11 deletions(-) rename src/apps/integrations/tests/{test_routes.py => test_integrations.py} (100%) rename src/apps/integrations/tests/{test_domain.py => unit/domain/test_integration.py} (100%) diff --git a/src/apps/integrations/api/integrations.py b/src/apps/integrations/api/integrations.py index b172586e872..11ab514f7d9 100644 --- a/src/apps/integrations/api/integrations.py +++ b/src/apps/integrations/api/integrations.py @@ -1,9 +1,10 @@ from fastapi import Body, Depends from apps.authentication.deps import get_current_user -from apps.integrations.domain import Integration +from apps.integrations.domain import Integration, IntegrationFilter from apps.integrations.service import IntegrationService from apps.shared.domain import ResponseMulti +from apps.shared.query_params import QueryParams, parse_query_params from apps.users.domain import User from infrastructure.database import atomic from infrastructure.database.deps import get_session @@ -24,6 +25,7 @@ async def enable_integration( async def disable_integration( user: User = Depends(get_current_user), session=Depends(get_session), + query_params: QueryParams = Depends(parse_query_params(IntegrationFilter)), ): async with atomic(session): - await IntegrationService(session, user).disable_integration() + await IntegrationService(session, user).disable_integration(query_params) diff --git a/src/apps/integrations/domain.py b/src/apps/integrations/domain.py index 5a4be4e6651..17880590e98 100644 --- a/src/apps/integrations/domain.py +++ b/src/apps/integrations/domain.py @@ -9,3 +9,7 @@ class AvailableIntegrations(str, Enum): class Integration(InternalModel): integration_type: AvailableIntegrations + + +class IntegrationFilter(InternalModel): + integration_types: list[AvailableIntegrations] | None diff --git a/src/apps/integrations/service/integrations.py b/src/apps/integrations/service/integrations.py index 4d614c15122..dca292a1d11 100644 --- a/src/apps/integrations/service/integrations.py +++ b/src/apps/integrations/service/integrations.py @@ -1,7 +1,6 @@ -import json - from apps.integrations.domain import AvailableIntegrations, Integration from apps.integrations.errors import UniqueIntegrationError +from apps.shared.query_params import QueryParams from apps.users.domain import User from apps.workspaces.crud.workspaces import UserWorkspaceCRUD @@ -21,11 +20,23 @@ async def enable_integration(self, integrations: list[Integration]): if len(set(integration_names)) != len(integration_names): raise UniqueIntegrationError() - workspace.integrations = json.dumps([integration.dict() for integration in integrations]) + workspace.integrations = [integration.dict() for integration in integrations] workspace = await UserWorkspaceCRUD(self.session).save(workspace) - return [Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)] + return [Integration.parse_obj(integration) for integration in workspace.integrations] - async def disable_integration(self): + async def disable_integration(self, query: QueryParams): workspace = await UserWorkspaceCRUD(self.session).get_by_user_id(user_id_=self.user.id) - workspace.integrations = None + print(query) + if query.filters: + workspace.integrations = ( + [ + integration + for integration in workspace.integrations + if integration["integration_type"] not in query.filters["integration_types"] + ] + if workspace.integrations + else workspace.integrations + ) + else: + workspace.integrations = None await UserWorkspaceCRUD(self.session).save(workspace) diff --git a/src/apps/integrations/tests/test_routes.py b/src/apps/integrations/tests/test_integrations.py similarity index 100% rename from src/apps/integrations/tests/test_routes.py rename to src/apps/integrations/tests/test_integrations.py diff --git a/src/apps/integrations/tests/test_domain.py b/src/apps/integrations/tests/unit/domain/test_integration.py similarity index 100% rename from src/apps/integrations/tests/test_domain.py rename to src/apps/integrations/tests/unit/domain/test_integration.py diff --git a/src/apps/workspaces/service/user_access.py b/src/apps/workspaces/service/user_access.py index 83b8d766035..ee35e684409 100644 --- a/src/apps/workspaces/service/user_access.py +++ b/src/apps/workspaces/service/user_access.py @@ -1,6 +1,8 @@ -import json import uuid from gettext import gettext as _ +from typing import List + +import pydantic import config from apps.answers.crud.answers import AnswersCRUD @@ -55,7 +57,7 @@ async def get_user_workspaces(self) -> list[UserWorkspace]: UserWorkspace( user_id=workspace.user_id, workspace_name=workspace.workspace_name, - integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)] + integrations=pydantic.parse_obj_as(List[Integration], workspace.integrations) if workspace.integrations else None, ) @@ -72,7 +74,7 @@ async def get_super_admin_workspaces(self) -> list[UserWorkspace]: UserWorkspace( user_id=workspace.user_id, workspace_name=workspace.workspace_name, - integrations=[Integration.parse_obj(integration) for integration in json.loads(workspace.integrations)] + integrations=pydantic.parse_obj_as(List[Integration], workspace.integrations) if workspace.integrations else None, )