From fa81c3d512a94eb86a889df8844785f2a1362a2b Mon Sep 17 00:00:00 2001 From: Nikita Melkozerov Date: Tue, 5 Dec 2023 14:27:07 +0100 Subject: [PATCH] Add workspace settings endpoints (#199) --- fixbackend/workspaces/repository.py | 22 +++++++++- fixbackend/workspaces/router.py | 34 +++++++++++++-- fixbackend/workspaces/schemas.py | 41 +++++++++++++++++-- .../{organizations => workspaces}/__init__.py | 0 .../repository_test.py | 18 +++++++- .../router_test.py | 27 ++++++++++++ 6 files changed, 132 insertions(+), 10 deletions(-) rename tests/fixbackend/{organizations => workspaces}/__init__.py (100%) rename tests/fixbackend/{organizations => workspaces}/repository_test.py (91%) rename tests/fixbackend/{organizations => workspaces}/router_test.py (74%) diff --git a/fixbackend/workspaces/repository.py b/fixbackend/workspaces/repository.py index 8618fd9b..82ea14ac 100644 --- a/fixbackend/workspaces/repository.py +++ b/fixbackend/workspaces/repository.py @@ -26,7 +26,7 @@ from fixbackend.auth.models import orm as auth_orm from fixbackend.dependencies import FixDependency, ServiceNames from fixbackend.graph_db.service import GraphDatabaseAccessManager -from fixbackend.ids import WorkspaceId, UserId +from fixbackend.ids import ExternalId, WorkspaceId, UserId from fixbackend.types import AsyncSessionMaker from fixbackend.workspaces.models import Workspace, WorkspaceInvite, orm from fixbackend.domain_events.publisher import DomainEventPublisher @@ -49,6 +49,11 @@ async def list_workspaces(self, user_id: UserId) -> Sequence[Workspace]: """List all workspaces where the user is a member.""" raise NotImplementedError + @abstractmethod + async def update_workspace(self, workspace_id: WorkspaceId, name: str, generate_external_id: bool) -> Workspace: + """Update a workspace.""" + raise NotImplementedError + @abstractmethod async def add_to_workspace(self, workspace_id: WorkspaceId, user_id: UserId) -> None: """Add a user to a workspace.""" @@ -125,6 +130,21 @@ async def get_workspace(self, workspace_id: WorkspaceId) -> Optional[Workspace]: org = results.unique().scalar_one_or_none() return org.to_model() if org else None + async def update_workspace(self, workspace_id: WorkspaceId, name: str, generate_external_id: bool) -> Workspace: + """Update a workspace.""" + async with self.session_maker() as session: + statement = select(orm.Organization).where(orm.Organization.id == workspace_id) + results = await session.execute(statement) + org = results.unique().scalar_one_or_none() + if org is None: + raise ValueError(f"Organization {workspace_id} does not exist.") + org.name = name + if generate_external_id: + org.external_id = ExternalId(uuid.uuid4()) + await session.commit() + await session.refresh(org) + return org.to_model() + async def list_workspaces(self, user_id: UserId) -> Sequence[Workspace]: async with self.session_maker() as session: statement = ( diff --git a/fixbackend/workspaces/router.py b/fixbackend/workspaces/router.py index 7def0819..28a81e35 100644 --- a/fixbackend/workspaces/router.py +++ b/fixbackend/workspaces/router.py @@ -25,7 +25,14 @@ from fixbackend.ids import WorkspaceId from fixbackend.workspaces.repository import WorkspaceRepositoryDependency from fixbackend.workspaces.dependencies import UserWorkspaceDependency -from fixbackend.workspaces.schemas import ExternalId, WorkspaceCreate, WorkspaceInviteRead, WorkspaceRead +from fixbackend.workspaces.schemas import ( + ExternalIdRead, + WorkspaceCreate, + WorkspaceInviteRead, + WorkspaceRead, + WorkspaceSettingsRead, + WorkspaceSettingsUpdate, +) def workspaces_router() -> APIRouter: @@ -56,6 +63,27 @@ async def get_workspace( return WorkspaceRead.from_model(org) + @router.get("/{workspace_id}/settings") + async def get_workspace_settings( + workspace: UserWorkspaceDependency, + ) -> WorkspaceSettingsRead: + """Get a workspace.""" + return WorkspaceSettingsRead.from_model(workspace) + + @router.patch("/{workspace_id}/settings") + async def update_workspace_settings( + workspace: UserWorkspaceDependency, + settings: WorkspaceSettingsUpdate, + workspace_repository: WorkspaceRepositoryDependency, + ) -> WorkspaceSettingsRead: + """Update a workspace.""" + org = await workspace_repository.update_workspace( + workspace_id=workspace.id, + name=settings.name, + generate_external_id=settings.generate_new_external_id, + ) + return WorkspaceSettingsRead.from_model(org) + @router.post("/") async def create_workspace( organization: WorkspaceCreate, @@ -163,8 +191,8 @@ async def get_cf_template( @router.get("/{workspace_id}/external_id") async def get_external_id( workspace: UserWorkspaceDependency, - ) -> ExternalId: + ) -> ExternalIdRead: """Get a workspaces external id.""" - return ExternalId(external_id=workspace.external_id) + return ExternalIdRead(external_id=workspace.external_id) return router diff --git a/fixbackend/workspaces/schemas.py b/fixbackend/workspaces/schemas.py index 8e828112..e6bb8ade 100644 --- a/fixbackend/workspaces/schemas.py +++ b/fixbackend/workspaces/schemas.py @@ -14,8 +14,7 @@ from datetime import datetime from typing import List -from uuid import UUID -from fixbackend.ids import WorkspaceId, UserId +from fixbackend.ids import WorkspaceId, UserId, ExternalId from pydantic import BaseModel, Field @@ -54,6 +53,40 @@ def from_model(cls, model: Workspace) -> "WorkspaceRead": ) +class WorkspaceSettingsRead(BaseModel): + id: WorkspaceId = Field(description="The workspace's unique identifier") + slug: str = Field(description="The workspace's unique slug, used in URLs") + name: str = Field(description="The workspace's name, a human-readable string") + external_id: ExternalId = Field(description="The workspace's external identifier") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "slug": "my-org", + "name": "My Organization", + "external_id": "00000000-0000-0000-0000-000000000000", + } + ] + } + } + + @classmethod + def from_model(cls, model: Workspace) -> "WorkspaceSettingsRead": + return WorkspaceSettingsRead( + id=model.id, + slug=model.slug, + name=model.name, + external_id=model.external_id, + ) + + +class WorkspaceSettingsUpdate(BaseModel): + name: str = Field(description="The workspace's name, a human-readable string") + generate_new_external_id: bool = Field(description="Whether to generate a new external identifier") + + class WorkspaceCreate(BaseModel): name: str = Field(description="Workspace name, a human-readable string") slug: str = Field(description="Workspace unique slug, used in URLs", pattern="^[a-z0-9-]+$") @@ -88,8 +121,8 @@ class WorkspaceInviteRead(BaseModel): } -class ExternalId(BaseModel): - external_id: UUID = Field(description="The organization's external identifier") +class ExternalIdRead(BaseModel): + external_id: ExternalId = Field(description="The organization's external identifier") model_config = { "json_schema_extra": { diff --git a/tests/fixbackend/organizations/__init__.py b/tests/fixbackend/workspaces/__init__.py similarity index 100% rename from tests/fixbackend/organizations/__init__.py rename to tests/fixbackend/workspaces/__init__.py diff --git a/tests/fixbackend/organizations/repository_test.py b/tests/fixbackend/workspaces/repository_test.py similarity index 91% rename from tests/fixbackend/organizations/repository_test.py rename to tests/fixbackend/workspaces/repository_test.py index 0997497e..23c7d372 100644 --- a/tests/fixbackend/organizations/repository_test.py +++ b/tests/fixbackend/workspaces/repository_test.py @@ -37,7 +37,7 @@ async def user(session: AsyncSession) -> User: @pytest.mark.asyncio -async def test_create_organization(workspace_repository: WorkspaceRepository, user: User) -> None: +async def test_create_workspace(workspace_repository: WorkspaceRepository, user: User) -> None: organization = await workspace_repository.create_workspace( name="Test Organization", slug="test-organization", owner=user ) @@ -55,7 +55,7 @@ async def test_create_organization(workspace_repository: WorkspaceRepository, us @pytest.mark.asyncio -async def test_get_organization(workspace_repository: WorkspaceRepository, user: User) -> None: +async def test_get_workspace(workspace_repository: WorkspaceRepository, user: User) -> None: # we can get an existing organization by id organization = await workspace_repository.create_workspace( name="Test Organization", slug="test-organization", owner=user @@ -69,6 +69,20 @@ async def test_get_organization(workspace_repository: WorkspaceRepository, user: assert retrieved_organization is None +@pytest.mark.asyncio +async def test_update_workspace(workspace_repository: WorkspaceRepository, user: User) -> None: + # we can get an existing organization by id + workspace = await workspace_repository.create_workspace( + name="Test Organization", slug="test-organization", owner=user + ) + + await workspace_repository.update_workspace(workspace.id, "foobar", True) + new_workspace = await workspace_repository.get_workspace(workspace.id) + assert new_workspace is not None + assert new_workspace.name == "foobar" + assert new_workspace.external_id != workspace.external_id + + @pytest.mark.asyncio async def test_list_organizations(workspace_repository: WorkspaceRepository, user: User) -> None: workspace1 = await workspace_repository.create_workspace( diff --git a/tests/fixbackend/organizations/router_test.py b/tests/fixbackend/workspaces/router_test.py similarity index 74% rename from tests/fixbackend/organizations/router_test.py rename to tests/fixbackend/workspaces/router_test.py index 39b93afe..b76552d1 100644 --- a/tests/fixbackend/organizations/router_test.py +++ b/tests/fixbackend/workspaces/router_test.py @@ -16,6 +16,7 @@ import uuid from typing import AsyncIterator, Sequence from uuid import UUID +from attrs import evolve import pytest from httpx import AsyncClient @@ -64,6 +65,13 @@ async def get_workspace(self, workspace_id: UUID) -> Workspace | None: async def list_workspaces(self, owner_id: UUID) -> Sequence[Workspace]: return [workspace] + async def update_workspace(self, workspace_id: WorkspaceId, name: str, generate_external_id: bool) -> Workspace: + if generate_external_id: + new_external_id = ExternalId(uuid.uuid4()) + else: + new_external_id = workspace.external_id + return evolve(workspace, name=name, external_id=new_external_id) + @pytest.fixture async def client(session: AsyncSession, default_config: Config) -> AsyncIterator[AsyncClient]: # noqa: F811 @@ -105,3 +113,22 @@ async def test_external_id(client: AsyncClient) -> None: async def test_cloudformation_template_url(client: AsyncClient, default_config: Config) -> None: response = await client.get(f"/api/workspaces/{org_id}/cf_template") assert response.json() == str(default_config.cf_template_url) + + +@pytest.mark.asyncio +async def test_get_workspace_settings(client: AsyncClient, default_config: Config) -> None: + response = await client.get(f"/api/workspaces/{org_id}/settings") + assert response.json().get("id") == str(org_id) + assert response.json().get("slug") == workspace.slug + assert response.json().get("name") == workspace.name + assert response.json().get("external_id") == str(external_id) + + +@pytest.mark.asyncio +async def test_update_workspace_settings(client: AsyncClient, default_config: Config) -> None: + payload = {"name": "new name", "generate_new_external_id": True} + response = await client.patch(f"/api/workspaces/{org_id}/settings", json=payload) + assert response.json().get("id") == str(org_id) + assert response.json().get("slug") == workspace.slug + assert response.json().get("name") == "new name" + assert response.json().get("external_id") != str(external_id)