Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/agents-api/agents_api/queries/projects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
88 changes: 88 additions & 0 deletions src/agents-api/agents_api/queries/projects/delete_project.py
Original file line number Diff line number Diff line change
@@ -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],
)
1 change: 1 addition & 0 deletions src/agents-api/agents_api/routers/projects/__init__.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions src/agents-api/agents_api/routers/projects/delete_project.py
Original file line number Diff line number Diff line change
@@ -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,
)
69 changes: 69 additions & 0 deletions src/agents-api/tests/test_project_delete.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/cli/src/julep_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@
"init",
"logs",
"ls",
"projects_app",
"run",
"save_config",
"sync",
Expand Down
5 changes: 5 additions & 0 deletions src/cli/src/julep_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions src/cli/src/julep_cli/projects.py
Original file line number Diff line number Diff line change
@@ -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)
Loading