# 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 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, ) 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) 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("10/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("30/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("60/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("60/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("20/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("10/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("10/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 if project.status == ProjectStatus.PAUSED: raise AuthorizationError( message="Project is already paused", error_code=ErrorCode.OPERATION_FORBIDDEN, ) if project.status == ProjectStatus.ARCHIVED: raise AuthorizationError( message="Cannot pause an archived project", error_code=ErrorCode.OPERATION_FORBIDDEN, ) if project.status == ProjectStatus.COMPLETED: raise AuthorizationError( message="Cannot pause a completed project", error_code=ErrorCode.OPERATION_FORBIDDEN, ) # 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): 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("10/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 if project.status == ProjectStatus.ACTIVE: raise AuthorizationError( message="Project is already active", error_code=ErrorCode.OPERATION_FORBIDDEN, ) if project.status == ProjectStatus.ARCHIVED: raise AuthorizationError( message="Cannot resume an archived project", error_code=ErrorCode.OPERATION_FORBIDDEN, ) if project.status == ProjectStatus.COMPLETED: raise AuthorizationError( message="Cannot resume a completed project", error_code=ErrorCode.OPERATION_FORBIDDEN, ) # 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): raise except Exception as e: logger.error(f"Error resuming project {project_id}: {e!s}", exc_info=True) raise