# app/crud/syndarix/sprint.py """Async CRUD operations for Sprint model using SQLAlchemy 2.0 patterns.""" import logging from datetime import date from typing import Any from uuid import UUID from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from app.crud.base import CRUDBase from app.models.syndarix import Issue, Sprint from app.models.syndarix.enums import IssueStatus, SprintStatus from app.schemas.syndarix import SprintCreate, SprintUpdate logger = logging.getLogger(__name__) class CRUDSprint(CRUDBase[Sprint, SprintCreate, SprintUpdate]): """Async CRUD operations for Sprint model.""" async def create(self, db: AsyncSession, *, obj_in: SprintCreate) -> Sprint: """Create a new sprint with error handling.""" try: db_obj = Sprint( project_id=obj_in.project_id, name=obj_in.name, number=obj_in.number, goal=obj_in.goal, start_date=obj_in.start_date, end_date=obj_in.end_date, status=obj_in.status, planned_points=obj_in.planned_points, velocity=obj_in.velocity, ) db.add(db_obj) await db.commit() await db.refresh(db_obj) return db_obj except IntegrityError as e: await db.rollback() error_msg = str(e.orig) if hasattr(e, "orig") else str(e) logger.error(f"Integrity error creating sprint: {error_msg}") raise ValueError(f"Database integrity error: {error_msg}") except Exception as e: await db.rollback() logger.error(f"Unexpected error creating sprint: {e!s}", exc_info=True) raise async def get_with_details( self, db: AsyncSession, *, sprint_id: UUID, ) -> dict[str, Any] | None: """ Get a sprint with full details including issue counts. Returns: Dictionary with sprint and related details """ try: # Get sprint with joined project result = await db.execute( select(Sprint) .options(joinedload(Sprint.project)) .where(Sprint.id == sprint_id) ) sprint = result.scalar_one_or_none() if not sprint: return None # Get issue counts issue_counts = await db.execute( select( func.count(Issue.id).label("total"), func.count(Issue.id) .filter(Issue.status == IssueStatus.OPEN) .label("open"), func.count(Issue.id) .filter(Issue.status == IssueStatus.CLOSED) .label("completed"), ).where(Issue.sprint_id == sprint_id) ) counts = issue_counts.one() return { "sprint": sprint, "project_name": sprint.project.name if sprint.project else None, "project_slug": sprint.project.slug if sprint.project else None, "issue_count": counts.total, "open_issues": counts.open, "completed_issues": counts.completed, } except Exception as e: logger.error( f"Error getting sprint with details {sprint_id}: {e!s}", exc_info=True ) raise async def get_by_project( self, db: AsyncSession, *, project_id: UUID, status: SprintStatus | None = None, skip: int = 0, limit: int = 100, ) -> tuple[list[Sprint], int]: """Get sprints for a specific project.""" try: query = select(Sprint).where(Sprint.project_id == project_id) if status is not None: query = query.where(Sprint.status == status) # Get total count count_query = select(func.count()).select_from(query.alias()) count_result = await db.execute(count_query) total = count_result.scalar_one() # Apply sorting (by number descending - newest first) query = query.order_by(Sprint.number.desc()) query = query.offset(skip).limit(limit) result = await db.execute(query) sprints = list(result.scalars().all()) return sprints, total except Exception as e: logger.error( f"Error getting sprints by project {project_id}: {e!s}", exc_info=True ) raise async def get_active_sprint( self, db: AsyncSession, *, project_id: UUID, ) -> Sprint | None: """Get the currently active sprint for a project.""" try: result = await db.execute( select(Sprint).where( Sprint.project_id == project_id, Sprint.status == SprintStatus.ACTIVE, ) ) return result.scalar_one_or_none() except Exception as e: logger.error( f"Error getting active sprint for project {project_id}: {e!s}", exc_info=True, ) raise async def get_next_sprint_number( self, db: AsyncSession, *, project_id: UUID, ) -> int: """Get the next sprint number for a project.""" try: result = await db.execute( select(func.max(Sprint.number)).where(Sprint.project_id == project_id) ) max_number = result.scalar_one_or_none() return (max_number or 0) + 1 except Exception as e: logger.error( f"Error getting next sprint number for project {project_id}: {e!s}", exc_info=True, ) raise async def start_sprint( self, db: AsyncSession, *, sprint_id: UUID, start_date: date | None = None, ) -> Sprint | None: """Start a planned sprint. Uses row-level locking (SELECT FOR UPDATE) to prevent race conditions when multiple requests try to start sprints concurrently. """ try: # Lock the sprint row to prevent concurrent modifications result = await db.execute( select(Sprint).where(Sprint.id == sprint_id).with_for_update() ) sprint = result.scalar_one_or_none() if not sprint: return None if sprint.status != SprintStatus.PLANNED: raise ValueError( f"Cannot start sprint with status {sprint.status.value}" ) # Check for existing active sprint with lock to prevent race condition # Lock all sprints for this project to ensure atomic check-and-update active_check = await db.execute( select(Sprint) .where( Sprint.project_id == sprint.project_id, Sprint.status == SprintStatus.ACTIVE, ) .with_for_update() ) active_sprint = active_check.scalar_one_or_none() if active_sprint: raise ValueError( f"Project already has an active sprint: {active_sprint.name}" ) sprint.status = SprintStatus.ACTIVE if start_date: sprint.start_date = start_date # Calculate planned points from issues points_result = await db.execute( select(func.sum(Issue.story_points)).where(Issue.sprint_id == sprint_id) ) sprint.planned_points = points_result.scalar_one_or_none() or 0 await db.commit() await db.refresh(sprint) return sprint except ValueError: raise except Exception as e: await db.rollback() logger.error(f"Error starting sprint {sprint_id}: {e!s}", exc_info=True) raise async def complete_sprint( self, db: AsyncSession, *, sprint_id: UUID, ) -> Sprint | None: """Complete an active sprint and calculate completed points. Uses row-level locking (SELECT FOR UPDATE) to prevent race conditions when velocity is being calculated and other operations might modify issues. """ try: # Lock the sprint row to prevent concurrent modifications result = await db.execute( select(Sprint).where(Sprint.id == sprint_id).with_for_update() ) sprint = result.scalar_one_or_none() if not sprint: return None if sprint.status != SprintStatus.ACTIVE: raise ValueError( f"Cannot complete sprint with status {sprint.status.value}" ) sprint.status = SprintStatus.COMPLETED # Calculate velocity (completed points) from closed issues # Note: Issues are not locked, but sprint lock ensures this sprint's # completion is atomic and prevents concurrent completion attempts points_result = await db.execute( select(func.sum(Issue.story_points)).where( Issue.sprint_id == sprint_id, Issue.status == IssueStatus.CLOSED, ) ) sprint.velocity = points_result.scalar_one_or_none() or 0 await db.commit() await db.refresh(sprint) return sprint except ValueError: raise except Exception as e: await db.rollback() logger.error(f"Error completing sprint {sprint_id}: {e!s}", exc_info=True) raise async def cancel_sprint( self, db: AsyncSession, *, sprint_id: UUID, ) -> Sprint | None: """Cancel a sprint (only PLANNED or ACTIVE sprints can be cancelled). Uses row-level locking to prevent race conditions with concurrent sprint status modifications. """ try: # Lock the sprint row to prevent concurrent modifications result = await db.execute( select(Sprint).where(Sprint.id == sprint_id).with_for_update() ) sprint = result.scalar_one_or_none() if not sprint: return None if sprint.status not in [SprintStatus.PLANNED, SprintStatus.ACTIVE]: raise ValueError( f"Cannot cancel sprint with status {sprint.status.value}" ) sprint.status = SprintStatus.CANCELLED await db.commit() await db.refresh(sprint) return sprint except ValueError: raise except Exception as e: await db.rollback() logger.error(f"Error cancelling sprint {sprint_id}: {e!s}", exc_info=True) raise async def get_velocity( self, db: AsyncSession, *, project_id: UUID, limit: int = 5, ) -> list[dict[str, Any]]: """Get velocity data for completed sprints.""" try: result = await db.execute( select(Sprint) .where( Sprint.project_id == project_id, Sprint.status == SprintStatus.COMPLETED, ) .order_by(Sprint.number.desc()) .limit(limit) ) sprints = list(result.scalars().all()) velocity_data = [] for sprint in reversed(sprints): # Return in chronological order velocity_ratio = None if sprint.planned_points and sprint.planned_points > 0: velocity_ratio = (sprint.velocity or 0) / sprint.planned_points velocity_data.append( { "sprint_number": sprint.number, "sprint_name": sprint.name, "planned_points": sprint.planned_points, "velocity": sprint.velocity, "velocity_ratio": velocity_ratio, } ) return velocity_data except Exception as e: logger.error( f"Error getting velocity for project {project_id}: {e!s}", exc_info=True, ) raise async def get_sprints_with_issue_counts( self, db: AsyncSession, *, project_id: UUID, skip: int = 0, limit: int = 100, ) -> tuple[list[dict[str, Any]], int]: """Get sprints with issue counts in optimized queries.""" try: # Get sprints sprints, total = await self.get_by_project( db, project_id=project_id, skip=skip, limit=limit ) if not sprints: return [], 0 sprint_ids = [s.id for s in sprints] # Get issue counts in bulk issue_counts = await db.execute( select( Issue.sprint_id, func.count(Issue.id).label("total"), func.count(Issue.id) .filter(Issue.status == IssueStatus.OPEN) .label("open"), func.count(Issue.id) .filter(Issue.status == IssueStatus.CLOSED) .label("completed"), ) .where(Issue.sprint_id.in_(sprint_ids)) .group_by(Issue.sprint_id) ) counts_map = { row.sprint_id: { "issue_count": row.total, "open_issues": row.open, "completed_issues": row.completed, } for row in issue_counts } # Combine results results = [ { "sprint": sprint, **counts_map.get( sprint.id, {"issue_count": 0, "open_issues": 0, "completed_issues": 0}, ), } for sprint in sprints ] return results, total except Exception as e: logger.error( f"Error getting sprints with counts for project {project_id}: {e!s}", exc_info=True, ) raise # Create a singleton instance for use across the application sprint = CRUDSprint(Sprint)