diff --git a/migrations/versions/8e4b4b8d1a88_add_soft_delete.py b/migrations/versions/8e4b4b8d1a88_add_soft_delete.py new file mode 100644 index 00000000..ad8138a3 --- /dev/null +++ b/migrations/versions/8e4b4b8d1a88_add_soft_delete.py @@ -0,0 +1,30 @@ +"""add soft delete + +Revision ID: 8e4b4b8d1a88 +Revises: 5c2f3eee5f90 +Create Date: 2025-01-20 14:08:40.851647 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8e4b4b8d1a88" +down_revision: Union[str, None] = "5c2f3eee5f90" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE workspaces + ADD COLUMN deleted_at DATETIME DEFAULT NULL; + """ + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE workspaces DROP COLUMN deleted_at;") diff --git a/migrations/versions/e6227073183d_merging_system_prompt_and_soft_deletes.py b/migrations/versions/e6227073183d_merging_system_prompt_and_soft_deletes.py new file mode 100644 index 00000000..64dd0717 --- /dev/null +++ b/migrations/versions/e6227073183d_merging_system_prompt_and_soft_deletes.py @@ -0,0 +1,23 @@ +"""merging system prompt and soft-deletes + +Revision ID: e6227073183d +Revises: 8e4b4b8d1a88, a692c8b52308 +Create Date: 2025-01-20 16:08:40.645298 + +""" + +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "e6227073183d" +down_revision: Union[str, None] = ("8e4b4b8d1a88", "a692c8b52308") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/codegate/api/v1.py b/src/codegate/api/v1.py index 73a8d195..363e2f47 100644 --- a/src/codegate/api/v1.py +++ b/src/codegate/api/v1.py @@ -79,8 +79,14 @@ async def create_workspace(request: v1_models.CreateWorkspaceRequest) -> v1_mode "/workspaces/{workspace_name}", tags=["Workspaces"], generate_unique_id_function=uniq_name, - status_code=204, ) async def delete_workspace(workspace_name: str): """Delete a workspace by name.""" - raise NotImplementedError + try: + _ = await wscrud.soft_delete_workspace(workspace_name) + except crud.WorkspaceDoesNotExistError: + raise HTTPException(status_code=404, detail="Workspace does not exist") + except Exception: + raise HTTPException(status_code=500, detail="Internal server error") + + return Response(status_code=204) diff --git a/src/codegate/db/connection.py b/src/codegate/db/connection.py index cca4e691..6e014916 100644 --- a/src/codegate/db/connection.py +++ b/src/codegate/db/connection.py @@ -304,6 +304,20 @@ async def update_session(self, session: Session) -> Optional[Session]: active_session = await self._execute_update_pydantic_model(session, sql, should_raise=True) return active_session + async def soft_delete_workspace(self, workspace: Workspace) -> Optional[Workspace]: + sql = text( + """ + UPDATE workspaces + SET deleted_at = CURRENT_TIMESTAMP + WHERE id = :id + RETURNING * + """ + ) + deleted_workspace = await self._execute_update_pydantic_model( + workspace, sql, should_raise=True + ) + return deleted_workspace + class DbReader(DbCodeGate): @@ -401,6 +415,7 @@ async def get_workspaces(self) -> List[WorkspaceActive]: w.id, w.name, s.active_workspace_id FROM workspaces w LEFT JOIN sessions s ON w.id = s.active_workspace_id + WHERE w.deleted_at IS NULL """ ) workspaces = await self._execute_select_pydantic_model(WorkspaceActive, sql) @@ -412,7 +427,7 @@ async def get_workspace_by_name(self, name: str) -> Optional[Workspace]: SELECT id, name, system_prompt FROM workspaces - WHERE name = :name + WHERE name = :name AND deleted_at IS NULL """ ) conditions = {"name": name} diff --git a/src/codegate/pipeline/cli/commands.py b/src/codegate/pipeline/cli/commands.py index 8902f409..233188ba 100644 --- a/src/codegate/pipeline/cli/commands.py +++ b/src/codegate/pipeline/cli/commands.py @@ -153,6 +153,7 @@ def subcommands(self) -> Dict[str, Callable[[List[str]], Awaitable[str]]]: "list": self._list_workspaces, "add": self._add_workspace, "activate": self._activate_workspace, + "remove": self._remove_workspace, } async def _list_workspaces(self, flags: Dict[str, str], args: List[str]) -> str: @@ -211,6 +212,25 @@ async def _activate_workspace(self, flags: Dict[str, str], args: List[str]) -> s return "An error occurred while activating the workspace" return f"Workspace **{workspace_name}** has been activated" + async def _remove_workspace(self, flags: Dict[str, str], args: List[str]) -> str: + """ + Remove a workspace + """ + if args is None or len(args) == 0: + return "Please provide a name. Use `codegate workspace remove workspace_name`" + + workspace_name = args[0] + if not workspace_name: + return "Please provide a name. Use `codegate workspace remove workspace_name`" + + try: + await self.workspace_crud.soft_delete_workspace(workspace_name) + except crud.WorkspaceDoesNotExistError: + return f"Workspace **{workspace_name}** does not exist" + except Exception: + return "An error occurred while removing the workspace" + return f"Workspace **{workspace_name}** has been removed" + @property def help(self) -> str: return ( diff --git a/src/codegate/workspaces/crud.py b/src/codegate/workspaces/crud.py index 2b44466d..9fcc63de 100644 --- a/src/codegate/workspaces/crud.py +++ b/src/codegate/workspaces/crud.py @@ -68,10 +68,6 @@ async def _is_workspace_active( async def activate_workspace(self, workspace_name: str): """ Activate a workspace - - Will return: - - True if the workspace was activated - - False if the workspace is already active or does not exist """ is_active, session, workspace = await self._is_workspace_active(workspace_name) if is_active: @@ -100,6 +96,31 @@ async def update_workspace_system_prompt( updated_workspace = await db_recorder.update_workspace(workspace_update) return updated_workspace + async def soft_delete_workspace(self, workspace_name: str): + """ + Soft delete a workspace + """ + if workspace_name == "": + raise WorkspaceCrudError("Workspace name cannot be empty.") + if workspace_name == "default": + raise WorkspaceCrudError("Cannot delete default workspace.") + + selected_workspace = await self._db_reader.get_workspace_by_name(workspace_name) + if not selected_workspace: + raise WorkspaceDoesNotExistError(f"Workspace {workspace_name} does not exist.") + + # Check if workspace is active, if it is, make the default workspace active + active_workspace = await self._db_reader.get_active_workspace() + if active_workspace and active_workspace.id == selected_workspace.id: + raise WorkspaceCrudError("Cannot delete active workspace.") + + db_recorder = DbRecorder() + try: + _ = await db_recorder.soft_delete_workspace(selected_workspace) + except Exception: + raise WorkspaceCrudError(f"Error deleting workspace {workspace_name}") + return + async def get_workspace_by_name(self, workspace_name: str) -> Workspace: workspace = await self._db_reader.get_workspace_by_name(workspace_name) if not workspace: