diff --git a/src/agents-api/agents_api/queries/projects/__init__.py b/src/agents-api/agents_api/queries/projects/__init__.py index 2b51cd430..65848310b 100644 --- a/src/agents-api/agents_api/queries/projects/__init__.py +++ b/src/agents-api/agents_api/queries/projects/__init__.py @@ -11,9 +11,11 @@ """ from .create_project import create_project +from .delete_project import delete_project from .list_projects import list_projects __all__ = [ "create_project", + "delete_project", "list_projects", ] diff --git a/src/agents-api/agents_api/queries/projects/delete_project.py b/src/agents-api/agents_api/queries/projects/delete_project.py new file mode 100644 index 000000000..765cc1da0 --- /dev/null +++ b/src/agents-api/agents_api/queries/projects/delete_project.py @@ -0,0 +1,88 @@ +""" +This module contains the functionality for deleting projects from the PostgreSQL database. +It constructs and executes SQL queries to remove project records and associated data. +""" + +from uuid import UUID + +from beartype import beartype + +from ...autogen.openapi_model import ResourceDeletedResponse +from ...common.utils.datetime import utcnow +from ...common.utils.db_exceptions import common_db_exceptions +from ...metrics.counters import query_metrics +from ..utils import pg_query, rewrap_exceptions, wrap_in_class + +# Delete project query that handles RESTRICT constraints by deleting associations first +# Wrapped in a transaction to ensure atomicity +delete_project_query = """ +BEGIN; + +-- First check if the project exists and is not the default project +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM projects + WHERE developer_id = $1 AND project_id = $2 + ) THEN + RAISE EXCEPTION 'Project not found'; + END IF; + + IF EXISTS ( + SELECT 1 FROM projects + WHERE developer_id = $1 AND project_id = $2 AND canonical_name = 'default' + ) THEN + RAISE EXCEPTION 'Cannot delete default project'; + END IF; +END $$; + +-- Delete all project associations to handle RESTRICT constraints +DELETE FROM project_agents WHERE project_id = $2 AND developer_id = $1; +DELETE FROM project_users WHERE project_id = $2 AND developer_id = $1; +DELETE FROM project_files WHERE project_id = $2 AND developer_id = $1; + +-- Then delete the project itself +DELETE FROM projects +WHERE developer_id = $1 +AND project_id = $2 +RETURNING project_id; + +COMMIT; +""" + + +@rewrap_exceptions(common_db_exceptions("project", ["delete"])) +@wrap_in_class( + ResourceDeletedResponse, + one=True, + transform=lambda d: { + "id": d["project_id"], + "deleted_at": utcnow(), + "jobs": [], + }, +) +@query_metrics("delete_project") +@pg_query +@beartype +async def delete_project( + *, + developer_id: UUID, + project_id: UUID, +) -> tuple[str, list]: + """ + Deletes a project and all its associations. + + Args: + developer_id: The developer's UUID + project_id: The project's UUID + + Returns: + tuple[str, list]: SQL query and parameters + + Raises: + Exception: If project not found or is the default project + """ + return ( + delete_project_query, + [developer_id, project_id], + ) \ No newline at end of file diff --git a/src/agents-api/agents_api/routers/projects/__init__.py b/src/agents-api/agents_api/routers/projects/__init__.py index ffc57284f..d4d57d0e6 100644 --- a/src/agents-api/agents_api/routers/projects/__init__.py +++ b/src/agents-api/agents_api/routers/projects/__init__.py @@ -1,5 +1,6 @@ # ruff: noqa: F401 from .create_project import create_project +from .delete_project import delete_project from .list_projects import list_projects from .router import router diff --git a/src/agents-api/agents_api/routers/projects/delete_project.py b/src/agents-api/agents_api/routers/projects/delete_project.py new file mode 100644 index 000000000..6d6921ef8 --- /dev/null +++ b/src/agents-api/agents_api/routers/projects/delete_project.py @@ -0,0 +1,30 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import Depends +from starlette.status import HTTP_202_ACCEPTED + +from ...autogen.openapi_model import ResourceDeletedResponse +from ...dependencies.developer_id import get_developer_id +from ...queries.projects.delete_project import delete_project as delete_project_query +from .router import router + + +@router.delete("/projects/{project_id}", status_code=HTTP_202_ACCEPTED, tags=["projects"]) +async def delete_project( + project_id: UUID, + x_developer_id: Annotated[UUID, Depends(get_developer_id)], +) -> ResourceDeletedResponse: + """Delete a project. + + Args: + project_id: ID of the project to delete + x_developer_id: Developer ID from header + + Returns: + ResourceDeletedResponse: The deleted project information + """ + return await delete_project_query( + developer_id=x_developer_id, + project_id=project_id, + ) \ No newline at end of file diff --git a/src/agents-api/tests/test_project_delete.py b/src/agents-api/tests/test_project_delete.py new file mode 100644 index 000000000..b69b96e6e --- /dev/null +++ b/src/agents-api/tests/test_project_delete.py @@ -0,0 +1,69 @@ +""" +Test cases for project deletion functionality. +""" + +import pytest +from uuid import uuid4 + +from agents_api.queries.projects.delete_project import delete_project +from tests.fixtures import pg_dsn, test_developer, test_project + + +@pytest.mark.asyncio +async def test_delete_project_success(dsn=pg_dsn, developer=test_developer, project=test_project): + """Test that a project can be successfully deleted.""" + # Create a new project to delete (not the default one) + from agents_api.queries.projects.create_project import create_project + from agents_api.autogen.openapi_model import CreateProjectRequest + + create_data = CreateProjectRequest( + name="Test Project to Delete", + canonical_name="test-delete-project", + metadata={"test": True} + ) + + created_project = await create_project( + developer_id=developer.developer_id, + data=create_data + ) + + # Delete the project + result = await delete_project( + developer_id=developer.developer_id, + project_id=created_project.id + ) + + # Verify the result + assert result.id == created_project.id + assert result.deleted_at is not None + assert result.jobs == [] + + +@pytest.mark.asyncio +async def test_delete_project_not_found(dsn=pg_dsn, developer=test_developer): + """Test that deleting a non-existent project raises an appropriate error.""" + non_existent_project_id = uuid4() + + with pytest.raises(Exception) as exc_info: + await delete_project( + developer_id=developer.developer_id, + project_id=non_existent_project_id + ) + + # The exact exception type and message may vary based on the database layer + assert exc_info.value is not None + + +@pytest.mark.asyncio +async def test_delete_project_wrong_developer(dsn=pg_dsn, developer=test_developer, project=test_project): + """Test that deleting a project with wrong developer ID raises an error.""" + wrong_developer_id = uuid4() + + with pytest.raises(Exception) as exc_info: + await delete_project( + developer_id=wrong_developer_id, + project_id=project.id + ) + + # The exact exception type and message may vary based on the database layer + assert exc_info.value is not None \ No newline at end of file diff --git a/src/cli/src/julep_cli/__init__.py b/src/cli/src/julep_cli/__init__.py index d1dc78e66..44cd2930f 100644 --- a/src/cli/src/julep_cli/__init__.py +++ b/src/cli/src/julep_cli/__init__.py @@ -7,6 +7,7 @@ from .init import init from .logs import logs from .ls import ls +from .projects import projects_app as projects_app from .run import run from .sync import sync from .tasks import tasks_app as tasks_app @@ -23,6 +24,7 @@ "init", "logs", "ls", + "projects_app", "run", "save_config", "sync", diff --git a/src/cli/src/julep_cli/app.py b/src/cli/src/julep_cli/app.py index 3161dcd25..ebb8d7202 100644 --- a/src/cli/src/julep_cli/app.py +++ b/src/cli/src/julep_cli/app.py @@ -42,11 +42,16 @@ help="Manage executions", context_settings={"help_option_names": ["-h", "--help"]}, ) +projects_app = WrappedTyper( + help="Manage projects", + context_settings={"help_option_names": ["-h", "--help"]}, +) app.add_typer(agents_app, name="agents") app.add_typer(tasks_app, name="tasks") app.add_typer(tools_app, name="tools") app.add_typer(executions_app, name="executions") +app.add_typer(projects_app, name="projects") # Version command diff --git a/src/cli/src/julep_cli/projects.py b/src/cli/src/julep_cli/projects.py new file mode 100644 index 000000000..f9b3490c6 --- /dev/null +++ b/src/cli/src/julep_cli/projects.py @@ -0,0 +1,107 @@ +""" +CLI commands for project management. +""" + +from typing import Annotated +from uuid import UUID + +import typer +from rich.progress import Progress, SpinnerColumn, TextColumn +from rich.text import Text + +from .app import console, error_console, projects_app +from .utils import get_julep_client + + +@projects_app.command() +def delete( + project_id: Annotated[UUID, typer.Option("--id", help="ID of the project to delete")], + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, +): + """Delete an existing project. + + This command will delete a project and all its associations (agents, users, files). + The default project cannot be deleted. + """ + if not yes: + confirm = typer.confirm(f"Are you sure you want to delete project '{project_id}'?") + if not confirm: + console.print(Text("Project deletion cancelled.", style="bold yellow"), highlight=True) + raise typer.Exit() + + try: + client = get_julep_client() + except Exception as e: + error_console.print(Text(f"Error initializing Julep client: {e}", style="bold red")) + raise typer.Exit(1) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + console=console, + ) as progress: + delete_project_task = progress.add_task("Deleting project...", start=False) + progress.start_task(delete_project_task) + + try: + client.projects.delete(project_id) + progress.update(delete_project_task, completed=True) + except Exception as e: + progress.update(delete_project_task, completed=True) + error_console.print(Text(f"Failed to delete project: {e}", style="bold red")) + raise typer.Exit(1) + + console.print(Text("Project deleted successfully.", style="bold green"), highlight=True) + + +@projects_app.command() +def list(): + """List all projects for the current developer.""" + try: + client = get_julep_client() + except Exception as e: + error_console.print(Text(f"Error initializing Julep client: {e}", style="bold red")) + raise typer.Exit(1) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + console=console, + ) as progress: + list_projects_task = progress.add_task("Fetching projects...", start=False) + progress.start_task(list_projects_task) + + try: + projects = client.projects.list() + progress.update(list_projects_task, completed=True) + except Exception as e: + progress.update(list_projects_task, completed=True) + error_console.print(Text(f"Failed to fetch projects: {e}", style="bold red")) + raise typer.Exit(1) + + if not projects: + console.print(Text("No projects found.", style="bold yellow"), highlight=True) + return + + from rich.table import Table + + table = Table(title="Projects", show_header=True, header_style="bold magenta") + table.add_column("ID", style="dim", width=36) + table.add_column("Name", style="bold") + table.add_column("Canonical Name", style="dim") + table.add_column("Created", style="dim") + + for project in projects: + table.add_row( + str(project.id), + project.name, + project.canonical_name, + project.created_at.strftime("%Y-%m-%d %H:%M:%S") if project.created_at else "N/A" + ) + + console.print(table, highlight=True) \ No newline at end of file