Reformatted multiline function calls, object definitions, and queries for improved code readability and consistency. Adjusted imports and constraints where necessary.
440 lines
15 KiB
Python
440 lines
15 KiB
Python
# 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)
|