Files
headquarter/apps/api/src/api/projects.py
T
alex 27c77af591 feat: workspace-first UI refresh - PR-2 workspace detail page
- 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
2026-06-01 17:04:44 +02:00

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