27c77af591
- Add workspace detail page (/workspaces/:id) with 4 tabs: - Files: file tree, viewer, editor, git toolbar (commit/push/pull/fetch) - Git: branch selector, commit history - Tools: instance grid, start tool modal - Settings: workspace info read-only - Add workspace API clients: workspace-files, workspace-git, workspace-instances - Add hooks: useWorkspaceFiles, useWorkspaceGit, useWorkspaceInstances - WorkspaceCard links to detail page via router Link - Add comprehensive CSS for workspace detail layout - Mobile: bottom tab bar, responsive file tree/split - TypeScript + eslint clean Quality gates: tsc --noEmit clean, eslint clean
295 lines
8.3 KiB
Python
295 lines
8.3 KiB
Python
import os
|
|
import shutil
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
from pydantic import BaseModel, ConfigDict
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.auth.dependencies import (
|
|
_get_owned_project,
|
|
_get_user,
|
|
get_current_user_id,
|
|
get_db_session,
|
|
)
|
|
from src.models.git_repository import GitRepository
|
|
from src.models.project import Project
|
|
from src.models.ssh_key import SSHKey
|
|
from src.models.tool_instance import ToolInstance
|
|
|
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
|
|
|
|
|
class ProjectCreate(BaseModel):
|
|
name: str
|
|
description: str | None = None
|
|
|
|
|
|
class ProjectUpdate(BaseModel):
|
|
name: str | None = None
|
|
description: str | None = None
|
|
|
|
|
|
class ProjectResponse(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
name: str
|
|
description: str | None
|
|
owner_id: uuid.UUID
|
|
default_ssh_key_id: uuid.UUID | None
|
|
|
|
|
|
class SetDefaultSSHKeyRequest(BaseModel):
|
|
ssh_key_id: uuid.UUID
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=ProjectResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create a new project",
|
|
description="Create a new project for the authenticated user.",
|
|
)
|
|
async def create_project(
|
|
data: ProjectCreate,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> Project:
|
|
"""Create a new project.
|
|
|
|
Args:
|
|
data: Project creation data including name and optional description.
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
The newly created project.
|
|
"""
|
|
user = await _get_user(session, user_id)
|
|
project = Project(
|
|
name=data.name,
|
|
description=data.description,
|
|
owner_id=user.id,
|
|
default_ssh_key_id=None,
|
|
)
|
|
session.add(project)
|
|
await session.commit()
|
|
await session.refresh(project)
|
|
return project
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
summary="List all projects",
|
|
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
|
)
|
|
async def list_projects(
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> list[dict]:
|
|
"""List all projects for the authenticated user.
|
|
|
|
Returns projects with nested repositories and workspaces for inline display.
|
|
"""
|
|
user = await _get_user(session, user_id)
|
|
result = await session.execute(
|
|
select(Project)
|
|
.where(Project.owner_id == user.id)
|
|
.order_by(Project.created_at.desc())
|
|
)
|
|
projects = result.scalars().all()
|
|
|
|
from src.models.workspace import Workspace
|
|
|
|
enriched = []
|
|
for project in projects:
|
|
repos_result = await session.execute(
|
|
select(GitRepository).where(GitRepository.project_id == project.id)
|
|
)
|
|
repositories = []
|
|
for repo in repos_result.scalars().all():
|
|
ws_result = await session.execute(
|
|
select(Workspace).where(Workspace.repo_id == repo.id)
|
|
)
|
|
workspaces = []
|
|
for ws in ws_result.scalars().all():
|
|
# Count instances
|
|
inst_result = await session.execute(
|
|
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
|
)
|
|
instance_count = inst_result.scalar() or 0
|
|
workspaces.append(
|
|
{
|
|
"id": str(ws.id),
|
|
"name": ws.name,
|
|
"branch": ws.branch,
|
|
"status": ws.status,
|
|
"instance_count": instance_count,
|
|
}
|
|
)
|
|
|
|
repositories.append(
|
|
{
|
|
"id": str(repo.id),
|
|
"name": repo.name,
|
|
"remote_url": repo.remote_url,
|
|
"workspaces": workspaces,
|
|
}
|
|
)
|
|
|
|
enriched.append(
|
|
{
|
|
"id": str(project.id),
|
|
"name": project.name,
|
|
"description": project.description,
|
|
"owner_id": str(project.owner_id),
|
|
"repositories": repositories,
|
|
"created_at": project.created_at.isoformat()
|
|
if project.created_at
|
|
else None,
|
|
}
|
|
)
|
|
|
|
return enriched
|
|
|
|
|
|
@router.get(
|
|
"/{project_id}",
|
|
response_model=ProjectResponse,
|
|
summary="Get a project",
|
|
description="Retrieve a specific project by ID.",
|
|
)
|
|
async def get_project(
|
|
project_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> Project:
|
|
"""Get a specific project by ID.
|
|
|
|
Args:
|
|
project_id: UUID of the project to retrieve.
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
The requested project.
|
|
"""
|
|
await _get_user(session, user_id)
|
|
return await _get_owned_project(project_id, user_id, session)
|
|
|
|
|
|
@router.patch(
|
|
"/{project_id}",
|
|
response_model=ProjectResponse,
|
|
summary="Update a project",
|
|
description="Update a project's name or description.",
|
|
)
|
|
async def update_project(
|
|
project_id: uuid.UUID,
|
|
data: ProjectUpdate,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> Project:
|
|
"""Update a project.
|
|
|
|
Args:
|
|
project_id: UUID of the project to update.
|
|
data: Project update data with optional name and description.
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
The updated project.
|
|
"""
|
|
await _get_user(session, user_id)
|
|
project = await _get_owned_project(project_id, user_id, session)
|
|
|
|
if data.name is not None:
|
|
project.name = data.name
|
|
if data.description is not None:
|
|
project.description = data.description
|
|
|
|
await session.commit()
|
|
await session.refresh(project)
|
|
return project
|
|
|
|
|
|
@router.delete(
|
|
"/{project_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Delete a project",
|
|
description="Delete a project and all its associated repositories.",
|
|
)
|
|
async def delete_project(
|
|
project_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> Response:
|
|
"""Delete a project and all its repositories.
|
|
|
|
Args:
|
|
project_id: UUID of the project to delete.
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
Empty response with 204 status code.
|
|
"""
|
|
await _get_user(session, user_id)
|
|
project = await _get_owned_project(project_id, user_id, session)
|
|
|
|
# Delete repositories from disk and database
|
|
result = await session.execute(
|
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
|
)
|
|
repositories = result.scalars().all()
|
|
for repo in repositories:
|
|
if os.path.exists(repo.path):
|
|
shutil.rmtree(repo.path)
|
|
await session.delete(repo)
|
|
|
|
await session.delete(project)
|
|
await session.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.patch(
|
|
"/{project_id}/default-ssh-key",
|
|
response_model=ProjectResponse,
|
|
summary="Set default SSH key",
|
|
description="Set the default SSH key for a project.",
|
|
)
|
|
async def set_default_ssh_key(
|
|
project_id: uuid.UUID,
|
|
data: SetDefaultSSHKeyRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> Project:
|
|
"""Set the default SSH key for a project.
|
|
|
|
Args:
|
|
project_id: UUID of the project.
|
|
data: Request containing the SSH key ID to set as default.
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
The updated project.
|
|
"""
|
|
user = await _get_user(session, user_id)
|
|
project = await _get_owned_project(project_id, user_id, session)
|
|
|
|
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
|
if ssh_key is None or ssh_key.user_id != user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="invalid ssh key",
|
|
)
|
|
|
|
project.default_ssh_key_id = data.ssh_key_id
|
|
await session.commit()
|
|
await session.refresh(project)
|
|
return project
|