Reformatted multiline function calls, object definitions, and queries for improved code readability and consistency. Adjusted imports and constraints where necessary.
660 lines
20 KiB
Python
660 lines
20 KiB
Python
# app/api/routes/projects.py
|
|
"""
|
|
Project management API endpoints for Syndarix.
|
|
|
|
These endpoints allow users to manage their AI-powered software consulting projects.
|
|
Users can create, read, update, and manage the lifecycle of their projects.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request, status
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.dependencies.auth import get_current_user
|
|
from app.core.database import get_db
|
|
from app.core.exceptions import (
|
|
AuthorizationError,
|
|
DuplicateError,
|
|
ErrorCode,
|
|
NotFoundError,
|
|
ValidationException,
|
|
)
|
|
from app.crud.syndarix.project import project as project_crud
|
|
from app.models.syndarix.enums import ProjectStatus
|
|
from app.models.user import User
|
|
from app.schemas.common import (
|
|
MessageResponse,
|
|
PaginatedResponse,
|
|
PaginationParams,
|
|
create_pagination_meta,
|
|
)
|
|
from app.schemas.syndarix.project import (
|
|
ProjectCreate,
|
|
ProjectResponse,
|
|
ProjectUpdate,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Initialize rate limiter
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
# Use higher rate limits in test environment
|
|
IS_TEST = os.getenv("IS_TEST", "False") == "True"
|
|
RATE_MULTIPLIER = 100 if IS_TEST else 1
|
|
|
|
|
|
def _build_project_response(project_data: dict[str, Any]) -> ProjectResponse:
|
|
"""
|
|
Build a ProjectResponse from project data dictionary.
|
|
|
|
Args:
|
|
project_data: Dictionary containing project and related counts
|
|
|
|
Returns:
|
|
ProjectResponse with all fields populated
|
|
"""
|
|
project = project_data["project"]
|
|
return ProjectResponse(
|
|
id=project.id,
|
|
name=project.name,
|
|
slug=project.slug,
|
|
description=project.description,
|
|
autonomy_level=project.autonomy_level,
|
|
status=project.status,
|
|
settings=project.settings,
|
|
owner_id=project.owner_id,
|
|
created_at=project.created_at,
|
|
updated_at=project.updated_at,
|
|
agent_count=project_data.get("agent_count", 0),
|
|
issue_count=project_data.get("issue_count", 0),
|
|
active_sprint_name=project_data.get("active_sprint_name"),
|
|
)
|
|
|
|
|
|
def _check_project_ownership(project: Any, current_user: User) -> None:
|
|
"""
|
|
Check if the current user owns the project or is a superuser.
|
|
|
|
Args:
|
|
project: The project to check ownership of
|
|
current_user: The authenticated user
|
|
|
|
Raises:
|
|
AuthorizationError: If user doesn't own the project and isn't a superuser
|
|
"""
|
|
if not current_user.is_superuser and project.owner_id != current_user.id:
|
|
raise AuthorizationError(
|
|
message="You do not have permission to access this project",
|
|
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Project CRUD Endpoints
|
|
# =============================================================================
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=ProjectResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create Project",
|
|
description="""
|
|
Create a new project for the current user.
|
|
|
|
The project will be owned by the authenticated user.
|
|
A unique slug is required for URL-friendly project identification.
|
|
|
|
**Rate Limit**: 10 requests/minute
|
|
""",
|
|
operation_id="create_project",
|
|
)
|
|
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
|
async def create_project(
|
|
request: Request,
|
|
project_in: ProjectCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Create a new project.
|
|
|
|
The authenticated user becomes the owner of the project.
|
|
"""
|
|
try:
|
|
# Set the owner to the current user
|
|
project_data = ProjectCreate(
|
|
name=project_in.name,
|
|
slug=project_in.slug,
|
|
description=project_in.description,
|
|
autonomy_level=project_in.autonomy_level,
|
|
status=project_in.status,
|
|
settings=project_in.settings,
|
|
owner_id=current_user.id,
|
|
)
|
|
|
|
project = await project_crud.create(db, obj_in=project_data)
|
|
logger.info(f"User {current_user.email} created project {project.slug}")
|
|
|
|
return ProjectResponse(
|
|
id=project.id,
|
|
name=project.name,
|
|
slug=project.slug,
|
|
description=project.description,
|
|
autonomy_level=project.autonomy_level,
|
|
status=project.status,
|
|
settings=project.settings,
|
|
owner_id=project.owner_id,
|
|
created_at=project.created_at,
|
|
updated_at=project.updated_at,
|
|
agent_count=0,
|
|
issue_count=0,
|
|
active_sprint_name=None,
|
|
)
|
|
|
|
except ValueError as e:
|
|
error_msg = str(e)
|
|
if "already exists" in error_msg.lower():
|
|
logger.warning(f"Duplicate project slug attempted: {project_in.slug}")
|
|
raise DuplicateError(
|
|
message=error_msg,
|
|
error_code=ErrorCode.DUPLICATE_ENTRY,
|
|
field="slug",
|
|
)
|
|
logger.error(f"Error creating project: {error_msg}", exc_info=True)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error creating project: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=PaginatedResponse[ProjectResponse],
|
|
summary="List Projects",
|
|
description="""
|
|
List projects for the current user with filtering and pagination.
|
|
|
|
Regular users see only their own projects.
|
|
Superusers can see all projects by setting `all_projects=true`.
|
|
|
|
**Rate Limit**: 30 requests/minute
|
|
""",
|
|
operation_id="list_projects",
|
|
)
|
|
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
|
async def list_projects(
|
|
request: Request,
|
|
pagination: PaginationParams = Depends(),
|
|
status_filter: ProjectStatus | None = Query(
|
|
None, alias="status", description="Filter by project status"
|
|
),
|
|
search: str | None = Query(
|
|
None, description="Search by name, slug, or description"
|
|
),
|
|
all_projects: bool = Query(False, description="Show all projects (superuser only)"),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
List projects with filtering, search, and pagination.
|
|
|
|
Regular users only see their own projects.
|
|
Superusers can view all projects if all_projects is true.
|
|
"""
|
|
try:
|
|
# Determine owner filter based on user role and request
|
|
owner_id = (
|
|
None if (current_user.is_superuser and all_projects) else current_user.id
|
|
)
|
|
|
|
projects_data, total = await project_crud.get_multi_with_counts(
|
|
db,
|
|
skip=pagination.offset,
|
|
limit=pagination.limit,
|
|
status=status_filter,
|
|
owner_id=owner_id,
|
|
search=search,
|
|
)
|
|
|
|
# Build response objects
|
|
project_responses = [_build_project_response(data) for data in projects_data]
|
|
|
|
pagination_meta = create_pagination_meta(
|
|
total=total,
|
|
page=pagination.page,
|
|
limit=pagination.limit,
|
|
items_count=len(project_responses),
|
|
)
|
|
|
|
return PaginatedResponse(data=project_responses, pagination=pagination_meta)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing projects: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.get(
|
|
"/{project_id}",
|
|
response_model=ProjectResponse,
|
|
summary="Get Project",
|
|
description="""
|
|
Get detailed information about a specific project.
|
|
|
|
Users can only access their own projects unless they are superusers.
|
|
|
|
**Rate Limit**: 60 requests/minute
|
|
""",
|
|
operation_id="get_project",
|
|
)
|
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
|
async def get_project(
|
|
request: Request,
|
|
project_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Get detailed information about a project by ID.
|
|
|
|
Includes agent count, issue count, and active sprint name.
|
|
"""
|
|
try:
|
|
project_data = await project_crud.get_with_counts(db, project_id=project_id)
|
|
|
|
if not project_data:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
project = project_data["project"]
|
|
_check_project_ownership(project, current_user)
|
|
|
|
return _build_project_response(project_data)
|
|
|
|
except (NotFoundError, AuthorizationError):
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting project {project_id}: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.get(
|
|
"/slug/{slug}",
|
|
response_model=ProjectResponse,
|
|
summary="Get Project by Slug",
|
|
description="""
|
|
Get detailed information about a project by its slug.
|
|
|
|
Users can only access their own projects unless they are superusers.
|
|
|
|
**Rate Limit**: 60 requests/minute
|
|
""",
|
|
operation_id="get_project_by_slug",
|
|
)
|
|
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
|
async def get_project_by_slug(
|
|
request: Request,
|
|
slug: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Get detailed information about a project by slug.
|
|
|
|
Includes agent count, issue count, and active sprint name.
|
|
"""
|
|
try:
|
|
project = await project_crud.get_by_slug(db, slug=slug)
|
|
|
|
if not project:
|
|
raise NotFoundError(
|
|
message=f"Project with slug '{slug}' not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
_check_project_ownership(project, current_user)
|
|
|
|
# Get project with counts
|
|
project_data = await project_crud.get_with_counts(db, project_id=project.id)
|
|
|
|
if not project_data:
|
|
raise NotFoundError(
|
|
message=f"Project with slug '{slug}' not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
return _build_project_response(project_data)
|
|
|
|
except (NotFoundError, AuthorizationError):
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting project by slug {slug}: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.patch(
|
|
"/{project_id}",
|
|
response_model=ProjectResponse,
|
|
summary="Update Project",
|
|
description="""
|
|
Update an existing project.
|
|
|
|
Only the project owner or a superuser can update a project.
|
|
Only provided fields will be updated.
|
|
|
|
**Rate Limit**: 20 requests/minute
|
|
""",
|
|
operation_id="update_project",
|
|
)
|
|
@limiter.limit(f"{20 * RATE_MULTIPLIER}/minute")
|
|
async def update_project(
|
|
request: Request,
|
|
project_id: UUID,
|
|
project_in: ProjectUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Update a project's information.
|
|
|
|
Only the project owner or superusers can perform updates.
|
|
"""
|
|
try:
|
|
project = await project_crud.get(db, id=project_id)
|
|
|
|
if not project:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
_check_project_ownership(project, current_user)
|
|
|
|
# Update the project
|
|
updated_project = await project_crud.update(
|
|
db, db_obj=project, obj_in=project_in
|
|
)
|
|
logger.info(f"User {current_user.email} updated project {updated_project.slug}")
|
|
|
|
# Get updated project with counts
|
|
project_data = await project_crud.get_with_counts(
|
|
db, project_id=updated_project.id
|
|
)
|
|
|
|
if not project_data:
|
|
# This shouldn't happen, but handle gracefully
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found after update",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
return _build_project_response(project_data)
|
|
|
|
except (NotFoundError, AuthorizationError):
|
|
raise
|
|
except ValueError as e:
|
|
error_msg = str(e)
|
|
if "already exists" in error_msg.lower():
|
|
logger.warning(f"Duplicate project slug attempted: {project_in.slug}")
|
|
raise DuplicateError(
|
|
message=error_msg,
|
|
error_code=ErrorCode.DUPLICATE_ENTRY,
|
|
field="slug",
|
|
)
|
|
logger.error(f"Error updating project: {error_msg}", exc_info=True)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating project {project_id}: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.delete(
|
|
"/{project_id}",
|
|
response_model=MessageResponse,
|
|
summary="Archive Project",
|
|
description="""
|
|
Archive a project (soft delete).
|
|
|
|
Only the project owner or a superuser can archive a project.
|
|
Archived projects are not deleted but are no longer accessible for active work.
|
|
|
|
**Rate Limit**: 10 requests/minute
|
|
""",
|
|
operation_id="archive_project",
|
|
)
|
|
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
|
async def archive_project(
|
|
request: Request,
|
|
project_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Archive a project by setting its status to ARCHIVED.
|
|
|
|
This is a soft delete operation. The project data is preserved.
|
|
"""
|
|
try:
|
|
project = await project_crud.get(db, id=project_id)
|
|
|
|
if not project:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
_check_project_ownership(project, current_user)
|
|
|
|
# Check if project is already archived
|
|
if project.status == ProjectStatus.ARCHIVED:
|
|
return MessageResponse(
|
|
success=True,
|
|
message=f"Project '{project.name}' is already archived",
|
|
)
|
|
|
|
archived_project = await project_crud.archive_project(db, project_id=project_id)
|
|
|
|
if not archived_project:
|
|
raise NotFoundError(
|
|
message=f"Failed to archive project {project_id}",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
logger.info(f"User {current_user.email} archived project {project.slug}")
|
|
|
|
return MessageResponse(
|
|
success=True,
|
|
message=f"Project '{archived_project.name}' has been archived",
|
|
)
|
|
|
|
except (NotFoundError, AuthorizationError):
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error archiving project {project_id}: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
# =============================================================================
|
|
# Project Lifecycle Endpoints
|
|
# =============================================================================
|
|
|
|
|
|
@router.post(
|
|
"/{project_id}/pause",
|
|
response_model=ProjectResponse,
|
|
summary="Pause Project",
|
|
description="""
|
|
Pause an active project.
|
|
|
|
Only ACTIVE projects can be paused.
|
|
Only the project owner or a superuser can pause a project.
|
|
|
|
**Rate Limit**: 10 requests/minute
|
|
""",
|
|
operation_id="pause_project",
|
|
)
|
|
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
|
async def pause_project(
|
|
request: Request,
|
|
project_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Pause an active project.
|
|
|
|
Sets the project status to PAUSED. Only ACTIVE projects can be paused.
|
|
"""
|
|
try:
|
|
project = await project_crud.get(db, id=project_id)
|
|
|
|
if not project:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
_check_project_ownership(project, current_user)
|
|
|
|
# Validate current status (business logic validation, not authorization)
|
|
if project.status == ProjectStatus.PAUSED:
|
|
raise ValidationException(
|
|
message="Project is already paused",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="status",
|
|
)
|
|
|
|
if project.status == ProjectStatus.ARCHIVED:
|
|
raise ValidationException(
|
|
message="Cannot pause an archived project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="status",
|
|
)
|
|
|
|
if project.status == ProjectStatus.COMPLETED:
|
|
raise ValidationException(
|
|
message="Cannot pause a completed project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="status",
|
|
)
|
|
|
|
# Update status to PAUSED
|
|
updated_project = await project_crud.update(
|
|
db, db_obj=project, obj_in=ProjectUpdate(status=ProjectStatus.PAUSED)
|
|
)
|
|
logger.info(f"User {current_user.email} paused project {project.slug}")
|
|
|
|
# Get project with counts
|
|
project_data = await project_crud.get_with_counts(
|
|
db, project_id=updated_project.id
|
|
)
|
|
|
|
if not project_data:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found after update",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
return _build_project_response(project_data)
|
|
|
|
except (NotFoundError, AuthorizationError, ValidationException):
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error pausing project {project_id}: {e!s}", exc_info=True)
|
|
raise
|
|
|
|
|
|
@router.post(
|
|
"/{project_id}/resume",
|
|
response_model=ProjectResponse,
|
|
summary="Resume Project",
|
|
description="""
|
|
Resume a paused project.
|
|
|
|
Only PAUSED projects can be resumed.
|
|
Only the project owner or a superuser can resume a project.
|
|
|
|
**Rate Limit**: 10 requests/minute
|
|
""",
|
|
operation_id="resume_project",
|
|
)
|
|
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
|
async def resume_project(
|
|
request: Request,
|
|
project_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Resume a paused project.
|
|
|
|
Sets the project status back to ACTIVE. Only PAUSED projects can be resumed.
|
|
"""
|
|
try:
|
|
project = await project_crud.get(db, id=project_id)
|
|
|
|
if not project:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
_check_project_ownership(project, current_user)
|
|
|
|
# Validate current status (business logic validation, not authorization)
|
|
if project.status == ProjectStatus.ACTIVE:
|
|
raise ValidationException(
|
|
message="Project is already active",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="status",
|
|
)
|
|
|
|
if project.status == ProjectStatus.ARCHIVED:
|
|
raise ValidationException(
|
|
message="Cannot resume an archived project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="status",
|
|
)
|
|
|
|
if project.status == ProjectStatus.COMPLETED:
|
|
raise ValidationException(
|
|
message="Cannot resume a completed project",
|
|
error_code=ErrorCode.VALIDATION_ERROR,
|
|
field="status",
|
|
)
|
|
|
|
# Update status to ACTIVE
|
|
updated_project = await project_crud.update(
|
|
db, db_obj=project, obj_in=ProjectUpdate(status=ProjectStatus.ACTIVE)
|
|
)
|
|
logger.info(f"User {current_user.email} resumed project {project.slug}")
|
|
|
|
# Get project with counts
|
|
project_data = await project_crud.get_with_counts(
|
|
db, project_id=updated_project.id
|
|
)
|
|
|
|
if not project_data:
|
|
raise NotFoundError(
|
|
message=f"Project {project_id} not found after update",
|
|
error_code=ErrorCode.NOT_FOUND,
|
|
)
|
|
|
|
return _build_project_response(project_data)
|
|
|
|
except (NotFoundError, AuthorizationError, ValidationException):
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error resuming project {project_id}: {e!s}", exc_info=True)
|
|
raise
|