fix: Add missing API endpoints and validation improvements
- Add cancel_sprint and delete_sprint endpoints to sprints.py - Add unassign_issue endpoint to issues.py - Add remove_issue_from_sprint endpoint to sprints.py - Add CRUD methods: remove_sprint_from_issues, unassign, remove_from_sprint - Add validation to prevent closed issues in active/planned sprints - Add authorization tests for SSE events endpoint - Fix IDOR vulnerabilities in agents.py and projects.py - Add Syndarix models migration (0004) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ All endpoints are scoped to a specific project for proper access control.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -37,6 +38,7 @@ from app.schemas.common import (
|
||||
from app.schemas.errors import ErrorCode
|
||||
from app.schemas.syndarix import (
|
||||
IssueResponse,
|
||||
IssueStatus,
|
||||
SprintComplete,
|
||||
SprintCreate,
|
||||
SprintResponse,
|
||||
@@ -51,6 +53,10 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Use higher rate limits in test environment
|
||||
IS_TEST = os.getenv("IS_TEST", "False") == "True"
|
||||
RATE_MULTIPLIER = 100 if IS_TEST else 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
@@ -195,7 +201,7 @@ def build_sprint_response(
|
||||
""",
|
||||
operation_id="create_sprint",
|
||||
)
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
||||
async def create_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -258,7 +264,7 @@ async def create_sprint(
|
||||
""",
|
||||
operation_id="list_sprints",
|
||||
)
|
||||
@limiter.limit("60/minute")
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def list_sprints(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -334,7 +340,7 @@ async def list_sprints(
|
||||
""",
|
||||
operation_id="get_active_sprint",
|
||||
)
|
||||
@limiter.limit("60/minute")
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_active_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -394,7 +400,7 @@ async def get_active_sprint(
|
||||
""",
|
||||
operation_id="get_sprint",
|
||||
)
|
||||
@limiter.limit("60/minute")
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -455,7 +461,7 @@ async def get_sprint(
|
||||
""",
|
||||
operation_id="update_sprint",
|
||||
)
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
||||
async def update_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -541,7 +547,7 @@ async def update_sprint(
|
||||
""",
|
||||
operation_id="start_sprint",
|
||||
)
|
||||
@limiter.limit("10/minute")
|
||||
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
||||
async def start_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -623,7 +629,7 @@ async def start_sprint(
|
||||
""",
|
||||
operation_id="complete_sprint",
|
||||
)
|
||||
@limiter.limit("10/minute")
|
||||
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
||||
async def complete_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -683,6 +689,162 @@ async def complete_sprint(
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{sprint_id}/cancel",
|
||||
response_model=SprintResponse,
|
||||
summary="Cancel Sprint",
|
||||
description="""
|
||||
Cancel a planned or active sprint.
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
**Authorization**: Project owner or superuser
|
||||
|
||||
**Business Rules**:
|
||||
- Only PLANNED or ACTIVE sprints can be cancelled
|
||||
- Issues in the sprint are NOT automatically removed
|
||||
- Cancelled sprints cannot be reactivated
|
||||
|
||||
**Rate Limit**: 10 requests/minute
|
||||
""",
|
||||
operation_id="cancel_sprint",
|
||||
)
|
||||
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
||||
async def cancel_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
sprint_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Cancel a sprint.
|
||||
|
||||
Cancellation is useful when a sprint needs to be abandoned.
|
||||
Issues remain in the sprint but can be moved to other sprints.
|
||||
"""
|
||||
# Verify project access
|
||||
await verify_project_ownership(db, project_id, current_user)
|
||||
|
||||
# Verify sprint exists and belongs to project
|
||||
await get_sprint_or_404(db, sprint_id, project_id)
|
||||
|
||||
try:
|
||||
cancelled_sprint = await sprint_crud.cancel_sprint(db, sprint_id=sprint_id)
|
||||
|
||||
if not cancelled_sprint:
|
||||
raise NotFoundError(
|
||||
message=f"Sprint {sprint_id} not found",
|
||||
error_code=ErrorCode.NOT_FOUND,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.id} cancelled sprint '{cancelled_sprint.name}' "
|
||||
f"(ID: {sprint_id}) in project {project_id}"
|
||||
)
|
||||
|
||||
# Get updated details
|
||||
details = await sprint_crud.get_with_details(db, sprint_id=sprint_id)
|
||||
if details:
|
||||
return build_sprint_response(
|
||||
sprint=details["sprint"],
|
||||
issue_count=details["issue_count"],
|
||||
open_issues=details["open_issues"],
|
||||
completed_issues=details["completed_issues"],
|
||||
project_name=details["project_name"],
|
||||
project_slug=details["project_slug"],
|
||||
)
|
||||
|
||||
return build_sprint_response(cancelled_sprint)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to cancel sprint {sprint_id}: {e}")
|
||||
raise ValidationException(
|
||||
message=str(e),
|
||||
error_code=ErrorCode.OPERATION_FORBIDDEN,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling sprint {sprint_id}: {e!s}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{sprint_id}",
|
||||
response_model=MessageResponse,
|
||||
summary="Delete Sprint",
|
||||
description="""
|
||||
Delete a sprint permanently.
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
**Authorization**: Project owner or superuser
|
||||
|
||||
**Business Rules**:
|
||||
- Only PLANNED or CANCELLED sprints can be deleted
|
||||
- ACTIVE or COMPLETED sprints must be cancelled first
|
||||
- Issues in the sprint will have their sprint_id set to NULL
|
||||
|
||||
**Rate Limit**: 10 requests/minute
|
||||
""",
|
||||
operation_id="delete_sprint",
|
||||
)
|
||||
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
|
||||
async def delete_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
sprint_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Delete a sprint permanently.
|
||||
|
||||
Only PLANNED or CANCELLED sprints can be deleted to preserve
|
||||
historical data for completed sprints.
|
||||
"""
|
||||
# Verify project access
|
||||
await verify_project_ownership(db, project_id, current_user)
|
||||
|
||||
# Verify sprint exists and belongs to project
|
||||
sprint = await get_sprint_or_404(db, sprint_id, project_id)
|
||||
|
||||
# Business rule: Only PLANNED or CANCELLED sprints can be deleted
|
||||
if sprint.status not in [SprintStatus.PLANNED, SprintStatus.CANCELLED]:
|
||||
raise ValidationException(
|
||||
message=f"Cannot delete sprint with status '{sprint.status.value}'. "
|
||||
f"Only PLANNED or CANCELLED sprints can be deleted.",
|
||||
error_code=ErrorCode.OPERATION_FORBIDDEN,
|
||||
field="status",
|
||||
)
|
||||
|
||||
try:
|
||||
# Remove sprint assignment from all issues first
|
||||
await issue_crud.remove_sprint_from_issues(db, sprint_id=sprint_id)
|
||||
|
||||
# Delete the sprint
|
||||
deleted = await sprint_crud.remove(db, id=sprint_id)
|
||||
|
||||
if not deleted:
|
||||
raise NotFoundError(
|
||||
message=f"Sprint {sprint_id} not found",
|
||||
error_code=ErrorCode.NOT_FOUND,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.id} deleted sprint '{sprint.name}' "
|
||||
f"(ID: {sprint_id}) from project {project_id}"
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message=f"Sprint '{sprint.name}' has been deleted.",
|
||||
)
|
||||
|
||||
except (NotFoundError, ValidationException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting sprint {sprint_id}: {e!s}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Sprint Issues Endpoints
|
||||
# ============================================================================
|
||||
@@ -704,7 +866,7 @@ async def complete_sprint(
|
||||
""",
|
||||
operation_id="get_sprint_issues",
|
||||
)
|
||||
@limiter.limit("60/minute")
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_sprint_issues(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -798,7 +960,7 @@ async def get_sprint_issues(
|
||||
""",
|
||||
operation_id="add_issue_to_sprint",
|
||||
)
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
||||
async def add_issue_to_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
@@ -839,6 +1001,18 @@ async def add_issue_to_sprint(
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
)
|
||||
|
||||
# Business rule: Cannot add closed issues to active/planned sprints
|
||||
if issue.status == IssueStatus.CLOSED and sprint.status in [
|
||||
SprintStatus.PLANNED,
|
||||
SprintStatus.ACTIVE,
|
||||
]:
|
||||
raise ValidationException(
|
||||
message="Cannot add closed issues to planned or active sprints. "
|
||||
"Reopen the issue first or use a different sprint.",
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
field="issue_id",
|
||||
)
|
||||
|
||||
try:
|
||||
# Update the issue's sprint_id
|
||||
from app.schemas.syndarix import IssueUpdate
|
||||
@@ -864,6 +1038,86 @@ async def add_issue_to_sprint(
|
||||
raise
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{sprint_id}/issues",
|
||||
response_model=MessageResponse,
|
||||
summary="Remove Issue from Sprint",
|
||||
description="""
|
||||
Remove an issue from a sprint.
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
**Authorization**: Project owner or superuser
|
||||
|
||||
**Business Rules**:
|
||||
- Issue must currently be in this sprint
|
||||
- Cannot modify COMPLETED sprints (use cancel first)
|
||||
|
||||
**Rate Limit**: 30 requests/minute
|
||||
""",
|
||||
operation_id="remove_issue_from_sprint",
|
||||
)
|
||||
@limiter.limit(f"{30 * RATE_MULTIPLIER}/minute")
|
||||
async def remove_issue_from_sprint(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
sprint_id: UUID,
|
||||
issue_id: UUID = Query(..., description="ID of the issue to remove from the sprint"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Remove an issue from the sprint.
|
||||
|
||||
The issue's sprint_id will be set to NULL.
|
||||
"""
|
||||
# Verify project access
|
||||
await verify_project_ownership(db, project_id, current_user)
|
||||
|
||||
# Verify sprint exists and belongs to project
|
||||
sprint = await get_sprint_or_404(db, sprint_id, project_id)
|
||||
|
||||
# Business rule: Cannot modify completed sprints
|
||||
if sprint.status == SprintStatus.COMPLETED:
|
||||
raise ValidationException(
|
||||
message="Cannot remove issues from a completed sprint. Cancel the sprint first.",
|
||||
error_code=ErrorCode.OPERATION_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Verify issue exists and is in this sprint
|
||||
issue = await issue_crud.get(db, id=issue_id)
|
||||
if not issue:
|
||||
raise NotFoundError(
|
||||
message=f"Issue {issue_id} not found",
|
||||
error_code=ErrorCode.NOT_FOUND,
|
||||
)
|
||||
|
||||
if issue.sprint_id != sprint_id:
|
||||
raise ValidationException(
|
||||
message=f"Issue is not in sprint '{sprint.name}'",
|
||||
error_code=ErrorCode.VALIDATION_ERROR,
|
||||
)
|
||||
|
||||
try:
|
||||
# Remove the issue from sprint
|
||||
await issue_crud.remove_from_sprint(db, issue_id=issue_id)
|
||||
|
||||
logger.info(
|
||||
f"User {current_user.id} removed issue {issue_id} from sprint {sprint_id}"
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
success=True,
|
||||
message=f"Issue '{issue.title}' removed from sprint '{sprint.name}'",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error removing issue {issue_id} from sprint {sprint_id}: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Sprint Metrics Endpoints
|
||||
# ============================================================================
|
||||
@@ -886,7 +1140,7 @@ async def add_issue_to_sprint(
|
||||
""",
|
||||
operation_id="get_project_velocity",
|
||||
)
|
||||
@limiter.limit("60/minute")
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def get_project_velocity(
|
||||
request: Request,
|
||||
project_id: UUID,
|
||||
|
||||
Reference in New Issue
Block a user