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