"""Project CRUD endpoints including sub-projects, function groups, and functions.""" from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_permission from app.db.session import get_async_session from app.models import User, Project, SubProject, ProjectFunctionGroup, ProjectFunction from app.schemas.project import ( ProjectCreateRequest, ProjectUpdateRequest, ProjectResponse, ProjectListResponse, SubProjectCreateRequest, SubProjectUpdateRequest, SubProjectResponse, SubProjectListResponse, ProjectFunctionGroupCreateRequest, ProjectFunctionGroupUpdateRequest, ProjectFunctionGroupResponse, ProjectFunctionGroupListResponse, ProjectFunctionCreateRequest, ProjectFunctionUpdateRequest, ProjectFunctionResponse, ProjectFunctionListResponse, ) router = APIRouter(prefix="/projects", tags=["projects"]) # ===== Project Endpoints ===== @router.get("", response_model=ProjectListResponse) async def list_projects( page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100), search: str | None = Query(None, description="Search in project name"), status: str | None = Query( None, description="Filter by status (draft, confirmed, in_progress, completed, cancelled)", ), current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """List projects with search, filters, pagination.""" account_id = current_user.account_id base_q = select(Project).where(Project.account_id == account_id) if search: pattern = f"%{search}%" base_q = base_q.where(Project.name.ilike(pattern)) if status: base_q = base_q.where(Project.status == status) count_q = select(func.count()).select_from(base_q.subquery()) total = (await session.execute(count_q)).scalar() or 0 q = base_q.order_by(Project.updated_at.desc()).offset((page - 1) * size).limit(size) result = await session.execute(q) items = result.scalars().all() return ProjectListResponse( items=[ProjectResponse.model_validate(i) for i in items], total=total, page=page, size=size, ) @router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) async def create_project( body: ProjectCreateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Create a new project.""" account_id = current_user.account_id project = Project(account_id=account_id, **body.model_dump()) session.add(project) await session.commit() await session.refresh(project) return ProjectResponse.model_validate(project) @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( project_id: str, current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """Get a specific project.""" account_id = current_user.account_id result = await session.execute( select(Project).where( Project.id == project_id, Project.account_id == account_id ) ) project = result.scalars().first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) return ProjectResponse.model_validate(project) @router.put("/{project_id}", response_model=ProjectResponse) async def update_project( project_id: str, body: ProjectUpdateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Update an existing project.""" account_id = current_user.account_id result = await session.execute( select(Project).where( Project.id == project_id, Project.account_id == account_id ) ) project = result.scalars().first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) update_data = body.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(project, field, value) await session.commit() await session.refresh(project) return ProjectResponse.model_validate(project) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project( project_id: str, current_user: User = Depends(require_permission("projects:delete")), session: AsyncSession = Depends(get_async_session), ): """Delete a project.""" account_id = current_user.account_id result = await session.execute( select(Project).where( Project.id == project_id, Project.account_id == account_id ) ) project = result.scalars().first() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) await session.delete(project) await session.commit() return None # ===== SubProject Endpoints ===== @router.get("/{project_id}/subprojects", response_model=SubProjectListResponse) async def list_subprojects( project_id: str, page: int = Query(1, ge=1), size: int = Query(50, ge=1, le=200), current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """List subprojects for a project.""" account_id = current_user.account_id # Verify project ownership proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) base_q = select(SubProject).where(SubProject.project_id == project_id) count_q = select(func.count()).select_from(base_q.subquery()) total = (await session.execute(count_q)).scalar() or 0 q = base_q.order_by(SubProject.sort_order).offset((page - 1) * size).limit(size) result = await session.execute(q) items = result.scalars().all() return SubProjectListResponse( items=[SubProjectResponse.model_validate(i) for i in items], total=total, page=page, size=size, ) @router.post( "/{project_id}/subprojects", response_model=SubProjectResponse, status_code=status.HTTP_201_CREATED, ) async def create_subproject( project_id: str, body: SubProjectCreateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Create a subproject within a project.""" account_id = current_user.account_id # Verify project ownership proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) # Verify parent subproject if provided if body.parent_id: parent_result = await session.execute( select(SubProject.id).where( SubProject.id == body.parent_id, SubProject.project_id == project_id, ) ) if not parent_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Parent subproject not found", ) sub = SubProject(project_id=project_id, **body.model_dump()) session.add(sub) await session.commit() await session.refresh(sub) return SubProjectResponse.model_validate(sub) @router.get("/{project_id}/subprojects/{sub_id}", response_model=SubProjectResponse) async def get_subproject( project_id: str, sub_id: str, current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """Get a specific subproject.""" account_id = current_user.account_id # Verify project ownership proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) result = await session.execute( select(SubProject).where( SubProject.id == sub_id, SubProject.project_id == project_id ) ) sub = result.scalars().first() if not sub: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found" ) return SubProjectResponse.model_validate(sub) @router.put("/{project_id}/subprojects/{sub_id}", response_model=SubProjectResponse) async def update_subproject( project_id: str, sub_id: str, body: SubProjectUpdateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Update a subproject.""" account_id = current_user.account_id # Verify project ownership proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) result = await session.execute( select(SubProject).where( SubProject.id == sub_id, SubProject.project_id == project_id ) ) sub = result.scalars().first() if not sub: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found" ) update_data = body.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(sub, field, value) await session.commit() await session.refresh(sub) return SubProjectResponse.model_validate(sub) @router.delete( "/{project_id}/subprojects/{sub_id}", status_code=status.HTTP_204_NO_CONTENT ) async def delete_subproject( project_id: str, sub_id: str, current_user: User = Depends(require_permission("projects:delete")), session: AsyncSession = Depends(get_async_session), ): """Delete a subproject.""" account_id = current_user.account_id # Verify project ownership proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) result = await session.execute( select(SubProject).where( SubProject.id == sub_id, SubProject.project_id == project_id ) ) sub = result.scalars().first() if not sub: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found" ) await session.delete(sub) await session.commit() return None # ===== ProjectFunctionGroup Endpoints ===== @router.get( "/{project_id}/function-groups", response_model=ProjectFunctionGroupListResponse ) async def list_function_groups( project_id: str, page: int = Query(1, ge=1), size: int = Query(50, ge=1, le=200), current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """List function groups for a project.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) base_q = select(ProjectFunctionGroup).where( ProjectFunctionGroup.project_id == project_id ) count_q = select(func.count()).select_from(base_q.subquery()) total = (await session.execute(count_q)).scalar() or 0 q = ( base_q.order_by(ProjectFunctionGroup.sort_order) .offset((page - 1) * size) .limit(size) ) result = await session.execute(q) items = result.scalars().all() return ProjectFunctionGroupListResponse( items=[ProjectFunctionGroupResponse.model_validate(i) for i in items], total=total, page=page, size=size, ) @router.post( "/{project_id}/function-groups", response_model=ProjectFunctionGroupResponse, status_code=status.HTTP_201_CREATED, ) async def create_function_group( project_id: str, body: ProjectFunctionGroupCreateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Create a function group within a project.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) fg = ProjectFunctionGroup(project_id=project_id, **body.model_dump()) session.add(fg) await session.commit() await session.refresh(fg) return ProjectFunctionGroupResponse.model_validate(fg) @router.get( "/{project_id}/function-groups/{fg_id}", response_model=ProjectFunctionGroupResponse ) async def get_function_group( project_id: str, fg_id: str, current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """Get a specific function group.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) result = await session.execute( select(ProjectFunctionGroup).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) fg = result.scalars().first() if not fg: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) return ProjectFunctionGroupResponse.model_validate(fg) @router.put( "/{project_id}/function-groups/{fg_id}", response_model=ProjectFunctionGroupResponse ) async def update_function_group( project_id: str, fg_id: str, body: ProjectFunctionGroupUpdateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Update a function group.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) result = await session.execute( select(ProjectFunctionGroup).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) fg = result.scalars().first() if not fg: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) update_data = body.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(fg, field, value) await session.commit() await session.refresh(fg) return ProjectFunctionGroupResponse.model_validate(fg) @router.delete( "/{project_id}/function-groups/{fg_id}", status_code=status.HTTP_204_NO_CONTENT ) async def delete_function_group( project_id: str, fg_id: str, current_user: User = Depends(require_permission("projects:delete")), session: AsyncSession = Depends(get_async_session), ): """Delete a function group.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) result = await session.execute( select(ProjectFunctionGroup).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) fg = result.scalars().first() if not fg: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) await session.delete(fg) await session.commit() return None # ===== ProjectFunction Endpoints ===== @router.get( "/{project_id}/function-groups/{fg_id}/functions", response_model=ProjectFunctionListResponse, ) async def list_project_functions( project_id: str, fg_id: str, page: int = Query(1, ge=1), size: int = Query(50, ge=1, le=200), current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """List functions within a function group.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) # Verify function group belongs to project fg_result = await session.execute( select(ProjectFunctionGroup.id).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) if not fg_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) base_q = select(ProjectFunction).where(ProjectFunction.function_group_id == fg_id) count_q = select(func.count()).select_from(base_q.subquery()) total = (await session.execute(count_q)).scalar() or 0 q = ( base_q.order_by(ProjectFunction.sort_order) .offset((page - 1) * size) .limit(size) ) result = await session.execute(q) items = result.scalars().all() return ProjectFunctionListResponse( items=[ProjectFunctionResponse.model_validate(i) for i in items], total=total, page=page, size=size, ) @router.post( "/{project_id}/function-groups/{fg_id}/functions", response_model=ProjectFunctionResponse, status_code=status.HTTP_201_CREATED, ) async def create_project_function( project_id: str, fg_id: str, body: ProjectFunctionCreateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Create a function within a function group.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) fg_result = await session.execute( select(ProjectFunctionGroup.id).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) if not fg_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) func_obj = ProjectFunction(function_group_id=fg_id, **body.model_dump()) session.add(func_obj) await session.commit() await session.refresh(func_obj) return ProjectFunctionResponse.model_validate(func_obj) @router.get( "/{project_id}/function-groups/{fg_id}/functions/{func_id}", response_model=ProjectFunctionResponse, ) async def get_project_function( project_id: str, fg_id: str, func_id: str, current_user: User = Depends(require_permission("projects:read")), session: AsyncSession = Depends(get_async_session), ): """Get a specific project function.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) fg_result = await session.execute( select(ProjectFunctionGroup.id).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) if not fg_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) result = await session.execute( select(ProjectFunction).where( ProjectFunction.id == func_id, ProjectFunction.function_group_id == fg_id, ) ) func_obj = result.scalars().first() if not func_obj: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function not found" ) return ProjectFunctionResponse.model_validate(func_obj) @router.put( "/{project_id}/function-groups/{fg_id}/functions/{func_id}", response_model=ProjectFunctionResponse, ) async def update_project_function( project_id: str, fg_id: str, func_id: str, body: ProjectFunctionUpdateRequest, current_user: User = Depends(require_permission("projects:write")), session: AsyncSession = Depends(get_async_session), ): """Update a project function.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) fg_result = await session.execute( select(ProjectFunctionGroup.id).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) if not fg_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) result = await session.execute( select(ProjectFunction).where( ProjectFunction.id == func_id, ProjectFunction.function_group_id == fg_id, ) ) func_obj = result.scalars().first() if not func_obj: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function not found" ) update_data = body.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(func_obj, field, value) await session.commit() await session.refresh(func_obj) return ProjectFunctionResponse.model_validate(func_obj) @router.delete( "/{project_id}/function-groups/{fg_id}/functions/{func_id}", status_code=status.HTTP_204_NO_CONTENT, ) async def delete_project_function( project_id: str, fg_id: str, func_id: str, current_user: User = Depends(require_permission("projects:delete")), session: AsyncSession = Depends(get_async_session), ): """Delete a project function.""" account_id = current_user.account_id proj_result = await session.execute( select(Project.id).where( Project.id == project_id, Project.account_id == account_id ) ) if not proj_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" ) fg_result = await session.execute( select(ProjectFunctionGroup.id).where( ProjectFunctionGroup.id == fg_id, ProjectFunctionGroup.project_id == project_id, ) ) if not fg_result.scalars().first(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found" ) result = await session.execute( select(ProjectFunction).where( ProjectFunction.id == func_id, ProjectFunction.function_group_id == fg_id, ) ) func_obj = result.scalars().first() if not func_obj: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function not found" ) await session.delete(func_obj) await session.commit() return None