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..11ab514f7d9 --- /dev/null +++ b/src/apps/integrations/api/integrations.py @@ -0,0 +1,31 @@ +from fastapi import Body, Depends + +from apps.authentication.deps import get_current_user +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 + +__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, count=len(integrations)) + + +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(query_params) diff --git a/src/apps/integrations/domain.py b/src/apps/integrations/domain.py new file mode 100644 index 00000000000..17880590e98 --- /dev/null +++ b/src/apps/integrations/domain.py @@ -0,0 +1,15 @@ +from enum import Enum + +from apps.shared.domain import InternalModel + + +class AvailableIntegrations(str, Enum): + LORIS = "LORIS" + + +class Integration(InternalModel): + integration_type: AvailableIntegrations + + +class IntegrationFilter(InternalModel): + integration_types: list[AvailableIntegrations] | None 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..dca292a1d11 --- /dev/null +++ b/src/apps/integrations/service/integrations.py @@ -0,0 +1,42 @@ +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 + +__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 = [integration.dict() for integration in integrations] + workspace = await UserWorkspaceCRUD(self.session).save(workspace) + return [Integration.parse_obj(integration) for integration in workspace.integrations] + + async def disable_integration(self, query: QueryParams): + workspace = await UserWorkspaceCRUD(self.session).get_by_user_id(user_id_=self.user.id) + 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/__init__.py b/src/apps/integrations/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/apps/integrations/tests/test_integrations.py b/src/apps/integrations/tests/test_integrations.py new file mode 100644 index 00000000000..ddca1aa4851 --- /dev/null +++ b/src/apps/integrations/tests/test_integrations.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/integrations/tests/unit/domain/test_integration.py b/src/apps/integrations/tests/unit/domain/test_integration.py new file mode 100644 index 00000000000..d3755e6448a --- /dev/null +++ b/src/apps/integrations/tests/unit/domain/test_integration.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/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..ee35e684409 100644 --- a/src/apps/workspaces/service/user_access.py +++ b/src/apps/workspaces/service/user_access.py @@ -1,9 +1,13 @@ import uuid from gettext import gettext as _ +from typing import List + +import pydantic 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 +53,16 @@ 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=pydantic.parse_obj_as(List[Integration], workspace.integrations) + if workspace.integrations + else None, + ) + for workspace in workspaces + ] async def get_super_admin_workspaces(self) -> list[UserWorkspace]: """ @@ -57,7 +70,16 @@ 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=pydantic.parse_obj_as(List[Integration], workspace.integrations) + if workspace.integrations + else None, + ) + 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..1d4d7d996c1 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2024_06_17_15_46-change_integrations_field_type_in_.py @@ -0,0 +1,38 @@ +"""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, + ), + ) + # ### end Alembic commands ###