forked from cardosofelipe/pragma-stack
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)
|